UNPKG

agents

Version:

A home for your AI agents

653 lines (513 loc) 22.4 kB
# Email Service Agents can send and receive email with Cloudflare's [Email Service](https://developers.cloudflare.com/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](https://developers.cloudflare.com/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](https://dash.cloudflare.com) 2. Navigate 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 `wrangler.jsonc`: ```jsonc { "send_email": [ { "name": "EMAIL", "remote": true // For local development } ] } ``` The `remote: true` option lets you call the real Email Service API during local development with `wrangler dev`. ## Quick Start ```ts import { Agent, callable, routeAgentEmail } from "agents"; import { createAddressBasedEmailResolver, type AgentEmail } from "agents/email"; import PostalMime from "postal-mime"; export class EmailAgent extends Agent { @callable() async sendWelcomeEmail(to: string) { 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: AgentEmail) { 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 ### Configuring the binding Add a `send_email` binding in `wrangler.jsonc`: ```jsonc { "send_email": [ { "name": "EMAIL", "remote": true } ] } ``` ### 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. ```ts @callable() async sendReceipt(to: string, orderId: string) { 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. ```ts 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.com` and `notificationagent+user123@example.com` both route to the `NotificationAgent` class. ### 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. ```ts 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:** ```ts 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. ```ts 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). ### Combining Resolvers You can combine resolvers to handle different scenarios: ```ts 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"); } }); } }; ``` ## Handling Emails in Your Agent ### The AgentEmail Interface When your agent's `onEmail` method is called, it receives an `AgentEmail` object: ```ts 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](https://www.npmjs.com/package/postal-mime) to parse the raw email: ```ts 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); } ``` ### Detecting Auto-Reply Emails Use `isAutoReplyEmail()` to detect auto-reply emails and avoid mail loops: ```ts 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. ### Replying to Emails Use `this.replyToEmail()` to send a reply through the inbound email's reply channel: ```ts 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 }); } ``` ### 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()`: ```ts async onEmail(email: AgentEmail) { 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: string) { 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 ```ts async onEmail(email: AgentEmail) { await email.forward("admin@example.com"); } ``` ### Rejecting Emails ```ts async onEmail(email: AgentEmail) { 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: ```ts async onEmail(email: AgentEmail) { 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 Code | Description | Solution | | ------------------------- | ---------------------------------- | ------------------------------------ | | `E_SENDER_NOT_VERIFIED` | Sender domain/address not verified | Verify in Cloudflare dashboard | | `E_RATE_LIMIT_EXCEEDED` | Sending rate limit reached | Implement exponential backoff | | `E_DAILY_LIMIT_EXCEEDED` | Daily quota exceeded | Wait for quota reset or upgrade plan | | `E_CONTENT_TOO_LARGE` | Email exceeds size limit | Reduce attachments or content | | `E_RECIPIENT_NOT_ALLOWED` | Recipient not in allowed list | Check allowed destination addresses | | `E_RECIPIENT_SUPPRESSED` | Recipient is on suppression list | Remove from suppression list | | `E_VALIDATION_ERROR` | Invalid email format | Check email addresses | | `E_TOO_MANY_RECIPIENTS` | More than 50 recipients | Split 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. Store the signing key as a Wrangler secret. Do not put it in `vars` or commit it to source control: ```bash pnpm exec wrangler secret put EMAIL_SECRET ``` 2. Use the combined resolver pattern: ```ts 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: ```ts 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 }); } ``` ### Enforcement Behavior 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 | ## Complete Example Here is a complete Email Service agent that sends outbound mail and handles secure replies: ```ts import { Agent, callable, routeAgentEmail } from "agents"; import { createAddressBasedEmailResolver, createSecureReplyEmailResolver, type AgentEmail } from "agents/email"; import PostalMime from "postal-mime"; interface Env { EmailAgent: DurableObjectNamespace<EmailAgent>; EMAIL: SendEmail; EMAIL_SECRET: string; } export class EmailAgent extends Agent<Env> { @callable() async sendWelcome(to: string) { 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: AgentEmail) { 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: 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"); } }); } } satisfies ExportedHandler<Env>; ``` ## API Reference ### sendEmail ```ts 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. | Option | Description | | ----------- | ------------------------------------------------------------------------- | | `binding` | The `send_email` binding (e.g. `this.env.EMAIL`). Required. | | `to` | Recipient address or array of addresses | | `from` | Sender address, or `{ email, name }` object | | `subject` | Email subject line | | `text` | Plain text body (at least one of `text`/`html` required) | | `html` | HTML body (at least one of `text`/`html` required) | | `replyTo` | Reply-to address for the recipient | | `cc` | CC recipient(s) | | `bcc` | BCC recipient(s) | | `inReplyTo` | Message-ID for threading (sets the `In-Reply-To` header) | | `headers` | Additional custom headers (agent headers take precedence if they collide) | | `secret` | Secret for HMAC signing of agent routing headers | ### routeAgentEmail ```ts 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. | ### createSecureReplyEmailResolver ```ts 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 | ### signAgentHeaders ```ts 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.