Email Contracts
Scope: src/email/ — the EmailAdapter interface, the Resend and Cloudflare
Email Service adapters, the durable delivery wrapper (durable.ts), typed
delivery errors (errors.ts), and the template builder; plus how the auth flows
in src/auth/routes.ts / email-delivery.ts send.
Guarantees
- Adapter uniformity. Every adapter implements the same
EmailAdapter(send, optionalsendBatch) and accepts the sameEmailMessage. Address inputs (string,{ email, name }, or arrays) normalize identically via the shared helpers. - Builder output is escaped. All caller-supplied content passed to the
template builder is HTML-escaped before rendering; the only unescaped path is
the explicit
.raw(html, text)block, which is the caller's responsibility. - Builder produces both representations.
.build()always returns bothhtmland a plaintexttextfallback. - Workers-safe. Resend uses
fetch; the Cloudflare adapter uses the Email Service binding via structural types (no@cloudflare/workers-typesimport). - Errors are explicit and typed. A non-2xx provider response (Resend), a
network failure, or a rejected binding send (Cloudflare) throws an
EmailDeliveryErrornaming the provider, withstatus(when HTTP),retryAfterMs(parsed fromRetry-After, seconds or HTTP-date), andpermanent.4xxother than408/409/425/429is permanent; everything else (including errors that are notEmailDeliveryError) is transient. - Idempotency key pass-through.
send(message, { idempotencyKey })reaches the adapter; Resend sends it as theIdempotency-Keyheader (single and batch).sendEmailBatchwithout a native batch API derives<key>:<index>.
Durable delivery (durableEmail / createDurableEmailAdapter)
- Queued send returns before the provider is contacted. With a
queue,sendresolves{ id: <messageId>, provider, status: "queued", taskId }after enqueuing onecovara.email.sendtask; the inner adapter is called only by the task handler. - Classification drives retries. Permanent errors are never retried and go
straight to the task DLQ. Transient errors retry with exponential backoff up
to
retry.maxAttempts(default 6), then go to the DLQ. Retry-Afterwins over backoff. When the error carriesretryAfterMs, the next attempt is scheduled that far out (KV worker:scheduledFor; Cloudflare Queues:delaySeconds, capped at 12h), capped atretry.maxRetryAfterMs.onDeliveryFailedfires exactly once per dead-lettered message, on the attempt that dead-letters it (permanent error, orctx.attempt >= maxAttempts). Hook errors are swallowed and never affect DLQ persistence.- Every message has an idempotency key (caller-supplied or a random UUID; it
is also the returned message id). Enqueuing a key whose task is still pending
returns the existing task id. When a KV is available (explicit
kv, else the global KV), a sent-marker is written immediately after the provider accepts and checked before every attempt, so a re-run (retry, DLQ replay, duplicate enqueue, crash after the marker) does not re-send. A marker write failure does not fail the task. - Payload bound. A queued message whose serialized form exceeds
maxPayloadBytes(default 120,000) is rejected atsendwith a permanentEmailDeliveryError(code: "payload_too_large"); nothing is enqueued. - Serialization is JSON-safe.
Uint8Arrayattachment content is stored as base64 with anencoding: "bytes"tag and restored to bytes before the adapter is called; string content passes through unchanged. - No-queue fallback. Without
queue,senddelivers inline with bounded retries (inline.maxAttempts, default 3; aRetry-Afteraboveinline.maxRetryAfterMsstops retrying), logs a one-time "not durable" warning per adapter instance, firesonDeliveryFailedwithmode: "inline"and rejects when all attempts fail.
Auth flows
- Enumeration-sensitive endpoints never fail on delivery.
POST /password/forgotandPOST /magic-link/requestcatch any delivery error (callback, composed send, missing adapter, enqueue failure), report it toemail.onDeliveryError(default:console.errorwithout the identifier or token) and return the same{ success: true }as for an unknown address.POST /verify/requeststill propagates delivery errors. - Composed auth emails are idempotent per token.
composeEmailmessages are sent withidempotencyKey = auth:<flow>:<sha256(token)>; the raw token never appears in the key. - Misconfiguration fails at setup. A flow with neither its callback nor
composeEmailmakesuseAuththrow.
Non-guarantees
- Plain adapters are best-effort. A bare adapter's
sendmakes one attempt and resolves when the provider accepts; wrap it withdurableEmail+ a queue for retries and a DLQ. Downstream delivery (inbox placement, bounces) is the provider's concern in every mode. - Exactly-once is not guaranteed. Durable delivery is at-least-once. A
worker that crashes after the provider accepted but before the sent-marker is
written will re-send on retry; Resend dedupes that via
Idempotency-Key(within Resend's 24h window), the Cloudflare adapter does not. Without any KV there is no sent-marker at all. - Multi-recipient Cloudflare sends are not atomic. The Cloudflare adapter sends one envelope per recipient; a transient failure part-way through retries the whole message, so earlier recipients may receive it twice.
- The inline fallback is not durable. A process crash or exhausted retries
loses the message (after reporting it to
onDeliveryFailed). - Plain
sendwithout a key is not deduplicated. Calling a bare adapter'ssendtwice sends twice; durable sends without a caller key get a fresh random key each time. - No template storage/versioning. The builder renders at call time; it does not persist or version templates.
- Provider feature parity is not normalized beyond the common
EmailMessagefields (e.g. scheduling, provider-specific analytics are not abstracted).
Test coverage
tests/email/errors.test.ts—Retry-Afterparsing, status classification, typed adapter errors,Idempotency-Keyheadertests/email/durable.test.ts— queued/inline delivery, retries, DLQ, hook, Retry-After, dedupe, payload limit, attachment round-trip, Cloudflare Queuestests/email/global.test.ts— global helpers forward idempotency keystests/auth/email-delivery.test.ts— auth flows: enumeration-safe failures, composed sends, durable mode, setup validation