Skip to content
Skip to the article
In Email: 6 articles
Email

Deploy the cloudflare-email-relay Worker

Deploy the multi-tenant Cloudflare Email Routing relay Worker with R2, Durable Objects, tenants, secrets, routing rules, the ops API and an end-to-end test.

Updated
Applies to
  • @avunu/cloudflare-email-relay 2.0.0
  • Wrangler 4
Tags
  • email
  • cloudflare
  • workers
  • deployment
Reading time
11 min

This document walks you through deploying cloudflare-email-relay, the Worker that receives mail from Cloudflare Email Routing and pushes it as a signed HTTPS request to the Odoo or Frappe instance that owns the recipient's domain. Use it when you are standing up a new relay, adding a tenant to an existing one, or checking that a deployment works. For the reasoning behind the design, see Email Without SMTP for Business Systems.

How the relay behaves

Knowing the moving parts makes the deployment steps easier to follow.

  • Intake. Email Routing hands the Worker one message per recipient. The Worker looks up the recipient's domain in a tenant table, writes the raw message to R2, and only then queues it.

  • Queue. Each tenant gets its own SQLite-backed Durable Object. Its alarm pushes queued messages to the ERP, oldest first, ten per pass, and retries failures on a schedule.

  • Ops API. The Worker's fetch() handler exposes a small bearer-token API for inspecting and retrying messages.

Because the message is stored before it is pushed, an ERP outage, a wrong secret or a bad deploy delays mail but does not lose it. A recipient whose domain belongs to no tenant, or to a disabled tenant, is rejected during the SMTP conversation. The relay never guesses a tenant.

Outbound mail does not pass through this Worker. Each ERP calls the Cloudflare Email Sending API directly.

Prerequisites

You need:

  • A Cloudflare account with R2 enabled. The relay uses SQLite-backed Durable Objects, which Cloudflare offers on the Workers Free plan as well as Workers Paid. Check Cloudflare's current limits against your mail volume. The Email Sending API that your ERPs use for outbound mail requires Workers Paid, but the relay does not call it.

  • The zone for each mail domain in that same account. Email Routing can only hand mail to a Worker in the account that owns the zone.

  • Node.js and Wrangler 4 (the repository itself builds against Wrangler ^4.146.0).

  • An ERP instance per tenant with its mail-receiving side set up: the mail_cloudflare addon for Odoo, or cloudflare_email_delivery for Frappe. The setup guides are Cloudflare Email for Odoo and Cloudflare Email for Frappe and ERPNext.

Note

The relay needs no nodejs_compat flag. It uses Web APIs only, so leave compatibility_flags out of your config.

Layout of a deployment

The package holds all of the relay logic. A deployment is a small repository of your own with three files per Worker:

index.ts        the wrapper that builds the relay
tenants.json    the committed tenant table
wrangler.jsonc  Worker name, account, R2 bucket, Durable Object binding and migration

The package is @avunu/cloudflare-email-relay, published to GitHub Packages (https://npm.pkg.github.com). Install it in your deployment repository with npm install @avunu/cloudflare-email-relay, after pointing the @avunu npm scope at that registry. This document does not cover registry authentication.

The wrapper

Create index.ts:

import { createRelay } from "@avunu/cloudflare-email-relay";
import tenants from "./tenants.json";

const relay = createRelay({ tenants });

export default relay.handler;
export const { InboxQueue } = relay;

createRelay validates the tenant table when the module loads, so a bad table fails the deploy instead of misrouting mail. The Durable Object class must be re-exported under the exact name InboxQueue, because the Wrangler config binds it by that name.

If several Workers share one R2 bucket, pass keyPrefix to createRelay so their objects do not collide. The default is inbox/, and objects are stored as <keyPrefix><slug>/<id>.eml.

The Wrangler config

Create wrangler.jsonc:

{
  "name": "email-relay",
  "main": "index.ts",
  "account_id": "<ACCOUNT_ID>",
  "compatibility_date": "2026-08-22",
  "observability": { "enabled": true },
  "r2_buckets": [{ "binding": "INBOX", "bucket_name": "email-relay-inbox" }],
  "durable_objects": {
    "bindings": [{ "name": "INBOX_QUEUE", "class_name": "InboxQueue" }]
  },
  "migrations": [{ "tag": "v1", "new_sqlite_classes": ["InboxQueue"] }]
}

Three details matter:

  • The bindings must be named INBOX (R2) and INBOX_QUEUE (Durable Object). The code reads them by those names.

  • The migration uses new_sqlite_classes, not new_classes, because the queue stores its rows in Durable Object SQLite. Keep the v1 tag as it is and do not edit it.

  • There are no vars. Every tunable lives in the tenant table, and everything sensitive is a Worker secret.

Create the bucket and set the ops token

Run these once per Worker.

  1. Create the R2 bucket that the INBOX binding points at:

    npx wrangler r2 bucket create email-relay-inbox
  2. Add a lifecycle rule as a safety net behind the Worker's own purge:

    npx wrangler r2 bucket lifecycle add email-relay-inbox --prefix inbox/ --expire-days 180

    If you set a different keyPrefix in createRelay, use that prefix here instead of inbox/.

  3. Set the ops token. Generate a long random value first; the Worker ignores any token shorter than 32 characters:

    openssl rand -hex 32
    npx wrangler secret put OPS_TOKEN

Warning

The lifecycle rule deletes objects regardless of delivery state. If an object disappears before the queue delivers it, the row is marked dead with the error "stored message missing from R2". Keep the expiry well beyond both the retry window (about seven days on the defaults) and the longest retentionDays you give any tenant. This follows from how the Worker handles a missing object; the 180 days in the example is the repository's suggested value.

Define a tenant

A tenant is one ERP instance. Its configuration is split in two: a public row in tenants.json, and a secret that holds the inbound URL and key.

The tenant table

tenants.json is an array with one object per tenant:

[
  {
    "slug": "example-co",
    "platform": "odoo",
    "domains": ["example.com", "*.example.com"],
    "enabled": true,
    "note": "Example Co, ticket 123",
    "retentionDays": 30,
    "maxAttempts": 32,
    "backoffSeconds": [60, 300, 900, 3600, 21600],
    "deliveryTimeoutSeconds": 30,
    "deliveryDelaySeconds": 0
  }
]

Only slug, platform and domains are required. The other fields show their defaults:

FieldMeaning
slugLowercase letters, digits and hyphens, 1 to 63 characters. It names the queue, the R2 prefix and the secret, so never change it after mail has flowed.
platformodoo or frappe. It is used for documentation and URL validation only; the delivery contract is the same for both.
domainsRecipient domains routed to this tenant, lowercase, internationalized names in punycode. *.example.com matches every subdomain but never example.com itself.
enabledWhen false, mail is rejected at intake with "Recipient domain is disabled on this relay". The tenant's queue stays reachable.
noteFree text up to 200 characters. The code never reads it.
retentionDaysDays a delivered message stays in R2, from 0 to 3650. 0 purges it as soon as the ERP accepts it.
maxAttemptsAttempts before a message is marked dead. 32 is about seven days on the default schedule.
backoffSecondsDelay after each failed attempt. The last value repeats.
deliveryTimeoutSecondsPer-attempt timeout, at most 60.
deliveryDelaySecondsWait before the first attempt. Leave it at 0 in production.

The table as a whole must follow three rules: slugs are unique, each domain belongs to one tenant, and a wildcard may not cover another tenant's domain, exact or wildcard. A table that breaks a rule makes createRelay throw. Because tenants cannot overlap, a message never has two possible owners. Within one tenant, an exact domain is matched before a wildcard.

To validate the table in CI without a Worker runtime, use the package's /tenants entry point, which runs in plain Node:

import { readFileSync } from "node:fs";
import { parseTenantTable } from "@avunu/cloudflare-email-relay/tenants";

parseTenantTable(JSON.parse(readFileSync("tenants.json", "utf8")));

The tenant secret

Each tenant has one Worker secret named TENANT_<SLUG>: the slug in upper case with hyphens turned into underscores. The slug example-co becomes TENANT_EXAMPLE_CO.

The value is a JSON string:

{
  "inboundUrl": "https://erp.example.com/mail_cloudflare/inbound/<KEY>",
  "secret": "<SHARED_SECRET>"
}
  • inboundUrl is the webhook URL your ERP shows you. In Odoo it is the Incoming Mail Server's "Webhook URL". In Frappe it is the Email Account's "Webhook URL". The ERP generates the key embedded in it, so treat the whole URL as a secret.

  • secret is the HMAC key the ERP shows beside the URL. It must be at least 16 characters.

  • accessClientId and accessClientSecret are optional and must be set together. Add them only when the inbound route sits behind a Cloudflare Access policy.

Set it with Wrangler and paste the JSON on one line when prompted:

npx wrangler secret put TENANT_EXAMPLE_CO

The Worker reads each tenant secret fresh on every delivery pass. If a secret is missing or malformed, that tenant's messages stay pending with no attempt charged, and the Worker logs tenant_config_error about once a minute. Intake and every other tenant keep working, and fixing the secret needs no redeploy.

Deploy

From the deployment repository:

npx wrangler deploy

Changing tenants.json (adding a tenant, changing a domain or a tunable) needs a redeploy, because the table is bundled into the Worker. Changing a secret does not.

Route mail to the Worker

In the Cloudflare dashboard, open the zone, then Email Routing, and send the addresses you want to receive (or the catch-all address) to the Worker with the Send to a Worker action, choosing your deployed Worker. Do this for every zone whose domains appear in the tenant table.

Domains that match no tenant are still rejected with the SMTP reply "Recipient domain is not served by this relay", so a routing rule for a domain missing from tenants.json gets mail refused instead of silently dropped.

Use the ops API

The ops API lives on the Worker's workers.dev hostname. GET /health is public and answers {"ok":true}. Every other route needs the token:

curl -H "Authorization: Bearer <OPS_TOKEN>" https://email-relay.<ACCOUNT_SUBDOMAIN>.workers.dev/tenants

If OPS_TOKEN is unset or shorter than 32 characters, every route except /health answers 404. A missing or wrong token gets 401.

RouteWhat it does
GET /tenantsEvery tenant's public config plus counts of pending, delivered, rejected and dead messages.
GET /tenants/<SLUG>The same for one tenant.
GET /tenants/<SLUG>/inbox?status=&limit=The tenant's queue rows, newest first. status is one of the four states. limit defaults to 50 and is capped at 500.
GET /tenants/<SLUG>/inbox/<ID>One row.
GET /tenants/<SLUG>/inbox/<ID>/rawThe stored message as an .eml download.
POST /tenants/<SLUG>/inbox/<ID>/retryRequeue one row. Returns 202, or 409 if it is already pending.
POST /tenants/<SLUG>/inbox/retry?status=deadRequeue every row in that state. status must be dead or rejected.
DELETE /tenants/<SLUG>/inbox/<ID>Remove the row and its stored message.

For another layer of protection, put a Cloudflare Access policy in front of the hostname, or set workers_dev to false and use a custom route behind Access. Neither needs a code change.

Verify a test message end to end

Check the pieces in order, from the Worker outward.

  1. Confirm the Worker is up:

    curl https://email-relay.<ACCOUNT_SUBDOMAIN>.workers.dev/health
    {"ok":true}
  2. Confirm the tenant is in the table and its counts are zero:

    curl -H "Authorization: Bearer <OPS_TOKEN>" https://email-relay.<ACCOUNT_SUBDOMAIN>.workers.dev/tenants/example-co
  3. Send a message from an outside mailbox, such as jane@example.com, to an address on the tenant's domain that your Email Routing rule sends to the Worker.

  4. Watch the logs. The Worker writes JSON log lines (observability is on in the config above), which you can read in the dashboard or with npx wrangler tail. A healthy message produces inbox_enqueued and email_stored events, followed by a delivery_result event with "outcome":"delivered".

  5. Check the queue:

    curl -H "Authorization: Bearer <OPS_TOKEN>" "https://email-relay.<ACCOUNT_SUBDOMAIN>.workers.dev/tenants/example-co/inbox?limit=5"

    The newest row should have status delivered. Then confirm in the ERP that the message arrived as a mail record.

  6. Optionally, route a spare domain's addresses to the Worker without adding that domain to the table, then send one message to it. The message should be refused during the SMTP conversation with the reason "Recipient domain is not served by this relay", which the sender's mail system normally reports back as a bounce. This proves unrouted mail is refused, and you will also see an email_unrouted log line.

Reading the result

How the ERP's answer to each attempt changes the row:

ERP responseRow becomes
Any 2xxdelivered
408, 429, any 5xx, a timeout or a network errorpending, retried on the backoff schedule, then dead when attempts run out
Any other 4xx, or any 3xxrejected, parked for an operator

Redirects are never followed, because that would replay a signed message to an unvetted origin. A rejected row after a first test usually means the wrong secret (often a 401), a wrong or stale key in inboundUrl (often a 404), or an inboundUrl that redirects. Fix the secret, then requeue the row:

curl -X POST -H "Authorization: Bearer <OPS_TOKEN>" "https://email-relay.<ACCOUNT_SUBDOMAIN>.workers.dev/tenants/example-co/inbox/retry?status=rejected"

Test locally first

You can run the relay on your machine against a local ERP before touching Cloudflare. In a checkout of the relay repository:

npm ci
cp .dev.vars.example .dev.vars
npm run dev

Edit TENANT_DEV in .dev.vars so inboundUrl and secret match your local ERP. Then post a sample message to the local Email Routing endpoint:

curl -X POST 'http://localhost:8787/cdn-cgi/local/email?from=jane@example.com&to=support@dev.test' \
  -H 'Content-Type: message/rfc822' --data-binary @test/fixtures/simple.eml

The development table routes dev.test and *.dev.test to the dev tenant.

Sources

This article is in the public domain (CC0 1.0), code samples included. Use it however helps you.