Blog

/

Building a multi-tenant support inbox on Cloudflare Workers

A shared inbox sounds simple — support@yourdomain.com, one mailbox, several agents reading from it. The moment you serve more than one customer through the same product, the shared inbox becomes a multi-tenant inbox, and the questions change. This post is the architecture for that version of the problem: per-tenant routing, Worker-side metadata, fan-out webhooks, D1 isolation.

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.

One inbox per tenant, isolated by Cloudflare account.