feat(emails): optional idempotency_key makes a replayed send deliver once
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:
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 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/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 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) invalidateSendEmailBodyand threads it throughsendEmailHandlertoprocessAndSendEmail. - Forwards to Resend as
{ idempotencyKey }; passesundefinedwhen absent to preserve existing behavior. - Tests: added forwarding/omission cases and updated existing assertions to pin the no-key path.
- Accepts