Case Study: When the Catch-All Worked Only for Outsiders

A catch-all address that works for every outside sender but silently drops mail from your own server is one of the more disorienting failures you can debug. The logs show external test messages landing in the catch-all mailbox, so the rule is clearly live; then cron sends a report to an address that does not exist, and it bounces with 550 no such user. This case study walks through that exact scenario on a Postfix host with a Dovecot backend, explains why the catch-all only served outsiders, and shows the two-line fix that put locally generated mail on the same path as inbound SMTP.

The report: external mail delivered, local mail bounced

The setup was ordinary. A small company ran its own mail server for about forty staff, with a catch-all mailbox intended to collect mail sent to misspelled or retired addresses so nothing important vanished. MX records pointed at the host, the server accepted inbound mail on port 25, and a quick test from a Gmail account to definitely-not-a-real-user@example.com arrived in the catch-all IMAP folder within seconds. Everyone assumed the job was done.

The trouble surfaced through monitoring. A nightly cron job on the same machine piped its output to alerts@example.com, an address that had never been created as a real mailbox because the catch-all was supposed to absorb it. Instead, the root user received a bounce message reading 550 5.1.1 Recipient address rejected: User unknown in local recipient table. The catch-all was accepting every message sent to the domain from outside, yet the same domain’s own server could not deliver to an unmatched local part. That contradiction is the whole puzzle in one line.

What made it worse was the inconsistency across senders. External mail to invented addresses landed fine. Mail relayed from a second internal application server, which authenticated over SMTP and sent through the same host, also landed fine. Only mail generated on the mail server itself, by cron and by a few shell scripts, hit the wall. The pattern pointed away from DNS and away from the catch-all configuration being wrong, and toward something about the delivery path depending on where the message originated.

Why the catch-all never saw locally generated mail

Postfix, like Exim, decides how to deliver a message based on the transport it selects for the recipient. Mail arriving from the network goes through the SMTP service, then through cleanup, then through the trivial-rewrite resolver, which consults virtual alias maps before choosing a transport. A catch-all entry in those maps is a fallback rule: when no exact match exists for the local part, the catch-all pattern catches the address and rewrites it to the real mailbox. That is how catch-all email works on the inbound side, and it is entirely a mail server setting. DNS only gets the message to the host; the server decides what happens next.

Locally generated mail takes a shortcut. When cron or a script invokes the sendmail compatibility command, Postfix injects the message through the pickup service and hands it to the local delivery agent by default for addresses in mydestination. The local delivery agent does its own lookup, usually against the system password file or a local alias database, and it does not read the virtual alias maps. So the catch-all rule, which lives in the virtual lookup, never runs. The local agent checks for a mailbox or an alias, finds neither, and returns the 550 no such user that ends up as a bounce message in root’s inbox.

The two lookups that should have been one

The core mistake was architectural rather than a typo. The domain’s addresses were split across two recipient tables: virtual mailboxes for real users and the catch-all, and local system accounts for anything the machine generated. Each table had its own rules, and only one of them knew about the fallback. The same gap explains why mail from the application server worked: it arrived over SMTP from the network, so it went through the virtual path and the catch-all caught it. The origin of the message, not the recipient, determined which lookup ran.

This is also where people confuse related features. A catch-all is not the same as an alias, forwarding, or a disposable address. An alias maps one specific local part to another destination. Forwarding relays mail to an external address. catch-all domain guide is worth a closer look. Throwaway addresses are created and discarded on demand. A catch-all is a blanket rule for every unmatched local part, and its value depends entirely on being the last thing consulted before delivery fails. If one class of mail skips that step, the rule is only half in force.

The fix: route local mail through the same alias path

The clean solution is to stop letting locally generated mail bypass the virtual lookup. Postfix gives you a direct control for this. In main.cf, setting local_transport = virtual tells the local delivery agent to hand off to the virtual transport, so addresses that match virtual mailboxes or the catch-all get rewritten and delivered the same way inbound SMTP mail does. After a reload, the next cron message to alerts@example.com was caught by the catch-all and appeared in the shared mailbox instead of bouncing.

If you would rather not change the transport for the whole domain, the narrower option is an explicit alias for the local part. Add a line to the aliases file mapping alerts to the catch-all mailbox address, run newaliases, and the local agent resolves it before consulting the passwd file. This works well when only a handful of addresses are involved, but it means maintaining two lists: every new system address has to be added by hand, and the catch-all remains invisible to the local path for anything you forget.

Whatever route you take, verify from both directions. Send a message from an external account to an invented address and confirm it lands in the catch-all. Then trigger a local message, for example with echo test | sendmail invented@example.com, and check that it lands in the same place. If the external test passes and the local test bounces, the catch-all is still configured on the mail server but not on the path the local agent uses.

Approach What it changes Effort and upkeep Best for
local_transport = virtual Sends all locally generated mail through the virtual alias lookup, so the catch-all applies to cron and scripts One setting, one reload; no ongoing maintenance Hosts where the catch-all should cover every sender, internal included
Explicit local alias Maps a named local part to the catch-all mailbox before the passwd lookup Manual entry per address, newaliases after each change Small, stable sets of system addresses
Real mailbox per system address Creates a dedicated mailbox so no fallback is needed Ongoing mailbox management and storage Addresses that need their own retention or access rules
Leave local delivery untouched Local mail keeps its own lookup and bounces on unknown addresses None, but bounces keep arriving Setups where a local bounce is the intended signal

What the logs and tests revealed, and how to avoid a repeat

Reading the mail log with the queue ID made the split obvious. The external message showed a rewrite from the invented address to the catch-all mailbox, then delivery through the virtual transport. The cron message showed no rewrite at all, a direct local delivery attempt, and the 550 rejection. That contrast is the fastest diagnostic you have: pull the queue ID from the bounce, grep the log, and compare the rewrite lines for a working message and a failing one. If the failing message never shows a virtual rewrite, the catch-all was never in play.

Two related checks caught problems that would have appeared later. First, confirm the MX host priority and DNS routing are correct so external mail reaches the accepting server at all; a catch-all setup requires DNS pointing at the right host and a server that accepts unknown recipients, and neither is optional. Second, keep outbound mail authentication separate in your head. SPF, DKIM and DMARC govern outbound mail only. They have nothing to do with whether the catch-all accepts an inbound message or a local one, and time spent checking DNS records for a local bounce is time wasted.

There is a real trade-off to weigh before switching the transport. Catch-all domains carry risks of spam to invented addresses, and once local mail also flows into the catch-all, cron output and script errors join the same pile. On this host the volume was modest, but the team added a recipient address filter to drop obvious dictionary spam and set up a weekly review of the catch-all folder so genuine mail did not get buried. If you run Postfix with Dovecot, the same principle holds for either IMAP or POP3 clients reading the mailbox; the delivery path is what matters, not how the mailbox is fetched.

Start by pulling the bounce for the failing message and checking whether the log shows a virtual rewrite. If it does not, the message took the local path and the catch-all never had a chance. Set local_transport = virtual in main.cf, reload the server, and retest with a locally injected message before you trust the fix. Keep the external test as well, since both paths should end in the same catch-all mailbox. Document which addresses are real mailboxes and which rely on the fallback, and add a log check to your monitoring so the next local bounce shows up in minutes rather than in a pile of cron mail nobody reads.