Outbound mail through mail Worker
ADR-2026-0005: Outbound mail through mail Worker
Section titled “ADR-2026-0005: Outbound mail through mail Worker”Context and problem statement
Section titled “Context and problem statement”The first feature that needs to send email out of the api Worker is human registration with email verification (six-digit code, 30-minute expiry, exponential resend backoff). Once that ships, more outbound flows are queued up:
- Agent revocation / key rotation notifications.
- Admin security alerts.
The mail Worker already owns inbound (CF Email Routing → email()
handler → mail_inbound_log) and agent-initiated outbound (POST /mails, agent JWT). The architectural question is whether system-
initiated outbound from api / admin-api / future Workers should:
- (a) route through the mail Worker via a service binding, or
- (b) be sent directly from each calling Worker by importing the
outbound sender abstractions from
packages/mailand reading provider credentials fromconfigD1.
Both work technically. The question is which scales as more callers are added and which keeps the critical-path api Worker isolated from external-provider variability.
Decision drivers
Section titled “Decision drivers”- api is the agent-facing critical path. Every authenticated agent request hits api. CPU / wall-time budget and isolate memory should not be shared with external HTTPS calls whose latency we do not control.
- mail already terminates external dependencies (postal-mime parsing, Resend / SES / CF Email Service HTTPS). Adding system outbound there does not change its risk profile; adding it to api does.
- Credential surface area should be minimal. Resend / SES API keys ought to live in as few isolates as possible.
- Audit symmetry. Inbound is logged in
mail_inbound_loginmailD1. Outbound naturally pairs withmail_outbound_login the same D1, in the same Worker. - Cost grows with the number of caller Workers in option (b): every new caller duplicates sender wiring, provider-credential reads, retry policy, audit logging, and observability scope.
Considered options
Section titled “Considered options”- (a) Service binding
api → mail, dedicated/_internal/notify- mail Worker exposes a privileged internal route guarded byX-Service-Key; callersawait env.MAIL_WORKER.fetch(...)with a{ template, to, context }payload. mail picks the sender, renders the template, persistsmail_outbound_log. - (b) Direct outbound per caller - each Worker imports
pickSenderfrompackages/mail/outbound, reads provider credentials fromconfigD1, and calls the sender locally. mail Worker stays inbound-only and agent-facing. - (c) Queue-based fan-out - caller enqueues into a Cloudflare Queue, mail Worker consumes. (Future option; out of scope for now since Queues add operational surface area not yet needed.)
Pros and cons
Section titled “Pros and cons”| (a) via mail | (b) direct from caller | (c) queue | |
|---|---|---|---|
| Credential blast radius | mail Worker only | every caller isolate | mail Worker only |
| Critical-path isolation (api) | full | none - outbound shares api isolate | full |
| External-provider failure containment | mail-local | bleeds into caller’s CPU/wall budget | mail-local + retry pool |
| Audit log location | single mail_outbound_log | scattered / shared D1 writes | single |
| Observability | one wrangler tail citizenry-mail | fan out across all callers | one |
| Per-send latency overhead | +1–3ms intra-colo RPC | ~0 | +queue lag (seconds+) |
| Template / i18n changes | mail Worker only | every caller redeploy | mail Worker only |
| First-caller setup cost | medium | low | high (Queue provisioning) |
| Long-term cost per new caller | O(1): one [[services]] line | O(N): sender + creds + audit + retry | O(1) |
| Reusability for future agent / admin flows | direct reuse | re-wire per Worker | direct reuse |
Decision
Section titled “Decision”We chose (a) - centralize all outbound mail through the mail Worker via service binding.
The decisive driver is the api / mail service-character asymmetry: api is closed and deterministic (its only I/O is its own D1), mail is open and bounded by external providers. Putting external-provider risk into the critical path Worker negates the value of that asymmetry. Option (a) confines external dependencies to one Worker, where observability, auditing, retry policy, and credential storage already live.
The intra-colo service-binding hop costs ~1–3 ms per send - negligible for asynchronous notifications.
Consequences
Section titled “Consequences”Positive
Section titled “Positive”- api Worker stays lean. No
aws4fetch, no Resend client, nopostal-mime, no provider-credential reads. Smaller bundle, faster cold start, smaller security audit surface. - External-provider failures cannot slow agent-facing routes. A
Resend / SES outage manifests as a 502 from
/_internal/notify; api’s JWT-verify and vault routes are unaffected. - Audit and observability are symmetric.
mail_inbound_log(from ADR-2026-0004 era) and the newmail_outbound_loglive side by side in the same D1, queryable with onewrangler d1 execute. - Each new outbound use case (agent alerts, admin alerts) adds a
[[services]]line in the caller’s wrangler.toml and oneawait env.MAIL_WORKER.fetch(...)call. No duplicated sender wiring. - Templates centralize. Subject / body / locale logic lives in
packages/mailand changes in one place.
Negative / cost
Section titled “Negative / cost”- One additional Worker hop per send (~1–3 ms intra-colo). Acceptable given the notifications are asynchronous from the user’s standpoint.
- A privileged internal route on the mail Worker (
/_internal/notify) must be carefully gated byX-Service-Key. We already use the same PSK between api and admin-api, so no new secret is introduced. - New
[[services]]bindings in callerwrangler.tomlfiles. Trivial but easy to miss in a fork.
Neutral
Section titled “Neutral”- The
[[send_email]](CF native) binding stays on the mail Worker only. Provider credentials inconfigD1 stay readable from any Worker (no change to the config-D1 model), but in practice only mail Worker reads them. - Direct outbound from agents (
POST /mailswith agent JWT) is unaffected - that flow already terminates at mail.
Implementation
Section titled “Implementation”This ADR is being introduced together with the human-verification feature, which is the first caller. The mechanical rollout:
- mail Worker
- Add
mail_outbound_logtable (new migration inpackages/mail/migrations/0003_…). - Add
/_internal/notifyroute gated byX-Service-Key. - Add a template registry (
packages/mail/src/templates/) seeded withhuman_verification.
- Add
- api Worker
apps/api/wrangler.toml: add[[services]] binding="MAIL_WORKER" service="citizenry-mail".apps/api/src/env.ts: type the binding asFetcher.- Add a thin
Notifierclient inapps/api/src/notifier.tsthat wraps the service-binding fetch + PSK header.
packages/identity- Define a
Notifierinterface ({ send(template, to, context) }), consumed by the human-verification service. - Service code is provider-agnostic: tests inject a mock Notifier; prod injects the api Worker’s MAIL_WORKER client.
- Define a
- As agent-revocation / admin-alert flows ship, each adds the same
[[services]]line in its caller and reuses the same/_internal/notifyroute with a new template key.
Compliance / follow-up
Section titled “Compliance / follow-up”- When a third Worker needs to send (e.g. admin-api for admin alerts),
reuse the same
[[services]]pattern. No re-decision needed. - If a future flow needs guaranteed delivery or retries beyond what
fits in a Worker request lifetime, revisit (c) - queue-based
fan-out. The
/_internal/notifycontract is intentionally request/response only at this stage. - Provider credentials remain operator-managed via admin api
PUT /v1/admin/config/mail.outbound.*(ADR-pre-dating choice - see the runtime-config table inCLAUDE.md).
References
Section titled “References”docs/reference/adr/2026-0004.md- agent-only product framing that justifies treating mail as a system-utility Worker rather than user-facing.packages/mail/src/service/index.ts- existing
MailSenderinterface reused as the inner implementation.
- existing
- Cloudflare Workers service bindings - intra-colo RPC, no public network egress between bound Workers.