Complete guide for Cloudflare Email Routing covering both Email Workers (receiving emails) and Send Email bindings (sending emails from Workers).
Use when: setting up email routing, creating email...
Status: Production Ready β Last Updated: 2025-10-23 Latest Versions: postal-mime@2.5.0, mimetext@3.0.27
Cloudflare Email Routing provides two complementary capabilities:
Both capabilities are free and work together to enable complete email functionality in Cloudflare Workers.
Prerequisites: Domain must be on Cloudflare DNS
hello@yourdomain.comyou@gmail.com)What you just did: Configured DNS and basic forwarding. Now let's add Workers for custom logic.
npm install postal-mime@2.5.0 mimetext@3.0.27
Why these packages:
postal-mime - Parse incoming email messages (headers, body, attachments)mimetext - Create email messages for sending/replyingCreate src/email.ts:
import { EmailMessage } from 'cloudflare:email';
import PostalMime from 'postal-mime';
export default {
async email(message, env, ctx) {
// Parse the incoming message
const parser = new PostalMime.default();
const email = await parser.parse(await new Response(message.raw).arrayBuffer());
console.log('From:', message.from);
console.log('To:', message.to);
console.log('Subject:', email.subject);
// Forward to verified destination
await message.forward('your-email@example.com');
},
};
Update wrangler.jsonc:
{
"name": "email-worker",
"main": "src/email.ts",
"compatibility_date": "2025-10-11"
}
npx wrangler deploy
# In Cloudflare Dashboard:
# Email > Email Routing > Email Workers
# Select your worker β Create route β Enter address (e.g., hello@yourdomain.com)
What you just did: Created a Worker that logs and forwards emails.
Update wrangler.jsonc:
{
"name": "my-worker",
"main": "src/index.ts",
"compatibility_date": "2025-10-11",
"send_email": [
{
"name": "EMAIL",
"destination_address": "notifications@yourdomain.com"
}
]
}
CRITICAL: destination_address must be:
import { EmailMessage } from 'cloudflare:email';
import { createMimeMessage } from 'mimetext';
export default {
async fetch(request, env, ctx) {
// Create email message
const msg = createMimeMessage();
msg.setSender({ name: 'My App', addr: 'noreply@yourdomain.com' });
msg.setRecipient('user@example.com');
msg.setSubject('Welcome to My App');
msg.addMessage({
contentType: 'text/plain',
data: 'Thank you for signing up!',
});
// Send via binding
const message = new EmailMessage(
'noreply@yourdomain.com',
'user@example.com',
msg.asRaw()
);
await env.EMAIL.send(message);
return new Response('Email sent!');
},
};
npx wrangler deploy
What you just did: Configured your Worker to send emails to verified addresses.
export default {
async email(message: ForwardableEmailMessage, env: Env, ctx: ExecutionContext) {
// Process email here
},
};
Parameters:
message - ForwardableEmailMessage objectenv - Environment bindings (KV, D1, secrets, etc.)ctx - Execution context (waitUntil for async operations)interface ForwardableEmailMessage {
readonly from: string; // Sender email
readonly to: string; // Recipient email
readonly headers: Headers; // Email headers
readonly raw: ReadableStream; // Raw email message
readonly rawSize: number; // Size in bytes
// Methods
setReject(reason: string): void;
forward(rcptTo: string, headers?: Headers): Promise<void>;
reply(message: EmailMessage): Promise<void>;
}
Only accept emails from approved senders:
export default {
async email(message, env, ctx) {
const allowList = [
'friend@example.com',
'coworker@company.com',
'support@vendor.com',
];
if (!allowList.includes(message.from)) {
message.setReject('Address not on allowlist');
return;
}
await message.forward('inbox@yourdomain.com');
},
};
When to use: Contact forms, private email addresses, team inboxes
Reject emails from specific senders or domains:
export default {
async email(message, env, ctx) {
const blockList = [
'spam@badactor.com',
'@suspicious-domain.com', // Block entire domain
];
const isBlocked = blockList.some(pattern =>
message.from.includes(pattern)
);
if (isBlocked) {
message.setReject('Sender blocked');
return;
}
await message.forward('inbox@yourdomain.com');
},
};
When to use: Spam filtering, blocking harassers, domain-level blocks
Extract email content and store in D1 or KV:
import PostalMime from 'postal-mime';
export default {
async email(message, env, ctx) {
// Parse email
const parser = new PostalMime.default();
const rawEmail = new Response(message.raw);
const email = await parser.parse(await rawEmail.arrayBuffer());
// Store in D1
await env.DB.prepare(
'INSERT INTO emails (from_addr, subject, text, received_at) VALUES (?, ?, ?, ?)'
).bind(
message.from,
email.subject,
email.text,
new Date().toISOString()
).run();
// Forward to inbox
await message.forward('inbox@yourdomain.com');
},
};
When to use: Email archiving, ticket systems, support inboxes, audit logs
Send automatic replies with custom logic:
import PostalMime from 'postal-mime';
import { createMimeMessage } from 'mimetext';
import { EmailMessage } from 'cloudflare:email';
export default {
async email(message, env, ctx) {
// Parse incoming email
const parser = new PostalMime.default();
const email = await parser.parse(await new Response(message.raw).arrayBuffer());
// Create reply
const msg = createMimeMessage();
msg.setSender({ name: 'Support Team', addr: 'support@yourdomain.com' });
msg.setRecipient(message.from);
msg.setHeader('In-Reply-To', message.headers.get('Message-ID'));
msg.setSubject(`Re: ${email.subject}`);
msg.addMessage({
contentType: 'text/plain',
data: `Thank you for your message about "${email.subject}". We'll respond within 24 hours.`,
});
// Send reply
const replyMessage = new EmailMessage(
'support@yourdomain.com',
message.from,
msg.asRaw()
);
await message.reply(replyMessage);
// Also forward to team inbox
await message.forward('team@yourdomain.com');
},
};
When to use: Out-of-office replies, support ticket acknowledgments, automated responses
Route emails to different destinations based on content:
import PostalMime from 'postal-mime';
export default {
async email(message, env, ctx) {
const parser = new PostalMime.default();
const email = await parser.parse(await new Response(message.raw).arrayBuffer());
const subject = email.subject.toLowerCase();
// Route based on subject keywords
if (subject.includes('urgent') || subject.includes('critical')) {
await message.forward('oncall@yourdomain.com');
} else if (subject.includes('invoice') || subject.includes('payment')) {
await message.forward('billing@yourdomain.com');
} else if (subject.includes('support') || subject.includes('help')) {
await message.forward('support@yourdomain.com');
} else {
await message.forward('inbox@yourdomain.com');
}
},
};
When to use: Department routing, priority filtering, category-based inboxes
{
"send_email": [
{
"name": "EMAIL",
"destination_address": "notifications@yourdomain.com"
}
]
}
Behavior: All emails sent via env.EMAIL go to this address.
{
"send_email": [
{
"name": "EMAIL",
"allowed_destination_addresses": [
"notifications@yourdomain.com",
"alerts@yourdomain.com",
"user@gmail.com"
]
}
]
}
Behavior: Can send to any address in the list.
{
"send_email": [
{
"name": "NOTIFICATIONS",
"destination_address": "notifications@yourdomain.com"
},
{
"name": "ALERTS",
"destination_address": "alerts@yourdomain.com"
}
]
}
Behavior: Use different bindings for different purposes.
import { EmailMessage } from 'cloudflare:email';
import { createMimeMessage } from 'mimetext';
const msg = createMimeMessage();
msg.setSender({ name: 'My App', addr: 'noreply@yourdomain.com' });
msg.setRecipient('user@example.com');
msg.setSubject('Welcome!');
msg.addMessage({
contentType: 'text/plain',
data: 'Welcome to our service!',
});
const email = new EmailMessage(
'noreply@yourdomain.com',
'user@example.com',
msg.asRaw()
);
await env.EMAIL.send(email);
import { EmailMessage } from 'cloudflare:email';
import { createMimeMessage } from 'mimetext';
const msg = createMimeMessage();
msg.setSender({ name: 'My App', addr: 'noreply@yourdomain.com' });
msg.setRecipient('user@example.com');
msg.setSubject('Welcome!');
// Add both plain text and HTML versions
msg.addMessage({
contentType: 'text/plain',
data: 'Welcome to our service!',
});
msg.addMessage({
contentType: 'text/html',
data: '<h1>Welcome!</h1><p>Thanks for joining us.</p>',
});
const email = new EmailMessage(
'noreply@yourdomain.com',
'user@example.com',
msg.asRaw()
);
await env.EMAIL.send(email);
import { EmailMessage } from 'cloudflare:email';
import { createMimeMessage } from 'mimetext';
const msg = createMimeMessage();
msg.setSender({ name: 'My App', addr: 'noreply@yourdomain.com' });
msg.setRecipient('user@example.com');
msg.setSubject('Password Reset');
// Add custom headers
msg.setHeader('X-Priority', '1');
msg.setHeader('X-Application-ID', 'my-app-123');
msg.addMessage({
contentType: 'text/plain',
data: 'Click here to reset your password...',
});
const email = new EmailMessage(
'noreply@yourdomain.com',
'user@example.com',
msg.asRaw()
);
await env.EMAIL.send(email);
When you enable Email Routing in the dashboard, Cloudflare automatically adds:
MX Records - Direct email to Cloudflare's servers
yourdomain.com. 300 IN MX 13 amir.mx.cloudflare.net.
yourdomain.com. 300 IN MX 86 linda.mx.cloudflare.net.
yourdomain.com. 300 IN MX 24 isaac.mx.cloudflare.net.
SPF Record - Authorize Cloudflare to send on your behalf
yourdomain.com. 300 IN TXT "v=spf1 include:_spf.mx.cloudflare.net ~all"
DKIM Records - Sign outgoing emails
Automatically configured per domain
If you need to migrate from another provider:
WARNING: Changing MX records will break Email Routing. Only do this if migrating providers.
This skill prevents 8 documented issues:
Error: Testing email workers fails with "Email Trigger not available to this workers"
Source: workers-sdk #3751
Why It Happens: Wrangler dev doesn't fully support email triggers; testing must be done via deployed Workers
Prevention:
wrangler tail for live debuggingError: Verified destination addresses show as "unverified" in Email Worker forwarding
Source: Community reports (Cloudflare Community)
Why It Happens: Bug in dashboard where addresses only show verified if also used in regular routing rules
Prevention:
Error: "421: Our system has detected an unusual rate of unsolicited mail originating from your IP address"
Source: Community reports
Why It Happens: Gmail may flag Cloudflare's IP ranges as suspicious due to shared infrastructure
Prevention:
Error: SPF permerror when routing through MailChannels
Source: Community discussion
Why It Happens: SPF record chain breaks when forwarding through multiple services
Prevention:
Error: Cannot see worker logs or email processing details
Source: Community reports
Why It Happens: Free plan has limited log retention and streaming
Prevention:
wrangler tail during development for live logsconsole.log() extensively in email workersError: Emails show as "Dropped" in Activity Log even when successfully forwarded
Source: Community reports
Why It Happens: Dashboard bug showing incorrect status
Prevention:
wrangler tail to verify processingError: Dashboard "Test Email Event" button remains in loading state forever
Source: workers-sdk #9195
Why It Happens: Bug in dashboard testing interface (unresolved as of 2025-10)
Prevention:
curl with local development instead (see Local Development section)wrangler tail to monitor processingError: "Rejected reason: Unknown error: failed to call worker: Worker call failed for 3 times, abortingβ¦"
Source: workers-sdk #9069, Community reports
Why It Happens: Worker crashes due to runtime errors, timeouts, or memory issues
Prevention:
ctx.waitUntil() for non-critical operationsWrangler simulates email reception via HTTP POST:
# Start dev server
npx wrangler dev
# In another terminal, send test email
curl http://localhost:8787 -X POST \
--data-binary @- << EOF
From: sender@example.com
To: recipient@yourdomain.com
Subject: Test Email
This is a test email body.
EOF
What happens: Wrangler logs the email processing and shows where forwarded emails would go.
Wrangler writes sent emails to local .eml files:
// Your worker code
await env.EMAIL.send(message);
Output in terminal:
[wrangler:inf] send_email binding called with the following message:
/tmp/miniflare-abc123/files/email/message-123.eml
View the email:
cat /tmp/miniflare-abc123/files/email/message-123.eml
{
"$schema": "node_modules/wrangler/config-schema.json",
"name": "email-worker",
"main": "src/email.ts",
"account_id": "YOUR_ACCOUNT_ID",
"compatibility_date": "2025-10-11",
"observability": {
"enabled": true
},
// Send email binding
"send_email": [
{
"name": "NOTIFICATIONS",
"destination_address": "notifications@yourdomain.com"
},
{
"name": "ALERTS",
"allowed_destination_addresses": [
"alerts@yourdomain.com",
"admin@yourdomain.com"
]
}
],
// Optional: Add other bindings
"d1_databases": [
{
"binding": "DB",
"database_name": "email-archive",
"database_id": "YOUR_DATABASE_ID"
}
],
"kv_namespaces": [
{
"binding": "EMAIL_CACHE",
"id": "YOUR_KV_ID"
}
]
}
interface Env {
// Send email bindings
EMAIL: SendEmail;
NOTIFICATIONS: SendEmail;
ALERTS: SendEmail;
// Other bindings
DB: D1Database;
EMAIL_CACHE: KVNamespace;
}
interface SendEmail {
send(message: EmailMessage): Promise<void>;
}
import { EmailMessage } from 'cloudflare:email';
interface ForwardableEmailMessage {
readonly from: string;
readonly to: string;
readonly headers: Headers;
readonly raw: ReadableStream;
readonly rawSize: number;
setReject(reason: string): void;
forward(rcptTo: string, headers?: Headers): Promise<void>;
reply(message: EmailMessage): Promise<void>;
}
declare module 'cloudflare:email' {
export class EmailMessage {
constructor(from: string, to: string, raw: string | ReadableStream);
}
}
postal-mime@2.5.0 installedmimetext@3.0.27 installedasync email() handlernpx wrangler deploywrangler tailsend_email binding configured in wrangler.jsoncdestination_address or allowed_destination_addresses specifiedenv.EMAIL.send()npx wrangler deploySolution:
npx wrangler deploywrangler tail to monitor processingSolution:
Solution:
Solution:
wrangler tail to see processing logsSolution:
wrangler tail --format pretty for live logsconsole.log() statements in workerSolution:
ctx.waitUntil() for non-blocking operationsimport PostalMime from 'postal-mime';
export default {
async email(message, env, ctx) {
const parser = new PostalMime.default();
const email = await parser.parse(await new Response(message.raw).arrayBuffer());
// Access attachments
if (email.attachments && email.attachments.length > 0) {
for (const attachment of email.attachments) {
console.log('Attachment:', attachment.filename);
console.log('Type:', attachment.mimeType);
console.log('Size:', attachment.content.length);
// Store in R2
await env.BUCKET.put(
`emails/${Date.now()}-${attachment.filename}`,
attachment.content
);
}
}
await message.forward('inbox@yourdomain.com');
},
};
import PostalMime from 'postal-mime';
export default {
async email(message, env, ctx) {
const parser = new PostalMime.default();
const email = await parser.parse(await new Response(message.raw).arrayBuffer());
// Extract task from email subject
const taskMatch = email.subject.match(/\[TASK\](.*)/i);
if (taskMatch) {
const taskDescription = taskMatch[1].trim();
// Create task in D1
await env.DB.prepare(
'INSERT INTO tasks (description, created_by, created_at) VALUES (?, ?, ?)'
).bind(
taskDescription,
message.from,
new Date().toISOString()
).run();
// Send confirmation
await message.reply(new EmailMessage(
'tasks@yourdomain.com',
message.from,
`Task created: ${taskDescription}`
));
}
},
};
export default {
async email(message, env, ctx) {
// Trigger Cloudflare Workflow based on email
if (message.from.endsWith('@trusted-domain.com')) {
await env.WORKFLOW.create({
params: {
emailFrom: message.from,
emailTo: message.to,
receivedAt: new Date().toISOString(),
},
});
}
await message.forward('inbox@yourdomain.com');
},
};
Required:
postal-mime@2.5.0 - Parse incoming email messagesmimetext@3.0.27 - Create email messages for sendingBuilt-in:
cloudflare:email - EmailMessage class (no installation needed)Optional:
@cloudflare/workers-types - TypeScript type definitions{
"dependencies": {
"postal-mime": "^2.5.0",
"mimetext": "^3.0.27"
},
"devDependencies": {
"@cloudflare/workers-types": "^4.20251014.0",
"wrangler": "^4.44.0"
}
}
Questions? Issues?
references/common-errors.md for detailed troubleshootingreferences/dns-setup.md for DNS configuration helpreferences/local-development.md for testing patternswrangler tail for live debugging