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:
- The substrate — what underlies every send + why
- The wrapper (
lib/mail.ts) — the per-worker abstraction + thepurposefield as forcing function - Recipient patterns — admin-routing, submitter-email-with-dev-guard, hardcoded
- How to add a new mail path / new worker
- 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 integration —
env.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 toMAIL-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-02 — adminEmail 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¶
- 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. - Pick a recipient pattern — admin-routing (most common), submitter-with-dev-guard (for customer-facing confirmations), or hardcoded (utility workers only).
- Compose
from—<Display Name> <noreply@rtopacks.com.au>is the dominant pattern.hello@rtopacks.com.auis used for warm-tone customer confirmations. Don't introduce a third domain. - 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);
}
- 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¶
- Declare the
send_emailbinding in the worker's wrangler config. For workers/ utility workers (wrangler.toml): For apps/ Next.js workers (wrangler.jsonc), declare at top-level + repeat underenv.staging:The binding is intentionally unrestricted (no"send_email": [ { "name": "EMAIL" } ], "env": { "staging": { "send_email": [ { "name": "EMAIL" } ] } }destination_addressfield) — 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). - Copy a
lib/mail.ts(orsrc/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). - If the worker is dual-env and uses admin-routing, copy the
env-urls.tsresolver pattern fromapps/site/app/lib/env-urls.ts(or similar) and add anadminEmailkey matching the env values. - Follow "Inside an existing worker" above.
What to verify after adding¶
npx wrangler deploysucceeds and the bindings list output includesenv.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=passin 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"¶
- Check CF Activity Log for the send. Filter by recipient or subject; default time window is 24h.
- If send not visible: check that the worker has
[[send_email]] name = "EMAIL"in its wrangler config + that the deploy actually shipped (wrangler deployoutput bindings list should includeenv.EMAIL). - If send Bounced: check that the recipient mailbox exists and is reachable. Workspace
@rtopacks.com.aumailboxes should accept; arbitrary external recipients may bounce. - If send Rejected: check sending reputation (Healthy/Degraded/Suspended in CF dashboard). Reputation degradation usually means too many bounces or spam reports.
- If send Delivered but not in inbox: check spam folder; check the recipient's
Authentication-Resultsheader fordmarc=pass spf=pass dkim=pass. Any failure there is a substrate issue (cf-bounce DNS modified, apex DMARC misconfigured, etc.).
Related material¶
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-01memory 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.tsinterface changes (new required fields, deprecatingpurpose, 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