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