Skip to content

Mail — Outbound email substrate

Version: 1.0 Updated: 2026-05-16 (post EMAIL-SEND-CF-MIGRATION-01 close) Applies to: every Worker that sends outbound email — 4 customer-facing (apps/site, apps/workspace, apps/admin, workers/internal-api) + 5 utility (cricos-sync, enrich-sync, prelaunch, session-archiver, tga-sync) Companion docs: notifications.md, ../ops/architecture-decisions.md (auth reference — ADR-024/025/027), ../ops/credential-discipline.md, ../ops/standing-rules.md


What this doc is

The canonical reference for how RTOpacks sends mail. Five sections:

  1. The substrate — what underlies every send + why
  2. The wrapper (lib/mail.ts) — the per-worker abstraction + the purpose field as forcing function
  3. Recipient patterns — admin-routing, submitter-email-with-dev-guard, hardcoded
  4. How to add a new mail path / new worker
  5. Failure modes + gotchas

If you're adding a mail path, read sections 2–4. If you're debugging delivery, read sections 1 + 5.


Why this doc exists

Mail was migrated from Resend + Gmail SMTP to Cloudflare Email Service across 15–16 May 2026 (EMAIL-SEND-CF-MIGRATION-01). The migration was structural — same wrapper, different substrate — and the wrapper shape is the same across all 9 workers that send mail. This doc captures the rationale and the patterns so the next mail-adjacent decision doesn't recompute them.

If this doc disappears, the rebuild path is: read the EMAIL-SEND-CF-MIGRATION-01 brief memory + outputs/time-machine-2026-05-15-evening.md + outputs/phase-4-execution-log-2026-05-16.md to reconstruct the full decision arc.


1. The substrate

Single-vendor: Cloudflare Email Service via the env.EMAIL send_email binding.

Why CF and not Resend / SES / Mailgun / Postmark:

  • Single-vendor architecture per CLOUDFLARE-FIRST RULE in standing-rules.md. Concentrates blast radius into one observable, controllable surface. The substrate's coherence is itself the load-bearing decision.
  • Authentication chain aligns natively under p=reject parent DMARC via the cf-bounce.<domain> subdomain pattern. No apex SPF/DKIM modification needed.
  • Free tier covers current scale (200 sends/day per zone; we're using ~10/day at present).
  • Workers-binding integrationenv.EMAIL.send() is a first-class CF Workers binding, no API token, no SDK, no rate-limit gymnastics.

The migration retired Resend + Gmail SMTP entirely. Apex SPF on rtopacks.com.au no longer includes amazonses; the resend._domainkey + send.rtopacks.com.au MX/SPF records are gone. See outputs/phase-4-execution-log-2026-05-16.md for the retirement detail.

cf-bounce subdomain — the load-bearing property

CF Email Service uses cf-bounce.<domain> as the envelope-from / Return-Path. SPF + DKIM are signed/aligned against cf-bounce, not the apex. The parent zone's DMARC policy (rtopacks.com.au = p=reject; sp=reject) is satisfied via relaxed alignment (subdomain alignment counts).

The architectural implication: enforcing strict DMARC at the apex doesn't break outbound mail, because alignment is achieved at the subdomain level. This is why the migration could proceed under p=reject without policy loosening.

Don't modify cf-bounce.<domain> DNS records. They are managed by CF Email Service when the zone is onboarded. Modifying or deleting them breaks the authentication chain.

Zone state

CF Email Service is enabled on both rtopacks.com.au and rtopacks.dev. Before deploying mail-path changes, confirm at the CF dashboard:

  • Status: Enabled
  • DNS records: Locked
  • Sending reputation: Healthy
  • Delivery rate: ≥ 99% (7d + 24h)
  • Daily quota: not exhausted

Dashboard URL pattern: https://dash.cloudflare.com/<account>/email-service/sending/<zone-id>/.../


2. The wrapper — lib/mail.ts

Every worker that sends mail imports a local mail wrapper (at apps/<app>/lib/mail.ts or apps/<app>/app/lib/mail.ts for the Next.js apps, at workers/<worker>/src/mail.ts or scripts/workers/<worker>/src/mail.ts for the workers). All 10 copies are structurally identical: same MailOptions interface, same sendMail signature, same validation rules.

Why per-worker copies (not a shared package)

The monorepo's packages/ui is for React components shared between apps. A shared mail wrapper at the package level is awkward right now because of CF Workers bundling + OpenNext + Turbopack interactions (per the build-tooling history captured in DEPLOY-PROD-WRAPPER-MISSING-01). Per-worker copies are the operationally stable shape.

The uniformity is enforced by review, not by import. If you change one, change all 10 in the same commit. Last unified pass was EMAIL-SEND-CF-MIGRATION-01 Phase 2b commit A (3ce2633b).

The interface

export interface MailOptions {
  to: string | string[];
  from: string;
  subject: string;
  purpose: string;          // wrapper-only; forcing function for MAIL-OBSERVABILITY-01
  html?: string;
  text?: string;
  cc?: string | string[];
  bcc?: string | string[];
  replyTo?: string;
}

export interface MailEnv {
  EMAIL: {
    send(options: Omit<MailOptions, "purpose">): Promise<{ messageId: string }>;
  };
}

export async function sendMail(
  env: MailEnv,
  options: MailOptions,
): Promise<{ messageId: string }>;

The purpose field — forcing function

purpose is a tag that does not reach CF Email Service — the wrapper strips it before forwarding. It exists to force every call site to name what category of mail it is.

Current purpose values in use:

Purpose Worker Trigger
magic-link apps/workspace User requests sign-in
magic-link-admin apps/admin Admin requests sign-in (path being unified per ADMIN-MAGIC-LINK-AUTH-01)
passkey-enrolment apps/admin Admin invited, sets up passkey
admin-invite apps/admin Tim invites a new admin
org-admin-welcome workers/internal-api Org provisioned, primary admin gets welcome
org-member-invite workers/internal-api Existing admin invites a new member
org-admin-cap-warning workers/internal-api Seat-cap threshold crossed
anomaly-alert workers/internal-api Anomaly detection fires
contact-form apps/site /api/contact submission
subscribe-admin-notify apps/site /api/subscribe — admin half
subscribe-confirm apps/site /api/subscribe — submitter half
lead-notification apps/site /api/lead submission
prelaunch-verification workers/prelaunch Waitlist signup
cricos-sync-complete workers/cricos-sync Cron sync finishes
cricos-sync-sentinel workers/cricos-sync SENTINEL-02 anomaly
enrich-sync-complete workers/enrich-sync Cron sync finishes
tga-sync-complete scripts/workers/tga-sync Cron sync finishes
(session-archiver: TBD on next failure path) workers/session-archiver (no recent send)

The forcing function is the discipline: every new mail path picks a purpose name at creation time, lowercase + hyphenated, distinct from existing tags. MAIL-OBSERVABILITY-01 (queued brief) will use these tags to group sends by category for audit/observability.

Validation

Three required-field checks; the wrapper throws on missing:

  • from — never empty (caller-supplied; sender normalisation is deferred to MAIL-SENDER-NORMALISATION-01)
  • purpose — never empty
  • At least one of html / text — never empty

Then forwards { to, from, subject, html, text, cc, bcc, replyTo } to env.EMAIL.send(). Returns { messageId }.


3. Recipient patterns

Three patterns cover all current mail paths.

Admin-routing — urls(env).adminEmail

The most common pattern. Mail goes to the admin mailbox for the current env:

  • prod: admin@rtopacks.com.au
  • staging: admin@rtopacks.dev
import { sendMail } from "../../lib/mail";
import { urls } from "../../lib/env-urls";

await sendMail(env, {
  from: "RTOpacks <noreply@rtopacks.com.au>",
  to: [urls(env).adminEmail],
  subject: "...",
  purpose: "contact-form",
  text: "...",
});

Used by: contact-form, subscribe-admin-notify, lead-notification, admin-invite, passkey-enrolment (admin path), anomaly-alert, org-admin-cap-warning. Established as a canonical pattern in EMAIL-SEND-CF-MIGRATION-01 Phase 2b commit I (48f8aab2 — the 5-site collapse).

The urls(env) resolver lives at apps/<app>/lib/env-urls.ts (or apps/site/app/lib/env-urls.ts) and workers/internal-api/src/env-urls.ts. It's the same resolver pattern as the URL helpers from DEV-PROD-LINK-SEAL-02adminEmail is one key on the same env-aware shape.

Submitter-email with dev guard

Mail goes to whoever submitted the form, but on staging it's redirected to client@rtopacks.dev to protect real submitter addresses during testing:

await sendMail(env, {
  from: "RTOpacks <hello@rtopacks.com.au>",
  to: env.ENV === "staging" ? ["client@rtopacks.dev"] : [submitterEmail.toLowerCase()],
  subject: "...",
  purpose: "subscribe-confirm",
  text: "...",
});

Used by: magic-link, subscribe-confirm, org-admin-welcome, org-member-invite. The guard preserves real-customer protection on dev while still exercising the substrate end-to-end. Don't remove the guard unless you're explicitly authorising real-submitter sends on staging.

Hardcoded admin@rtopacks.com.au

Utility workers (cricos-sync, enrich-sync, tga-sync, session-archiver, prelaunch) are single-env (prod only — no env.staging block in wrangler config). They hardcode admin@rtopacks.com.au as the recipient because there's no env to branch on:

await sendMail(env as any, {
  from: "RTOpacks Sync <noreply@rtopacks.com.au>",
  to: ["admin@rtopacks.com.au"],
  subject: `${outcomeEmoji} ${label} complete — run ${runId}`,
  purpose: "cricos-sync-complete",
  html,
  text,
});

Used by: cricos-sync-complete, cricos-sync-sentinel, enrich-sync-complete, tga-sync-complete. The prelaunch-verification send is similar but uses the form-submitted email as the to (real customer mail, not admin-routed).

The 4-mailbox architecture

Recipients route into one of four real Workspace mailboxes:

Mailbox Use
admin@rtopacks.com.au prod admin routing
client@rtopacks.com.au prod customer mail (prelaunch verification, some welcome flows)
admin@rtopacks.dev dev admin routing
client@rtopacks.dev dev submitter-email-guard redirect

All four are real Workspace mailboxes Tim reads. No null routes, no /dev/null sinks. The 4-mailbox shape is load-bearing for the dev-environment substrate verification — see EMAIL-SEND-CF-MIGRATION-01 Phase 3a-dev walks.


4. How to add a new mail path

Inside an existing worker

  1. Pick a purpose name. Convention: lowercase, hyphenated, distinct from existing tags. Match the existing taxonomy (see the purpose table above). Examples for a new path: org-suspension-notice, passkey-reset-confirm, billing-receipt.
  2. Pick a recipient pattern — admin-routing (most common), submitter-with-dev-guard (for customer-facing confirmations), or hardcoded (utility workers only).
  3. Compose from<Display Name> <noreply@rtopacks.com.au> is the dominant pattern. hello@rtopacks.com.au is used for warm-tone customer confirmations. Don't introduce a third domain.
  4. Call sendMail(env, { from, to, subject, purpose, html?, text? }). Wrap in try/catch where mail failure shouldn't fail the request — typical pattern for side-effect mails following a primary action:
try {
  await sendMail(env, { /* ... */ });
} catch (err) {
  console.error("[purpose-tag] mail failed:", err instanceof Error ? err.message : err);
}
  1. Verify on staging if applicable; otherwise verify in CF Email Service Activity Log post-deploy that the send fires + sending reputation stays Healthy.

Inside a new worker

  1. Declare the send_email binding in the worker's wrangler config. For workers/ utility workers (wrangler.toml):
    [[send_email]]
    name = "EMAIL"
    
    For apps/ Next.js workers (wrangler.jsonc), declare at top-level + repeat under env.staging:
    "send_email": [
      { "name": "EMAIL" }
    ],
    "env": {
      "staging": {
        "send_email": [
          { "name": "EMAIL" }
        ]
      }
    }
    
    The binding is intentionally unrestricted (no destination_address field) — sends can go to any address. Restricting at the binding level is heavier than what we need; recipient policy lives in code (admin-routing / submitter-guard).
  2. Copy a lib/mail.ts (or src/mail.ts) from an existing worker. All 10 copies are byte-identical. Don't drift the shape — the uniformity is load-bearing for Q32 equivalence pattern that closes most mail-substrate verification walks (only one explicit walk is needed; the rest inherit substrate proof from structural identity).
  3. If the worker is dual-env and uses admin-routing, copy the env-urls.ts resolver pattern from apps/site/app/lib/env-urls.ts (or similar) and add an adminEmail key matching the env values.
  4. Follow "Inside an existing worker" above.

What to verify after adding

  • npx wrangler deploy succeeds and the bindings list output includes env.EMAIL (unrestricted)
  • CF Email Service Activity Log shows the send fires (recipient + subject visible, result Delivered)
  • Recipient mailbox shows the mail with dmarc=pass spf=pass dkim=pass in the Authentication-Results header
  • Sending reputation stays Healthy in CF dashboard

5. Failure modes + gotchas

Mail send is non-blocking by convention

Most call sites wrap sendMail in try/catch where the catch is empty or just logs. If mail fails, the primary action (org provisioned, contact stored, etc.) still completes. Don't propagate sendMail errors unless the mail send IS the primary action (rare — most mail is a side-effect of a primary action).

Daily quota — 200 sends/day per zone

Free tier. Current usage ~10/day; far from the cap. A noisy retry loop or accidental fan-out could burn it. Check daily quota in CF dashboard if you're adding a high-volume mail path.

MIME encoding in subject lines is a display quirk

Non-ASCII characters in subjects (em-dash, ✅, emoji) show in CF Email Service Activity Log as raw MIME-encoded text (=?utf-8?b?...?=). Recipients decode correctly — this is a CF dashboard display issue only. Observed via CF-ACTIVITY-LOG-MIME-DISPLAY-OBSERVATION (memory).

Sender attribution in Activity Log is by FROM zone, not TO zone

All current sends use noreply@rtopacks.com.au (or hello@, sync@), so they show up in the rtopacks.com.au Activity Log regardless of where the recipient is. The rtopacks.dev zone shows zero sends. Worth knowing when filtering or auditing — see MAIL-SENDER-NORMALISATION-01 (queued) for the env-aware from follow-up.

Apex routing on rtopacks.com.au is currently the prelaunch worker

The prelaunch worker's wildcard route claims rtopacks.com.au/* and www.rtopacks.com.au/*. apps/site has custom-domain bindings on the same hostnames but the prelaunch worker's wildcard route wins for most paths. /api/lead on apps/site is currently reachable only via the workers.dev URL (rtopacks-site.dark-firefly-3289.workers.dev).

This will change when prelaunch retires and the marketing apex becomes apps/site (future DEV-PROD-CUTOVER-NN). Until then, mail-path testing on apps/site goes via workers.dev URL.

Don't touch cf-bounce.<domain> DNS records

Managed by CF Email Service when the zone is onboarded. Modifying or deleting them breaks the authentication chain. If you need to re-onboard a zone, do it via CF dashboard (the Email Service section's "Enable" flow), not direct DNS API edits.

Credential discipline applies

Per ops/credential-discipline.md + feedback_terminal_paste_credential_discipline.md (memory). Wrangler ops authenticate via OAuth-cached token (no CLOUDFLARE_API_TOKEN env var needed). CF API ops use $CF_API_TOKEN env-var indirection — never echo or paste the token value into terminal output that gets captured.

Debug recipe — "why isn't my mail arriving"

  1. Check CF Activity Log for the send. Filter by recipient or subject; default time window is 24h.
  2. If send not visible: check that the worker has [[send_email]] name = "EMAIL" in its wrangler config + that the deploy actually shipped (wrangler deploy output bindings list should include env.EMAIL).
  3. If send Bounced: check that the recipient mailbox exists and is reachable. Workspace @rtopacks.com.au mailboxes should accept; arbitrary external recipients may bounce.
  4. If send Rejected: check sending reputation (Healthy/Degraded/Suspended in CF dashboard). Reputation degradation usually means too many bounces or spam reports.
  5. If send Delivered but not in inbox: check spam folder; check the recipient's Authentication-Results header for dmarc=pass spf=pass dkim=pass. Any failure there is a substrate issue (cf-bounce DNS modified, apex DMARC misconfigured, etc.).

  • notifications.md — NOTIFY-01 sync-completion email pattern (subset of this doc; cron-sync-specific shape).
  • ../ops/architecture-decisions.md — auth substrate (ADR-024/025/027, canonical identity model). Magic-link is the surface where mail and auth meet.
  • ../ops/credential-discipline.md — how Alex handles API tokens (including CF API access for mail-substrate work).
  • ../ops/standing-rules.md §"Operating principles" — CLOUDFLARE-FIRST RULE (substrate decision rationale), EXECUTION-AUTHORITY-LOOSE-USE-01 (who runs credential-touching ops).
  • outputs/time-machine-2026-05-15-evening.md + outputs/phase-4-execution-log-2026-05-16.md — historical record of the migration; commit refs, version IDs, send-fired ledger, brief queue surfaced.
  • EMAIL-SEND-CF-MIGRATION-01 memory file — brief context, the 7 canonical rules surfaced during execution.

Revisit triggers

Update this doc when:

  • A new mail path is added with a new recipient pattern not covered above
  • The substrate changes (e.g., CF Email Service paid-tier upgrade, multi-region considerations, cross-account sending)
  • lib/mail.ts interface changes (new required fields, deprecating purpose, etc.)
  • A new purpose-tag taxonomy is adopted (MAIL-OBSERVABILITY-01 ships)
  • The 4-mailbox architecture changes (real-customer mail starts going to non-Tim addresses)
  • DMARC policy on either zone changes
  • Prelaunch worker retires and apex routing flips to apps/site