Skip to content

Receive email: MX to mailbox, end to end

The full inbound path — MX records, Email Routing, the worker handler, the queue, and the mail screen — plus the one thing Cloudflare will not let the product read back.

ReceivingIntermediate12 min readUpdated

01The whole path, in one diagram

Five hops from the sending server to the screen. Each one is observable, each one can be the thing that is wrong, and knowing which is which turns “inbound is broken” into a question with an answer.

TL;DR

Everything cheap happens at hop three, inside an open SMTP conversation. Everything expensive happens at hop four, where the CPU budget is 60 seconds.

DNS
1. MX record
Published by Cloudflare
CLOUDFLARE
2. Email Routing
A rule matches the recipient
WORKERS
3. email() handler
Check, stream, enqueue
QUEUES
4. ms-inbound
Parse, thread, score
STORAGE
5. R2, D1, /app/mail
Your bucket, your database
HopWhat happens there
MX recordPublished by Cloudflare itself when you enable Email Routing on the zone. Nothing to copy.
Email RoutingA rule matches the recipient and sends the message to a Worker. SPF, DKIM and DMARC are already evaluated here.
email() handlerThree things only: does the recipient exist, stream the raw bytes to R2, enqueue. A slow handler defers real mail.
ms-inbound queueParsing, attachments, threading, spam scoring — where the CPU budget is 60 seconds instead of a few milliseconds of goodwill.
R2, D1 and /app/mailRaw message and attachments in your bucket, headers and threads in your database, the thread list on screen.

One thing has to be captured at hop three and cannot be recovered later: Authentication-Results. Cloudflare has already run SPF, DKIM and DMARC by the time your handler is called, and it records the verdicts in that header. The connecting IP is gone by hop four, so re-deriving the SPF result there is not merely expensive, it is impossible. The three verdicts are read at the door and carried on the queue message, and they end up as three columns on every inbound row — which is what lets you filter a support inbox by “DMARC failed” rather than guessing from the display name.

02MX records and routing rules

You do not publish MX records by hand. Enabling Email Routing on a Cloudflare zone makes Cloudflare publish its own MX records into the zone it already controls — three of them, at the apex. What you configure is the rule that decides what happens to a message once it has arrived.

TL;DR

In the Cloudflare dashboard: Email → Email Routing, enable it, then add a rule whose action is Send to a Worker and whose script is this instance.

Cloudflare’s catch-all rule
  • Lives in the zone, configured in the Cloudflare dashboard
  • Decides whether a message for an arbitrary address is handed to your Worker at all
  • For most deployments this is the rule you want, because the alternative is maintaining the same address list in two systems and discovering the drift when a message vanishes
A mailbox’s catch-all flag
  • Lives in this instance, on one mailbox
  • Decides whether an address with no exact mailbox is accepted once it gets here
  • Only one mailbox per domain may hold it, enforced by a partial unique index in the database rather than by the UI — two catch-alls would make delivery depend on row order
Inside the instanceRouted to the Worker?What the sender gets
An exact-address mailbox existsYesDelivered to that mailbox. Exact address always wins.
No exact match, one mailbox holds the catch-all flagYesDelivered to the catch-all mailbox.
NeitherYes550 5.1.1 No such mailbox — which is why the first message anybody sends to a freshly routed domain bounces if there is no mailbox behind it.
Irrelevant — no routing ruleNoThe message never reaches the Worker at all.

Exact address first, then the domain’s catch-all. That order is not arbitrary: a mailbox with its own forwarding webhook, its own agent flag and its own threads must keep receiving its own mail even when a catch-all exists beside it.

03Mailboxes and who can read them

A mailbox is an address plus a small amount of policy. It is the unit that owns threads, the unit a webhook is attached to, and the unit an agent is or is not allowed to touch — which is why support@ and agent@ should be two mailboxes rather than one with a filter.

TL;DR

“Which mail can a model see” is a question about which mailboxes have the agent flag, answered once, in a place you can audit, rather than in a prompt. The flag defaults to off.

POST /v1/inbound/mailboxes
{
  "address": "[email protected]",
  "name": "Support",
  "is_catch_all": true,
  "agent_enabled": false   // an MCP agent cannot see this mailbox
}

The agent flag is a scope boundary, not a preference. The MCP server reads mail through the same mailboxes you do. An agent inbox on its own address is inspectable, revocable and separable in the logs; an agent given read access to your whole support queue is none of those things.

On every inbound rowWhat it holds
from_address, to_addressesThe envelope as it arrived.
subject, snippetWhat the thread list shows without opening anything.
parse_statusWhether the MIME parse on the queue consumer succeeded.
matched_byWhich rule matched — the exact address, or the catch-all.
spf, dkim, dmarcThe three edge verdicts, captured at hop three.
spam_scoreScored on the queue consumer, where there is CPU budget for it.
raw_key, body_keyTwo R2 keys: the raw RFC 5322 bytes exactly as they arrived, and the extracted body. Both in your own bucket.

04How replies get threaded

When MailySend sends a message that expects a reply, the reply-to address carries a signed token: reply+<token>@inbound.yourdomain.com. When the reply comes back, that token names the thread it belongs to, with no heuristics involved.

TL;DR

The token is the workspace id and the thread id, base64url encoded, with a truncated HMAC over that payload appended — so verification is a hash and a timing-safe comparison, with no database lookup.

To:       [email protected]
From:     [email protected]
In-Reply-To: <[email protected]>
Reply-To: reply+eyJ3cyI…[email protected]   ← the signal

This is a higher-confidence signal than References, and the reason is behavioural rather than theoretical. Mail clients mangle References: they truncate it, drop it on a forward, rebuild it wrongly after an edit, and rewrite subjects with localised Re: prefixes that a naive normaliser will not match. What clients reliably preserve is the address they were told to reply to — that is the one field the entire user interface is built around. There is also no way to point a reply at somebody else’s thread by editing the address, because a forged token fails the signature check.

FIRST
The signed token
THEN
In-Reply-To
THEN
Normalised subject + participants
ELSE
A new thread

05The one thing that cannot be shown

There is one piece of this setup the dashboard cannot show you, and rather than inventing a plausible indicator it links you to the place where the real value lives. Cloudflare’s Email Routing catch-all rule is not readable over its API. Not rate-limited, not awkward — not exposed.

TL;DR

So the receiving panel says what it measured, says what it did not, and puts a button straight to the Email Routing page for the one thing you have to confirm with your own eyes.

FactCan the dashboard know?
MX points at CloudflareYes — a DNS lookup
A mailbox exists on this domainYes — its own database
A mailbox holds the catch-all flagYes — its own database
Email Routing’s catch-all rule sends to this WorkerNo — not exposed by the API

The tempting design is a green tick that means “MX verified” and lets the reader infer the rest. That is the version this product refuses to ship. A verified MX is not a working setup, and an indicator that reads like one converts a five-minute configuration check into an afternoon of debugging a system that was telling you it was fine.

A MIRROR YOU CANNOT REFRESH IS WORSE THAN NO MIRROR
Storing the setting locally when the operator ticks a box here would produce a value that is right on the day it is written and silently wrong forever after — somebody changes the rule in Cloudflare, the copy here keeps saying what it said last year, and now the dashboard is actively lying rather than merely quiet. The general principle runs through the whole product: state that lives in someone else’s system and cannot be read back is not mirrored, it is linked.
01
Open Email Routing

In the Cloudflare dashboard for this zone. This is the one piece of state the product cannot read for you.

02
Confirm the catch-all rule

Its action must be Send to a Worker, and the script must be this instance.

03
Send yourself a message

Then watch it appear in the mail screen. About thirty seconds, and it is the only test that covers every hop.

What just happened

Mail addressed to your domain now lands in a mailbox you can read, with the raw bytes and any attachments in your own R2 bucket and the edge’s SPF, DKIM and DMARC verdicts recorded against each message. The thing most likely to bite you is that receiving needs two things to be true in two different places — a Cloudflare routing rule pointing at this Worker, and an address that exists inside the instance. Get one without the other and the first test message bounces with a 550, which looks like a broken deployment and is not one.

Common questions

Read next

YOUR ACCOUNT, YOUR MAIL

Nothing to sign up for. Just deploy it.

Every guide on this site describes software you run yourself.