Postfix Catch-All and Plus-Addressing: The Lookup Order That Decides Everything
Plus-addressing and catch-all get conflated constantly, but they are two unrelated Postfix mechanisms that collide in a fixed, non-obvious order. Learn the exact table-lookup walk Postfix does for user+tag@domain and both features become trivial to configure — and you'll understand why a catch-all silently swallows every tag you forgot to define.
EvilMail TeamJuly 11, 202611 min read
Someone sets recipient_delimiter = +, expects [email protected] to just work, and one of two things happens: everything bounces, or everything lands in the wrong mailbox. So they bolt on a catch-all to "fix" it — and now literally every string@domain gets accepted, including the dictionary attack that kicked off at 3 a.m.
The confusion is structural. Plus-addressing and catch-all are two different Postfix features doing two different jobs, and the moment both are switched on, one overrides the other in a fixed order that is nowhere near obvious. You don't need to memorize a dozen main.cf parameters to get this right. You need to know exactly which table keys Postfix tries, and in what sequence, when a tagged address arrives. Learn that ladder and both features fall into place — including the ways they sabotage each other.
Two features people conflate
`recipient_delimiter` is address-extension parsing, standardized as subaddressing in RFC 5233. Set it to + and Postfix reads [email protected] as user jane
Postfix Catch-All + Plus-Addressing (recipient_delimiter) Done Right — EvilMail Blog
plus the *address extension*
shop
— the
$extension
variable in the Postfix docs. Nothing gets created. No mailbox called
jane+shop
springs into existence. It is purely a hint: "when you look this address up in a table and miss, try again with the tag stripped off." The envelope recipient is never modified; only the lookup key is.
A catch-all is the opposite kind of thing. It is a single literal entry — @example.com — sitting in virtual_alias_maps, matching any address at that domain that nothing more specific claimed. It is a fallback *destination*, not a parsing rule.
You can run either one alone and both are useful. The trouble is that they meet inside the same table, and a catch-all's entire job is to be the last thing standing — which means it will happily catch every tag you never defined.
Switch on plus-addressing
Two lines and a reload:
bash
# /etc/postfix/main.cf
recipient_delimiter = +
bash
postfix reload
Now for [email protected], Postfix strips at the first delimiter: user jane, extension shop. Three things worth knowing before you trust it:
Multiple / multi-character delimiters. Postfix 2.11+ accepts more than one: recipient_delimiter = +- lets both jane+shop and jane-shop work. The *leftmost delimiter character in the address* wins the split — so jane+a-b splits at +, extension a-b.
`propagate_unmatched_extensions`. Default is canonical, virtual. This is the subtle one. When Postfix strips the tag and matches the bare key — with no exact match on the full key — it re-appends the extension to the lookup *result*. So if [email protected] maps to [email protected], then [email protected] resolves to [email protected]: the tag survives to the next hop. That is what lets Dovecot still see shop downstream.
The delimiter only affects the lookup. The envelope recipient the sending server handed you (RCPT TO:<[email protected]>) is preserved verbatim. Postfix rewrites the *key it searches with*, not the mail.
The lookup order that decides everything
This is the crux. When [email protected] hits virtual_alias_maps, Postfix walks a fixed ladder from most-specific to least-specific and takes the first hit:
Read that ladder twice, because it explains every "why did my mail go there" ticket you will ever file. A specific [email protected] entry stops the walk at rung 2 and the tag rides along to the destination. A tag whose base user isn't a key sails past rung 2 and lands in the catch-all at rung 3 — where the extension is simply dropped. The catch-all is not greedy by malice; it is greedy by position.
Run postmap after *every* edit to that file — the hashed .db is what Postfix actually reads, and forgetting this is the single most common self-inflicted outage in this whole area.
Now the counter-argument every senior admin makes before typing that second line. A catch-all is an unbounded recipient surface. It accepts sales@, admin@, a8f3c1@, every dictionary-attack guess, every misdirected bounce. It is a backscatter amplifier and a magnet for directory-harvest attacks, and — the part people forget — disposable-email classifiers actively fingerprint catch-all MX behavior, so turning it on can quietly degrade your domain's reputation on the receiving side.
The disciplined alternative when you want tags but not a swamp:
Enumerate your real users, keep the delimiter on, and Postfix will accept [email protected] (because jane is known) while rejecting [email protected] at RCPT time. Infinite personal tags, none of the wildcard exposure. Scope any actual @domain catch-all key to *only* the domains that genuinely need it — this is per-file, per-key, so a transactional domain and a disposable domain can live in the same Postfix under completely different rules.
For temp-mail infrastructure like evilmail, the calculus inverts: the catch-all *is* the product. Every random localpart must resolve. The right move there is isolation — the wildcard belongs exclusively on the disposable domains, never anywhere near a customer's transactional or authentication mail.
Make Dovecot file the tags
Here is the trap that eats an afternoon: Postfix strips nothing on the transport handoff. After the virtual_alias_maps match, with propagate_unmatched_extensions doing its job, Dovecot receives the *full* [email protected] over LMTP. Postfix's recipient_delimiter governed the Postfix-side table lookup and nothing else. Whether shop becomes a folder is decided entirely by Dovecot's own settings.
So set the delimiter on both sides:
bash
# dovecot conf.d/15-lda.conf
recipient_delimiter = +
# auto-file jane+shop into a "shop" subfolder, no Sieve:
lmtp_save_to_detail_mailbox = yes
# create/subscribe the folder on first delivery
lda_mailbox_autocreate = yes
lda_mailbox_autosubscribe = yes
lmtp_save_to_detail_mailbox = yes is the entire feature for most people: jane+receipts drops into a receipts mailbox, jane+github into github, automatically. When you want conditional logic instead of one-folder-per-tag, use Sieve with the subaddress extension — :detail is the tag, :matches "*" captures it:
The rule that matters: the delimiter must match on both sides. If Postfix splits on + and Dovecot splits on -, your tags evaporate into the INBOX and you'll stare at correct-looking Postfix config for an hour.
Test the whole chain before trusting it
Don't eyeball config and declare victory. Query the maps directly, then push a real message.
bash
# raw key lookup — proves the specific and catch-all keys resolve
postmap -q "[email protected]" hash:/etc/postfix/virtual
postmap -q "@example.com" hash:/etc/postfix/virtual
# GOTCHA: this does NOT simulate delimiter stripping.
# postmap looks up the literal string. It will "miss" on a tag
# even when live delivery would succeed via rung 2.
postmap -q "[email protected]" hash:/etc/postfix/virtual
# confirm the live settings actually loaded
postconf -n | grep -E 'recipient_delimiter|virtual_alias|reject_unlisted'
That postmap -q on the tagged address is a classic false alarm — it reports a miss because postmap does exactly one literal lookup with no delimiter logic, while the running trivial-rewrite daemon walks the full ladder. Test the real path with swaks:
orig_to= is what the sender dialed; to= is which map entry won. If to= shows catchall@ when you expected a personal mailbox, you just watched a tag fall through to rung 3 — the base user wasn't a key. That one log pair tells you more than any amount of re-reading main.cf.
What breaks in the wild
Signup forms still reject `+`. Less common than it used to be, but plenty of validators and payment portals still refuse a plus in the localpart. Treat tags as a power-user tool for yourself, not something to hand a non-technical relative and expect to survive every web form.
Disposable-email lists fingerprint catch-all MX. Run a wildcard on a domain you also use for real correspondence and you'll hit occasional reputation friction. Keep catch-alls on disposable domains only.
Providers disagree on behavior. Gmail, Fastmail, and Proton all honor +, but folder handling and normalization differ. Never assume a *remote* system honors the tag you invented — the tag is yours, on your server, under your delimiter.
`+tags` don't touch outbound deliverability. Sending *from* [email protected] is fine. DMARC and SPF alignment are evaluated on the domain, not the localpart, so the tag is invisible to alignment and changes nothing about whether your mail authenticates.
Ship-it checklist
recipient_delimiter set on both Postfix (main.cf) and Dovecot (15-lda.conf) — and they match.
Catch-all @domain keys scoped only to domains that genuinely need them; nowhere near transactional or auth mail.
smtpd_reject_unlisted_recipient decision made consciously — enumerated users for tags-without-wildcard, or catch-all with eyes open.
postmap /etc/postfix/virtual run after *every* edit; verify the .db timestamp changed.
lmtp_save_to_detail_mailbox = yes (or a Sieve fileinto rule) in place, with lda_mailbox_autocreate.
swaks tested three addresses: a defined tag, an undefined tag, and pure garbage — confirm each lands where the ladder says it should.
mail.log line inspected: orig_to= vs to= proves the winning mailbox.
Downstream forms and validators warned that + may not be accepted everywhere.
Get the lookup order into your head and none of this is guesswork anymore. The catch-all is the bottom rung; the delimiter is a strip-and-retry hint; everything else is just knowing which key wins and where the tag survives.