Skip to content

Email

Agents can send and receive email with Cloudflare Email Service. This guide shows how to send outbound email with the Workers binding, route inbound mail into Agents, and handle follow-up replies securely.

Prerequisites

Before using email with Agents, you need:

  1. A domain onboarded to Cloudflare Email Service.
  2. A send_email binding in wrangler.jsonc for outbound email.
  3. An Email Service routing rule that sends inbound mail to your Worker.
  4. Optional: an EMAIL_SECRET secret if you want secure reply routing.

Domain setup

  1. Log in to the Cloudflare Dashboard.
  2. Go to Compute & AI > Email Service.
  3. Select Onboard Domain and choose your domain.
  4. Add the DNS records (SPF and DKIM) to authorize sending.

DNS changes usually complete within 5-15 minutes for domains using Cloudflare DNS, but can take up to 24 hours to propagate globally.

Wrangler configuration

Add the email binding to your Worker:

JSONC
{
"$schema": "./node_modules/wrangler/config-schema.json",
"send_email": [
{
"name": "EMAIL",
"remote": true
}
]
}

The remote = true option lets you call the real Email Service API during local development with wrangler dev.

Quick start

JavaScript
import { Agent, callable, routeAgentEmail } from "agents";
import { createAddressBasedEmailResolver } from "agents/email";
import PostalMime from "postal-mime";
export class EmailAgent extends Agent {
@callable()
async sendWelcomeEmail(to) {
await this.sendEmail({
binding: this.env.EMAIL,
to,
from: "support@yourdomain.com",
replyTo: "support@yourdomain.com",
subject: "Welcome to our service",
text: "Thanks for signing up. Reply to this email if you need help.",
});
}
async onEmail(email) {
const raw = await email.getRaw();
const parsed = await PostalMime.parse(raw);
console.log("Received email from:", email.from);
console.log("Subject:", parsed.subject);
await this.replyToEmail(email, {
fromName: "Support Agent",
body: "Thanks for your email! We received it.",
});
}
}
export default {
async email(message, env) {
await routeAgentEmail(message, env, {
resolver: createAddressBasedEmailResolver("EmailAgent"),
});
},
};

Sending outbound email

Using sendEmail()

sendEmail() sends outbound email through a send_email binding that you pass explicitly. It automatically injects agent routing headers (X-Agent-Name, X-Agent-ID) into every message, and optionally signs them with HMAC-SHA256 so that replies can be routed back to the same agent instance.

JavaScript
class MyAgent extends Agent {
@callable()
async sendReceipt(to, orderId) {
const result = await this.sendEmail({
binding: this.env.EMAIL,
to,
from: { email: "billing@yourdomain.com", name: "Billing Bot" },
replyTo: "billing@yourdomain.com",
subject: `Receipt for order ${orderId}`,
text: `Your receipt for order ${orderId} is ready.`,
secret: this.env.EMAIL_SECRET,
});
return result.messageId;
}
}

When secret is provided, the agent signs the routing headers so that replies verified by createSecureReplyEmailResolver route back to the same agent instance.

Set replyTo to the mailbox that routes back to your Worker when you want recipients to continue the conversation with the same agent.

Routing inbound mail

Resolvers determine which Agent instance receives an incoming email. Choose the resolver that matches your use case.

For basic Email Service sending and receiving, createAddressBasedEmailResolver() is enough. The secure reply resolver below is optional and specific to Agents SDK reply signing, not a requirement of Email Service itself.

createAddressBasedEmailResolver

Recommended for inbound mail. Routes emails based on the recipient address.

JavaScript
import { createAddressBasedEmailResolver } from "agents/email";
const resolver = createAddressBasedEmailResolver("EmailAgent");

Routing logic:

Recipient AddressAgent NameAgent ID
support@example.comEmailAgent (default)support
sales@example.comEmailAgent (default)sales
NotificationAgent+user123@example.comNotificationAgentuser123

The sub-address format (agent+id@domain) allows routing to different agent namespaces and instances from a single email domain.

createSecureReplyEmailResolver

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.

JavaScript
import { createSecureReplyEmailResolver } from "agents/email";
const resolver = createSecureReplyEmailResolver(env.EMAIL_SECRET);

When your agent sends an email with replyToEmail() or sendEmail() 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 has not expired before routing.

Options:

JavaScript
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.

createCatchAllEmailResolver

For single-instance routing. Routes all emails to a specific agent instance regardless of the recipient address.

JavaScript
import { createCatchAllEmailResolver } from "agents/email";
const resolver = createCatchAllEmailResolver("EmailAgent", "default");

When to use: When you have a single agent instance that handles all emails (for example, a shared inbox).

Combining resolvers

You can combine resolvers to handle different scenarios:

JavaScript
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 do not match any routing rule
onNoRoute: (email) => {
console.warn(`No route found for email from ${email.from}`);
email.setReject("Unknown recipient");
},
});
},
};

Handling emails in your Agent

The AgentEmail interface

When your agent's onEmail method is called, it receives an AgentEmail object:

TypeScript
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
};

Parsing email content

Use a library like postal-mime to parse the raw email:

JavaScript
import PostalMime from "postal-mime";
class MyAgent extends Agent {
async onEmail(email) {
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);
}
}

Detecting auto-reply emails

Use isAutoReplyEmail() to detect auto-reply emails and avoid mail loops:

JavaScript
import { isAutoReplyEmail } from "agents/email";
import PostalMime from "postal-mime";
class MyAgent extends Agent {
async onEmail(email) {
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.

Replying to emails

Use this.replyToEmail() to send a reply through the inbound email's reply channel:

JavaScript
class MyAgent extends Agent {
async onEmail(email) {
await this.replyToEmail(email, {
fromName: "Support Bot", // Display name for the sender
subject: "Re: Your inquiry", // Optional, defaults to "Re: "
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
});
}
}

Deferred replies

replyToEmail() requires a live AgentEmail object, so it only works inside onEmail(). If you need to reply later — from a scheduled task, a callable method, or after a human-in-the-loop approval — store the sender info in state and use sendEmail():

JavaScript
class MyAgent extends Agent {
async onEmail(email) {
const raw = await email.getRaw();
const parsed = await PostalMime.parse(raw);
this.setState({
...this.state,
pendingReply: {
to: email.from,
messageId: parsed.messageId,
subject: parsed.subject,
},
});
}
@callable()
async sendDelayedReply(body) {
const { pendingReply } = this.state;
if (!pendingReply) return;
await this.sendEmail({
binding: this.env.EMAIL,
to: pendingReply.to,
from: "support@yourdomain.com",
subject: `Re: ${pendingReply.subject}`,
text: body,
inReplyTo: pendingReply.messageId,
secret: this.env.EMAIL_SECRET,
});
}
}

The inReplyTo field sets the In-Reply-To header so mail clients thread the reply correctly. The secret signs the agent routing headers so that follow-up replies route back to this agent instance via createSecureReplyEmailResolver.

Forwarding emails

JavaScript
class MyAgent extends Agent {
async onEmail(email) {
await email.forward("admin@example.com");
}
}

Rejecting emails

JavaScript
class MyAgent extends Agent {
async onEmail(email) {
if (isSpam(email)) {
email.setReject("Message rejected as spam");
return;
}
// Process the email...
}
}

Error handling

When sending emails via sendEmail() or replyToEmail(), handle these common errors:

JavaScript
class MyAgent extends Agent {
async onEmail(email) {
try {
await this.replyToEmail(email, {
fromName: "Support Bot",
body: "Thanks for your email!",
});
} catch (error) {
switch (error.code) {
case "E_SENDER_NOT_VERIFIED":
console.error("Sender domain not verified. Verify in dashboard.");
break;
case "E_RATE_LIMIT_EXCEEDED":
console.error("Rate limit exceeded. Back off and retry.");
break;
case "E_DAILY_LIMIT_EXCEEDED":
console.error("Daily sending quota reached.");
break;
case "E_CONTENT_TOO_LARGE":
console.error("Email content exceeds size limit.");
break;
default:
console.error("Email sending failed:", error.message);
}
}
}
}

Common error codes

Error CodeDescriptionSolution
E_SENDER_NOT_VERIFIEDSender domain/address not verifiedVerify in Cloudflare dashboard
E_RATE_LIMIT_EXCEEDEDSending rate limit reachedImplement exponential backoff
E_DAILY_LIMIT_EXCEEDEDDaily quota exceededWait for quota reset or upgrade plan
E_CONTENT_TOO_LARGEEmail exceeds size limitReduce attachments or content
E_RECIPIENT_NOT_ALLOWEDRecipient not in allowed listCheck allowed destination addresses
E_RECIPIENT_SUPPRESSEDRecipient is on suppression listRemove from suppression list
E_VALIDATION_ERRORInvalid email formatCheck email addresses
E_TOO_MANY_RECIPIENTSMore than 50 recipientsSplit into multiple sends

Secure reply routing

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.

How it works

  1. Outbound: When you call replyToEmail() or sendEmail() with a secret, the agent signs the routing headers (X-Agent-Name, X-Agent-ID) using HMAC-SHA256.
  2. Inbound: createSecureReplyEmailResolver verifies the signature before routing.
  3. Enforcement: If an email was routed via the secure resolver, replyToEmail() requires a secret (or explicit null to opt-out).

Setup

  1. Add a secret to your Worker:

    JSONC
    {
    "$schema": "./node_modules/wrangler/config-schema.json",
    "vars": {
    "EMAIL_SECRET": "change-me-in-production"
    }
    }

    For production, use Wrangler secrets instead:

    Terminal window
    npx wrangler secret put EMAIL_SECRET
  2. Use the combined resolver pattern:

    JavaScript
    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);
    },
    });
    },
    };
  3. Sign outbound emails:

    JavaScript
    class MyAgent extends Agent {
    async onEmail(email) {
    await this.replyToEmail(email, {
    fromName: "My Agent",
    body: "Thanks for your email!",
    secret: this.env.EMAIL_SECRET, // Signs the routing headers
    });
    }
    }

Enforcement behavior

When an email is routed via createSecureReplyEmailResolver, the replyToEmail() method enforces signing:

secret valueBehavior
"my-secret"Signs headers (secure)
undefined (omitted)Throws error - must provide secret or explicit opt-out
nullAllowed but not recommended - explicitly opts out of signing

Complete example

Here is a complete Email Service agent that sends outbound mail and handles secure replies:

JavaScript
import { Agent, callable, routeAgentEmail } from "agents";
import {
createAddressBasedEmailResolver,
createSecureReplyEmailResolver,
} from "agents/email";
import PostalMime from "postal-mime";
export class EmailAgent extends Agent {
@callable()
async sendWelcome(to) {
return this.sendEmail({
binding: this.env.EMAIL,
to,
from: "support@yourdomain.com",
subject: "Welcome!",
text: "Thanks for signing up.",
secret: this.env.EMAIL_SECRET,
});
}
async onEmail(email) {
const raw = await email.getRaw();
const parsed = await PostalMime.parse(raw);
console.log(`Email from ${email.from}: ${parsed.subject}`);
const emails = this.state.emails || [];
emails.push({
from: email.from,
subject: parsed.subject,
receivedAt: new Date().toISOString(),
});
this.setState({ ...this.state, emails });
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) {
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) => {
const replyRouting = await secureReplyResolver(email, env);
if (replyRouting) return replyRouting;
return addressResolver(email, env);
},
onNoRoute: (email) => {
console.warn(`No route found for email from ${email.from}`);
email.setReject("Unknown recipient");
},
});
},
};

API reference

sendEmail

TypeScript
async sendEmail(options: {
binding: EmailSendBinding;
to: string | string[];
from: string | { email: string; name?: string };
subject: string;
text?: string;
html?: string;
replyTo?: string | { email: string; name?: string };
cc?: string | string[];
bcc?: string | string[];
inReplyTo?: string;
headers?: Record<string, string>;
secret?: string;
}): Promise<EmailSendResult>;

Send an outbound email through the Email Service binding. Automatically injects X-Agent-Name and X-Agent-ID headers. When secret is provided, signs headers with HMAC-SHA256 for secure reply routing.

OptionDescription
bindingThe send_email binding (for example, this.env.EMAIL). Required.
toRecipient address or array of addresses
fromSender address, or \{ email, name \} object
subjectEmail subject line
textPlain text body (at least one of text/html required)
htmlHTML body (at least one of text/html required)
replyToReply-to address for the recipient
ccCC recipient(s)
bccBCC recipient(s)
inReplyToMessage-ID for threading (sets the In-Reply-To header)
headersAdditional custom headers (agent headers take precedence if they collide)
secretSecret for HMAC signing of agent routing headers

routeAgentEmail

TypeScript
function routeAgentEmail<Env>(
email: ForwardableEmailMessage,
env: Env,
options: {
resolver: EmailResolver;
onNoRoute?: (email: ForwardableEmailMessage) => void | Promise<void>;
},
): Promise<void>;

Routes an incoming email to the appropriate Agent based on the resolver's decision.

OptionDescription
resolverFunction that determines which agent to route the email to
onNoRouteOptional 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.

createSecureReplyEmailResolver

TypeScript
function createSecureReplyEmailResolver(
secret: string,
options?: {
maxAge?: number;
onInvalidSignature?: (
email: ForwardableEmailMessage,
reason: SignatureFailureReason,
) => void;
},
): EmailResolver;
type SignatureFailureReason =
| "missing_headers"
| "expired"
| "invalid"
| "malformed_timestamp";

Creates a resolver for routing email replies with signature verification.

OptionDescription
secretSecret key for HMAC verification (must match the key used to sign)
maxAgeMaximum age of signature in seconds (default: 30 days / 2592000 seconds)
onInvalidSignatureOptional callback for logging when signature verification fails

signAgentHeaders

TypeScript
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.

Next steps