Four things Cloudflare's email handler did that I didn't expect
Send one message to two addresses that belong to the same mailbox, and your email() handler runs twice. Same Message-ID, same body, same sender. Only message.to differs. I'm building a small open-source mail app, still
Send one message to two addresses that belong to the same mailbox, and your email() handler runs twice. Same Message-ID, same body, same sender. Only message.to differs.
I'm building a small open-source mail app, still in early development, that runs on Cloudflare Workers. Mail comes in through Email Routing's email() handler and goes out through the Email Service send_email binding. Unit tests with made-up messages told me nothing about what follows. Real mail did.
1. A message to two addresses arrives twice
Email Routing hands each recipient its own copy. That fits SMTP, where each RCPT TO is its own delivery, but I had been thinking of "a message" as one thing.
The handler is also at-least-once: a slow handler is cancelled and run again, and a sender retries after a temporary refusal. So I already had a rule that the same message twice is kept once. The key is a hash of the mailbox and the Message-ID, From, Date and Subject, and when two messages share a key I compare the bodies too, so that someone who guesses a message's headers can't get a real one dropped.
That rule throws the second copy away, and with it the fact that the mail also came to the second address. In my app an address can belong to an AI agent, so "which address did this come to" decides whose mail it is. An agent waiting for mail to its own address would never have found it.
So the copy that gets dropped first adds its recipient to the one that was kept. Simplified from durable-object.ts:
const COPIES_WITHIN_MS = 60 * 60 * 1000;
#cameAlso(kept: Copy & { id: string }, copy: Copy) {
if (copy.verifiedDomain !== kept.verifiedDomain) return;
if (Date.now() - kept.arrivedAt > COPIES_WITHIN_MS) return;
const had = recipientsOf(kept.envelopeTo);
const to = [...new Set([...had, ...recipientsOf(copy.envelopeTo)])];
const cameFor = kept.cameFor ?? copy.cameFor;
if (to.length === had.length && cameFor === kept.cameFor) return;
this.ctx.storage.sql.exec(
"UPDATE message SET envelope_to = ?, came_for = ? WHERE id = ?",
to.join(", "),
cameFor,
kept.id,
);
}
Two guards matter. The copy has to be proven as the kept one was (same verified sender domain), and it has to arrive within an hour. Without them, someone could send an old message again, word for word, and make it look like it came to an agent's address. A retry of the same copy changes nothing, because its recipient is already in the list. The test sends one message to both addresses, then a second pair where one copy fails DMARC, and checks that the second pair doesn't merge.
2. The binding sent my Base64 as the file
The Email Service binding types an attachment's content as string | ArrayBuffer | ArrayBufferView, with the comment "Base64 string or binary content", and Cloudflare's example for attachments passes a Base64 string. My outgoing mail is stored as Base64, so I passed that.
On staging, every file arrived as its own Base64 text. Measured on 2026-11-09, the binding sends a string's characters as the file's bytes and doesn't decode it. The fix is to hand it bytes:
export function bytesOfBase64(content: string): Uint8Array {
const binary = atob(content);
const bytes = new Uint8Array(binary.length);
for (let at = 0; at < binary.length; at++) bytes[at] = binary.charCodeAt(at);
return bytes;
}
// in the send path
attachments: files.map((file) => ({
...file,
content: typeof file.content === "string" ? bytesOfBase64(file.content) : file.content,
})),
The other provider I can send through takes Base64 and decodes it, so the two send paths now differ on purpose. I don't know whether the binding's behavior has changed since I measured, so test with your own file before you trust either reading of the docs.
The part I'm most glad about is the test. My stand-in for the binding now records a string as its characters too, as the real one does, so a test that sends a file fails if the code hands it Base64. Before that the stand-in accepted whatever it was given, which is why the tests had passed.
3. A fetch to your own zone skips your security settings
Webhooks make my Worker call URLs that people give it, and one of them can be a hostname on the same Cloudflare zone as the Worker. Without the global_fetch_strictly_public compatibility flag, that request is routed to the zone's origin, ignoring any Worker mapped to the URL and bypassing Cloudflare's security settings. With the flag, it goes in the front door like anyone's request.
The docs do say this, on the compatibility flags page. It is easy to miss, because nothing about it looks like an email problem, and a test that fetches a stub won't show it. I set it in wrangler.jsonc with a comment explaining why, so nobody removes it as clutter:
// A request Mail makes, such as a webhook's, to a host on its own zone goes in through
// Cloudflare like anyone's, past its security settings and to the Worker that serves it,
// never straight to the origin behind them.
"compatibility_flags": ["nodejs_compat", "global_fetch_strictly_public"],
4. message.raw is a stream you can read once
message.raw is a ReadableStream. I needed two things from it: the header block, to decide who sent the mail and whether it is wanted, and the whole message, to put in R2 without holding it in memory. I budget the handler at about 2 seconds of CPU, so parsing the whole message there is out. tee() gives two branches:
const [forHeaders, forStoring] = message.raw.tee();
const headers = parseHeaders(await readHeaderBlock(forHeaders));
// ... ask the mailbox for room, then stream the rest to R2
await raw.put({ object, size: message.rawSize }, forStoring);
readHeaderBlock reads to the first empty line and cancels its branch. If the mailbox is full, the other branch is cancelled too and the handler throws, which makes the sender try again later. A permanent refusal (setReject) is only for an address that doesn't exist: a failure of mine is not the sender's fault.
A related trap: message.headers joins every copy of a repeated header with ", ". A sender can write their own Authentication-Results: dmarc=pass, and it ends up in the same string as Cloudflare's. So I read the header block from the raw stream, where order is kept, and trust only the Authentication-Results that Cloudflare adds above its own Received line. Cloudflare's docs say to read the first one through message.headers, which is the same idea. I wanted to see the order myself.
Was it worth testing with real mail?
Yes, and the lesson is about stand-ins. The first two of these were invisible to tests that used a fake email() message or a fake binding, because the fakes did what I expected, not what Cloudflare does. Each fix went in with a change to the fake, so the behavior can't drift back.
What has your own email() handler or send_email binding done that the docs or your tests didn't predict?
The code is open source if you want the details: apps/web/src/server/mailbox/durable-object.ts in the mail app's repository.
I drafted this with an AI assistant (Claude), which also wrote much of the code it describes. I checked the facts and numbers against the code.
Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.