Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Conversation

@devkiran
Copy link
Collaborator

@devkiran devkiran commented Oct 31, 2025

Summary by CodeRabbit

  • Bug Fixes
    • Improved idempotent handling for batched transactional emails across partner notifications, account merges, program summaries, payout processing, and workflow approvals by propagating idempotency metadata through the email pipeline.
    • Result: more reliable duplicate suppression on retries and consistent single-delivery behavior for these scheduled and workflow-triggered notifications.

@vercel
Copy link
Contributor

vercel bot commented Oct 31, 2025

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Preview Updated (UTC)
dub Ready Ready Preview Oct 31, 2025 4:24pm

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Oct 31, 2025

Note

Other AI code review bot(s) detected

CodeRabbit has detected other AI code review bot(s) in this pull request and will avoid duplicating their findings in the review comments. This may lead to a less comprehensive review.

Walkthrough

Replaces per-call deduplicationId/Idempotency-Key header usage with a propagated top-level idempotencyKey across batch-email APIs and callers. Function signatures and calls were updated to accept and forward idempotencyKey (queue, package, and cron/workflow routes); Resend integration and QStash deduplication now use computed idempotencyKey.

Changes

Cohort / File(s) Summary
Email package public API
packages/email/src/index.ts
Extend sendBatchEmail signature to accept options?: { idempotencyKey?: string } and forward options to Resend path.
Resend transport
packages/email/src/send-via-resend.ts
Add optional options?: { idempotencyKey?: string } param; pass idempotencyKey into resend.batch.send when present; on missing Resend return { data: null, error: null }.
Queueing helper
apps/web/lib/email/queue-batch-email.ts
Replace deduplicationId option with idempotencyKey; compute per-batch idempotencyKey (append batch suffix when multiple batches); set QStash deduplicationId and Idempotency-Key header from computed key; include idempotencyKey in logs.
Send-batch endpoint
apps/web/app/(ee)/api/cron/send-batch-email/route.ts
Extract Idempotency-Key header and pass it as idempotencyKey to sendBatchEmail.
Cron: bounties notify
apps/web/app/(ee)/api/cron/bounties/notify-partners/route.ts
Use sendBatchEmail(..., { idempotencyKey: \bounty-notify/${bountyId}-page-${page}` })` instead of header-based idempotency.
Cron: merge partner accounts
apps/web/app/(ee)/api/cron/merge-partner-accounts/route.ts
Switch to batched email payload and pass idempotencyKey in send call; consolidate both email payloads into batch.
Cron: partner program summary
apps/web/app/(ee)/api/cron/partner-program-summary/route.ts
Add idempotencyKey built from program id and month (yyyy-MM) to batch send call.
Cron: payouts
apps/web/app/(ee)/api/cron/payouts/process/process-payouts.ts
Replace queueBatchEmail deduplicationId usage with idempotencyKey: \payout-confirmed/${invoice.id}``.
Workflow: partner-approved
apps/web/app/(ee)/api/workflows/partner-approved/route.ts
Move idempotency config from header object to top-level idempotencyKey option; adjust key format to application-approved/${programEnrollment.id}.

Sequence Diagram(s)

sequenceDiagram
    autonumber
    participant Cron as Cron/Workflow Route
    participant Queue as queueBatchEmail
    participant QStash as QStash (dequeue)
    participant SendRoute as send-batch-email Route
    participant Email as sendBatchEmail
    participant Resend as Resend API

    Cron->>Queue: queueBatchEmail(emails[], { idempotencyKey })
    Queue->>Queue: compute per-batch idempotencyKey\n(append suffix if multiple batches)
    Queue->>QStash: enqueue payload + deduplicationId (from idempotencyKey)
    QStash->>SendRoute: deliver queued payload (includes Idempotency-Key header)
    SendRoute->>Email: sendBatchEmail(payload, { idempotencyKey })
    Email->>Resend: sendBatchEmailViaResend(payload, { idempotencyKey })
    Resend-->>Email: response (uses Idempotency-Key for dedupe)
    Email-->>SendRoute: result
    SendRoute-->>Cron: 200 / status
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

  • Files needing extra attention:
    • apps/web/lib/email/queue-batch-email.ts — per-batch key computation and QStash header/dedup metadata.
    • packages/email/src/send-via-resend.ts — conditional second-argument call to resend.batch.send and altered error return shape.
    • Consistency of idempotencyKey formats across cron/workflow routes.

Possibly related PRs

Suggested reviewers

  • steven-tey
  • TWilson023

Poem

🐰 I hopped through queues with a key in my paw,
Batches united, no duplicates to gnaw.
Headers now travel in one tidy string,
Resend nods kindly — the emails take wing.
A rabbit's small cheer for idempotent spring! ✨

Pre-merge checks and finishing touches

❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 66.67% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title Check ✅ Passed The PR title "Fix Resend batch Idempotency key" is directly and fully related to the main objective of the changeset. The pull request systematically renames the deduplication key parameter to idempotencyKey throughout the email infrastructure (queue-batch-email.ts, sendBatchEmail, and sendBatchEmailViaResend) and updates all internal callers across multiple cron and workflow routes to use the new parameter name. The title is concise, specific, and clearly identifies both the service (Resend) and the key change (idempotency key handling), allowing a teammate scanning the history to immediately understand the primary purpose of the PR without ambiguity.
✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch fix-resend-batch-idempotency

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull Request Overview

This PR adds idempotency key support to batch email sending functionality to prevent duplicate emails. The change introduces a consistent pattern where idempotency keys are used for both QStash deduplication (for queuing) and Resend idempotency (for sending), replacing the previous inconsistent approach where headers were sometimes passed directly to queueBatchEmail.

  • Added idempotencyKey parameter to sendBatchEmail and sendBatchEmailViaResend functions
  • Updated queueBatchEmail to use idempotencyKey instead of deduplicationId for both QStash and Resend
  • Standardized all call sites to use the new idempotencyKey option pattern

Reviewed Changes

Copilot reviewed 9 out of 9 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
packages/email/src/send-via-resend.ts Added optional idempotencyKey parameter to sendBatchEmailViaResend and passed it to Resend's batch.send API
packages/email/src/index.ts Added optional idempotencyKey parameter to sendBatchEmail wrapper function
apps/web/lib/email/queue-batch-email.ts Renamed deduplicationId to idempotencyKey and added logic to pass it as both QStash deduplication ID and Resend idempotency header
apps/web/app/(ee)/api/workflows/partner-approved/route.ts Migrated from inline headers to using the new idempotencyKey option pattern
apps/web/app/(ee)/api/cron/send-batch-email/route.ts Added extraction of Idempotency-Key header and passed it to sendBatchEmail
apps/web/app/(ee)/api/cron/payouts/process/process-payouts.ts Renamed deduplicationId to idempotencyKey in option parameter
apps/web/app/(ee)/api/cron/partner-program-summary/route.ts Added idempotencyKey option to queueBatchEmail call
apps/web/app/(ee)/api/cron/merge-partner-accounts/route.ts Added idempotencyKey option to sendBatchEmail call
apps/web/app/(ee)/api/cron/bounties/notify-partners/route.ts Migrated from inline headers to using the new idempotency Key option pattern

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@devkiran
Copy link
Collaborator Author

@coderabbitai full review. Are there any breaking changes?

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Oct 31, 2025

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Search for any remaining usage of deduplicationId to find potential breaking changes
echo "=== Searching for deduplicationId usage ==="
rg -n 'deduplicationId' --type=ts --type=js -C2

echo -e "\n=== Searching for queueBatchEmail usage to check if any calls need updating ==="
rg -n 'queueBatchEmail\s*\(' --type=ts --type=js -C3

echo -e "\n=== Searching for sendBatchEmailViaResend usage ==="
rg -n 'sendBatchEmailViaResend' --type=ts --type=js -C2

Length of output: 3783


🏁 Script executed:

#!/bin/bash
# Check the actual queueBatchEmail call in the script to see if it uses deduplicationId
echo "=== Checking queueBatchEmail usage in send-batch-emails.tsx ==="
cat apps/web/scripts/send-batch-emails.tsx

echo -e "\n=== Check all files modified in this PR ==="
git diff --name-only origin/main...HEAD

echo -e "\n=== Check if there are any external consumers of the changed packages ==="
# Check package.json files that might depend on the email package
fd package.json --exec cat {} \; | grep -A5 -B5 "@dub/email" || echo "No external dependencies found"

Length of output: 2304


🏁 Script executed:

#!/bin/bash
# Get the actual diff to see the changes
echo "=== Checking queue-batch-email.ts changes ==="
git diff HEAD~1 apps/web/lib/email/queue-batch-email.ts

echo -e "\n=== Checking send-via-resend.ts changes ==="
git diff HEAD~1 packages/email/src/send-via-resend.ts

echo -e "\n=== Checking index.ts changes ==="
git diff HEAD~1 packages/email/src/index.ts

Length of output: 839


🏁 Script executed:

#!/bin/bash
# Let's check the actual file contents to understand the changes
echo "=== Current queue-batch-email.ts signature ==="
rg -A10 'export async function queueBatchEmail' apps/web/lib/email/queue-batch-email.ts

echo -e "\n=== Current sendBatchEmailViaResend signature ==="
rg -A10 'export const sendBatchEmailViaResend' packages/email/src/send-via-resend.ts

echo -e "\n=== Current sendBatchEmail signature ==="
rg -A10 'export const sendBatchEmail' packages/email/src/index.ts

echo -e "\n=== Check all callers to see if they were updated ==="
rg -n 'queueBatchEmail\(' apps/web/app --type=ts -A2 | head -50

Length of output: 1156


🏁 Script executed:

#!/bin/bash
# Check all files in the PR to see which ones were modified
echo "=== List all TypeScript files that call queueBatchEmail ==="
rg -l 'queueBatchEmail' --type=ts

echo -e "\n=== Check the payout process file that was mentioned in AI summary ==="
cat apps/web/app/(ee)/api/cron/payouts/process/process-payouts.ts | grep -A10 -B10 'queueBatchEmail'

echo -e "\n=== Verify all sendBatchEmail direct calls ==="
rg -n 'sendBatchEmail\(' packages/email/src --type=ts -C3

Length of output: 586


🏁 Script executed:

#!/bin/bash
# Check the payout process file - need to escape the path
echo "=== Check the payout process file ==="
cat 'apps/web/app/(ee)/api/cron/payouts/process/process-payouts.ts' | grep -A10 -B10 'queueBatchEmail'

echo -e "\n=== Check another caller ==="
cat 'apps/web/app/(ee)/api/cron/payouts/charge-succeeded/send-stripe-payouts.ts' | grep -A10 -B10 'queueBatchEmail'

echo -e "\n=== Check direct sendBatchEmail calls ==="
rg -n 'sendBatchEmail\(' apps/web/app --type=ts -C2

Length of output: 12573


🏁 Script executed:

#!/bin/bash
# Check if the perplexity script uses deduplicationId
echo "=== Check perplexity script ==="
cat apps/web/scripts/perplexity/deactivate-partners.ts

echo -e "\n=== Check for any remaining usage of deduplicationId in queueBatchEmail calls ==="
rg -n 'queueBatchEmail.*deduplicationId' --type=ts --type=js

echo -e "\n=== Check the return value handling of sendBatchEmailViaResend ==="
rg -n 'sendBatchEmailViaResend' --type=ts -A5

echo -e "\n=== Check if @dub/email package is exported from anywhere ==="
rg -n 'export.*from.*@dub/email' --type=ts

Length of output: 3475


🏁 Script executed:

#!/bin/bash
# Check what's exported from the email package
echo "=== Check exports from email package ==="
cat packages/email/src/index.ts

echo -e "\n=== Check if there are any type exports that reference deduplicationId ==="
rg -n 'export.*type.*' packages/email/src --type=ts

echo -e "\n=== Verify the workflow file mentioned in AI summary ==="
cat 'apps/web/app/(ee)/api/workflows/partner-approved/route.ts' | head -100

Length of output: 5601


@devkiran: I've analyzed the PR for breaking changes. Here's what I found:

Breaking Changes Analysis

Good news: There are NO external breaking changes

Internal API Changes (All Updated in this PR):

  1. queueBatchEmail function (apps/web/lib/email/queue-batch-email.ts):

    • Changed: Options parameter from { deduplicationId?: string } to { idempotencyKey?: string }
    • Impact: All internal callers have been updated in this PR
    • Search result: No remaining usage of deduplicationId with queueBatchEmail found in the codebase
  2. sendBatchEmail function (packages/email/src/index.ts):

    • Added: Optional parameter options?: { idempotencyKey?: string }
    • Impact: Backward compatible (new optional parameter)
  3. sendBatchEmailViaResend function (packages/email/src/send-via-resend.ts):

    • Added: Optional parameter options?: { idempotencyKey?: string }
    • Changed: Error return value from undefined to { data: null, error: null }
    • Impact: Internal function only called from within the email package

Why No Breaking Changes:

  1. Workspace-internal packages: The @dub/email package uses workspace:* dependency, meaning it's not published externally
  2. Complete migration: All callers in the codebase have been updated in this PR to use idempotencyKey instead of deduplicationId
  3. No external consumers: These packages are only used within this monorepo

Files Updated in this PR:

  • apps/web/app/(ee)/api/cron/bounties/notify-partners/route.ts
  • apps/web/app/(ee)/api/cron/merge-partner-accounts/route.ts
  • apps/web/app/(ee)/api/cron/partner-program-summary/route.ts
  • apps/web/app/(ee)/api/cron/payouts/process/process-payouts.ts
  • apps/web/app/(ee)/api/cron/send-batch-email/route.ts
  • apps/web/app/(ee)/api/workflows/partner-approved/route.ts
  • ✅ Core email infrastructure files

The PR is safe to merge without concerns about breaking external dependencies.

✅ Actions performed

Full review triggered.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

♻️ Duplicate comments (1)
packages/email/src/send-via-resend.ts (1)

74-74: Redundant expression with || undefined.

The expression options?.idempotencyKey || undefined is redundant. When options?.idempotencyKey is falsy (including undefined), it already evaluates to undefined, so the || undefined doesn't change the behavior.

Apply this diff:

-  const idempotencyKey = options?.idempotencyKey || undefined;
+  const idempotencyKey = options?.idempotencyKey;
🧹 Nitpick comments (1)
apps/web/lib/email/queue-batch-email.ts (1)

42-47: Idempotency key computation logic is sound.

The logic correctly handles:

  • Single batch → uses the key as-is (no suffix needed)
  • Multiple batches → appends -batch-${i} to create unique per-batch keys
  • No key provided → undefined (no deduplication)

This ensures proper deduplication for retries while avoiding collisions across different batch configurations.

Optional: The nested ternary could be flattened for readability:

-      const idempotencyKey = options?.idempotencyKey
-        ? batches.length > 1
-          ? `${options.idempotencyKey}-batch-${i}`
-          : options.idempotencyKey
-        : undefined;
+      const idempotencyKey = 
+        !options?.idempotencyKey ? undefined
+        : batches.length > 1 ? `${options.idempotencyKey}-batch-${i}`
+        : options.idempotencyKey;
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 51f28df and b154e6f.

📒 Files selected for processing (9)
  • apps/web/app/(ee)/api/cron/bounties/notify-partners/route.ts (1 hunks)
  • apps/web/app/(ee)/api/cron/merge-partner-accounts/route.ts (1 hunks)
  • apps/web/app/(ee)/api/cron/partner-program-summary/route.ts (1 hunks)
  • apps/web/app/(ee)/api/cron/payouts/process/process-payouts.ts (1 hunks)
  • apps/web/app/(ee)/api/cron/send-batch-email/route.ts (2 hunks)
  • apps/web/app/(ee)/api/workflows/partner-approved/route.ts (1 hunks)
  • apps/web/lib/email/queue-batch-email.ts (2 hunks)
  • packages/email/src/index.ts (1 hunks)
  • packages/email/src/send-via-resend.ts (2 hunks)
🧰 Additional context used
🧠 Learnings (2)
📚 Learning: 2025-09-17T17:44:03.965Z
Learnt from: TWilson023
Repo: dubinc/dub PR: 2857
File: apps/web/lib/actions/partners/update-program.ts:96-0
Timestamp: 2025-09-17T17:44:03.965Z
Learning: In apps/web/lib/actions/partners/update-program.ts, the team prefers to keep the messagingEnabledAt update logic simple by allowing client-provided timestamps rather than implementing server-controlled timestamp logic to avoid added complexity.

Applied to files:

  • apps/web/app/(ee)/api/workflows/partner-approved/route.ts
  • apps/web/app/(ee)/api/cron/partner-program-summary/route.ts
📚 Learning: 2025-07-11T16:28:55.693Z
Learnt from: devkiran
Repo: dubinc/dub PR: 2635
File: packages/prisma/schema/payout.prisma:24-25
Timestamp: 2025-07-11T16:28:55.693Z
Learning: In the Dub codebase, multiple payout records can now share the same stripeTransferId because payouts are grouped by partner and processed as single Stripe transfers. This is why the unique constraint was removed from the stripeTransferId field in the Payout model - a single transfer can include multiple payouts for the same partner.

Applied to files:

  • apps/web/app/(ee)/api/cron/payouts/process/process-payouts.ts
🔇 Additional comments (14)
apps/web/app/(ee)/api/cron/partner-program-summary/route.ts (1)

340-342: LGTM! Idempotency key format is appropriate.

The idempotency key format partner-summary/${program.id}/${format(currentMonth, "yyyy-MM")} correctly ensures monthly deduplication per program, which aligns with the cron schedule and prevents duplicate summary emails.

apps/web/app/(ee)/api/cron/payouts/process/process-payouts.ts (1)

308-308: LGTM! Idempotency key format is correct.

The idempotency key payout-confirmed/${invoice.id} correctly ensures that payout confirmation emails for the same invoice are deduplicated.

apps/web/app/(ee)/api/workflows/partner-approved/route.ts (1)

247-249: LGTM! Idempotency format aligned with other routes.

The idempotency key now uses the slash separator format application-approved/${programEnrollment.id}, consistent with the pattern established across other routes in this PR.

apps/web/app/(ee)/api/cron/send-batch-email/route.ts (2)

49-49: LGTM! Header extraction is correct.

The idempotency key is properly extracted from the Idempotency-Key header and normalized to undefined when absent.


145-147: LGTM! Idempotency key propagation is correct.

The idempotency key is properly forwarded to the sendBatchEmail function through the options object.

apps/web/app/(ee)/api/cron/bounties/notify-partners/route.ts (1)

136-138: LGTM! Idempotency key format supports pagination.

The idempotency key bounty-notify/${bountyId}-page-${page} correctly ensures deduplication per bounty and page, supporting the paginated email sending flow.

apps/web/app/(ee)/api/cron/merge-partner-accounts/route.ts (1)

225-251: LGTM! Batch structure and idempotency key are correct.

The restructured call properly batches both emails (sourceEmail and targetEmail) in a single sendBatchEmail call with an appropriate idempotency key merge-partner-accounts/${userId}.

packages/email/src/index.ts (1)

31-36: LGTM! Backward-compatible API extension.

The optional options parameter is properly added and forwarded to the Resend implementation. The SMTP fallback path correctly ignores idempotency since it doesn't support it.

packages/email/src/send-via-resend.ts (3)

57-60: LGTM! Signature extension is backward compatible.

The optional options parameter properly extends the API without breaking existing callers.


66-69: LGTM! Consistent error return shape.

The normalized return value { data: null, error: null } ensures consistency with the Resend path, improving error handling predictability.


76-79: LGTM! Conditional idempotency key handling is correct.

The conditional second argument ensures the idempotency key is only passed to Resend when provided, maintaining clean API usage.

apps/web/lib/email/queue-batch-email.ts (3)

18-23: LGTM – Parameter rename improves clarity.

The rename from deduplicationId to idempotencyKey better reflects the dual purpose (QStash deduplication + Resend idempotency), and the inline comment clearly documents this behavior.

Per the PR objectives, all internal callers have been updated and no external consumers exist.


49-61: Correct application of idempotency key to both systems.

The computed idempotencyKey is correctly applied to:

  1. QStash deduplication (line 54) – prevents duplicate queue messages
  2. Resend idempotency (line 58) – prevents duplicate email sends

The conditional spread operators ensure both fields are only added when an idempotency key is provided, maintaining backward compatibility with callers that don't specify the key.


65-71: Improved logging for better observability.

The structured log format with conditional idempotencyKey inclusion makes it easier to trace and debug deduplication behavior across batch operations.

@steven-tey steven-tey merged commit a14e27f into main Oct 31, 2025
15 checks passed
@steven-tey steven-tey deleted the fix-resend-batch-idempotency branch October 31, 2025 17:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants