Skip to content

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:

TierDispatch CF nameIsolation inside
Long-lived (prod, staging, dev, …){stack}-{logical}-{env}Scripts keep real env suffix
Ephemeral (wfp.ephemeralEnvPattern, e.g. ^pr-)Collapsed to {stack}-{logical}-ephemeralScripts still {service}-{workspace}-pr-42

Email uses the same collapse, as DNS hostnames:

TierRouting hostnameSending hostnameIsolation inside
prodpost.prod.<zone>notify.prod.<zone>App local-parts on that host
stagingpost.staging.<zone>notify.staging.<zone>same
devpost.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

ts
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.

EnvNamespaceExample addressIngress script
prodpost.prod.aat.exampleuser@acme.post.prod.aat.exampleaat-email_ingress-prod
stagingpost.staging.aat.exampleuser@acme.post.staging.aat.exampleaat-email_ingress-staging
devpost.dev.aat.exampleuser@acme.post.dev.aat.exampleaat-email_ingress-dev
pr-42post.ephemeral.aat.example (shared)pr-42-jane@caa.post.ephemeral.aat.examplecatch-all → durable dump (resolveEnv)
pr-99same sharedpr-99-jane@caa.post.ephemeral.aat.examplesame 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):

  1. Ensure post.ephemeral. + notify.ephemeral. only (not post.pr-42.)
  2. Catch-all unchanged (resolveEnv, e.g. prod dump Worker)
  3. No per-PR CF routing rules required — pr-42-jane@….ephemeral.… falls through to catch-all
  4. 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

DeletedRetained
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)

  1. email.routing.domains[] required — enable via POST …/email/routing/dns { name }
  2. Apex only if listed
  3. No ${tamer:env} in domain name fields
  4. Ephemeral dump label is ephemeral (not preview)
  5. Ephemeral envs only ensure *.ephemeral.* domains
  6. Env-scoped ephemeral-hostname rules must include ${tamer:env} in the local-part. Durable dump rules (preserveOnDestroy + resolveEnv) are exempt; prefer routing.catchAll for *@….ephemeral.…
  7. Catch-all uses resolveEnv (default prod); destroy/gc never retargets it
  8. No dispatchNamespace on rule/catch-all worker targets
  9. 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:

ModeConfigUse when
Unrestricted{ logicalName, binding, remote? } onlyMailbox / product send-from-any (enforce from/to in app code). Needs Workers Paid Sending.
Allowlisted+ allowedSenderAddresses / allowedDestinationAddresses / destinationAddressTransactional / 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

ts
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.

Released under the Tamer Evaluation License.