Cloudflare Email Routing can hand every email sent to your domain to a Worker. It's free, there's no mail server to run, and no IMAP polling. Combine a catch-all rule with an email handler and you get something genuinely useful: unlimited inbound addresses on your domain, each of which turns mail into a JSON POST against whatever HTTP pipeline you already run.
We shipped exactly this for AnyHook (every endpoint is now also an email address), and most of the hour it took went into three design decisions rather than code.
The setup
- Enable Email Routing on your zone. Cloudflare adds the MX records, and existing named rules like
hello@keep working, since named rules take precedence over the catch-all - Create a catch-all rule with the action Send to a Worker
- Export an
emailhandler next to yourfetchhandler:
export default {
async fetch(request, env, ctx) { /* your existing worker */ },
async email(message, env, ctx) { /* new */ },
};
The handler receives a ForwardableEmailMessage: from, to, headers, rawSize, a raw stream of the complete MIME message, and two verbs, forward() and setReject().
Decision 1: the address is your router
A catch-all receives mail for every local part, so the address itself can carry routing information. Ours encode a tenant and an app:
{user-slug}.{app-slug}@anyhook.net
The parsing rule that makes this safe is to split on the last dot, and it's only safe because slugs are sanitised to [a-z0-9-] at creation. They can never contain a dot, so the split is unambiguous by construction rather than by convention. If your identifiers can contain dots, pick a separator they can't contain, or this router misroutes.
export function parseInboxAddress(to: string) {
const local = (to.split("@")[0] ?? "").toLowerCase();
const dot = local.lastIndexOf(".");
if (dot <= 0 || dot === local.length - 1) return null;
const userSlug = local.slice(0, dot);
const appSlug = local.slice(dot + 1);
if (!/^[a-z0-9-]+$/.test(userSlug) || !/^[a-z0-9-]+$/.test(appSlug)) return null;
return { userSlug, appSlug };
}
Lowercase first; senders do not preserve local-part case. And keep this a pure function: it's the branchy part, which makes it the testable part. Ours has eight tests (case, hyphens, leading/trailing dots, +tag, underscores, path traversal, empty local part). The glue around it has none, because the glue isn't branchy.
Decision 2: reject at SMTP, never swallow
A catch-all is a magnet for every probe aimed at your domain. The tempting move is to silently drop whatever doesn't parse. Don't, because a sender who gets no error assumes delivery. For a typo'd address, that's a human waiting for a reply that never comes.
const parsed = parseInboxAddress(message.to);
if (!parsed) {
message.setReject("Address must be {user}.{app}@example.com");
return;
}
setReject bounces at SMTP time, so the sending server generates a proper failure notice. Same for oversized mail, and check rawSize before buffering the stream, not after:
if (message.rawSize > MAX_BYTES) {
message.setReject(`Message exceeds ${MAX_BYTES} bytes`);
return;
}
Inbound messages can be up to 25 MiB. Finding out a message is too big by loading it into Worker memory is the wrong order.
Decision 3: don't build a second pipeline
The email handler could parse MIME, apply quotas, write to the database, and enqueue delivery, duplicating everything your HTTP path already does. That's two pipelines that drift apart forever.
Instead, repackage the message as the HTTP request it would have been, and call your own fetch handler:
export async function handleEmail(message, env, ctx) {
const parsed = parseInboxAddress(message.to);
if (!parsed) { message.setReject("…"); return; }
const raw = await new Response(message.raw).text();
const req = new Request(
`https://in.example.com/${parsed.userSlug}/${parsed.appSlug}`,
{
method: "POST",
headers: {
"content-type": "application/json",
"x-transport": "email",
},
body: JSON.stringify({
type: "email.received",
from: message.from,
to: message.to,
subject: message.headers.get("subject"),
message_id: message.headers.get("message-id"),
size: message.rawSize,
raw,
}),
}
);
const res = await handler.fetch(req, env, ctx);
if (!res.ok && res.status !== 202) {
message.setReject(`Rejected (${res.status})`);
}
}
Everything downstream (authentication of the route, rate limits, quotas, payload caps, queueing, retries, logging) is the same code path your webhooks take. One pipeline, two transports. When the HTTP path gains a feature, email has it too, automatically. The x-transport: email header is the only thing that distinguishes them, for UIs that want to render an envelope instead of a payload.
Note the last four lines: if your own pipeline rejects the event (quota exceeded, app paused), propagate that as an SMTP rejection too. The bounce message is your error reporting channel to the sender.
Things to know before you rely on it
- Ship the whole
rawstring downstream and parse it there with a real MIME library. The convenience fields (subject,from) are for routing and display; attachments, HTML parts, and DKIM headers live inraw - Named rules survive. A catch-all only sees mail no named rule claims, so an existing
hello@forward is untouched - The Worker sees post-Cloudflare headers. ARC and authentication results are stamped by the receiving edge before your code runs, which is useful signal, free of charge
- Free-plan CPU limits apply to email handlers like any Worker. The repackaging pattern keeps the handler thin, which helps
If you'd rather not run this yourself, this exact pattern is what AnyHook hosts: one keyless API call returns an endpoint that is simultaneously a webhook URL and an email address. Both feed one event log you can read over HTTP or MCP.