Routing a multi-tenant catch-all without leaking between tenants
A catch-all turns 'whose mailbox is this?' into a routing decision. Matching a local part without checking the domain put one customer's login code in another customer's inbox.
Yowl’s mail routing is one pure function, with the whole permutation table under test. Which domain belongs to which organisation, whether a short name means anything, whether a message belongs to one mailbox or two — all of it decided in one place, with no I/O, so every combination of sender and recipient is a unit test rather than something you find out in production.
This page is the builder’s version, and it is here for a reason worth stating plainly: we are asking
you to put your company’s mail on a multi-tenant catch-all, so you are entitled to know we have made
this mistake and what we did about it. Yowl runs a root catch-all across every customer domain and
yowl.co.za itself. If the routing is wrong, the failure is not lost mail — it is your mail in
somebody else’s inbox. The rest of this page is the one time that happened, and the model that
replaced the code which allowed it.
If you are evaluating rather than building, the short version: an address is classified exactly once into a closed set, a short name means something only on our own domain, and every routing decision records why it was made so that a right answer reached the wrong way fails its test.
On a single domain, a catch-all is a delivery setting: turn it on and everything arrives. On a platform that runs a catch-all and hands addresses out to customers, it stops being a setting and becomes a routing decision — and the failure mode is not lost mail, it is mail delivered to the wrong tenant.
We shipped exactly that. Here is what it cost.
The bug: a local part is not an identity
Customers reach us two ways. Either they link their own domain, or they get an address on ours made from
their organisation’s short name — acme@platform.example.
So the routing code needed to answer “which org owns this recipient?”, and it did it with a fallback chain: try the domain, then try the local part against the list of org short names, then try the sender. Reasonable-looking, and wrong, because the second step never checked the domain.
A customer whose org short name was brent therefore owned every brent@anything that reached us. Mail
to brent@some-other-company.example — a completely unrelated domain that happened to be linked by a
different customer — was filed into the first customer’s mailbox. A login one-time-passcode went to the
wrong tenant that way, and the misrouting had been live for weeks before anyone noticed, because every
individual delivery looked plausible.
The fix is one line of reasoning, not one line of code: a short name means something only on our own domain. Off it, the short-name lookup is unreachable — not deprioritised, not a later fallback, unreachable.
Two rules that keep the class of bug visible
The fallback chain hid this for a long time, and it hid a second defect at the same time. What replaced it was not a better chain:
Classify each address exactly once, into a closed set. Every recipient resolves to one of: belongs to a tenant (via a verified linked domain, or via a short name on our own domain), belongs to the platform, or external. A union with no fallthrough means a new case has to be handled rather than quietly landing in whatever the last branch was.
Never name a lookup more generally than what it matches. The old function was called “org by address”, which is a promise it could not keep — it matched a local part. A function named for what it actually does is one a reviewer can see is being misused.
And when a routing decision is made, record why. Each resolved mailbox carries the reason it was chosen, asserted in tests, so a right answer reached by the wrong route reads differently from a right one. That is the assertion that would have caught this.
An unclaimed local part belongs to the platform
The second thing that surprises people: if the domain is your root catch-all, then a typo of a tenant’s name is yours, not theirs.
acme@platform.example is Acme’s. acmee@platform.example is not Acme’s near-miss — it is unclaimed, so
it belongs to the operator. That is the opposite of the intuition, and it is the right answer, because the
alternative is fuzzy-matching recipients to tenants, which is the same mistake as the bug above with extra
steps.
It also means the platform must refuse to allocate some local parts at all — the certificate-validation set and the RFC role addresses, plus every address the platform itself sends from. A tenant who claims the local part your own service mail comes from has claimed your outbound.
One message can belong to two mailboxes
The last one falls out of the model rather than from a bug. If two tenants on your platform mail each other, that is two conversations, not one ambiguous record: the sender’s copy and the recipient’s copy, each correct described from its own side.
Which means the routing function returns a list of mailboxes, not a mailbox, and the direction of a message is derived per mailbox rather than passed in. Get that wrong and one tenant’s sent mail appears in the other’s record of who they are talking to — a quieter leak than the first one, and harder to see.
If you are building this
Make the whole thing pure and put the permutation table under test. Ours takes the database facts — which domains are verified to which tenant, which short names exist — as an argument, so the routing decision itself has no I/O and every combination of sender and recipient is a unit test rather than an integration test nobody writes.
The permutations that matter are the boring ones: tenant→tenant, tenant→external, external→tenant, platform→tenant, a short name on a foreign domain, an unverified domain, the same address on both sides. That last one is a real case — an organisation mailing itself — and it is the one that decides whether you return one mailbox or two.