Workers API
Process incoming emails with the email() handler in Cloudflare Workers. Forward, reply, reject, or process emails programmatically.
Process incoming emails using the email() handler in your Cloudflare Workers. This allows you to programmatically handle email routing with custom logic.
Add the email handler function to your Worker's exported handlers:
export default { async email(message, env, ctx): Promise<void> { // Process incoming email await message.forward("destination@example.com"); },} satisfies ExportedHandler<Env>;| Parameter | Type | Description |
|---|---|---|
message | ForwardableEmailMessage | The incoming email message |
env | object | Worker environment bindings (KV, EMAIL, etc.) |
ctx | object | Execution context with waitUntil function |
The message parameter provides access to the incoming email:
interface ForwardableEmailMessage { readonly from: string; // Sender email address (envelope MAIL FROM) readonly to: string; // Recipient email address (envelope RCPT TO) readonly headers: Headers; // Email headers (Subject, Message-ID, etc.) readonly raw: ReadableStream; // Raw MIME email content stream readonly rawSize: number; // Size of raw email in bytes readonly canBeForwarded: boolean; // Whether the message can be forwarded
// Actions setReject(reason: string): void; forward(rcptTo: string, headers?: Headers): Promise<EmailSendResult>; reply(message: EmailMessage): Promise<EmailSendResult>;}export default { async email(message, env, ctx): Promise<void> { // Access email metadata console.log(`From: ${message.from}`); console.log(`To: ${message.to}`); console.log(`Size: ${message.rawSize} bytes`);
// Access headers const subject = message.headers.get("subject"); const date = message.headers.get("date"); const messageId = message.headers.get("message-id");
console.log(`Subject: ${subject}`); console.log(`Date: ${date}`); console.log(`Message-ID: ${messageId}`); },};Use postal-mime ↗ to parse the MIME structure of an incoming email. The parser handles multipart boundaries, transfer encodings, and character sets correctly.
import PostalMime from "postal-mime";
export default { async email(message, env, ctx): Promise<void> { const email = await PostalMime.parse(message.raw);
console.log(`Subject: ${email.subject}`); console.log(`Text: ${email.text}`); console.log(`HTML: ${email.html}`); },};Forward incoming emails to verified destination addresses:
export default { async email(message, env, ctx): Promise<void> { // Forward to a single address await message.forward("team@example.com"); },};export default { async email(message, env, ctx): Promise<void> { const recipient = message.to; const subject = message.headers.get("subject") || "";
// Route based on recipient if (recipient.includes("support@")) { await message.forward("support-team@example.com"); } else if (recipient.includes("sales@")) { await message.forward("sales-team@example.com"); } else if (subject.toLowerCase().includes("urgent")) { await message.forward("urgent@example.com"); } else { // Default routing await message.forward("general@example.com"); } },};export default { async email(message, env, ctx): Promise<void> { const subject = message.headers.get("subject") || "";
if (subject.toLowerCase().includes("security")) { // Forward to multiple addresses for security issues await Promise.all([ message.forward("security@example.com"), message.forward("admin@example.com"), message.forward("ciso@example.com"), ]); } else { await message.forward("general@example.com"); } },};Add custom headers when forwarding. Only headers with an X- prefix can be added through forward(). Other headers are removed.
export default { async email(message, env, ctx): Promise<void> { // Create custom headers const customHeaders = new Headers(); customHeaders.set("X-Processed-By", "Email-Worker"); customHeaders.set("X-Processing-Time", new Date().toISOString()); customHeaders.set("X-Original-Recipient", message.to); customHeaders.set("X-Spam-Score", "0.1"); // Example spam score
// Forward with custom headers await message.forward("processed@example.com", customHeaders); },};Send automatic replies with message.reply(). Replies built this way are threaded with the original message and pass through the same SMTP session, so they preserve the original Message-ID chain.
Replies through the Workers API must satisfy the following requirements, otherwise reply() throws an exception:
- The incoming email must have a valid DMARC result.
- An email can only be replied to once per
EmailMessageevent. - The recipient in the reply must match the sender of the incoming email.
- The outgoing sender domain must match the domain that received the email.
- The reply is rejected if the incoming email has more than 100 entries in its
Referencesheader, to prevent reply loops and abuse.
The reply payload is an EmailMessage built from a raw MIME string. The examples below use mimetext ↗ to build the MIME body. The mimetext package requires the nodejs_compat compatibility flag.
import { EmailMessage } from "cloudflare:email";import { createMimeMessage } from "mimetext";
export default { async email(message, env, ctx): Promise<void> { const subject = message.headers.get("subject") || ""; const messageId = message.headers.get("Message-ID");
const reply = createMimeMessage(); if (messageId) { reply.setHeader("In-Reply-To", messageId); reply.setHeader("References", messageId); } reply.setSender(message.to); reply.setRecipient(message.from); reply.setSubject(`Re: ${subject}`); reply.addMessage({ contentType: "text/plain", data: "Thank you for your message. We have received your email and will respond shortly.", }); reply.addMessage({ contentType: "text/html", data: "<h1>Thank you for your message</h1><p>We have received your email and will respond shortly.</p>", });
await message.reply( new EmailMessage(message.to, message.from, reply.asRaw()), );
// Also forward to human team await message.forward("team@example.com"); },};import { EmailMessage } from "cloudflare:email";import { createMimeMessage } from "mimetext";
export default { async email(message, env, ctx): Promise<void> { const sender = message.from; const recipient = message.to; const subject = message.headers.get("subject") || ""; const messageId = message.headers.get("Message-ID");
// Don't reply to automated emails if ( sender.includes("noreply") || sender.includes("no-reply") || subject.toLowerCase().includes("automated") ) { await message.forward("team@example.com"); return; }
// Customized auto-reply based on recipient let html = "";
if (recipient.includes("support@")) { html = ` <h1>Support Request Received</h1> <p>Thank you for contacting support. Your request has been assigned ticket #${Date.now()}.</p> <p>Expected response time: 2-4 hours during business hours.</p> `; } else if (recipient.includes("sales@")) { html = ` <h1>Sales Inquiry Received</h1> <p>Thank you for your interest in our products.</p> <p>A sales representative will contact you within 24 hours.</p> `; } else { html = ` <h1>Message Received</h1> <p>Thank you for your message. We will respond within 2 business days.</p> `; }
const reply = createMimeMessage(); if (messageId) { reply.setHeader("In-Reply-To", messageId); reply.setHeader("References", messageId); } reply.setSender(recipient); reply.setRecipient(sender); reply.setSubject(`Re: ${subject}`); reply.addMessage({ contentType: "text/plain", data: html.replace(/<[^>]*>/g, ""), }); reply.addMessage({ contentType: "text/html", data: html });
await message.reply(new EmailMessage(recipient, sender, reply.asRaw()));
// Forward to appropriate team await message.forward("team@example.com"); },};Reject emails with a permanent SMTP error:
export default { async email(message, env, ctx): Promise<void> { const sender = message.from;
// Block specific senders const blockedDomains = ["spam.com", "unwanted.net"]; const senderDomain = sender.split("@")[1];
if (blockedDomains.includes(senderDomain)) { message.setReject("Sender domain not allowed"); return; }
// Continue processing await message.forward("inbox@example.com"); },};export default { async email(message, env, ctx): Promise<void> { const subject = message.headers.get("subject") || "";
// Reject based on subject content const spamKeywords = ["buy now", "limited time", "act fast", "urgent"]; const containsSpam = spamKeywords.some((keyword) => subject.toLowerCase().includes(keyword), );
if (containsSpam) { message.setReject("Message appears to be spam"); return; }
// Check message size if (message.rawSize > 25 * 1024 * 1024) { // 25 MiB limit (inbound message size) message.setReject("Message too large"); return; }
// Continue processing await message.forward("inbox@example.com"); },};Handle errors gracefully in email processing:
export default { async email(message, env, ctx): Promise<void> { try { // Main email processing logic await processEmail(message, env); } catch (error) { console.error("Email processing failed:", error);
// Log error for monitoring if (env.ERROR_LOGS) { await env.ERROR_LOGS.put( `error-${Date.now()}`, JSON.stringify({ error: error.message, stack: error.stack, from: message.from, to: message.to, timestamp: new Date().toISOString(), }), ); }
// Fallback: forward to admin try { await message.forward("admin@example.com"); } catch (fallbackError) { console.error("Fallback forwarding failed:", fallbackError); // Last resort: reject the email message.setReject("Internal processing error"); } } },};
async function processEmail(message, env) { // Your main email processing logic here const recipient = message.to;
if (recipient.includes("support@")) { await message.forward("support@example.com"); } else if (recipient.includes("sales@")) { await message.forward("sales@example.com"); } else { await message.forward("general@example.com"); }}- Test locally: Email routing development
- Manage rules and addresses programmatically with the Email Routing REST API
- Set up email routing configuration
- See email routing examples for advanced email processing
- Learn about spam filtering with Workers