Files
dotfiles/dot_claude/skills/cloudflare-email-service/references/sending.md
T
Prad NukalaandClaude Fable 5 20891f3910 config(git,claude,ssh): templated gitconfig; claude settings/commands/hooks/skills; ssh config
gitconfig: name/email from chezmoi data, gh credential helper by PATH
lookup (portable to darwin). claude: no credentials, history, projects,
sessions, or plugin caches. ssh config as private_ (0600).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-09 14:53:46 -04:00

9.3 KiB

Sending Emails — Workers Binding & Agents SDK

Send emails from Cloudflare Workers using the native binding, or from AI agents using the Agents SDK. If your app is NOT on Workers, use the REST API instead.

Workers Binding

Configuration

// wrangler.jsonc
{
  "send_email": [
    { "name": "EMAIL" }
  ]
}

For local development, add "remote": true so email sends are proxied to the real service:

{ "send_email": [{ "name": "EMAIL", "remote": true }] }

Run npx wrangler types to auto-generate the Env interface with your EMAIL binding. This produces a worker-configuration.d.ts with the real SendEmail, EmailAttachment, EmailAddress, and related types from the workerd runtime. Always use these generated types — do not define them manually.

Note: Workers binding uses email in the from object (EmailAddress type). REST API uses address. See rest-api.md.

send()

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    const response = await env.EMAIL.send({
      to: "user@example.com",
      from: { email: "welcome@yourdomain.com", name: "My App" },
      subject: "Welcome!",
      html: "<h1>Welcome!</h1><p>Thanks for signing up.</p>",
      text: "Welcome! Thanks for signing up.",
    });
    return new Response(`Sent: ${response.messageId}`);
  },
} satisfies ExportedHandler<Env>;

Multiple recipients (max 50 combined to + cc + bcc):

const response = await env.EMAIL.send({
  to: ["user1@example.com", "user2@example.com"],
  cc: ["manager@company.com"],
  bcc: ["archive@company.com"],
  from: { email: "orders@yourdomain.com", name: "Orders" },
  replyTo: "support@yourdomain.com",
  subject: "Order Confirmation #12345",
  html: "<h1>Your order is confirmed</h1>",
  text: "Your order is confirmed",
});

Attachments

Important: The Workers binding and REST API handle attachment content differently:

  • Workers binding: content accepts string | ArrayBuffer | ArrayBufferView. Strings are treated as raw content (not base64). For text files, pass the raw string. For binary files (images, PDFs), pass an ArrayBuffer. Note: ArrayBuffer does not work with "remote": true in local dev — deploy to test binary attachments.
  • REST API: content is always a base64-encoded string. See rest-api.md.
// Text file attachment — pass raw string content
const response = await env.EMAIL.send({
  to: "customer@example.com",
  from: "invoices@yourdomain.com",
  subject: "Your Report",
  html: "<h1>Report attached</h1>",
  text: "Report attached.",
  attachments: [{
    content: "Name,Amount\nWidget A,100\nWidget B,250", // Raw text, NOT base64
    filename: "report.csv",
    type: "text/csv",
    disposition: "attachment",
  }],
});

// Binary file attachment — use ArrayBuffer
const pdfBytes = await fetchPdfFromSomewhere(); // Returns ArrayBuffer
const response = await env.EMAIL.send({
  to: "customer@example.com",
  from: "invoices@yourdomain.com",
  subject: "Your Invoice",
  html: "<h1>Invoice attached</h1>",
  text: "Invoice attached.",
  attachments: [{
    content: pdfBytes,
    filename: "invoice-12345.pdf",
    type: "application/pdf",
    disposition: "attachment",
  }],
});

// Inline image — reference in HTML with cid:<contentId>
const imageBytes = await fetchImageFromSomewhere(); // Returns ArrayBuffer
const response = await env.EMAIL.send({
  to: "user@example.com",
  from: "marketing@yourdomain.com",
  subject: "New Product",
  html: '<img src="cid:product-hero" alt="Product" />',
  attachments: [{
    content: imageBytes,
    filename: "product.png",
    type: "image/png",
    disposition: "inline",
    contentId: "product-hero",
  }],
});

Total email size (body + attachments) cannot exceed 25 MiB.

Custom Headers

Only whitelisted headers allowed. See the headers reference.

const response = await env.EMAIL.send({
  to: "user@example.com",
  from: "notifications@yourdomain.com",
  subject: "Your weekly digest",
  html: "<h1>Weekly Digest</h1>",
  headers: {
    "In-Reply-To": "<original-message-id@yourdomain.com>",
    "List-Unsubscribe": "<https://yourdomain.com/unsubscribe?id=abc123>",
    "List-Unsubscribe-Post": "List-Unsubscribe=One-Click",
    "X-Campaign-ID": "weekly-digest-2026-03",
  },
});

Legacy EmailMessage API

The EmailMessage API remains supported. Uses raw MIME via mimetext:

import { EmailMessage } from "cloudflare:email";
import { createMimeMessage } from "mimetext";

const msg = createMimeMessage();
msg.setSender({ name: "Sender", addr: "sender@yourdomain.com" });
msg.setRecipient("recipient@example.com");
msg.setSubject("Hello");
msg.addMessage({ contentType: "text/html", data: "<h1>Hello</h1>" });

await env.EMAIL.send(new EmailMessage("sender@yourdomain.com", "recipient@example.com", msg.asRaw()));

Requires npm install mimetext and "nodejs_compat" in compatibility flags.

Agents SDK Email

Agents can receive and reply to emails natively via the Agents SDK.

// wrangler.jsonc
{
  "durable_objects": {
    "bindings": [{ "name": "EmailAgent", "class_name": "EmailAgent" }]
  },
  "migrations": [{ "tag": "v1", "new_sqlite_classes": ["EmailAgent"] }],
  "send_email": [{ "name": "EMAIL", "destination_address": "reply@yourdomain.com" }]
}
import { Agent } from "agents";
import { type AgentEmail } from "agents/email";
import PostalMime from "postal-mime";

export class EmailAgent extends Agent<Env, State> {
  async onEmail(email: AgentEmail) {
    const parsed = await PostalMime.parse(await email.getRaw());
    await this.replyToEmail(email, {
      fromName: "My Agent",
      subject: `Re: ${parsed.subject}`,
      body: "Thanks for your email! I'll look into this.",
    });
  }
}

Route emails to agents with resolvers:

import { routeAgentEmail } from "agents";
import { createAddressBasedEmailResolver } from "agents/email";

export default {
  async email(message, env) {
    await routeAgentEmail(message, env, {
      resolver: createAddressBasedEmailResolver("EmailAgent"),
    });
  },
};

Resolver types: createAddressBasedEmailResolver (recipient → instance name), createSecureReplyEmailResolver(secret) (HMAC-signed replies), createCatchAllEmailResolver("Agent", "default") (single inbox). Use isAutoReplyEmail(email.headers) to skip vacation responders.

Error Handling

try {
  const response = await env.EMAIL.send({ /* ... */ });
} catch (error) {
  // error.code is one of the E_* error codes
  console.error(`Failed: ${error.code} - ${error.message}`);
}

These error codes are for the Workers binding (thrown as Error objects with .code and .message). The REST API returns standard Cloudflare API numeric error codes instead — see rest-api.md.

Error Code What It Means What to Do
E_VALIDATION_ERROR Invalid payload Check email format, required fields
E_FIELD_MISSING Required field missing Add to, from, or subject
E_TOO_MANY_RECIPIENTS Combined to/cc/bcc exceeds 50 Split into multiple sends
E_SENDER_NOT_VERIFIED Domain not onboarded Run wrangler email sending enable yourdomain.com or onboard in Dashboard
E_RECIPIENT_NOT_ALLOWED Recipient not in allowed list Add to allowed_destination_addresses
E_RECIPIENT_SUPPRESSED Address bounced or reported spam Remove from your list; check suppression list in Dashboard
E_SENDER_DOMAIN_NOT_AVAILABLE Domain not available for sending Complete domain onboarding
E_CONTENT_TOO_LARGE Content exceeds 25 MiB Reduce attachments or body
E_RATE_LIMIT_EXCEEDED Rate limit hit Retry with exponential backoff
E_DAILY_LIMIT_EXCEEDED Daily quota reached Wait or request limit increase
E_DELIVERY_FAILED SMTP delivery failure Check recipient address, retry if transient
E_INTERNAL_SERVER_ERROR Service temporarily unavailable Retry with exponential backoff
E_HEADER_NOT_ALLOWED Header not on whitelist Use an allowed header; see headers reference
E_HEADER_USE_API_FIELD Must use API field instead Set From, To, etc. via the dedicated API fields, not headers
E_HEADER_VALUE_INVALID Header value is malformed or empty Fix the value format (e.g., List-Unsubscribe needs angle-bracket URIs)
E_HEADER_VALUE_TOO_LONG Header value exceeds 2,048 bytes Shorten the header value
E_HEADER_NAME_INVALID Invalid header name Fix characters or keep under 100 bytes
E_HEADERS_TOO_LARGE Total headers exceed 16 KB Reduce number or size of custom headers
E_HEADERS_TOO_MANY More than 20 non-X headers Reduce to 20 or fewer whitelisted headers

For E_RATE_LIMIT_EXCEEDED and E_DELIVERY_FAILED, retry with exponential backoff. For validation errors (E_VALIDATION_ERROR, E_FIELD_MISSING, E_SENDER_NOT_VERIFIED), fix the request — retrying won't help.

Restricted Bindings

Restrict which from addresses a binding can use:

{
  "send_email": [{
    "name": "RESTRICTED_EMAIL",
    "allowed_sender_addresses": ["noreply@yourdomain.com", "support@yourdomain.com"]
  }]
}