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

SW
3mo ago
+48-9

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:45email #1 sent
21:28:47production deployment dpl_AmuEQAhVAt3TJvRNnPoLVshGKMEk goes live
21:40:32email #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{…} ]):

testasserts
forwards idempotencyKey to Resendthe fix
omits Resend options entirely when no key is givenno 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 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.