A multi-tenant Cloudflare Email Routing Worker that turns inbound mail into signed HTTPS pushes to the ERP that owns the recipient's domain — an Odoo database running mail_cloudflare or a Frappe site running cloudflare_email_delivery. Every message is stored in R2 before anything is pushed, and each tenant has its own durable retry queue, so an ERP outage, a wrong secret or a bad deploy can delay a message but never lose it.
This package holds all of the logic. Deployments live in a separate fleet repository that supplies, per Worker, a wrangler.jsonc, a committed tenants.json and one secret per tenant — see Deployment.
Outbound mail does not pass through here: each ERP calls the Cloudflare Email Sending API directly.
| Term | Meaning |
|---|---|
| worker | One deployment: one wrangler.jsonc, one R2 bucket, one ops token. A shared relay serves many tenants; a dedicated worker serves one. |
| tenant | One ERP instance. A slug, a platform (odoo or frappe), the domains routed to it, its tunables — and, as a secret, its inbound URL and key. |
| domain | The routing key: the domain of the envelope recipient. Exact (acme.example) or a wildcard for every subdomain (*.acme.example). |
A recipient whose domain belongs to no tenant, or to a disabled one, is rejected at SMTP time with a fixed reason — the relay never guesses a tenant and never drops mail silently.
import { createRelay } from "@avunu/cloudflare-email-relay";
import tenants from "./tenants.json";
const relay = createRelay({ tenants });
export default relay.handler; // email() + fetch()
export const { InboxQueue } = relay; // the Durable Object, bound to this tablecreateRelay({ tenants, keyPrefix? }) validates the table (a bad table throws, so it fails the deploy) and returns the handler plus the InboxQueue class the wrapper must re-export under that exact name for wrangler.jsonc to bind. Also exported: the zod schemas and helpers (TenantSchema, TenantTableSchema, TenantSecretSchema, parseTenantTable, parseTenantSecret, secretNameFor, SLUG_PATTERN, DOMAIN_PATTERN), TenantRegistry, the record types and VERSION. The schemas alone are available from @avunu/cloudflare-email-relay/tenants, which runs in plain Node so a fleet's CI can validate its tables with the Worker's own rules.
email()— Email Routing hands over one message for one recipient. The envelope is validated (no control characters, one header line each), the recipient's domain is looked up in the table, and the message is streamed into R2 asinbox/<tenant>/<ulid>.emlwithDelivered-To:andReturn-Path:prepended exactly as a delivering MTA would write them. Only then is a row inserted into the tenant'sInboxQueue. A store failure is rethrown, so Cloudflare answers the sending MTA with a temporary failure and it retries.InboxQueue— one SQLite-backed Durable Object per tenant (idFromName(slug)). Its alarm delivers due rows oldest first, ten per pass, reads the tenant's secret fresh each pass, and applies the outcome in a singleUPDATE … WHERE status = 'pending'so an operator action racing an in-flight attempt resolves in the database. Delivered rows are purged from R2 and the table after the tenant'sretentionDays.fetch()— the ops API, per tenant, behind one bearer token.
Each attempt is POST <tenant's inboundUrl> with the stored message as the body, redirect: manual.
| Header | Value |
|---|---|
| Content-Type | message/rfc822 |
| X-Email-Relay-Id | the row's ULID (stable across attempts — the ERP may deduplicate on it) |
| X-Email-Relay-Tenant | the tenant slug |
| X-Email-Relay-Timestamp | Unix seconds, fresh per attempt |
| X-Email-Relay-Signature | v1= + hex HMAC-SHA256(secret, "." + body bytes) |
| X-Email-Relay-Envelope-From / -To | the SMTP envelope (percent-encoded if not printable ASCII; From is empty for a bounce) |
| X-Email-Relay-Attempt | 1-based attempt number |
| User-Agent | cloudflare-email-relay/ |
| CF-Access-Client-Id / -Secret | only when the tenant's secret carries an Access service token |
The body already contains Delivered-To (the envelope recipient — the ERP should check its domain is one it owns) and Return-Path (<> for a bounce) as its first two header lines.
Verification, as both ERP integrations do it:
expected = hmac.new(secret.encode(), f"{timestamp}.".encode() + body, hashlib.sha256).hexdigest()
ok = hmac.compare_digest(signature, f"v1={expected}") and abs(time.time() - int(timestamp)) <= 300Sign the timestamp string exactly as received. The cross-implementation vector every side pins: HMAC("key", 1700000000, b"hello") → v1=4d583a269f4f276a3fa80ff31b5a01879a848096983222a17893d198418939aa.
Replay protection is the timestamp window; within it a replayed request is a no-op because both ERPs deduplicate on the message's own Message-ID. The v1= prefix exists so a v2= that also signs the relay id can be introduced without a flag day.
The ERP answers with JSON — {"ok": true, "remote_ref": "<record id or name>" | null, "id": "<relay id>"} on success, {"ok": false, "error": "<one line>"} otherwise. Frappe's {"message": …} wrapper and {"exception": …} error shape are understood, as are the pre-contract thread_id (Odoo) and communication (Frappe) fields.
| ERP responds | Row becomes |
|---|---|
| 2xx | delivered — remoteRef recorded when present |
| 408, 429, 5xx, timeout, network error | pending — retried on the schedule; dead once attempts run out |
| any other 4xx, or a 3xx (redirects are never followed) | rejected — parked for an operator |
The ERP-side URLs the fleet expects: Odoo https://<odoo>/mail_cloudflare/inbound/<key>; Frappe https://<site>/api/method/cloudflare_email_delivery.api.inbound?key=<key>. Both embed a per-server key generated by the ERP, so the URL itself is a secret.
The committed half, one row per tenant in the fleet's tenants.json:
Table-wide rules: slugs unique; every domain claimed by one tenant; a wildcard may not cover a domain (exact or wildcard) of another tenant. A table that breaks a rule fails createRelay().
The secret half, one Worker secret per tenant named TENANT_<SLUG> (acme-eu → TENANT_ACME_EU), holding a JSON string:
{
"inboundUrl": "https://erp.acme.example/mail_cloudflare/inbound/<key>",
"secret": "<≥16 chars>",
"accessClientId": "…",
"accessClientSecret": "…"
}accessClientId/accessClientSecret are optional, both or neither. The secret is read on every alarm pass: a missing or malformed one parks that tenant's rows as pending with no attempt charged and logs tenant_config_error once a minute until it is fixed — intake and every other tenant carry on. Fixing the secret needs no redeploy.
GET /health is public. Everything else needs Authorization: Bearer <OPS_TOKEN> (≥ 32 characters; while unset or shorter, every ops route answers 404). One token per Worker — tenants never receive it.
| Route | Effect |
|---|---|
| GET /tenants | every tenant's public config plus {pending, delivered, rejected, dead} counts |
| GET /tenants/:slug | one tenant |
| GET /tenants/:slug/inbox?status=&limit= | its rows, newest first (limit ≤ 500, default 50) |
| GET /tenants/:slug/inbox/:id · …/:id/raw | one row · the stored .eml |
| POST /tenants/:slug/inbox/:id/retry | requeue one row: 202, or 409 when it is already pending |
| POST /tenants/:slug/inbox/retry?status=dead|rejected | requeue every row in that state |
| DELETE /tenants/:slug/inbox/:id | drop the row and its object |
The ops API lives on the Worker's workers.dev hostname. For an extra layer, put an Access policy in front of it (an Access application on that hostname, or workers_dev: false plus a custom route behind Access) — no code change is involved.
A Worker is three files in the fleet:
index.ts the six-line wrapper above
tenants.json the committed table
wrangler.jsonc name, account_id, the R2 bucket, the Durable Object binding and its migration
{
"name": "email-relay",
"main": "index.ts",
"account_id": "<the account that owns the zones>",
"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"] }],
}Then, once per Worker: wrangler r2 bucket create <bucket>, an R2 lifecycle rule as a safety net for the purge (wrangler r2 bucket lifecycle add <bucket> --prefix inbox/ --expire-days 180), wrangler secret put OPS_TOKEN, and wrangler deploy. Once per tenant: wrangler secret put TENANT_<SLUG> with the JSON above, and in the zone's Email Routing the addresses (or the catch-all) → Send to a Worker → this Worker. Email Routing can only hand mail to a Worker in the same Cloudflare account as the zone, which is what decides between the shared relay and a dedicated Worker for a client that owns its account.
Requires the Workers Paid plan (Durable Objects). No nodejs_compat: the relay uses Web APIs only.
npm ci
npm run check # oxfmt + oxlint (incl. type-aware) + tsc, unit and integration configs
npm test # unit (Node) + integration (workerd: R2, Durable Object, alarm, ops API)
cp .dev.vars.example .dev.vars # then edit TENANT_DEV to point at a local ERP
npm run dev # wrangler dev with dev/tenants.json
curl -X POST 'http://localhost:8787/cdn-cgi/local/email?from=alice@example.com&to=support@dev.test' \
-H 'Content-Type: message/rfc822' --data-binary @test/fixtures/simple.emlThe integration suites run the relay against a fake Odoo and a fake Frappe with four fixture tenants (alpha odoo, beta frappe, gamma disabled, delta with a broken secret); see test/integration/outbound.ts.
Conventional Commits on main drive release-please; merging the release PR tags v<version> and publishes to GitHub Packages. src/version.ts carries the version the relay sends as its User-Agent.
{ "slug": "acme", // [a-z0-9-], ≤ 63 chars; immutable — it names the queue, the R2 prefix and the secret "platform": "odoo", // or "frappe"; documentation and URL validation only, the contract is identical "domains": ["acme.example", "*.acme.example"], // lowercase A-labels; a wildcard never matches its apex "enabled": true, // false: reject at intake ("Recipient domain is disabled on this relay") "note": "Acme Co, ticket 123", // free text, never read "retentionDays": 30, // 0 = purge on delivery "maxAttempts": 32, // ≈ 7 days on the default schedule "backoffSeconds": [60, 300, 900, 3600, 21600], // last value repeats "deliveryTimeoutSeconds": 30, // ≤ 60 "deliveryDelaySeconds": 0, }