The “shared inbox” trap
Most SaaS products start with one team answering one shared inbox and grow into a per-customer inbox: tenant A sends to support@tenant-a.com, tenant B sends to support@tenant-b.com, and both addresses land at the same Worker. The naive shape — one row per inbound message, the tenant resolved by a regex on the recipient domain — works for the first hundred tickets and falls apart at the first data-residency audit.
The fix is to commit to the right boundary up front: each tenant is a separate domain with its own Cloudflare zone, its own Worker binding, its own D1 database. Multi-tenant stops meaning "rows mixed together with a tenant_id column" and starts meaning "tenants are physically separate accounts." Everything else — RBAC, audit, billing — inherits that separation.
The routing table that makes it possible
The Worker that processes inbound mail needs to know which tenant’s domain a message arrived at before it can decide what to do with it. The simplest signal is the to domain — that is the tenant id. The Worker inspects it, looks up the routing rule for the mailbox in D1, and resolves the action in one round-trip:
async function resolveRouting(env, toAddress) {
const [mailbox, domain] = toAddress.toLowerCase().split("@");
const row = await env.DB.prepare(
`SELECT tenant_id, action FROM route_rule
WHERE domain = ? AND mailbox = ? LIMIT 1`,
).bind(domain, mailbox).first();
if (!row) return null; // no rule → drop, do not bounce
return { tenantId: row.tenant_id, action: row.action };
}The action enum is small: "shared_inbox", "webhook", "drop". Keeping it small is what lets the rest of the pipeline stay simple. Adding a new destination shape is one row in the table, not a new code path.
Worker-side metadata injection
The Worker runs on every inbound message. That is the moment to attach the metadata the rest of the pipeline needs: tenant id, plan tier, account status, the URL of the webhook the tenant configured for inbound events. None of that lives in the email headers — it lives in your D1, looked up by the domain the message arrived at.
The enrichment is the part of a SaaS support platform that quietly gets monetized. The naive alternative is to call back into Flowmails from your ticketing service to resolve the tenant — every callback is a latency tax and a failure mode. Injecting the metadata at the Worker layer means your ticketing code sees a clean payload that already knows everything it needs.
async function enrich(env, message, tenantId) {
const tenant = await env.DB.prepare(
`SELECT plan, status, webhook_url FROM tenant WHERE id = ?`,
).bind(tenantId).first();
return {
tenantId,
plan: tenant.plan,
status: tenant.status,
webhookUrl: tenant.webhook_url,
messageId: message.headers.get("message-id"),
};
}Fan-out webhooks, one per tenant
Every tenant configures their own webhook URL during onboarding. The Worker holds a per-tenant delivery record, retries with exponential backoff on failure, and records the outcome so the dashboard can show a red dot when a tenant’s endpoint is down. The shape of the delivery is a single POST with a JSON body — the receiving service sees a clean envelope, not a raw email message.
This is the piece that turns an inbox into a platform. Each tenant now has a programmable inbound event stream keyed to their own domain; the worker is the multiplexer, the retry layer is the reliability layer, and the per-tenant URL is the tenant’s own integration point.
The D1 schema, scaled up
Four tables cover the multi-tenant shape: tenant, route_rule, ticket, message. Every row carries a tenant_id — not because the data is mixed, but because the dashboard wants to render the tenant name without a cross-database join. The composite indexes (tenant_id, mailbox, status) cover the inbox queries that drive the UI.
The schema is identical in shape to a single-tenant helpdesk. The difference is the index prefix — tenant_id is the first column on every index, which is what keeps one tenant’s burst of inbound traffic from affecting another tenant’s dashboard latency.
When to split accounts
A multi-tenant D1 is the right shape until one tenant needs a residency boundary the shared database cannot satisfy. That is the moment to spin up a new Cloudflare account for the tenant, run a separate Worker, and link the routing rule to the new binding. The migration is one record update in the central routing table — no message history moves, no schema changes, no rebuild.
Build the architecture so that split is one decision, not a migration project. The cost of designing for it up front is essentially zero; the cost of bolting it on later is enormous.
Where to read more
The SaaS support page is the persona-level view of the same architecture, and build a helpdesk in 30 lines is the single-tenant version. For the inbound wiring side of the same setup, the email webhook playbook covers the HMAC and retry contract. The route tables walkthrough is the matching piece for route_rule.