All posts
Multi-provider email servers: one MX, many backends
Gabe 16 min read

Multi-provider email servers: one MX, many backends

One port 25 can front several mailbox backends at once — a cloud product, a self-hosted store, a staging box — each owning its own domains. How per-domain routing works in MailKite Server, what Postfix, Exim, Haraka, maddy and ZoneMTA do instead, and how the same split looks on a hosted ESP.

One MX edge, three backends A sender connects once to a single MX edge on port 25. The edge asks every backend for its accepted domains and builds a routing table. It then signs and POSTs each recipient only to the backend that owns that recipient's domain: MailKite Cloud, a self-hosted backend, or a staging backend. Each backend has its own secret and its own storage. sender MTA someone else's queue :25 MX edge (Haraka) one IP · one PTR · one TLS cert domain → owner RCPT checks the union queue fans out by owner all 2xx → 250, else 451 MailKite Cloud secret A · owns billing.example.com POST /api/ingest Self-hosted backend secret B · owns agents.example.com POST /api/ingest Staging backend secret C · owns staging.example.com POST /api/ingest

Each backend also serves GET /api/mx/accepted-domains, cached on the edge for 30 s.

The edge never stores mail. It decides who owns a domain, then signs the same raw message separately for each owning backend.

TL;DR: routing a domain to a different mail host is old hat — Postfix has transport_maps, Exim has routers. Routing a domain to an arbitrary HTTP backend, each with its own secret and its own claim list, is the part the classic stacks don’t give you. MailKite Server (the open-source project we maintain) adds it with one optional file, and the failure semantics are the interesting part.

The word “backend” here does not mean a database driver or a storage engine. It means a service that owns mail for a set of domains: it decides which recipients exist, stores the message, and does whatever comes next. A backend can be MailKite Cloud, the SQLite process in api-local/, another product with its own database, or a staging box. The MX edge is deliberately ignorant of all of it.

What a multi-backend email server is

One SMTP listener, several independent mailbox systems behind it. The edge maps every recipient domain to exactly one owner:

{
  "backends": [
    { "name": "cloud", "url": "https://api.mailkite.dev", "secretEnv": "MAILKITE_HMAC_SECRET" },
    { "name": "local", "url": "https://mail.internal.example", "secretEnv": "LOCAL_HMAC_SECRET" }
  ]
}

Put that in config/backends.json and the edge appends /api/mx/accepted-domains and /api/ingest to each base URL. Remove the file and it is a single-backend edge again, driven by MAILKITE_API_URL and MAILKITE_HMAC_SECRET. There is no migration and no second code path: the multi-backend router wraps the same cache and queue hooks the single-backend plugin used.

The secrets never live in that file. secretEnv is the name of an environment variable, so the routing table can be committed and reviewed without leaking credentials.

Why put several backends behind one MX

The common case is not “many tenants.” It’s “one organisation, several blast radii.”

  • Product split. A support inbox, an agent inbox, and a marketing domain can share an edge while each keeps its own database, retention policy, and incident surface. A bad deploy in one does not take the others offline.
  • Migration in slices. Add the new backend, give it one domain, and move domains over as they verify. The old backend keeps serving the rest until it has none.
  • Self-hosted and cloud side by side. Keep regulated mail on hardware you control and put the low-sensitivity product on a managed backend, behind the same PTR and TLS certificate.
  • Staging and test domains. A test backend can claim *.test.example without a second public IP or a second reputation to warm.
  • Vendor boundary. Two products can share port 25 without sharing a database, an admin UI, or a credential. Each backend sees only its own accepted domains and its own ingest traffic.

That last point is the one worth dwelling on. A shared MX is a physical-infrastructure decision. A shared backend is an application decision. Multi-backend routing lets you make them separately.

How the routing works

The edge makes two decisions, at two different SMTP phases.

At RCPT, each backend’s GET /api/mx/accepted-domains response is cached with a 30-second TTL. A recipient is accepted if any backend claims its domain, and the owning backend’s name is recorded on the transaction. A cache miss triggers a live re-check, so a domain verified a second ago is accepted on its first message. If a backend has never produced a list and the API is unreachable, the edge tempfails rather than bouncing a domain that backend might own.

At queue, recipients are grouped by owner. The raw RFC822 bytes are POSTed once per owning backend, signed with that backend’s secret, with x-mailkite-rcpt carrying only that backend’s recipients. Every group must return 2xx before the edge tells the sender 250. One failure turns the whole message into DENYSOFT, and the sender retries.

That is at-least-once delivery, not exactly-once. The diagram shows why it has to be:

A partial ingest failure becomes a retry A message addressed to one recipient on backend A and one on backend B is accepted at RCPT. At queue time the edge posts to both. A returns 200 and stores the message; B returns 500. The edge answers DENYSOFT, so the sender retries the entire message. On the retry, B stores it and A must deduplicate on recipient and content hash. 1 message · 2 recipients a@one.example · b@two.example RCPT accepts both owner noted per address queue groups by owner one POST per backend backend A → 200 stored backend B → 500 not stored edge → DENYSOFT (451) sender owns the retry sender retries the whole message both recipients, again A sees a duplicate dedupe on hash B stores it now durable Cross-backend recipient mixes are rare: one message to two organisations' domains at once.

The contract is at-least-once. Ingest must deduplicate on recipient + content hash. Transit headers are excluded from the hash.

All groups must succeed for a 250. A partial failure is a tempfail, not a silent half-delivery.

You can check a backend’s claim list before trusting the routing table:

curl -fsS -H "Authorization: Bearer $LOCAL_HMAC_SECRET" \
  https://mail.internal.example/api/mx/accepted-domains
# {"domains":["agents.example.com"]}

Two backends claiming the same domain is not an error the edge can resolve. Order is priority: the earlier entry wins and the edge logs the conflict once per domain. That makes appending a backend safe — a new backend cannot shadow an existing one — but it also means the file is an authority document, not a set of preferences.

What other open-source servers do

The classic MTAs solve a narrower version of this problem, and they solve it well. The distinction is what sits on the other end of the route.

ProjectWhat “route elsewhere” meansPer-domain?Arbitrary HTTP backend?
Postfixtransport_maps / relay_domains choose an SMTP, LMTP, or local transportYes, domain or addressNo — needs a pipe/milter/adapter you own
EximRouters select transports (smtp, lmtp, pipe, appendfile)Yes, with full conditional logicNot natively; a transport can invoke a program
OpenSMTPDmatch rules dispatch to actions (relay, lmtp, maildir)Yes, via rules and tablesNo
HarakaPlugins on SMTP hooks; recipient-routes maps a domain to an MXYesOnly if you write the HTTP queue plugin
maddyPipelines of modules; targets are modules or SMTP/LMTP upstreamsPer-domain, in-processNo
StalwartOne all-in-one server with its own store and directoryTenancy inside one storeNo
ZoneMTADelivery zones bind outbound traffic to different IPs and poolsOutbound, per zoneNo
PostalInbound routes per address to HTTP, SMTP, or a mailboxYes, inside PostalYes, but Postal remains the store
WildDuckA mailbox store (MongoDB) behind an MTAMailbox data, not routingNo
MailKite ServerEach domain is claimed by an HTTP backend with its own secretYesYes

Postfix is the closest mature analogue, and worth showing because the model is sound:

# /etc/postfix/main.cf
relay_domains = one.example, two.example
transport_maps = hash:/etc/postfix/transport_maps

# /etc/postfix/transport_maps
one.example    lmtp:[10.0.0.5]:24
two.example    smtp:[mail.two.example]
postmap /etc/postfix/transport_maps && postfix reload

That routes each domain to a different mail host, with the queue, retries, and DSNs you already trust. If the destination is an application rather than a mailbox server, you are back to writing an adapter — a pipe transport, a milter, or a small SMTP-to-HTTP bridge — and owning its retry semantics yourself. That bridge is exactly what the Haraka queue plugin in MailKite Server is.

Exim goes further on conditional routing and correspondingly further into configuration language. Haraka gives you the most direct hook: recipient-routes maps domain to MX, and a queue plugin can POST wherever you like. maddy keeps the route in-process, which is simpler until the destination is not a mail server. Stalwart and WildDuck are better understood as mailbox platforms than as routing edges. ZoneMTA’s zones are an outbound reputation tool, not an inbound-domain tool. Postal’s inbound routes are real HTTP delivery, but they terminate inside Postal’s own store.

None of that makes them worse. It makes them different tools. If both sides of your route speak SMTP, Postfix or Exim is the boring, correct answer.

The trade-offs

  • A cache is a cache. A newly verified domain can take up to the TTL to appear, and a backend outage serves its domains from the last good list. The live re-check on miss keeps first-message acceptance working; it does not make the edge stateless.
  • Priority is configuration, not a constraint. Two backends can claim the same domain. The edge warns and picks the first; it cannot know which one you meant.
  • Partial failure is normal. Any non-2xx group means DENYSOFT, and the sender retries everything. Backends must be idempotent. Cross-backend recipient mixes make this visible; same-domain retries make it routine.
  • It is not multi-tenancy. The routing table is operator-controlled. A backend can claim domains; it cannot add itself, read another backend’s list, or sign another backend’s ingest.
  • The edge is still a single point of failure for port 25. Multi-backend spreads storage and application risk, not network risk.
  • Inbound only, for now. Submission and IMAP remain single-backend in the current release; their auth is user-credential-based and needs its own routing design.

The same split on a hosted ESP

If you would rather not own the router, the equivalent is configuration inside the provider. MailKite Cloud, which we build, is one backend for all your domains rather than several backends behind one edge. You get the same separation of products with less of the failure surface — and less control over where the bytes land.

The shape is: one domain per product, MX pointed at the provider, a webhook or route per domain, and a scoped key per tenant. Add each domain in the dashboard first (or with a session token), then create the route your application owns:

import { MailKite } from "mailkite";

const mk = new MailKite(process.env.MAILKITE_API_KEY);

await mk.createRoute({
  match: "*@billing.example.com",
  action: "webhook",
  destination: "https://billing.internal/hooks/inbound",
});

The domain must be verified and its MX published before mail arrives; the route decides which matching messages go where once it does. Catch-all and per-address matches are both available, so support@ and *@ can end at different endpoints.

The responsibility split is the real comparison:

Self-hosted edge, several backendsHosted ESP
Routing tableYours (backends.json)Provider’s per-domain routes
SecretsOne per backend, in your environmentProvider-issued keys, scoped per domain
StorageWhichever backend owns the domainProvider’s store
Port 25, PTR, TLSYoursProvider’s
Failure domainAny backend, or your edgeProvider’s shared infrastructure
Blast radius between productsSeparate stores and secretsSeparate domains, shared provider
MigrationMove one domain at a timeRepoint MX and re-verify

Other ESPs expose a similar per-domain switch — SendGrid’s Inbound Parse, Mailgun routes, Postmark’s inbound webhook — but each still terminates in that provider’s store. The difference is not whether you can split domains. It’s who owns the routing table and where the bytes land.

The hybrid is also real in one direction: run the open-source edge yourself and point MAILKITE_API_URL at Cloud for storage, or set SMARTHOST=cloud in api-local to keep storage local and send through Cloud. The reverse direction — a hosted MX handing mail to a backend you run — follows from the same contract, but treat it as an operator setup rather than a self-serve Cloud toggle.

Which one to pick

  • Pick Postfix or Exim if every destination is another SMTP or LMTP host. You get decades of queue behaviour for free.
  • Pick Postfix/Exim plus an adapter if one domain needs an application and the rest are mail hosts. Budget for the adapter’s retries and idempotency.
  • Pick MailKite Server’s multi-backend mode when several HTTP backends must share one edge without sharing storage, secrets, or a deploy.
  • Pick a hosted ESP when you want per-domain separation but not the port 25, PTR, TLS, and queue work. That is the trade: you stop operating the router, and the provider becomes the backend.

The useful question is not “which server has more features.” It’s “where do I want the boundary between infrastructure I share and state I isolate?” Multi-backend routing just makes that boundary a config file instead of a second IP.

The spec is in docs/multi-backend.md, the implementation is lib/backends.js, and the setup walkthrough is the routing guide.

Sources

Discuss this post: Hacker News Share on X Share on LinkedIn

Related posts