Postfix Virtual Mailbox vs Virtual Alias Domains: How Each Routes Mail
Two config classes, one job each. virtual_alias_domains rewrite an address and hand it back to the router; virtual_mailbox_domains are where the router stops and writes to disk. Conflate them and you get "User unknown" bounces, catch-alls that eat real inboxes, and forwarded mail rotting in Gmail spam. Here's the pipeline so you can predict routing instead of guessing.
EvilMail TeamJuly 9, 202610 min read
Two bounce lines from two different mail.log files, same underlying mistake:
text
status=bounced (User unknown in virtual alias table)
status=bounced (User unknown in virtual mailbox table)
Both come from putting a domain in the wrong class. The admin in the first case swears the domain forwards mail; it does not, because a virtual_alias_domain stores nothing and has no map entry to redirect through. The admin in the second case swears the mailbox exists; Postfix disagrees, because the address was never added to virtual_mailbox_maps. Neither is a Dovecot problem, a DNS problem, or a permissions problem. Both are routing-class problems.
The entire subject rests on one sentence most tutorials never say out loud: `virtual_alias_domains` rewrite an address and hand it back to the router, while `virtual_mailbox_domains` are where the router stops and writes the message to disk. Get that distinction into your bones and you can read the transport a message will take before you ever hit send. Everything below is that pipeline, in order.
Two domain classes, one job each
`virtual_alias_domains` is pure redirection. Postfix keeps no mailbox for these domains. Every recipient in such a domain *must* resolve to another address through virtual_alias_maps, or the message bounces with User unknown in virtual alias table. There is no fallback local delivery — the class exists solely to rewrite x@here into y@somewhere and re-inject it.
`virtual_mailbox_domains` is the opposite: Postfix is the *final destination*. A recipient that matches here is handed to virtual_transport (default virtual, on any real deployment lmtp:unix:private/dovecot-lmtp), which writes the message to storage. The router stops here. Nothing gets re-injected.
The hard rule that trips people up: a domain may appear in exactly one of `mydestination`, `virtual_alias_domains`, `virtual_mailbox_domains`, or `relay_domains`. List example.com in two of them and Postfix logs do not list domain example.com in BOTH ... and delivery becomes undefined — usually whichever class resolves first wins, which is not something you want decided by parameter order.
One default worth memorizing: virtual_alias_domains = $virtual_alias_maps. Out of the box, any domain that has entries in your alias maps is *automatically* treated as an alias domain. Convenient, and also the source of a lot of "why is this domain suddenly bouncing" confusion when someone drops a stray catch-all into the map.
How Postfix actually routes a recipient
Here is the part that makes the rest predictable. virtual_alias_maps is not the alias-domain lookup table — it is address-level recipient rewriting (the modern name for the old virtual_maps parameter), consulted for every recipient in every class, *before* the domain-class and transport decision is made. That is why an alias can retarget an address that lives inside a mailbox domain: the rewrite happens first, the class decision happens on the rewritten address.
Expansion is recursive up to virtual_alias_recursion_limit and capped in total fan-out by virtual_alias_expansion_limit — both default to 1000. Aliases pointing at aliases resolve until they hit a real (non-alias) address or blow the limit, at which point you get expansion-error deferrals.
Trace it once: the RCPT arrives, the cleanup daemon normalizes it and expands virtual_alias_maps recursively, and only then does the resolver (trivial-rewrite) classify the resulting domain. An alias domain with no match bounces; an alias match re-injects and the loop runs again on the new address; a mailbox domain goes to virtual_transport and stops. That "hands back vs stops" split is the whole thesis, drawn.
Run postmap after *every* edit to a hash: or btree: map — the .db is what Postfix reads, not the text file, and forgetting this is the number-one cause of "I added the alias but it still bounces." If your backend is mysql: or pgsql:, there is no postmap compile step, but the same postmap -q query works against the connection config file and is the fastest way to confirm the SQL actually returns a row.
Configuring a mailbox domain (real inboxes)
The modern path hands storage entirely to Dovecot over LMTP:
Postfix's only job here is to confirm the address exists in virtual_mailbox_maps and hand the message to Dovecot; quota, sieve, and Maildir layout are Dovecot's problem. The legacy file-based path, where Postfix itself writes the Maildir, still shows up on older boxes:
That trailing slash matters: example.com/user/ means Maildir format (a directory), while example.com/user with no slash means mbox (a single file). Get it wrong and you either can't deliver or you can't index. On evilmail's own hosts everything runs uid/gid 5000 under /var/mail/vhosts/{domain}/{user}/, which is exactly this legacy shape kept for operational reasons.
The overlap trap: aliases inside a mailbox domain
You will *still* want aliases on a mailbox domain. RFC 2142 requires postmaster@ and abuse@ to be deliverable, and you often want a sales@ that fans out to three people. That is completely legal, because virtual_alias_maps is global and runs before the class decision — you can point [email protected] at a real inbox even though example.com is a mailbox domain.
Here is where it bites. Because alias maps are consulted *first*, an @example.com catch-all in virtual_alias_maps is matched beforevirtual_mailbox_maps ever gets a look. Add this to forward "anything unknown" somewhere:
and you have just silently rerouted mail for every real mailbox on the domain — including postmaster@. The domain-wide catch-all overrides the mailbox lookup because rewriting happens upstream of delivery. Nobody bounces; nobody complains; mail simply stops arriving in real inboxes and no log line screams about it.
Two fixes, in order of preference:
Don't put a domain-wide catch-all in `virtual_alias_maps` on a mailbox domain. If you need catch-all behavior, implement it in Dovecot/LMTP as a per-domain catch-all mailbox, not in Postfix's global address rewriting.
If you must keep it, add explicit self-mapping entries for every real user so they win on specificity: [email protected][email protected]. Postfix tries the full-address key before the @domain wildcard, so the real users map to themselves and pass through untouched, and only genuinely-unknown addresses hit the catch-all.
Why forwarding breaks SPF/DMARC, and SRS
Forwarding is where alias domains quietly wreck deliverability. When [email protected] forwards to Gmail, Postfix keeps the original envelope sender (MAIL FROM: [email protected]) and re-sends from *your* IP. Gmail then runs SPF against origin.com, sees your server's IP is not in origin.com's SPF record, and fails the check. DMARC alignment breaks with it. Result: spam folder on a good day, a 550 5.7.26 rejection on a bad one.
Sender Rewriting Scheme (SRS) fixes this by rewriting the envelope sender to *your* domain on the way out, so SPF checks a domain you actually authorize, and reversing it on bounces so DSNs still reach the real sender. postsrsd is the standard implementation:
Mailbox domains never have this problem, because delivery is local — nothing is re-injected, so there is no second SPF check to fail. SRS is strictly a forwarding/alias concern. If you run any alias domain that forwards off-server, run postsrsd; skipping it is why "our forwards go to spam" tickets exist.
Decide and diagnose
Pick the class first, then configure:
Forward-only, no storage (vanity domains, personal-to-Gmail) → virtual_alias_domains + virtual_alias_maps, and add postsrsd.
You host the inboxes → virtual_mailbox_domains + virtual_mailbox_maps + virtual_transport = lmtp:...dovecot-lmtp.
You need both real inboxes and a few aliases → mailbox class for the domain, with *selective* entries in virtual_alias_maps for postmaster@/abuse@/sales@. No domain-wide catch-all.
Never list one domain in two classes. postconf it if unsure.
User unknown in virtual alias table → domain is in virtual_alias_domains but the address has no map entry and there is no catch-all. Add the alias (and postmap).
User unknown in virtual mailbox table → domain is in virtual_mailbox_domains but the address is not in virtual_mailbox_maps. Add the mailbox row; confirm with postmap -q.
status=sent (delivered via lmtp ...) → correct mailbox delivery to Dovecot. This is what a healthy mailbox domain looks like.
status=sent (forwarded as ...) → alias expansion re-injected the mail. Correct for an alias domain; if you see this on what should be a *stored* address, a catch-all is eating it.
alias expansion error (temporary or permanent) → you hit virtual_alias_expansion_limit/virtual_alias_recursion_limit or built an alias loop. Break the cycle or raise the limit deliberately.