Mailbox platform (consumer patterns)
How to build a shared mailbox / email product on top of Tamer-managed Cloudflare Email Service. Tamer owns env namespaces, Routing/Sending domains, catch-all, and deploy; your Worker owns addressing, storage, and product policy.
This is the pattern used by mailbox apps (e.g. HQBase-style / AAT): one ingress, app-level aliases, unrestricted send with policy in code.
What Tamer owns vs what you own
| Layer | Owner |
|---|---|
post.{env|ephemeral}.… / notify.… hostnames | Tamer email config |
| Catch-all → durable dump / ingress Worker | Tamer routing.catchAll (resolveEnv) |
| Optional prefixed ephemeral rules | Tamer email.rules |
Wrangler send_email binding | Tamer resources.emailSend |
| Alias → mailbox resolution | Your app (D1 / DO / etc.) |
| Ephemeral env parse + DISPATCH | Your app |
| Unknown recipient policy | Your app |
| MIME parse, raw store, dedupe | Your app |
| From/to allow policy for open send | Your app |
Do not create a Cloudflare Routing rule per mailbox alias. That fights ephemeral envs and turns every user signup into an IaC apply.
Recommended architecture (shared ephemeral dump)
Mirror WFP: one dump hostname, one durable dump Worker, env in the address / tenant script name — not a new CF rule or ingress Worker per PR.
Internet mail
→ MX on post.{env|ephemeral}.… (e.g. caa.post.ephemeral.…)
→ Email Routing (literal rules + zone catch-all)
→ durable account Worker `email_ingress` (`email()` handler)
→ if hostname is *.ephemeral.*: parse ^(pr-\d+)- from local-part
→ DISPATCHER.get("api-{ws}-{env}")
→ else: resolve mailbox in your DB / prod policyCloudflare’s catch-all is zone-wide (there is no *@hostname matcher). Enable the ephemeral routing hostname; unmatched addresses such as pr-42-jane@caa.post.ephemeral.… hit routing.catchAll.
1. Catch-all → one durable dump Worker
Pin catch-all with resolveEnv (never CLI --env). Deploy that Worker from the pinned env; bind it to the ephemeral dispatch namespace (and prod if the same Worker also handles long-lived hosts).
email: {
zoneId: "…",
routing: {
domains: [/* post.prod / post.ephemeral — see Email Service guide */],
catchAll: {
resolveEnv: "prod",
action: { type: "worker", worker: "email_ingress" },
},
},
// Prefer no per-PR rules — addresses fall through to catch-all.
}Optional: a durable dump rule on an ephemeral hostname without ${tamer:env} needs both preserveOnDestroy and resolveEnv (same pin idea). Still prefer catch-all for true *@….ephemeral.… delivery.
2. App-level mailbox + ephemeral routing
In email(message, env):
- Normalize
message.to(envelope recipient). - If hostname matches
*.ephemeral.*(or your dump host):- Parse env from the local-part (
^(pr-\d+)-/ your pattern). env.DISPATCHER.get(\api-${workspace}-${env}`)` (or your RPC) into the tenant script in the ephemeral dispatch NS.
- Parse env from the local-part (
- Else look up mailbox / workspace by address in D1 (or equivalent).
- Known → deliver to that mailbox’s inbox.
- Unknown → product catch-all policy (folder, reject, or admin mailbox). Store the policy on the domain row in your DB — not as CF rule churn.
Keep today’s address shape: pr-42-jane@caa.post.ephemeral.….
3. Unrestricted emailSend for mailbox products
resources: {
emailSend: [
{ logicalName: "mail", binding: "MAIL_SENDER", remote: true },
],
}No allowed_* fields → Wrangler { name: "MAIL_SENDER" }. Enforce sender domains / recipient policy in application code. Use allowlisted bindings for transactional-only Workers (password reset, billing) — see Email Service.
4. Parse once; store raw + structured
- Persist the raw message (R2 / object store) for audit and reprocess.
- Persist a parsed projection (headers, text/html, attachment metadata) for UI and search.
- Dedupe on
(Message-ID, envelope-recipient)so CF retries do not double-file.
5. Domain catch-all policy in the app DB
Per custom domain / workspace: reject | store | route_to_mailbox. Evaluate after CF has already delivered to your ingress — CF catch-all is delivery plumbing; product catch-all is UX.
6. DISPATCH behind ingress (required for WFP tenants)
Email Routing cannot target Workers for Platforms dispatch scripts. Keep an account dump / ingress Worker, then env.DISPATCH.get(scriptName).fetch(…) (or your own RPC) into the tenant Worker. Tamer rejects dispatchNamespace on routing actions for this reason.
7. Onboarding / readiness UX
If you ship a “connect domain” wizard, mirror Tamer’s status inspect:
- Routing enabled + DNS ready
- Catch-all → your dump / ingress script
- Sending subdomain enabled for the brand hostname
tamer status already prints this bundle for operators. Product UIs can call the same Cloudflare APIs (or a thin admin Worker) with the same step list and resumable errors (Workers Paid, Zone Settings Edit).
Env matrix (reuse Tamer namespaces)
| Env | Inbound host | Isolation |
|---|---|---|
prod / staging / dev | post.{env}.… | Host + ingress script |
pr-* (ephemeral) | post.ephemeral.… (shared) | Local-part env prefix + durable dump Worker → DISPATCH |
Tenant aliases like user@acme.post.prod.… stay in your DB. Only use ${tamer:env}-…@ CF literal rules when you intentionally want a per-env ingress Worker (Path C-alt) — not for per-user mailbox addresses.
See also
- Email Service — Tamer config, lifecycle Paths A–D, invariants
- Single-Product Multi-Tenant — WFP / DISPATCH