The answer. Two separate products share the word "email". Sending is a binding: declare a send_email binding named EMAIL, verify the sending domain in Email Service, and call env.EMAIL.send() with plain fields; it returns a messageId. For a raw MIME message (attachments, custom headers) build it with mimetext and send an EmailMessage from cloudflare:email. Receiving is Email Routing: point an address or a catch-all at a Worker, and the runtime calls email(message, env, ctx) with the envelope, headers and a raw stream you can parse with postal-mime. From there message.forward() relays to a verified destination and message.reply() answers in-thread. The 2024 rule that "Cloudflare does not send mail" no longer holds; what remains true is that there are no mailboxes, only routing and sending.
The pattern.
{ "send_email": [{ "name": "EMAIL", "remote": true }] } // remote: true = real sends during wrangler dev
export default {
async fetch(request, env) {
const r = await env.EMAIL.send({ to: "reader@example.org", from: "guide@example.com",
subject: "Your guide", text: "Link inside.", html: "<p>Link inside.</p>" });
return Response.json({ id: r.messageId });
},
async email(message, env, ctx) { // inbound via Email Routing
if (message.headers.get("subject")?.startsWith("[bounce]")) return message.setReject("no");
await message.forward("inbox@example.org");
},
} satisfies ExportedHandler<Env>;
Watch out.
reply()refuses unless the inbound mail passed DMARC, the reply goes back to the sender, and the sending domain is the one that received it. It is for auto-replies, not outreach.- Local
wrangler devsimulates sends (writes the MIME to a temp file) unless the binding hasremote: true; then it really sends. - Deliverability is still your job: SPF, DKIM and DMARC on the sending domain, and a queue with retries in front of sends that must not be lost.
Related: queues-batches-acks-and-retries · rate-limiting-binding-counts-per-key-in-the-worker · provider-balance-checkin-cron-after-card-lapse