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>
This commit is contained in:
Prad NukalaandClaude Fable 5 committed 2026-08-09 14:53:46 -04:00
1 parent 3c691e8dc5
commit 20891f3910
476 files changed
+59086 -7

No files matched your search

@@ -0,0 +1,125 @@
# CLI, MCP, and Project Setup
Manage Cloudflare Email Service from the command line and coding agents.
For full CLI reference, run `npx wrangler email --help`. For Dashboard setup, see the [getting started docs](https://developers.cloudflare.com/email-service/get-started/).
## Wrangler Email Commands
```
wrangler email routing
├── enable/disable <domain> # Toggle email routing
├── dns get <domain> # Show required DNS records
├── rules list/create/update/delete # Manage routing rules
└── addresses list/create/delete # Destination addresses (account-scoped)
wrangler email sending
├── enable/disable <domain> # Toggle email sending
├── dns get <domain> # Show sending DNS records (SPF, DKIM)
├── send --from --to ... # Send an email (builder flags)
└── send-raw --from --to ... # Send a raw MIME email
```
## Domain Setup
### Via Dashboard
1. Navigate to **Compute & AI** > **Email Service** > **Email Sending** (or **Email Routing**)
2. Select **Onboard Domain** > choose domain > **Add records and onboard**
This auto-adds SPF (TXT) and DKIM (CNAME/TXT) records. DNS usually propagates within 5-15 minutes.
### Via CLI
```bash
npx wrangler email sending enable yourdomain.com
npx wrangler email sending dns get yourdomain.com # Verify records
```
## Local Development
Add `"remote": true` to send real emails during `wrangler dev`:
```jsonc
{ "send_email": [{ "name": "EMAIL", "remote": true }] }
```
```bash
npx wrangler dev
```
Emails are actually sent — use test addresses you control. Remove `"remote": true` before deploying.
## Cloudflare MCP Server
If you have the [Cloudflare MCP server](https://github.com/cloudflare/mcp) (`https://mcp.cloudflare.com/mcp`) configured, you can manage Email Service through its `search` and `execute` tools.
Use `search` to find email sending endpoints:
```javascript
// search tool — find all email sending API endpoints
async () => {
const results = [];
for (const [path, methods] of Object.entries(spec.paths)) {
if (path.includes('email/sending')) {
for (const [method, op] of Object.entries(methods)) {
results.push({ method: method.toUpperCase(), path, summary: op.summary });
}
}
}
return results;
}
```
Then use `execute` to call them — for example, checking sending limits or sending an email:
```javascript
// execute tool — check sending quota
async () => {
return cloudflare.request({
method: "GET",
path: `/accounts/${accountId}/email/sending/limits`
});
}
// execute tool — send an email
async () => {
return cloudflare.request({
method: "POST",
path: `/accounts/${accountId}/email/sending/send`,
body: {
to: "user@example.com",
from: { address: "notifications@yourdomain.com", name: "My App" },
subject: "Deployment Complete",
html: "<h1>Deployed!</h1>",
text: "Deployed!"
}
});
}
```
GraphQL analytics queries also work through `execute` — see [deliverability.md](deliverability.md#graphql-analytics-api) for query examples. Note that email analytics are **zone-level** datasets (`emailSendingAdaptiveGroups`, `emailSendingAdaptive`) queried under `viewer > zones`, and require the **Analytics Read** token permission.
## Sending from CLI / Agents
```bash
npx wrangler email sending send \
--from "agent@yourdomain.com" \
--to "developer@company.com" \
--subject "Deployment Complete" \
--text "Your Worker was deployed successfully."
```
Or via REST API:
```bash
curl "https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/email/sending/send" \
--header "Authorization: Bearer ${CLOUDFLARE_API_TOKEN}" \
--header "Content-Type: application/json" \
--data '{
"to": "developer@company.com",
"from": {"address": "agent@yourdomain.com", "name": "Build Agent"},
"subject": "Deployment Complete",
"text": "Your Worker was deployed successfully."
}'
```
@@ -0,0 +1,285 @@
# Email Deliverability & Best Practices
For full details, see the [deliverability docs](https://developers.cloudflare.com/email-service/concepts/deliverability/) and [email authentication docs](https://developers.cloudflare.com/email-service/concepts/email-authentication/). All the monitoring endpoints below can be called via the [REST API](rest-api.md), [Wrangler CLI, or the Cloudflare MCP server](cli-and-mcp.md).
## What Cloudflare Handles
When you onboard a domain, Cloudflare auto-configures:
- **SPF** — TXT records authorizing Cloudflare's sending infrastructure
- **DKIM** — Records for cryptographic signing of outbound emails
- **IP reputation** — Managed sending infrastructure optimized for deliverability
- **Soft bounce retries** — Automatic exponential backoff for temporary failures
- **Suppression lists** — Hard-bounced addresses automatically blocked
- **Feedback loops** — ISP complaint signals processed and acted on
Consider adding a **DMARC** record if you don't have one: `v=DMARC1; p=quarantine; rua=mailto:dmarc-reports@yourdomain.com`
## Bounce Handling
**Hard bounces** — permanent failures (address doesn't exist, domain doesn't exist). Never retried. Address auto-added to suppression list. Sending to suppressed address returns `E_RECIPIENT_SUPPRESSED`.
**Soft bounces** — temporary failures (mailbox full, server down, greylisting). Cloudflare auto-retries with exponential backoff.
## Suppression Lists
**Account list** (your account) — spam complaints from recipients. Cloudflare integrates with Postmasters to auto-suppress. You can manually add/remove addresses in the Dashboard.
See the [suppressions docs](https://developers.cloudflare.com/email-service/concepts/suppressions/) for details.
## Your Responsibilities
### Content
- Include both HTML and plain text versions
- Use a recognizable sender name: `{ email: "noreply@app.com", name: "My App" }`
- Write honest subject lines — avoid ALL CAPS, excessive punctuation
- Include `List-Unsubscribe` headers for recurring emails
- Use full URLs from your domain — avoid URL shorteners
### List Quality
- Validate email addresses before sending
- Implement double opt-in for subscriptions
- Honor unsubscribe requests promptly
### Transactional Only
Email Service is for **transactional email** (triggered by user actions: signups, password resets, order confirmations). Marketing/bulk campaigns are not permitted — use a dedicated marketing platform.
## Monitoring Deliverability
### Dashboard
Per-domain and account-wide analytics are available in the Cloudflare dashboard:
1. Log in to the [Cloudflare dashboard](https://dash.cloudflare.com) and select your account.
2. Go to **Compute & AI** > **Email Service**.
3. Select a domain or view account-wide metrics.
4. Select the **Analytics** tab.
### Send Response
Every send (REST API or Workers binding) returns immediate delivery feedback. Check the response to track per-send outcomes:
```json
{
"result": {
"delivered": ["user@example.com"],
"permanent_bounces": ["bad@nonexistent.com"],
"queued": ["slow@recipient.com"]
}
}
```
Log these to build your own delivery rate metrics.
### Sending Limits
Check your account's daily sending quota:
```bash
curl "https://api.cloudflare.com/client/v4/accounts/{account_id}/email/sending/limits" \
--header "Authorization: Bearer <API_TOKEN>"
```
Returns:
```json
{
"result": {
"quota": { "value": 5000, "unit": "day" }
}
}
```
### Suppression List
Addresses that hard-bounced or received spam complaints are auto-suppressed. You can query and manage suppressions via the API.
**List suppressions (account-wide):**
```bash
curl "https://api.cloudflare.com/client/v4/accounts/{account_id}/email/sending/suppression?page=1&per_page=100&order=created_at&direction=desc" \
--header "Authorization: Bearer <API_TOKEN>"
```
Returns:
```json
{
"page": 1,
"per_page": 100,
"total": 2,
"result": [
{
"id": "396a5436-d4b0-42a6-b3fc-48e8fa522321",
"email": "bounced@example.com",
"reason": "hard_bounce",
"created_at": "2026-03-15T10:00:00Z",
"expires_at": null
}
]
}
```
Query params: `page`, `per_page` (max 1000), `order` (`email` | `expires_at` | `created_at`), `direction` (`asc` | `desc`).
**Manually suppress an address:**
```bash
curl -X POST "https://api.cloudflare.com/client/v4/accounts/{account_id}/email/sending/suppression" \
--header "Authorization: Bearer <API_TOKEN>" \
--header "Content-Type: application/json" \
--data '{ "email": "user@example.com", "expires_at": "2026-06-01T00:00:00Z" }'
```
`expires_at` is optional — omit for permanent suppression.
**Remove a suppression:**
```bash
curl -X DELETE "https://api.cloudflare.com/client/v4/accounts/{account_id}/email/sending/suppression/{suppression_id}" \
--header "Authorization: Bearer <API_TOKEN>"
```
Zone-level suppressions are also available at `/zones/{zone_id}/email/sending/suppression` with the same interface.
### GraphQL Analytics API
Email Service exposes two zone-level datasets via the [GraphQL Analytics API](https://developers.cloudflare.com/analytics/graphql-api/). You can explore the schema interactively at [graphql.cloudflare.com/explorer](https://graphql.cloudflare.com/explorer). Metrics are retained for 31 days.
| Dataset | Description |
|---------|-------------|
| `emailSendingAdaptiveGroups` | Aggregated counts grouped by dimensions (status, date, domain, auth results, etc.) |
| `emailSendingAdaptive` | Individual email events with full detail (from, to, subject, messageId, errors, etc.) |
These are **zone-level** datasets — query under `viewer > zones`, not `accounts`.
**Aggregated dimensions** (`emailSendingAdaptiveGroups`):
| Dimension | Type | Description |
|-----------|------|-------------|
| `date` | Date | Day-level grouping |
| `datetime` | Time | Exact timestamp (also: `datetimeMinute`, `datetimeFiveMinutes`, `datetimeFifteenMinutes`, `datetimeHour`) |
| `status` | string | Delivery status |
| `eventType` | string | Event type |
| `sendingDomain` | string | The sending domain |
| `envelopeTo` | string | Recipient address |
| `errorCause` | string | Error cause for failed sends |
| `arc`, `dkim`, `dmarc`, `spf` | string | Email authentication results |
| `isSpam`, `isNDR`, `isLastEvent` | uint8 | Boolean flags |
| `spamScore`, `spamThreshold` | uint32 | Spam scoring |
**Individual event fields** (`emailSendingAdaptive`) additionally include: `from`, `to`, `subject`, `messageId`, `sessionId`, `errorDetail`.
**Email counts by status and date:**
```graphql
query EmailSendingByStatus(
$zoneTag: string!
$start: Date!
$end: Date!
) {
viewer {
zones(filter: { zoneTag: $zoneTag }) {
emailSendingAdaptiveGroups(
filter: { date_geq: $start, date_leq: $end }
limit: 10000
orderBy: [date_DESC]
) {
count
dimensions {
date
status
}
}
}
}
}
```
**Filter by status (e.g. only failures):**
```graphql
query EmailFailures(
$zoneTag: string!
$start: Date!
$end: Date!
) {
viewer {
zones(filter: { zoneTag: $zoneTag }) {
emailSendingAdaptiveGroups(
filter: { date_geq: $start, date_leq: $end, status: "deliveryFailed" }
limit: 10000
orderBy: [date_DESC]
) {
count
dimensions {
date
errorCause
sendingDomain
}
}
}
}
}
```
**Individual email events (troubleshooting):**
```graphql
query RecentEmailEvents(
$zoneTag: string!
$start: Time!
$end: Time!
) {
viewer {
zones(filter: { zoneTag: $zoneTag }) {
emailSendingAdaptive(
filter: { datetime_geq: $start, datetime_leq: $end }
limit: 50
orderBy: [datetime_DESC]
) {
datetime
from
to
subject
status
eventType
sendingDomain
messageId
errorCause
errorDetail
dkim
dmarc
spf
isSpam
}
}
}
}
```
Note: `emailSendingAdaptive` filters use `datetime_geq`/`datetime_leq` (Time type, e.g. `"2026-04-01T00:00:00Z"`), while `emailSendingAdaptiveGroups` uses `date_geq`/`date_leq` (Date type, e.g. `"2026-04-01"`).
**curl example:**
```bash
curl "https://api.cloudflare.com/client/v4/graphql" \
--header "Authorization: Bearer <API_TOKEN>" \
--header "Content-Type: application/json" \
--data '{
"query": "query($zoneTag:string!,$start:Date!,$end:Date!){viewer{zones(filter:{zoneTag:$zoneTag}){emailSendingAdaptiveGroups(filter:{date_geq:$start,date_leq:$end},limit:10000,orderBy:[date_DESC]){count,dimensions{date,status}}}}}",
"variables": {
"zoneTag": "<ZONE_ID>",
"start": "2026-03-15",
"end": "2026-04-15"
}
}'
```
## Metrics to Watch
| Metric | Target | If Out of Range |
|--------|--------|-----------------|
| Delivery rate | > 95% | Check for invalid addresses; verify DNS records |
| Hard bounce rate | < 2% | Clean your email list |
| Complaint rate | < 0.1% | Make unsubscribe easier; stop unwanted emails |
@@ -0,0 +1,184 @@
# Sending Emails — REST API
Send emails via HTTP requests from any application. If your app runs on Cloudflare Workers, use the [Workers binding](sending.md) instead — simpler and more performant.
For the full OpenAPI specification, refer to the [Email Sending API reference](https://developers.cloudflare.com/api/resources/email_sending/methods/send).
## Endpoint
```
POST https://api.cloudflare.com/client/v4/accounts/{account_id}/email/sending/send
```
## Authentication
Authenticate with a [Cloudflare API token](https://developers.cloudflare.com/fundamentals/api/get-started/create-token/) that has email sending permission:
```
Authorization: Bearer <API_TOKEN>
```
## Request Fields
**IMPORTANT:** The REST API uses different field names than the Workers binding in some cases.
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `to` | string or string[] | Yes | Recipient(s), max 50 combined with cc/bcc |
| `from` | string or `{address, name}` | Yes | Sender — **uses `address` not `email`** for object form |
| `subject` | string | Yes | Email subject line |
| `html` | string | No* | HTML body |
| `text` | string | No* | Plain text body |
| `cc` | string or string[] | No | CC recipients |
| `bcc` | string or string[] | No | BCC recipients |
| `reply_to` | string or `{address, name}` | No | **Snake_case**, not camelCase |
| `attachments` | array | No | File attachments and inline images |
| `headers` | object | No | Custom email headers |
*At least one of `html` or `text` required. Include both for best deliverability.
**Key differences from Workers binding:** `from` uses `address` (REST) vs `email` (Workers), `reply_to` (REST) vs `replyTo` (Workers), `content_id` (REST) vs `contentId` (Workers) for inline attachments, errors use numeric codes (REST) vs string `E_*` codes (Workers), and responses return delivery status (`delivered`/`permanent_bounces`/`queued`) instead of just `messageId`.
## Examples
Simple send:
```bash
curl "https://api.cloudflare.com/client/v4/accounts/{account_id}/email/sending/send" \
--header "Authorization: Bearer <API_TOKEN>" \
--header "Content-Type: application/json" \
--data '{
"to": "user@example.com",
"from": "welcome@yourdomain.com",
"subject": "Welcome!",
"html": "<h1>Welcome!</h1><p>Thanks for signing up.</p>",
"text": "Welcome! Thanks for signing up."
}'
```
Multiple recipients with CC/BCC:
```bash
curl "https://api.cloudflare.com/client/v4/accounts/{account_id}/email/sending/send" \
--header "Authorization: Bearer <API_TOKEN>" \
--header "Content-Type: application/json" \
--data '{
"to": ["user1@example.com", "user2@example.com"],
"cc": ["manager@company.com"],
"bcc": ["archive@company.com"],
"from": { "address": "orders@yourdomain.com", "name": "Orders" },
"reply_to": "support@yourdomain.com",
"subject": "Order Confirmation",
"html": "<h1>Your order is confirmed</h1>",
"text": "Your order is confirmed"
}'
```
With attachments:
```bash
curl "https://api.cloudflare.com/client/v4/accounts/{account_id}/email/sending/send" \
--header "Authorization: Bearer <API_TOKEN>" \
--header "Content-Type: application/json" \
--data '{
"to": "customer@example.com",
"from": "invoices@yourdomain.com",
"subject": "Your Invoice",
"html": "<h1>Invoice attached</h1>",
"attachments": [
{
"content": "JVBERi0xLjQKJeLjz9MK...",
"filename": "invoice.pdf",
"type": "application/pdf",
"disposition": "attachment"
}
]
}'
```
With inline images (embedded via `cid:` URI):
```bash
curl "https://api.cloudflare.com/client/v4/accounts/{account_id}/email/sending/send" \
--header "Authorization: Bearer <API_TOKEN>" \
--header "Content-Type: application/json" \
--data '{
"to": "user@example.com",
"from": "newsletter@yourdomain.com",
"subject": "Check this out",
"html": "<h1>Hello!</h1><img src=\"cid:logo\">",
"attachments": [
{
"content": "iVBORw0KGgoAAAANSUhEUg...",
"filename": "logo.png",
"type": "image/png",
"disposition": "inline",
"content_id": "logo"
}
]
}'
```
Attachment fields differ by type:
- **Regular attachment**: `content` (base64), `filename`, `type` (MIME), `disposition: "attachment"`
- **Inline image**: `content` (base64), `filename`, `type` (MIME), `disposition: "inline"`, `content_id` (referenced in HTML as `cid:<content_id>`)
With custom headers:
```bash
curl "https://api.cloudflare.com/client/v4/accounts/{account_id}/email/sending/send" \
--header "Authorization: Bearer <API_TOKEN>" \
--header "Content-Type: application/json" \
--data '{
"to": "user@example.com",
"from": "notifications@yourdomain.com",
"subject": "Your weekly digest",
"html": "<h1>Weekly Digest</h1>",
"headers": {
"List-Unsubscribe": "<https://yourdomain.com/unsubscribe?id=abc123>",
"List-Unsubscribe-Post": "List-Unsubscribe=One-Click",
"X-Campaign-ID": "weekly-digest-2026-03"
}
}'
```
See the [email headers reference](https://developers.cloudflare.com/email-service/reference/headers/) for the full list of allowed headers.
## Response
Success:
```json
{
"success": true,
"errors": [],
"messages": [],
"result": {
"delivered": ["recipient@example.com"],
"permanent_bounces": [],
"queued": []
}
}
```
Error (numeric codes, not `E_*` string codes like Workers):
```json
{
"success": false,
"errors": [{ "code": 1000, "message": "Sender domain not verified" }],
"result": null
}
```
## Error Handling
| Status | Meaning | Retry? |
|--------|---------|--------|
| 200 | Success | N/A |
| 400 | Validation error | No — fix the request |
| 401 | Invalid API token | No — check your token |
| 429 | Rate limited | Yes — exponential backoff |
| 500 | Server error | Yes — exponential backoff |
Only retry on 429 and 500. Validation errors (400) won't succeed on retry.
@@ -0,0 +1,199 @@
# Receiving & Routing Inbound Email
Handle incoming emails sent to your domain via a Worker's `email()` handler. Forward, reply, reject, or parse emails programmatically.
For full API details, see the [Email Routing docs](https://developers.cloudflare.com/email-service/api/route-emails/email-handler/).
## Email Handler
Export an `email()` function from your Worker. No special wrangler binding needed — a routing rule connects incoming addresses to your Worker.
```typescript
export default {
async email(message, env, ctx): Promise<void> {
console.log(`Email from ${message.from} to ${message.to}`);
await message.forward("team@company.com");
},
} satisfies ExportedHandler<Env>;
```
Set up routing rules in **Dashboard** > **Compute & AI** > **Email Service** > **Email Routing** > **Routing Rules**, or via `wrangler email routing rules create`.
## ForwardableEmailMessage
The `message` parameter is a `ForwardableEmailMessage`. Run `npx wrangler types` to get the full type definition. Key properties and methods:
- `message.from` / `message.to` — envelope addresses (SMTP MAIL FROM / RCPT TO). `message.from` is trustworthy; header addresses can be spoofed.
- `message.headers` — `Headers` object (use `.get("subject")`, `.get("message-id")`, etc.)
- `message.raw` — `ReadableStream<Uint8Array>` of raw MIME content. **Single use** — buffer before accessing.
- `message.rawSize` — size in bytes
- `message.setReject(reason)` — reject with a permanent SMTP error
- `message.forward(rcptTo, headers?)` — forward to a verified destination
- `message.reply(emailMessage)` — reply with an `EmailMessage` object
## Core Operations
### Forward
```typescript
await message.forward("team@company.com");
// With custom headers
await message.forward("team@company.com", new Headers({
"X-Original-Recipient": message.to,
}));
```
Destination must be verified first (Dashboard or `wrangler email routing addresses create`).
### Reject
```typescript
message.setReject("Your message was blocked");
```
### Reply
Using `env.EMAIL.send()` (recommended — no extra dependencies):
```typescript
async email(message, env, ctx) {
const subject = message.headers.get("subject") || "";
await env.EMAIL.send({
to: message.from,
from: message.to,
subject: `Re: ${subject}`,
html: "<p>Thanks! We'll respond shortly.</p>",
text: "Thanks! We'll respond shortly.",
});
await message.forward("team@company.com");
}
```
Using `message.reply()` with MIME (more control, requires `mimetext` + `nodejs_compat`):
```typescript
import { EmailMessage } from "cloudflare:email";
import { createMimeMessage } from "mimetext";
async email(message, env, ctx) {
const msg = createMimeMessage();
const messageId = message.headers.get("Message-ID");
if (messageId) msg.setHeader("In-Reply-To", messageId);
msg.setSender({ name: "Support", addr: "support@yourdomain.com" });
msg.setRecipient(message.from);
msg.setSubject("Re: " + (message.headers.get("subject") || ""));
msg.addMessage({ contentType: "text/plain", data: "Thanks for reaching out!" });
await message.reply(new EmailMessage("support@yourdomain.com", message.from, msg.asRaw()));
}
```
## Parsing Emails
Use [postal-mime](https://www.npmjs.com/package/postal-mime) to parse raw MIME content:
```typescript
import PostalMime from "postal-mime";
async email(message, env, ctx) {
const rawBuffer = await new Response(message.raw).arrayBuffer();
const parsed = await PostalMime.parse(rawBuffer);
console.log("Subject:", parsed.subject);
console.log("Text:", parsed.text);
console.log("Attachments:", parsed.attachments.length);
}
```
## Store and Reply Later (Human-in-the-Loop)
A common pattern is to store incoming emails in a Durable Object (SQLite) so a human or AI agent can review and reply later — rather than replying immediately in the `email()` handler. This enables support inboxes, approval workflows, and AI-drafted replies.
### Architecture
```
Email Routing → email() handler → parse + store in DO → user reviews later → reply via send_email binding
```
The `email()` handler stores the email and returns immediately. Replies happen later via a separate HTTP request or agent action.
### Receive and Store
```typescript
import PostalMime from "postal-mime";
export class MailboxDO extends DurableObject {
async storeEmail(from: string, to: string, subject: string, body: string,
messageId: string, inReplyTo: string | null) {
this.ctx.storage.sql.exec(
`INSERT INTO emails (sender, recipient, subject, body, message_id, in_reply_to, date, read)
VALUES (?, ?, ?, ?, ?, ?, datetime('now'), 0)`,
from, to, subject, body, messageId, inReplyTo
);
}
}
export default {
async email(message, env, ctx) {
const raw = await new Response(message.raw).arrayBuffer();
const parsed = await PostalMime.parse(raw);
const id = env.MAILBOX.idFromName(message.to);
const stub = env.MAILBOX.get(id);
await stub.storeEmail(
message.from,
message.to,
parsed.subject || "(no subject)",
parsed.text || parsed.html || "",
message.headers.get("message-id") || "",
message.headers.get("in-reply-to") || null,
);
// Optionally trigger an AI agent to draft a reply (non-blocking)
// ctx.waitUntil(notifyAgent(env, message.to, emailId));
},
} satisfies ExportedHandler<Env>;
```
### Reply Later
When a user (or agent) decides to reply, build proper threading headers and send via the `send_email` binding:
```typescript
// In an HTTP handler or agent tool — not in the email() handler
async function replyToStoredEmail(env: Env, original: StoredEmail, replyBody: string) {
// Build threading headers (In-Reply-To + References per RFC 2822)
const headers: Record<string, string> = {};
if (original.messageId) {
headers["In-Reply-To"] = original.messageId;
headers["References"] = original.messageId;
}
await env.EMAIL.send({
to: original.sender,
from: original.recipient,
subject: `Re: ${original.subject}`,
text: replyBody,
html: `<p>${replyBody}</p>`,
headers,
});
}
```
### Key Design Points
- **Buffer `message.raw` once**, parse with `postal-mime`, then store structured fields. Don't store the raw stream.
- **Extract `Message-ID`, `In-Reply-To`, and `References`** headers during ingest for threading. Fall back to subject-based matching for emails without threading headers.
- **Use Durable Object SQLite** for per-mailbox storage — each mailbox gets its own DO instance keyed by email address, providing natural isolation.
- **Store attachments separately** in R2 (binary blobs), with metadata in SQLite.
- **Defer heavy work** (AI drafting, notifications) via `ctx.waitUntil()` so the `email()` handler returns quickly.
- **Never auto-send from the `email()` handler** in a human-in-the-loop flow. Store a draft, let the user review, then send via a separate action.
## Gotchas
- **`message.raw` is single-use.** Buffer first: `const raw = await new Response(message.raw).arrayBuffer()`
- **Destinations must be verified.** Forwarding to unverified addresses fails silently.
- **Handler must act.** If your handler returns without consuming raw, forwarding, or rejecting, the email is dropped.
- **DMARC/SPF for replies.** If sending replies, ensure your domain has proper SPF/DKIM records (auto-configured on domain onboarding).
@@ -0,0 +1,252 @@
# 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](rest-api.md) instead.
## Workers Binding
### Configuration
```jsonc
// wrangler.jsonc
{
"send_email": [
{ "name": "EMAIL" }
]
}
```
For local development, add `"remote": true` so email sends are proxied to the real service:
```jsonc
{ "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](rest-api.md).
## send()
```typescript
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):
```typescript
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](rest-api.md).
```typescript
// 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](https://developers.cloudflare.com/email-service/reference/headers/).
```typescript
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`:
```typescript
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.
```jsonc
// 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" }]
}
```
```typescript
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:
```typescript
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
```typescript
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](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](https://developers.cloudflare.com/email-service/reference/headers/) |
| `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:
```jsonc
{
"send_email": [{
"name": "RESTRICTED_EMAIL",
"allowed_sender_addresses": ["noreply@yourdomain.com", "support@yourdomain.com"]
}]
}
```