Skip to content

Outbound mail through mail Worker

ADR-2026-0005: Outbound mail through mail Worker

Section titled “ADR-2026-0005: Outbound mail through mail Worker”

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/mail and reading provider credentials from config D1.

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.

  • 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_log in mail D1. Outbound naturally pairs with mail_outbound_log in 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.
  1. (a) Service binding api → mail, dedicated /_internal/notify - mail Worker exposes a privileged internal route guarded by X-Service-Key; callers await env.MAIL_WORKER.fetch(...) with a { template, to, context } payload. mail picks the sender, renders the template, persists mail_outbound_log.
  2. (b) Direct outbound per caller - each Worker imports pickSender from packages/mail/outbound, reads provider credentials from config D1, and calls the sender locally. mail Worker stays inbound-only and agent-facing.
  3. (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.)
(a) via mail(b) direct from caller(c) queue
Credential blast radiusmail Worker onlyevery caller isolatemail Worker only
Critical-path isolation (api)fullnone - outbound shares api isolatefull
External-provider failure containmentmail-localbleeds into caller’s CPU/wall budgetmail-local + retry pool
Audit log locationsingle mail_outbound_logscattered / shared D1 writessingle
Observabilityone wrangler tail citizenry-mailfan out across all callersone
Per-send latency overhead+1–3ms intra-colo RPC~0+queue lag (seconds+)
Template / i18n changesmail Worker onlyevery caller redeploymail Worker only
First-caller setup costmediumlowhigh (Queue provisioning)
Long-term cost per new callerO(1): one [[services]] lineO(N): sender + creds + audit + retryO(1)
Reusability for future agent / admin flowsdirect reusere-wire per Workerdirect reuse

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.

  • api Worker stays lean. No aws4fetch, no Resend client, no postal-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 new mail_outbound_log live side by side in the same D1, queryable with one wrangler d1 execute.
  • Each new outbound use case (agent alerts, admin alerts) adds a [[services]] line in the caller’s wrangler.toml and one await env.MAIL_WORKER.fetch(...) call. No duplicated sender wiring.
  • Templates centralize. Subject / body / locale logic lives in packages/mail and changes in one place.
  • 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 by X-Service-Key. We already use the same PSK between api and admin-api, so no new secret is introduced.
  • New [[services]] bindings in caller wrangler.toml files. Trivial but easy to miss in a fork.
  • The [[send_email]] (CF native) binding stays on the mail Worker only. Provider credentials in config D1 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 /mails with agent JWT) is unaffected - that flow already terminates at mail.

This ADR is being introduced together with the human-verification feature, which is the first caller. The mechanical rollout:

  1. mail Worker
    • Add mail_outbound_log table (new migration in packages/mail/migrations/0003_…).
    • Add /_internal/notify route gated by X-Service-Key.
    • Add a template registry (packages/mail/src/templates/) seeded with human_verification.
  2. api Worker
    • apps/api/wrangler.toml: add [[services]] binding="MAIL_WORKER" service="citizenry-mail".
    • apps/api/src/env.ts: type the binding as Fetcher.
    • Add a thin Notifier client in apps/api/src/notifier.ts that wraps the service-binding fetch + PSK header.
  3. packages/identity
    • Define a Notifier interface ({ 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.
  4. As agent-revocation / admin-alert flows ship, each adds the same [[services]] line in its caller and reuses the same /_internal/notify route with a new template key.
  • 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/notify contract 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 in CLAUDE.md).
  • 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 MailSender interface reused as the inner implementation.
  • Cloudflare Workers service bindings - intra-colo RPC, no public network egress between bound Workers.