diff --git a/README.md b/README.md index e1e2eb724..0ed31f143 100644 --- a/README.md +++ b/README.md @@ -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) @@ -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 diff --git a/package.json b/package.json index 9d6f02729..311d4064d 100644 --- a/package.json +++ b/package.json @@ -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." } } }, diff --git a/workers/app.ts b/workers/app.ts index 607525f78..c8e8aa886 100644 --- a/workers/app.ts +++ b/workers/app.ts @@ -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"; @@ -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, ) { diff --git a/workers/index.ts b/workers/index.ts index fd3359ce7..dd9c99893 100644 --- a/workers/index.ts +++ b/workers/index.ts @@ -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; @@ -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; @@ -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 }); }); @@ -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: "" } }; @@ -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; } @@ -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 || "", diff --git a/workers/lib/email-helpers.ts b/workers/lib/email-helpers.ts index 1e640c681..d7d061e05 100644 --- a/workers/lib/email-helpers.ts +++ b/workers/lib/email-helpers.ts @@ -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 ───────────────────────────────────────────────────── /** diff --git a/workers/types.ts b/workers/types.ts index c89667274..810116b3b 100644 --- a/workers/types.ts +++ b/workers/types.ts @@ -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; +} diff --git a/wrangler.jsonc b/wrangler.jsonc index 53a65cd0d..98300c5b8 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -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": [