cloudflare-email-routing — independently scanned and version-tracked by SaferSkills.
SaferSkills independently audited cloudflare-email-routing (Agent Skill) and scored it 100/100 (green). The audit ran 55 deterministic rules across Security, Supply Chain, Maintenance, Transparency, and Community; it found 0 high-severity and 0 lower-severity findings. The full rule-by-rule trace and per-finding evidence are below. Free, methodology-open.
Findings & checks · 0 flagged
Every scanned point with the score it earned and what moved between them.
First recorded scan — no prior version to compare against.
The primary manifest — the file an agent reads to learn what this artifact does.
Status: Production Ready ✅ Last Updated: 2025-10-23 Latest Versions: [email protected], [email protected]
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
[email protected][email protected])What you just did: Configured DNS and basic forwarding. Now let's add Workers for custom logic.
#### 1. Install Dependencies
npm install [email protected] [email protected]Why these packages:
postal-mime - Parse incoming email messages (headers, body, attachments)mimetext - Create email messages for sending/replying#### 2. Create Email Worker
Create 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('[email protected]');
},
};#### 3. Configure Wrangler
Update wrangler.jsonc:
{
"name": "email-worker",
"main": "src/email.ts",
"compatibility_date": "2025-10-11"
}#### 4. Deploy and Bind
npx wrangler deploy
# In Cloudflare Dashboard:
# Email > Email Routing > Email Workers
# Select your worker → Create route → Enter address (e.g., [email protected])What you just did: Created a Worker that logs and forwards emails.
#### 1. Configure Send Email Binding
Update wrangler.jsonc:
{
"name": "my-worker",
"main": "src/index.ts",
"compatibility_date": "2025-10-11",
"send_email": [
{
"name": "EMAIL",
"destination_address": "[email protected]"
}
]
}CRITICAL: destination_address must be:
#### 2. Send Email from Worker
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: '[email protected]' });
msg.setRecipient('[email protected]');
msg.setSubject('Welcome to My App');
msg.addMessage({
contentType: 'text/plain',
data: 'Thank you for signing up!',
});
// Send via binding
const message = new EmailMessage(
'[email protected]',
'[email protected]',
msg.asRaw()
);
await env.EMAIL.send(message);
return new Response('Email sent!');
},
};#### 3. Deploy
npx wrangler deployWhat you just did: Configured your Worker to send emails to verified addresses.
#### EmailEvent Handler
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)#### ForwardableEmailMessage Properties
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 = [
'[email protected]',
'[email protected]',
'[email protected]',
];
if (!allowList.includes(message.from)) {
message.setReject('Address not on allowlist');
return;
}
await message.forward('[email protected]');
},
};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 = [
'[email protected]',
'@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('[email protected]');
},
};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('[email protected]');
},
};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: '[email protected]' });
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(
'[email protected]',
message.from,
msg.asRaw()
);
await message.reply(replyMessage);
// Also forward to team inbox
await message.forward('[email protected]');
},
};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('[email protected]');
} else if (subject.includes('invoice') || subject.includes('payment')) {
await message.forward('[email protected]');
} else if (subject.includes('support') || subject.includes('help')) {
await message.forward('[email protected]');
} else {
await message.forward('[email protected]');
}
},
};When to use: Department routing, priority filtering, category-based inboxes
#### Single Destination (Simple)
{
"send_email": [
{
"name": "EMAIL",
"destination_address": "[email protected]"
}
]
}Behavior: All emails sent via env.EMAIL go to this address.
#### Multiple Destinations (Flexible)
{
"send_email": [
{
"name": "EMAIL",
"allowed_destination_addresses": [
"[email protected]",
"[email protected]",
"[email protected]"
]
}
]
}Behavior: Can send to any address in the list.
#### Multiple Bindings (Organized)
{
"send_email": [
{
"name": "NOTIFICATIONS",
"destination_address": "[email protected]"
},
{
"name": "ALERTS",
"destination_address": "[email protected]"
}
]
}Behavior: Use different bindings for different purposes.
#### Basic Text Email
import { EmailMessage } from 'cloudflare:email';
import { createMimeMessage } from 'mimetext';
const msg = createMimeMessage();
msg.setSender({ name: 'My App', addr: '[email protected]' });
msg.setRecipient('[email protected]');
msg.setSubject('Welcome!');
msg.addMessage({
contentType: 'text/plain',
data: 'Welcome to our service!',
});
const email = new EmailMessage(
'[email protected]',
'[email protected]',
msg.asRaw()
);
await env.EMAIL.send(email);#### HTML Email
import { EmailMessage } from 'cloudflare:email';
import { createMimeMessage } from 'mimetext';
const msg = createMimeMessage();
msg.setSender({ name: 'My App', addr: '[email protected]' });
msg.setRecipient('[email protected]');
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(
'[email protected]',
'[email protected]',
msg.asRaw()
);
await env.EMAIL.send(email);#### Email with Custom Headers
import { EmailMessage } from 'cloudflare:email';
import { createMimeMessage } from 'mimetext';
const msg = createMimeMessage();
msg.setSender({ name: 'My App', addr: '[email protected]' });
msg.setRecipient('[email protected]');
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(
'[email protected]',
'[email protected]',
msg.asRaw()
);
await env.EMAIL.send(email);When you enable Email Routing in the dashboard, Cloudflare automatically adds:
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. yourdomain.com. 300 IN TXT "v=spf1 include:_spf.mx.cloudflare.net ~all" Automatically configured per domainIf 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: [email protected]
To: [email protected]
Subject: Test Email
This is a test email body.
EOFWhat 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.emlView 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": "[email protected]"
},
{
"name": "ALERTS",
"allowed_destination_addresses": [
"[email protected]",
"[email protected]"
]
}
],
// 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);
}
}[email protected] installed[email protected] 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('[email protected]');
},
};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(
'[email protected]',
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('[email protected]');
},
};Required:
[email protected] - Parse incoming email messages[email protected] - 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~30 seconds. Free. No account. Every finding cites a rule and a line of evidence.