Agents can receive and process emails using Cloudflare's Email Routing. This guide covers how to route inbound emails to your Agents and handle replies securely.
- A domain configured with Cloudflare Email Routing
- An Email Worker configured to receive emails
- An Agent to process emails
import { Agent, routeAgentEmail } from "agents";
import { createAddressBasedEmailResolver, type AgentEmail } from "agents/email";
// Your Agent that handles emails
export class EmailAgent extends Agent {
async onEmail(email: AgentEmail) {
console.log("Received email from:", email.from);
console.log("Subject:", email.headers.get("subject"));
// Reply to the email
await this.replyToEmail(email, {
fromName: "My Agent",
body: "Thanks for your email!"
});
}
}
// Route emails to your Agent
export default {
async email(message, env) {
await routeAgentEmail(message, env, {
resolver: createAddressBasedEmailResolver("EmailAgent")
});
}
};Resolvers determine which Agent instance receives an incoming email. Choose the resolver that matches your use case.
Recommended for inbound mail. Routes emails based on the recipient address.
import { createAddressBasedEmailResolver } from "agents/email";
const resolver = createAddressBasedEmailResolver("EmailAgent");Routing logic:
| Recipient Address | Agent Name | Agent ID |
|---|---|---|
support@example.com |
EmailAgent (default) |
support |
sales@example.com |
EmailAgent (default) |
sales |
NotificationAgent+user123@example.com |
NotificationAgent |
user123 |
The sub-address format (agent+id@domain) allows routing to different agent namespaces and instances from a single email domain.
Note: Agent class names in the recipient address are matched case-insensitively. Email infrastructure often lowercases addresses, so
NotificationAgent+user123@example.comandnotificationagent+user123@example.comboth route to theNotificationAgentclass.
For reply flows with signature verification. Verifies that incoming emails are authentic replies to your outbound emails, preventing attackers from routing emails to arbitrary agent instances.
import { createSecureReplyEmailResolver } from "agents/email";
const resolver = createSecureReplyEmailResolver(env.EMAIL_SECRET);When your agent sends an email with replyToEmail() and a secret, it signs the routing headers with a timestamp. When a reply comes back, this resolver verifies the signature and checks that it hasn't expired before routing.
Options:
const resolver = createSecureReplyEmailResolver(env.EMAIL_SECRET, {
// Maximum age of signature in seconds (default: 30 days)
maxAge: 7 * 24 * 60 * 60, // 7 days
// Callback for logging/debugging signature failures
onInvalidSignature: (email, reason) => {
console.warn(`Invalid signature from ${email.from}: ${reason}`);
// reason can be: "missing_headers", "expired", "invalid", "malformed_timestamp"
}
});When to use: If your agent initiates email conversations and you need replies to route back to the same agent instance securely.
For single-instance routing. Routes all emails to a specific agent instance regardless of the recipient address.
import { createCatchAllEmailResolver } from "agents/email";
const resolver = createCatchAllEmailResolver("EmailAgent", "default");When to use: When you have a single agent instance that handles all emails (e.g., a shared inbox).
You can combine resolvers to handle different scenarios:
export default {
async email(message, env) {
const secureReplyResolver = createSecureReplyEmailResolver(
env.EMAIL_SECRET
);
const addressResolver = createAddressBasedEmailResolver("EmailAgent");
await routeAgentEmail(message, env, {
resolver: async (email, env) => {
// First, check if this is a signed reply
const replyRouting = await secureReplyResolver(email, env);
if (replyRouting) return replyRouting;
// Otherwise, route based on recipient address
return addressResolver(email, env);
},
// Handle emails that don't match any routing rule
onNoRoute: (email) => {
console.warn(`No route found for email from ${email.from}`);
email.setReject("Unknown recipient");
}
});
}
};When your agent's onEmail method is called, it receives an AgentEmail object:
type AgentEmail = {
from: string; // Sender's email address
to: string; // Recipient's email address
headers: Headers; // Email headers (subject, message-id, etc.)
rawSize: number; // Size of the raw email in bytes
getRaw(): Promise<Uint8Array>; // Get the full raw email content
reply(options): Promise<void>; // Send a reply
forward(rcptTo, headers?): Promise<void>; // Forward the email
setReject(reason): void; // Reject the email with a reason
};Use a library like postal-mime to parse the raw email:
import PostalMime from "postal-mime";
async onEmail(email: AgentEmail) {
const raw = await email.getRaw();
const parsed = await PostalMime.parse(raw);
console.log("Subject:", parsed.subject);
console.log("Text body:", parsed.text);
console.log("HTML body:", parsed.html);
console.log("Attachments:", parsed.attachments);
}Use isAutoReplyEmail() to detect auto-reply emails and avoid mail loops:
import { isAutoReplyEmail } from "agents/email";
import PostalMime from "postal-mime";
async onEmail(email: AgentEmail) {
const raw = await email.getRaw();
const parsed = await PostalMime.parse(raw);
// Detect auto-reply emails to avoid sending duplicate responses
if (isAutoReplyEmail(parsed.headers)) {
console.log("Skipping auto-reply email");
return;
}
// Process the email...
}This checks for standard RFC 3834 headers (Auto-Submitted, X-Auto-Response-Suppress, Precedence) that indicate an email is an auto-reply.
Use this.replyToEmail() to send a reply:
async onEmail(email: AgentEmail) {
await this.replyToEmail(email, {
fromName: "Support Bot", // Display name for the sender
subject: "Re: Your inquiry", // Optional, defaults to "Re: <original subject>"
body: "Thanks for contacting us!", // Email body
contentType: "text/plain", // Optional, defaults to "text/plain"
headers: { // Optional custom headers
"X-Custom-Header": "value"
},
secret: this.env.EMAIL_SECRET // Optional, signs headers for secure reply routing
});
}async onEmail(email: AgentEmail) {
await email.forward("admin@example.com");
}async onEmail(email: AgentEmail) {
if (isSpam(email)) {
email.setReject("Message rejected as spam");
return;
}
// Process the email...
}When your agent sends emails and expects replies, use secure reply routing to prevent attackers from forging headers to route emails to arbitrary agent instances.
- Outbound: When you call
replyToEmail()with asecret, the agent signs the routing headers (X-Agent-Name,X-Agent-ID) using HMAC-SHA256 - Inbound:
createSecureReplyEmailResolververifies the signature before routing - Enforcement: If an email was routed via the secure resolver,
replyToEmail()requires a secret (or explicitnullto opt-out)
- Add a secret to your
wrangler.jsonc:
For production, use Wrangler secrets instead:
wrangler secret put EMAIL_SECRET- Use the combined resolver pattern:
export default {
async email(message, env) {
const secureReplyResolver = createSecureReplyEmailResolver(
env.EMAIL_SECRET
);
const addressResolver = createAddressBasedEmailResolver("EmailAgent");
await routeAgentEmail(message, env, {
resolver: async (email, env) => {
const replyRouting = await secureReplyResolver(email, env);
if (replyRouting) return replyRouting;
return addressResolver(email, env);
}
});
}
};- Sign outbound emails:
async onEmail(email: AgentEmail) {
await this.replyToEmail(email, {
fromName: "My Agent",
body: "Thanks for your email!",
secret: this.env.EMAIL_SECRET // Signs the routing headers
});
}When an email is routed via createSecureReplyEmailResolver, the replyToEmail() method enforces signing:
secret value |
Behavior |
|---|---|
"my-secret" |
Signs headers (secure) |
undefined (omitted) |
Throws error - must provide secret or explicit opt-out |
null |
Allowed but not recommended - explicitly opts out of signing |
Here's a complete email agent with secure reply routing:
import { Agent, routeAgentEmail } from "agents";
import {
createAddressBasedEmailResolver,
createSecureReplyEmailResolver,
type AgentEmail
} from "agents/email";
import PostalMime from "postal-mime";
interface Env {
EmailAgent: DurableObjectNamespace<EmailAgent>;
EMAIL_SECRET: string;
}
export class EmailAgent extends Agent<Env> {
async onEmail(email: AgentEmail) {
const raw = await email.getRaw();
const parsed = await PostalMime.parse(raw);
console.log(`Email from ${email.from}: ${parsed.subject}`);
// Store the email in state
const emails = this.state.emails || [];
emails.push({
from: email.from,
subject: parsed.subject,
receivedAt: new Date().toISOString()
});
this.setState({ ...this.state, emails });
// Send auto-reply with signed headers
await this.replyToEmail(email, {
fromName: "Support Bot",
body: `Thanks for your email! We received: "${parsed.subject}"`,
secret: this.env.EMAIL_SECRET
});
}
}
export default {
async email(message, env: Env) {
const secureReplyResolver = createSecureReplyEmailResolver(
env.EMAIL_SECRET,
{
maxAge: 7 * 24 * 60 * 60, // 7 days
onInvalidSignature: (email, reason) => {
console.warn(`Invalid signature from ${email.from}: ${reason}`);
}
}
);
const addressResolver = createAddressBasedEmailResolver("EmailAgent");
await routeAgentEmail(message, env, {
resolver: async (email, env) => {
// Try secure reply routing first
const replyRouting = await secureReplyResolver(email, env);
if (replyRouting) return replyRouting;
// Fall back to address-based routing
return addressResolver(email, env);
},
onNoRoute: (email) => {
console.warn(`No route found for email from ${email.from}`);
email.setReject("Unknown recipient");
}
});
}
} satisfies ExportedHandler<Env>;function routeAgentEmail<Env>(
email: ForwardableEmailMessage,
env: Env,
options: {
resolver: EmailResolver<Env>;
onNoRoute?: (email: ForwardableEmailMessage) => void | Promise<void>;
}
): Promise<void>;Routes an incoming email to the appropriate Agent based on the resolver's decision.
| Option | Description |
|---|---|
resolver |
Function that determines which agent to route the email to |
onNoRoute |
Optional callback invoked when no routing information is found. Use this to reject the email or perform custom handling. If not provided, a warning is logged and the email is dropped. |
function createSecureReplyEmailResolver<Env>(
secret: string,
options?: {
maxAge?: number;
onInvalidSignature?: (
email: ForwardableEmailMessage,
reason: SignatureFailureReason
) => void;
}
): EmailResolver<Env>;
type SignatureFailureReason =
| "missing_headers"
| "expired"
| "invalid"
| "malformed_timestamp";Creates a resolver for routing email replies with signature verification.
| Option | Description |
|---|---|
secret |
Secret key for HMAC verification (must match the key used to sign) |
maxAge |
Maximum age of signature in seconds (default: 30 days / 2592000 seconds) |
onInvalidSignature |
Optional callback for logging when signature verification fails |
function signAgentHeaders(
secret: string,
agentName: string,
agentId: string
): Promise<Record<string, string>>;Manually sign agent routing headers. Returns an object with X-Agent-Name, X-Agent-ID, X-Agent-Sig, and X-Agent-Sig-Ts headers.
Useful when sending emails through external services while maintaining secure reply routing. The signature includes a timestamp and will be valid for 30 days by default.