Receiving & Routing Inbound Email
Handle incoming emails sent to your domain via a Worker's email() handler. Forward, reply, reject, or parse emails programmatically.
For full API details, see the Email Routing docs.
Email Handler
Export an email() function from your Worker. No special wrangler binding needed — a routing rule connects incoming addresses to your Worker.
export default {
async email(message, env, ctx): Promise<void> {
console.log(`Email from ${message.from} to ${message.to}`);
await message.forward("team@company.com");
},
} satisfies ExportedHandler<Env>;Set up routing rules in Dashboard > Compute & AI > Email Service > Email Routing > Routing Rules, or via wrangler email routing rules create.
ForwardableEmailMessage
The message parameter is a ForwardableEmailMessage. Run npx wrangler types to get the full type definition. Key properties and methods:
message.from/message.to— envelope addresses (SMTP MAIL FROM / RCPT TO).message.fromis trustworthy; header addresses can be spoofed.message.headers—Headersobject (use.get("subject"),.get("message-id"), etc.)message.raw—ReadableStream<Uint8Array>of raw MIME content. Single use — buffer before accessing.message.rawSize— size in bytesmessage.setReject(reason)— reject with a permanent SMTP errormessage.forward(rcptTo, headers?)— forward to a verified destinationmessage.reply(emailMessage)— reply with anEmailMessageobject
Core Operations
Forward
await message.forward("team@company.com");
// With custom headers
await message.forward("team@company.com", new Headers({
"X-Original-Recipient": message.to,
}));Destination must be verified first (Dashboard or wrangler email routing addresses create).
Reject
message.setReject("Your message was blocked");Reply
Using env.EMAIL.send() (recommended — no extra dependencies):
async email(message, env, ctx) {
const subject = message.headers.get("subject") || "";
await env.EMAIL.send({
to: message.from,
from: message.to,
subject: `Re: ${subject}`,
html: "<p>Thanks! We'll respond shortly.</p>",
text: "Thanks! We'll respond shortly.",
});
await message.forward("team@company.com");
}Using message.reply() with MIME (more control, requires mimetext + nodejs_compat):
import { EmailMessage } from "cloudflare:email";
import { createMimeMessage } from "mimetext";
async email(message, env, ctx) {
const msg = createMimeMessage();
const messageId = message.headers.get("Message-ID");
if (messageId) msg.setHeader("In-Reply-To", messageId);
msg.setSender({ name: "Support", addr: "support@yourdomain.com" });
msg.setRecipient(message.from);
msg.setSubject("Re: " + (message.headers.get("subject") || ""));
msg.addMessage({ contentType: "text/plain", data: "Thanks for reaching out!" });
await message.reply(new EmailMessage("support@yourdomain.com", message.from, msg.asRaw()));
}Parsing Emails
Use postal-mime to parse raw MIME content:
import PostalMime from "postal-mime";
async email(message, env, ctx) {
const rawBuffer = await new Response(message.raw).arrayBuffer();
const parsed = await PostalMime.parse(rawBuffer);
console.log("Subject:", parsed.subject);
console.log("Text:", parsed.text);
console.log("Attachments:", parsed.attachments.length);
}Store and Reply Later (Human-in-the-Loop)
A common pattern is to store incoming emails in a Durable Object (SQLite) so a human or AI agent can review and reply later — rather than replying immediately in the email() handler. This enables support inboxes, approval workflows, and AI-drafted replies.
Architecture
Email Routing → email() handler → parse + store in DO → user reviews later → reply via send_email bindingThe email() handler stores the email and returns immediately. Replies happen later via a separate HTTP request or agent action.
Receive and Store
import PostalMime from "postal-mime";
export class MailboxDO extends DurableObject {
async storeEmail(from: string, to: string, subject: string, body: string,
messageId: string, inReplyTo: string | null) {
this.ctx.storage.sql.exec(
`INSERT INTO emails (sender, recipient, subject, body, message_id, in_reply_to, date, read)
VALUES (?, ?, ?, ?, ?, ?, datetime('now'), 0)`,
from, to, subject, body, messageId, inReplyTo
);
}
}
export default {
async email(message, env, ctx) {
const raw = await new Response(message.raw).arrayBuffer();
const parsed = await PostalMime.parse(raw);
const id = env.MAILBOX.idFromName(message.to);
const stub = env.MAILBOX.get(id);
await stub.storeEmail(
message.from,
message.to,
parsed.subject || "(no subject)",
parsed.text || parsed.html || "",
message.headers.get("message-id") || "",
message.headers.get("in-reply-to") || null,
);
// Optionally trigger an AI agent to draft a reply (non-blocking)
// ctx.waitUntil(notifyAgent(env, message.to, emailId));
},
} satisfies ExportedHandler<Env>;Reply Later
When a user (or agent) decides to reply, build proper threading headers and send via the send_email binding:
// In an HTTP handler or agent tool — not in the email() handler
async function replyToStoredEmail(env: Env, original: StoredEmail, replyBody: string) {
// Build threading headers (In-Reply-To + References per RFC 2822)
const headers: Record<string, string> = {};
if (original.messageId) {
headers["In-Reply-To"] = original.messageId;
headers["References"] = original.messageId;
}
await env.EMAIL.send({
to: original.sender,
from: original.recipient,
subject: `Re: ${original.subject}`,
text: replyBody,
html: `<p>${replyBody}</p>`,
headers,
});
}Key Design Points
- Buffer
message.rawonce, parse withpostal-mime, then store structured fields. Don't store the raw stream. - Extract
Message-ID,In-Reply-To, andReferencesheaders during ingest for threading. Fall back to subject-based matching for emails without threading headers. - Use Durable Object SQLite for per-mailbox storage — each mailbox gets its own DO instance keyed by email address, providing natural isolation.
- Store attachments separately in R2 (binary blobs), with metadata in SQLite.
- Defer heavy work (AI drafting, notifications) via
ctx.waitUntil()so theemail()handler returns quickly. - Never auto-send from the
email()handler in a human-in-the-loop flow. Store a draft, let the user review, then send via a separate action.
Gotchas
message.rawis single-use. Buffer first:const raw = await new Response(message.raw).arrayBuffer()- Destinations must be verified. Forwarding to unverified addresses fails silently.
- Handler must act. If your handler returns without consuming raw, forwarding, or rejecting, the email is dropped.
- DMARC/SPF for replies. If sending replies, ensure your domain has proper SPF/DKIM records (auto-configured on domain onboarding).