Skip to content

feat(emails): optional idempotency_key makes a replayed send deliver once - #807

Open
sweetmantech wants to merge 1 commit into
mainfrom
fix/email-idempotency-key
Open

feat(emails): optional idempotency_key makes a replayed send deliver once#807
sweetmantech wants to merge 1 commit into
mainfrom
fix/email-idempotency-key

Conversation

@sweetmantech

@sweetmantech sweetmantech commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Implements docs#285 and closes the idempotent-send row of chat#1918.

Merge order: docs#285 first, then this.

Why

runAgentWorkflow runs the entire agent loop — including the email send — inside a single runAgentStep, so a crash or a redeploy re-executes from minute zero and delivers the report again.

Reproduced 2026-07-30 on chat 329c4760-646b-4749-a7e7-82fd64f9cf72:

time (UTC) event
21:21:45 email #1 sent
21:28:47 production deployment dpl_AmuEQAhVAt3TJvRNnPoLVshGKMEk goes live
21:40:32 email #2 sent

Both passes ran end to end — pass 2's content was deduped against a ledger file pass 1 had written and committed. Method Music received the same report 3× on 2026-07-29 and 3× again on 2026-07-30.

The fix

sendEmailWithResend already accepted options?: CreateEmailRequestOptions — the plumbing existed, processAndSendEmail just never passed anything. Three small changes:

  • validateSendEmailBody accepts an optional idempotency_key (max 256 chars)
  • sendEmailHandler threads it through
  • processAndSendEmail forwards it to Resend as { idempotencyKey }, and passes undefined when absent so behavior is byte-identical for existing callers

Scope

This lands the primitive. Deriving the key automatically per turn (chatId + assistantMessageId) belongs with the workflow decomposition, because assistantMessageId does not exist at this API boundary today. Landing it now means any caller that knows its own retry identity is exactly-once immediately, and it remains the defence-in-depth layer the issue calls for even once steps exist — a crash after the Resend call but before the send step journals would still re-send without it.

Tests

RED→GREEN, 2 new tests in processAndSendEmail.test.ts, both confirmed RED (expected "spy" to be called with arguments: [ Anything, ObjectContaining{…} ]):

test asserts
forwards idempotencyKey to Resend the fix
omits Resend options entirely when no key is given no behavior change for existing callers

The 4 pre-existing single-argument assertions were updated to (payload, undefined), which pins the no-key path explicitly.

  • pnpm exec vitest run lib/emails31 files, 201 tests, all pass
  • pnpm exec eslint lib/emails → clean
  • pnpm exec tsc --noEmit → the one TS2339 in processAndSendEmail.test.ts is pre-existing on main (verified by stashing: same single error), untouched here

Remaining verification

Needs a preview check that two sends with the same idempotency_key deliver one email and return the same Resend id. Unit tests cover the forwarding, not Resend's dedupe behavior.

🤖 Generated with Claude Code


Summary by cubic

Adds optional idempotency for email sends: callers can pass idempotency_key so retries or replays deliver once. Implements docs#285 and addresses chat#1918.

  • Bug Fixes
    • Accepts idempotency_key (max 256) in validateSendEmailBody and threads it through sendEmailHandler to processAndSendEmail.
    • Forwards to Resend as { idempotencyKey }; passes undefined when absent to preserve existing behavior.
    • Tests: added forwarding/omission cases and updated existing assertions to pin the no-key path.

Written for commit c158c6c. Summary will update on new commits.

Review in cubic

…once

runAgentWorkflow runs the whole agent loop, including the email send, inside a
single step, so a crash or a redeploy re-executes from the top and delivers the
report again. Reproduced 2026-07-30: send 21:21:45, production deploy 21:28:47,
second send 21:40:32 on one chat.

sendEmailWithResend already accepted Resend request options; processAndSendEmail
simply never passed any. Threads an optional idempotency_key from the request
body through to Resend, which dedupes on it.

Deriving the key per turn (chatId + assistantMessageId) lands with the workflow
decomposition — assistantMessageId is not available at this API boundary yet.

Implements docs#285.

chat#1918
@vercel

vercel Bot commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

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

Project Deployment Actions Updated (UTC)
api Ready Ready Preview Jul 31, 2026 12:57am

Request Review

@cursor

cursor Bot commented Jul 31, 2026

Copy link
Copy Markdown

Bugbot is not enabled for your account, so this pull request was not reviewed.

Enable Bugbot in the Cursor dashboard to get automatic reviews on future PRs.

@coderabbitai

coderabbitai Bot commented Jul 31, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@sweetmantech, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 26 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 5140a319-478e-4733-adca-27771bc0b7c5

📥 Commits

Reviewing files that changed from the base of the PR and between f38823c and c158c6c.

⛔ Files ignored due to path filters (1)
  • lib/emails/__tests__/processAndSendEmail.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
📒 Files selected for processing (3)
  • lib/emails/processAndSendEmail.ts
  • lib/emails/sendEmailHandler.ts
  • lib/emails/validateSendEmailBody.ts

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.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

3 issues found across 4 files

Confidence score: 3/5

  • In lib/emails/processAndSendEmail.ts, treating an explicitly empty idempotency_key as falsy can bypass idempotent sending, so retries may send duplicate emails to users — check specifically for undefined and reject empty keys in validation.
  • In lib/emails/processAndSendEmail.ts, the new comment implies stronger guarantees than Resend actually provides; after the 24-hour retention window, replays can still deliver again, which could mislead future changes — narrow the wording to 24-hour deduplication or add durable dedup logic.
  • In lib/emails/validateSendEmailBody.ts, idempotency_key was added but validation tests were not updated, leaving regressions around optional-field handling more likely to slip through — extend the optional-fields test coverage to include this field explicitly.
Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="lib/emails/processAndSendEmail.ts">

<violation number="1" location="lib/emails/processAndSendEmail.ts:18">
P2: The new comment overstates the guarantee: a replay after Resend’s 24-hour retention window can deliver again; describe this as deduplication within Resend’s 24-hour window or add durable deduplication for longer-lived retries.</violation>

<violation number="2" location="lib/emails/processAndSendEmail.ts:69">
P2: An explicitly supplied empty `idempotency_key` is silently downgraded to a non-idempotent send, allowing a retry to duplicate delivery; use an explicit `undefined` check here and reject empty keys in the request schema.</violation>
</file>

<file name="lib/emails/validateSendEmailBody.ts">

<violation number="1" location="lib/emails/validateSendEmailBody.ts:22">
P2: The new `idempotency_key` field is added to the validation schema but the validation test file doesn't cover it. The "all optional fields" test no longer exercises all optional fields now that `idempotency_key` exists, and there are no tests for the `max(256)` constraint or for accepting a valid key. Adding test coverage for the schema-level validation would catch regressions in the field's constraints.</violation>
</file>
Architecture diagram
sequenceDiagram
    participant Caller as Caller (runAgentWorkflow / API)
    participant Handler as sendEmailHandler
    participant Validator as validateSendEmailBody
    participant Processor as processAndSendEmail
    participant Resend as Resend API

    Note over Caller,Handler: A crash or redeploy may replay the same send<br>(e.g., runAgentWorkflow restarts from turn 0)

    Caller->>Handler: POST /send-email (body with optional idempotency_key)

    Handler->>Validator: validate body (schema includes idempotency_key, max 256 chars)
    Validator-->>Handler: validated data (idempotency_key present if provided)

    Handler->>Processor: processAndSendEmail({ ..., idempotencyKey: idempotency_key })

    Note over Processor: NEW: idempotencyKey forwarded to Resend options

    alt idempotencyKey is provided
        Processor->>Resend: sendEmailWithResend(payload, { idempotencyKey })
        Resend->>Resend: deduplicates on idempotencyKey
        Resend-->>Processor: success (may be same id if replayed)
    else no idempotencyKey
        Processor->>Resend: sendEmailWithResend(payload, undefined)
        Note over Processor,Resend: Existing behavior – no options sent
        Resend-->>Processor: success (new send each time)
    end

    Processor-->>Handler: result
    Handler-->>Caller: response
Loading

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

headers?: Record<string, string>;
room_id?: string;
/**
* Makes the send exactly-once for a retried or replayed caller. Scheduled

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2: The new comment overstates the guarantee: a replay after Resend’s 24-hour retention window can deliver again; describe this as deduplication within Resend’s 24-hour window or add durable deduplication for longer-lived retries.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At lib/emails/processAndSendEmail.ts, line 18:

<comment>The new comment overstates the guarantee: a replay after Resend’s 24-hour retention window can deliver again; describe this as deduplication within Resend’s 24-hour window or add durable deduplication for longer-lived retries.</comment>

<file context>
@@ -14,6 +14,12 @@ export interface ProcessAndSendEmailInput {
   headers?: Record<string, string>;
   room_id?: string;
+  /**
+   * Makes the send exactly-once for a retried or replayed caller. Scheduled
+   * runs re-execute on crash or redeploy and would otherwise deliver the same
+   * report again (chat#1918). Forwarded to Resend, which dedupes on it.
</file context>

html: htmlWithLayout,
headers,
},
idempotencyKey ? { idempotencyKey } : undefined,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2: An explicitly supplied empty idempotency_key is silently downgraded to a non-idempotent send, allowing a retry to duplicate delivery; use an explicit undefined check here and reject empty keys in the request schema.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At lib/emails/processAndSendEmail.ts, line 69:

<comment>An explicitly supplied empty `idempotency_key` is silently downgraded to a non-idempotent send, allowing a retry to duplicate delivery; use an explicit `undefined` check here and reject empty keys in the request schema.</comment>

<file context>
@@ -51,14 +57,17 @@ export async function processAndSendEmail(
+      html: htmlWithLayout,
+      headers,
+    },
+    idempotencyKey ? { idempotencyKey } : undefined,
+  );
 
</file context>
Suggested change
idempotencyKey ? { idempotencyKey } : undefined,
idempotencyKey !== undefined ? { idempotencyKey } : undefined,

html: z.string().optional(),
headers: z.record(z.string(), z.string()).default({}).optional(),
chat_id: z.string().optional(),
idempotency_key: z

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2: The new idempotency_key field is added to the validation schema but the validation test file doesn't cover it. The "all optional fields" test no longer exercises all optional fields now that idempotency_key exists, and there are no tests for the max(256) constraint or for accepting a valid key. Adding test coverage for the schema-level validation would catch regressions in the field's constraints.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At lib/emails/validateSendEmailBody.ts, line 22:

<comment>The new `idempotency_key` field is added to the validation schema but the validation test file doesn't cover it. The "all optional fields" test no longer exercises all optional fields now that `idempotency_key` exists, and there are no tests for the `max(256)` constraint or for accepting a valid key. Adding test coverage for the schema-level validation would catch regressions in the field's constraints.</comment>

<file context>
@@ -19,6 +19,10 @@ export const sendEmailBodySchema = z
     html: z.string().optional(),
     headers: z.record(z.string(), z.string()).default({}).optional(),
     chat_id: z.string().optional(),
+    idempotency_key: z
+      .string()
+      .max(256, "idempotency_key must be at most 256 characters")
</file context>

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.

1 participant