feat(emails): optional idempotency_key makes a replayed send deliver once - #807
feat(emails): optional idempotency_key makes a replayed send deliver once#807sweetmantech wants to merge 1 commit into
Conversation
…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
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
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. |
|
Warning Review limit reached
Next review available in: 26 minutes Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the 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 configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Plus Run ID: ⛔ Files ignored due to path filters (1)
📒 Files selected for processing (3)
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. Comment |
There was a problem hiding this comment.
3 issues found across 4 files
Confidence score: 3/5
- In
lib/emails/processAndSendEmail.ts, treating an explicitly emptyidempotency_keyas falsy can bypass idempotent sending, so retries may send duplicate emails to users — check specifically forundefinedand 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_keywas 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
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 |
There was a problem hiding this comment.
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, |
There was a problem hiding this comment.
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>
| 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 |
There was a problem hiding this comment.
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>
Implements docs#285 and closes the idempotent-send row of chat#1918.
Merge order: docs#285 first, then this.
Why
runAgentWorkflowruns the entire agent loop — including the email send — inside a singlerunAgentStep, 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:dpl_AmuEQAhVAt3TJvRNnPoLVshGKMEkgoes liveBoth 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
sendEmailWithResendalready acceptedoptions?: CreateEmailRequestOptions— the plumbing existed,processAndSendEmailjust never passed anything. Three small changes:validateSendEmailBodyaccepts an optionalidempotency_key(max 256 chars)sendEmailHandlerthreads it throughprocessAndSendEmailforwards it to Resend as{ idempotencyKey }, and passesundefinedwhen absent so behavior is byte-identical for existing callersScope
This lands the primitive. Deriving the key automatically per turn (
chatId + assistantMessageId) belongs with the workflow decomposition, becauseassistantMessageIddoes 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{…} ]):idempotencyKeyto ResendThe 4 pre-existing single-argument assertions were updated to
(payload, undefined), which pins the no-key path explicitly.pnpm exec vitest run lib/emails→ 31 files, 201 tests, all passpnpm exec eslint lib/emails→ cleanpnpm exec tsc --noEmit→ the oneTS2339inprocessAndSendEmail.test.tsis pre-existing onmain(verified by stashing: same single error), untouched hereRemaining verification
Needs a preview check that two sends with the same
idempotency_keydeliver 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_keyso retries or replays deliver once. Implements docs#285 and addresses chat#1918.idempotency_key(max 256) invalidateSendEmailBodyand threads it throughsendEmailHandlertoprocessAndSendEmail.{ idempotencyKey }; passesundefinedwhen absent to preserve existing behavior.Written for commit c158c6c. Summary will update on new commits.