Email Service
Tamer manages Cloudflare Email Service in-platform: Worker email() + resources.emailSend. Forward-to-mailbox stays in the API as an escape hatch — not the happy path.
Namespace model (same convention as WFP)
WFP dispatch namespaces today:
| Tier | Dispatch CF name | Isolation inside |
|---|---|---|
Long-lived (prod, staging, dev, …) | {stack}-{logical}-{env} | Scripts keep real env suffix |
Ephemeral (wfp.ephemeralEnvPattern, e.g. ^pr-) | Collapsed to {stack}-{logical}-ephemeral | Scripts still {service}-{workspace}-pr-42 |
Email uses the same collapse, as DNS hostnames:
| Tier | Routing hostname | Sending hostname | Isolation inside |
|---|---|---|---|
prod | post.prod.<zone> | notify.prod.<zone> | App local-parts on that host |
staging | post.staging.<zone> | notify.staging.<zone> | same |
dev | post.dev.<zone> | notify.dev.<zone> | same |
Ephemeral (ephemeralEnvPattern) | post.ephemeral.<zone> (shared) | notify.ephemeral.<zone> (shared) | Env in the address local-part + one durable dump Worker (parse + DISPATCH). Optional: ${tamer:env}-…@ CF rules → per-env ingress |
Not used: the word preview in hostnames. The shared dump label is literally ephemeral, matching …-ephemeral dispatch namespaces.
Never: post.pr-42.<zone> (per-branch domain). That is the email equivalent of a new dispatch namespace per PR — forbidden. Domain name fields are literal (no ${tamer:env}).
Pattern source: wfp.ephemeralEnvPattern. If wfp is absent, no env is ephemeral (every env needs its own hostname entries).
Config
export default defineConfig({
stack: "aat",
wfp: {
ephemeralEnvPattern: "^pr-", // same switch as dispatch-namespace sharing
},
email: {
zoneId: "…",
routing: {
domains: [
{
logicalName: "postProd",
name: "post.prod.aat.example",
skipEnvs: ["staging", "dev", "pr-*"],
preserveOnDestroy: true,
},
{
logicalName: "postStaging",
name: "post.staging.aat.example",
skipEnvs: ["prod", "dev", "pr-*"],
preserveOnDestroy: true,
},
{
logicalName: "postDev",
name: "post.dev.aat.example",
skipEnvs: ["prod", "staging", "pr-*"],
preserveOnDestroy: true,
},
{
logicalName: "postEphemeral",
name: "post.ephemeral.aat.example",
skipEnvs: ["prod", "staging", "dev"],
preserveOnDestroy: true,
},
],
catchAll: {
// Shared dump for *@post.ephemeral.… (and other unmatched hosts).
// Pinned — never CLI --env. Deploy email_ingress once from this env.
resolveEnv: "prod",
action: { type: "worker", worker: "email_ingress" },
},
},
sending: {
domains: [
{
logicalName: "notifyProd",
name: "notify.prod.aat.example",
skipEnvs: ["staging", "dev", "pr-*"],
preserveOnDestroy: true,
},
{
logicalName: "notifyEphemeral",
name: "notify.ephemeral.aat.example",
skipEnvs: ["prod", "staging", "dev"],
preserveOnDestroy: true,
},
// … staging / dev notify hosts …
],
},
// Optional: per-env CF literal rules (alt to shared dump). Omit for
// *@….ephemeral.… → catch-all dump Worker (recommended WFP mirror).
// rules: [
// {
// logicalName: "envIngress",
// matcher: {
// type: "literal",
// value: "${tamer:env}-ingress@post.ephemeral.aat.example",
// },
// action: { type: "worker", worker: "email_ingress" },
// skipEnvs: ["prod", "staging", "dev"],
// },
// ],
},
workers: {
email_ingress: defineWorker({
resources: {
emailSend: [
{
logicalName: "transactional",
binding: "EMAIL",
remote: true,
},
],
},
}),
},
});Apex MX is touched only if the apex hostname is listed in routing.domains. There is no implicit empty-body zone enable.
Optional: omit the .prod. label and use post.aat.example as a bare prod brand host — still a literal long-lived entry, not a different mechanism. Default examples use post.prod. so every long-lived env parallels {…}-{env}.
Address matrix
Recommended (shared dump — mirrors WFP): one dump hostname, one durable dump Worker; env only in the address / tenant script name.
| Env | Namespace | Example address | Ingress script |
|---|---|---|---|
prod | post.prod.aat.example | user@acme.post.prod.aat.example | aat-email_ingress-prod |
staging | post.staging.aat.example | user@acme.post.staging.aat.example | aat-email_ingress-staging |
dev | post.dev.aat.example | user@acme.post.dev.aat.example | aat-email_ingress-dev |
pr-42 | post.ephemeral.aat.example (shared) | pr-42-jane@caa.post.ephemeral.aat.example | catch-all → durable dump (resolveEnv) |
pr-99 | same shared | pr-99-jane@caa.post.ephemeral.aat.example | same dump Worker |
Cloudflare Email Routing has a zone-wide catch-all (no *@hostname matcher). Enable caa.post.ephemeral.… (or post.ephemeral.…) as a routing domain; unmatched mail on that host hits routing.catchAll. The dump Worker parses ^(pr-\d+)- (or your ephemeralEnvPattern) from the local-part and DISPATCHER.get("api-{ws}-{env}") into the ephemeral dispatch namespace.
Optional alt: ${tamer:env}-ingress@post.ephemeral.… literal rules → {stack}-email_ingress-{env} per PR (extra CF rule + ingress script churn).
Outbound: notify.{prod\|staging\|dev\|ephemeral}.… matching the env’s tier.
Lifecycle
Path A — long-lived apply
tamer apply --env staging → ensure only *.staging.* Routing/Sending domains; catch-all still → prod ingress (resolveEnv: "prod").
Path B — attach
tamer deploy --env {env} → {stack}-email_ingress-{env} + EMAIL. DISPATCH / mailbox routing behind ingress is consumer code.
Path C — ephemeral spin-up (shared dump)
tamer apply --env pr-42 (matches ephemeralEnvPattern):
- Ensure
post.ephemeral.+notify.ephemeral.only (notpost.pr-42.) - Catch-all unchanged (
resolveEnv, e.g. prod dump Worker) - No per-PR CF routing rules required —
pr-42-jane@….ephemeral.…falls through to catch-all - Deploy / keep the durable dump Worker (bound to the ephemeral dispatch NS); tenant scripts still carry the env suffix
Optional Path C-alt (per-env ingress): prefixed literal rules with ${tamer:env} → {stack}-email_ingress-{env}, then deploy that ingress.
Durable dump rules (when you need a fixed literal on an ephemeral host without ${tamer:env}) require both preserveOnDestroy and resolveEnv — same pin idea as catch-all. Prefer catch-all for true *@….ephemeral.….
Path D — ephemeral tear-down
| Deleted | Retained |
|---|---|
Env-scoped rules for pr-42 (if any) | post.ephemeral. / notify.ephemeral. |
| Per-env ingress scripts (Path C-alt only) | Catch-all → durable dump |
Durable dump rules (preserveOnDestroy + resolveEnv) | |
All *.prod|staging|dev.* domains | |
Other PR’s env-scoped rules (pr-99-…) |
Parallel to dispatch destroy: leave shared …-ephemeral namespace; remove only that env’s scripts / env-scoped rules.
Invariants (fail-closed)
email.routing.domains[]required — enable viaPOST …/email/routing/dns{ name }- Apex only if listed
- No
${tamer:env}in domainnamefields - Ephemeral dump label is
ephemeral(notpreview) - Ephemeral envs only ensure
*.ephemeral.*domains - Env-scoped ephemeral-hostname rules must include
${tamer:env}in the local-part. Durable dump rules (preserveOnDestroy+resolveEnv) are exempt; preferrouting.catchAllfor*@….ephemeral.… - Catch-all uses
resolveEnv(defaultprod); destroy/gc never retargets it - No
dispatchNamespaceon rule/catch-all worker targets - Env destroy deletes that env’s rules only; domains + durable dump rules + catch-all are retained (
preserveOnDestroy)
emailSend bindings
resources.emailSend becomes Wrangler send_email. Restriction fields are optional:
| Mode | Config | Use when |
|---|---|---|
| Unrestricted | { logicalName, binding, remote? } only | Mailbox / product send-from-any (enforce from/to in app code). Needs Workers Paid Sending. |
| Allowlisted | + allowedSenderAddresses / allowedDestinationAddresses / destinationAddress | Transactional / notification Workers with a fixed from or to set |
Unrestricted emits { name: "EMAIL" } — same shape as a mailbox platform’s unrestricted sender binding. Prefer allowlists unless the product truly needs open send.
Status readiness
tamer status --env <env> (non-local) live-inspects the zone:
- routing — Email Routing enabled + CF status
- catch-all — enabled and pointed at the Worker from
catchAll.resolveEnv - sending — desired hostnames for this env enabled live
State rows still print underneath; live mismatches show as hints (not enabled on Cloudflare, catch-all [mismatch], etc.). Soft-fails when the token lacks a scope — status still prints state-only rows.
Worker code
export default {
async fetch(request, env) {
await env.EMAIL.send({
to: "user@example.com",
from: "noreply@notify.prod.aat.example",
subject: "Hello",
text: "Hi",
});
return new Response("ok");
},
async email(message, env) {
// inbound → DISPATCH / product logic is yours
},
};Building a shared mailbox product on top of this? See Mailbox platform.
Token permissions
CLOUDFLARE_API_TOKEN needs Email Routing + Email Sending scopes, including Zone Settings Edit for Routing DNS enable. Unrestricted outbound Sending requires Workers Paid / Sending entitlement. Apply surfaces remapped errors when CF rejects for plan or authentication.