Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 22 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ https://github.com/cloudflare/agentic-inbox/issues/4#issuecomment-4269118513

### To set up

1. Deploy to Cloudflare. The deploy flow will automatically provision R2, Durable Objects, and Workers AI. You'll be prompted for **DOMAINS**, which is the domain (yourdomain.com) you want to receive emails for (email@yourdomain.com).
1. Deploy to Cloudflare. The deploy flow will automatically provision R2, Durable Objects, and Workers AI. You'll be prompted for **DOMAINS**, which is the domain (yourdomain.com) you want to receive emails for (email@yourdomain.com). To serve more than one domain from a single instance, pass a comma-separated list (e.g. `yourdomain.com,anotherdomain.com`) — see [Using multiple domains](#using-multiple-domains).

[![Deploy to Cloudflare](https://deploy.workers.cloudflare.com/button)](https://deploy.workers.cloudflare.com/?url=https://github.com/cloudflare/agentic-inbox)

Expand Down Expand Up @@ -59,9 +59,29 @@ npm run dev

### Configuration

1. Set your domain in `wrangler.jsonc`
1. Set your domain (or domains) in `wrangler.jsonc` via the `DOMAINS` var
2. Create an R2 bucket named `agentic-inbox`: `wrangler r2 bucket create agentic-inbox`

### Using multiple domains

A single instance can serve multiple domains. Set `DOMAINS` to a comma-separated list:

```jsonc
"DOMAINS": "example.com,another.com"
```

Then, for **each** domain:

- Add a catch-all [Email Routing](https://developers.cloudflare.com/email-routing/) rule that forwards to this Worker (for receiving)
- Verify the domain for [Email Service](https://developers.cloudflare.com/email-service/) (for sending)

Notes:

- The **New Mailbox** dialog shows a domain picker automatically once more than one domain is configured; mailbox creation is restricted to the configured domains.
- If you set `EMAIL_ADDRESSES` to restrict mailbox creation, it may list addresses across any of the configured domains (e.g. `["hello@example.com", "hi@another.com"]`).
- Incoming mail is filed by the **envelope recipient** -- the address Email Routing delivered to -- not by the `To:` header. This is what keeps domains apart when a message is addressed to several people at once, arrives via Bcc, or carries a forged `To:`.
- Adding a domain to `DOMAINS` does not by itself divert any mail. A domain only starts delivering here once you add its Email Routing catch-all rule, so you can configure a domain ahead of cutting its MX over.

### Deploy

```bash
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
"products": ["Workers", "Durable Objects", "R2", "Workers AI"],
"bindings": {
"DOMAINS": {
"description": "Your domain with [Email Routing](https://developers.cloudflare.com/email-routing/) enabled (e.g. `example.com`). After deploying, create a catch-all Email Routing rule pointing to this Worker."
"description": "Your domain with [Email Routing](https://developers.cloudflare.com/email-routing/) enabled (e.g. `example.com`). For multiple domains, pass a comma-separated list (e.g. `example.com,another.com`). After deploying, create a catch-all Email Routing rule pointing to this Worker for each domain."
}
}
},
Expand Down
4 changes: 2 additions & 2 deletions workers/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { jwtVerify, createRemoteJWKSet } from "jose";
import { createRequestHandler } from "react-router";
import { app as apiApp, receiveEmail } from "./index";
import { EmailMCP } from "./mcp";
import type { Env } from "./types";
import type { Env, IncomingEmailEvent } from "./types";

export { MailboxDO } from "./durableObject";
export { EmailAgent } from "./agent";
Expand Down Expand Up @@ -111,7 +111,7 @@ app.all("*", (c) => {
export default {
fetch: app.fetch,
async email(
event: { raw: ReadableStream; rawSize: number },
event: IncomingEmailEvent,
env: Env,
ctx: ExecutionContext,
) {
Expand Down
45 changes: 30 additions & 15 deletions workers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,12 @@ import {
generateMessageId,
buildThreadingHeaders,
listMailboxes,
resolveMailboxId,
} from "./lib/email-helpers";
import { SendEmailRequestSchema } from "./lib/schemas";
import { handleReplyEmail, handleForwardEmail } from "./routes/reply-forward";
import { Folders } from "../shared/folders";
import type { Env } from "./types";
import type { Env, IncomingEmailEvent } from "./types";
import { requireMailbox, type MailboxContext } from "./lib/mailbox";

type AppContext = Context<MailboxContext>;
Expand Down Expand Up @@ -50,6 +51,12 @@ function slugify(text: string) { // can return "" for non-alphanumeric input
.replace(/--+/g, "-").replace(/^-+/, "").replace(/-+$/, "");
}

// Parse the comma-separated DOMAINS var into a trimmed, non-empty list.
// Supports multiple domains on one instance, e.g. "example.com,another.com".
function parseDomains(raw: string | undefined): string[] {
return (raw || "").split(",").map((d) => d.trim()).filter(Boolean);
}

function intQuery(c: AppContext, key: string): number | undefined {
const v = c.req.query(key);
if (!v) return undefined;
Expand Down Expand Up @@ -86,8 +93,7 @@ app.use("/api/v1/mailboxes/:mailboxId/*", requireMailbox);
// -- Config ---------------------------------------------------------

app.get("/api/v1/config", (c) => {
const domainsRaw = c.env.DOMAINS || "";
const domains = domainsRaw.split(",").map((d) => d.trim()).filter(Boolean);
const domains = parseDomains(c.env.DOMAINS);
const emailAddresses = c.env.EMAIL_ADDRESSES ?? [];
return c.json({ domains, emailAddresses });
});
Expand All @@ -103,9 +109,18 @@ app.post("/api/v1/mailboxes", async (c) => {
const { name, settings, email: rawEmail } = CreateMailboxBody.parse(await c.req.json());
const email = rawEmail.toLowerCase();
const allowedAddresses = (c.env.EMAIL_ADDRESSES ?? []) as string[];
if (allowedAddresses.length > 0 && !allowedAddresses.map((a) => a.toLowerCase()).includes(email)) {
const isExplicitlyAllowed = allowedAddresses.map((a) => a.toLowerCase()).includes(email);
if (allowedAddresses.length > 0 && !isExplicitlyAllowed) {
return c.json({ error: "Mailbox creation is restricted to configured EMAIL_ADDRESSES" }, 403);
}
// When DOMAINS is configured, mailboxes must be on one of those domains — this mirrors the
// front-end domain picker. Explicit EMAIL_ADDRESSES entries bypass the check (they are the
// authoritative allow-list and may legitimately span domains).
const domains = parseDomains(c.env.DOMAINS);
const domain = email.split("@")[1];
if (!isExplicitlyAllowed && domains.length > 0 && (!domain || !domains.some((d) => d.toLowerCase() === domain))) {
return c.json({ error: "Mailbox domain must be one of the configured DOMAINS" }, 400);
}
const key = `mailboxes/${email}.json`;
if (await c.env.BUCKET.head(key)) return c.json({ error: "Mailbox already exists" }, 409);
const defaultSettings = { fromName: name, forwarding: { enabled: false, email: "" }, signature: { enabled: false, text: "" }, autoReply: { enabled: false, subject: "", message: "" } };
Expand Down Expand Up @@ -345,23 +360,23 @@ async function streamToArrayBuffer(stream: ReadableStream, streamSize: number) {
return result;
}

async function receiveEmail(event: { raw: ReadableStream; rawSize: number }, env: Env, ctx: ExecutionContext) {
async function receiveEmail(event: IncomingEmailEvent, env: Env, ctx: ExecutionContext) {
const rawEmail = await streamToArrayBuffer(event.raw, event.rawSize);
const parsedEmail = await new PostalMime().parse(rawEmail);

if (!parsedEmail.to?.length || !parsedEmail.to[0].address) throw new Error("received email with empty to");

const allowedAddresses = ((env.EMAIL_ADDRESSES ?? []) as string[]).map((a) => a.toLowerCase());
const allRecipients = parsedEmail.to.map((t) => t.address?.toLowerCase()).filter(Boolean) as string[];
const configuredDomains = parseDomains(env.DOMAINS).map((d) => d.toLowerCase());
const allRecipients = (parsedEmail.to || []).map((t) => t.address?.toLowerCase()).filter(Boolean) as string[];
const ccRecipients = (parsedEmail.cc || []).map((e) => e.address?.toLowerCase()).filter(Boolean) as string[];
const bccRecipients = (parsedEmail.bcc || []).map((e) => e.address?.toLowerCase()).filter(Boolean) as string[];

let mailboxId: string | undefined;
if (allowedAddresses.length > 0) {
mailboxId = allRecipients.find((addr) => allowedAddresses.includes(addr));
if (!mailboxId) { console.log(`Ignoring email: no recipient matches EMAIL_ADDRESSES.`); return; }
} else { mailboxId = allRecipients[0]; }
if (!mailboxId) throw new Error("received email with no valid recipient address");
const mailboxId = resolveMailboxId(
event.to,
[...allRecipients, ...ccRecipients, ...bccRecipients],
allowedAddresses,
configuredDomains,
);
if (!mailboxId) { console.log(`Ignoring email: no recipient matches EMAIL_ADDRESSES or DOMAINS.`); return; }

const messageId = crypto.randomUUID();
if (!(await env.BUCKET.head(`mailboxes/${mailboxId}.json`))) { console.log(`Ignoring email for ${mailboxId}: mailbox does not exist`); return; }
Expand Down Expand Up @@ -394,7 +409,7 @@ async function receiveEmail(event: { raw: ReadableStream; rawSize: number }, env

await stub.createEmail(Folders.INBOX, {
id: messageId, subject: parsedEmail.subject || "",
sender: (parsedEmail.from?.address || "").toLowerCase(), recipient: allRecipients.join(", "),
sender: (parsedEmail.from?.address || "").toLowerCase(), recipient: allRecipients.join(", ") || mailboxId,
cc: ccRecipients.join(", ") || null, bcc: bccRecipients.join(", ") || null,
date: new Date().toISOString(), // uses receive time, not the email's Date header
body: parsedEmail.html || parsedEmail.text || "",
Expand Down
49 changes: 49 additions & 0 deletions workers/lib/email-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,55 @@ export class SenderValidationError extends Error {
}
}

// ── Inbound Recipient Resolution ───────────────────────────────────

/**
* Decide which mailbox an inbound message belongs to.
*
* The envelope recipient (SMTP RCPT TO) is authoritative: it is the address Email
* Routing actually delivered to. Header addresses are not — with several domains in
* play the To: header may list another domain first, may omit our address entirely
* (Bcc), or may simply be forged. Headers are therefore only a fallback for events
* that carry no envelope (local dev, replayed messages).
*
* Returns the mailbox address, or null when the message should be ignored.
*
* @param envelopeTo Envelope recipient, if the runtime supplied one.
* @param headerRecipients To/Cc/Bcc addresses, in preference order.
* @param allowedAddresses EMAIL_ADDRESSES allow-list; empty means "no restriction".
* @param configuredDomains Parsed DOMAINS list; empty means "no restriction".
*/
export function resolveMailboxId(
envelopeTo: string | undefined,
headerRecipients: string[],
allowedAddresses: string[],
configuredDomains: string[],
): string | null {
const allowed = allowedAddresses.map((a) => a.trim().toLowerCase()).filter(Boolean);
const domains = configuredDomains.map((d) => d.trim().toLowerCase()).filter(Boolean);
const isAllowed = (addr: string) => allowed.length === 0 || allowed.includes(addr);
const onConfiguredDomain = (addr: string) => {
if (domains.length === 0) return true;
const domain = addr.split("@")[1];
return !!domain && domains.includes(domain);
};

const envelope = envelopeTo?.trim().toLowerCase();
if (envelope) {
// Deliberately not filtered by DOMAINS: a stale DOMAINS value must not silently drop
// mail that Email Routing was configured to deliver here. Mailbox existence in R2 is
// the real gate, and the caller checks it.
return isAllowed(envelope) ? envelope : null;
}

const candidates = headerRecipients.map((a) => a.trim().toLowerCase()).filter(Boolean);
return (
candidates.find((addr) => isAllowed(addr) && onConfiguredDomain(addr)) ??
candidates.find((addr) => isAllowed(addr)) ??
null
);
}

// ── Message ID ─────────────────────────────────────────────────────

/**
Expand Down
15 changes: 15 additions & 0 deletions workers/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,18 @@ export interface Env extends Cloudflare.Env {
POLICY_AUD: string;
TEAM_DOMAIN: string;
}

/**
* The subset of Cloudflare's `ForwardableEmailMessage` that inbound handling needs.
*
* `to` / `from` are the SMTP envelope addresses. `to` is what Email Routing actually
* delivered to, which is the authoritative mailbox for the message when more than one
* domain is served. Both are optional so replayed or hand-built events (local dev,
* tests) that carry only the raw stream still type-check.
*/
export interface IncomingEmailEvent {
raw: ReadableStream;
rawSize: number;
to?: string;
from?: string;
}
6 changes: 6 additions & 0 deletions wrangler.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,13 @@
// Production deploys must also define POLICY_AUD and TEAM_DOMAIN.
// TEAM_DOMAIN may be the base Access URL or the full /cdn-cgi/access/certs URL.
// The worker now fails closed outside local development if Access is not configured.
//
// DOMAINS accepts a single domain or a comma-separated list to serve multiple
// domains from one instance, e.g. "example.com,another.com". Each domain needs its
// own Email Routing catch-all rule, and must be verified for outbound sending.
"DOMAINS": "example.com",
// EMAIL_ADDRESSES optionally restricts mailbox creation to specific addresses, and
// may span the configured domains, e.g. ["hello@example.com", "hi@another.com"].
"EMAIL_ADDRESSES": []
},
"send_email": [
Expand Down