From cd8604363e24d4eb84c5801dcf9e1ec6db82564a Mon Sep 17 00:00:00 2001 From: Pierre Fouquet Date: Fri, 29 May 2026 09:28:27 +0100 Subject: [PATCH 1/2] Support multiple domains per inbox A single instance can already serve multiple domains by setting DOMAINS to a comma-separated list: the /api/v1/config endpoint splits it, and the New Mailbox dialog renders a domain picker when more than one is configured. This was undocumented, so users assumed it was unsupported (issue #8). Make it a documented, first-class feature and add a matching backend guard: - Add a parseDomains() helper and reuse it in /api/v1/config. - Reject mailbox creation on domains outside the configured DOMAINS, mirroring the front-end picker. Explicit EMAIL_ADDRESSES entries bypass the check, so the auto-create flow (which may span domains) is unaffected. No DOMAINS configured means no restriction, preserving existing behavior. - Document multi-domain setup in the README ("Using multiple domains"), wrangler.jsonc, and the deploy-time DOMAINS binding description. Closes #8 Co-Authored-By: Claude Opus 4.8 --- README.md | 22 ++++++++++++++++++++-- package.json | 2 +- workers/index.ts | 20 +++++++++++++++++--- wrangler.jsonc | 6 ++++++ 4 files changed, 44 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index e1e2eb724..ec6ec412f 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,27 @@ 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"]`). + ### 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/index.ts b/workers/index.ts index fd3359ce7..4301fe494 100644 --- a/workers/index.ts +++ b/workers/index.ts @@ -50,6 +50,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 +92,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 +108,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: "" } }; 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": [ From 04969a8c904cf7ec1d08ec9253e163429bb1ca26 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 3 Sep 2026 11:42:02 +0000 Subject: [PATCH 2/2] Route inbound mail by envelope recipient Serving more than one domain from one instance does not work reliably on the receive side. receiveEmail picks parsedEmail.to[0] -- the first address in the To: header -- as the mailbox. That is not the address the message was actually delivered to, so with several domains configured: - a message addressed to an external recipient first is filed against that address, finds no mailbox, and is silently dropped - a Bcc'd message, whose recipient never appears in the headers, is dropped - a forged To: header can steer a message at another mailbox Resolve the mailbox from the SMTP envelope recipient (event.to) instead, which is the address Email Routing delivered to and the only signal that separates domains correctly. Header addresses (To/Cc/Bcc) remain a fallback for events that carry no envelope, such as local dev or a replayed message, and that fallback now prefers an address on a configured domain over header order. The envelope path is deliberately not filtered by DOMAINS: a stale DOMAINS value must not silently drop mail that Email Routing was configured to deliver to the Worker. Mailbox existence in R2 remains the real gate. The decision is extracted into resolveMailboxId() in lib/email-helpers so the rule sits in one readable place, and IncomingEmailEvent widens the handler's event type to carry the envelope fields. Single-domain deployments are unaffected: with one configured domain there is only one address the envelope can name. Verified: typecheck and build pass; resolveMailboxId exercised against 15 cases covering two domains, Bcc-only delivery, forged headers, the EMAIL_ADDRESSES allow-list, stale DOMAINS, and the no-envelope fallback. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01UJmpmeXN1r87S3c2rJ7V8Y --- README.md | 2 ++ workers/app.ts | 4 +-- workers/index.ts | 25 +++++++++--------- workers/lib/email-helpers.ts | 49 ++++++++++++++++++++++++++++++++++++ workers/types.ts | 15 +++++++++++ 5 files changed, 81 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index ec6ec412f..0ed31f143 100644 --- a/README.md +++ b/README.md @@ -79,6 +79,8 @@ 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 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 4301fe494..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; @@ -359,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; } @@ -408,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; +}