Why is Postfix not delivering? Diagnosing stuck mail with postsleuth
The queue is growing, users say mail to Gmail "didn't arrive", and mailq prints a wall of text. This walkthrough shows how to get from that wall to a cause and a next step with postsleuth, a read-only CLI (and MCP server) for Postfix.
Where the output comes from: sections 1–5 run postsleuth v0.1.0 against the fixture in the repository (testdata/queue.json and testdata/mail.log, generated by testdata/gen.py to look like a small outbound relay on a bad day; domains and IPs are documentation ranges). The "real Postfix" section is copied from the project's CI log, where a throwaway Postfix 3.7 runs in Docker. Nothing below was edited apart from removing colour.
The usual loop
Diagnosing stuck Postfix mail by hand usually goes: mailq | less, pick a reason that looks common, grep the mail log for it, count with sort | uniq -c (which fails because every line has a different address and IP), run qshape to see whether it is new or old, then dig TXT records for SPF and DMARC. postsleuth does the same reads (postqueue -j, the mail log, DNS) and does the grouping for you. It never changes the queue unless you start it with --allow-mutations.
go install github.com/Blackman99/postsleuth/cmd/postsleuth@latest
# or a static binary from GitHub Releases
1. Group the queue by reason
On the mail host you would run postsleuth queue. Here we feed it the saved postqueue -j output:
postsleuth queue --queue-file testdata/queue.json
QUEUE 212 messages · deferred 205 · active 6 · hold 1 · oldest 1d23h (F5EC72BA69)
✗ 150 rate_limit gmail.com, googlemail.com
421-4.7.28 Gmail has detected an unusual rate of unsolicited mail originating from your IP addr…
→ The receiver is throttling you: lower smtp_destination_concurrency_limit / add
smtp_destination_rate_delay for that domain, and check your sending reputation.
✗ 34 blocklist hotmail.com, outlook.com
550 5.7.1 Unfortunately, messages from [ip] weren't sent. Please contact your Internet service …
→ The receiver rejects your IP or domain because of a blocklist entry: look the IP up (Spamhaus,
Microsoft SNDS) and request delisting after fixing the cause.
✗ 14 timeout oldcorp.example
connect to mx.oldcorp.example[ip]: Connection timed out
→ Connections to port 25 time out: many providers block outbound port 25, so test with nc -vz MX 25
and use a relayhost if blocked.
✗ 4 dns acme-typo.example
Host or domain name not found. Name service error for name=acme-typo.example type=MX: Host not …
→ DNS lookup for the destination failed: check the domain exists and has MX/A records, and that this
server's resolver works (dig MX domain).
! 3 greylisting smallbiz.example
450 4.7.1 <addr>: Recipient address rejected: Greylisted, see http://postgrey.schweikert.ch/hel…
→ Greylisting: normal, Postfix retries automatically. Only a problem if it persists for hours.
212 messages become five lines. Addresses, IPs and tracking IDs are stripped from each delay reason before grouping, so 150 Gmail deferrals that differ only by recipient collapse into one rate_limit group. Each group gets a category (there are 17, from rate_limit and blocklist to ptr, auth and tls) and a hint. Greylisting is a warning (!), not a problem (✗): Postfix will retry it by itself.
To see the actual messages behind a group, filter by category:
postsleuth queue --queue-file testdata/queue.json --category blocklist --list --limit 1
78715BBD26 deferred 20h6m 85428 billing@example.com
→ j.smith141@hotmail.com
host outlook-com.olc.protection.outlook.com[52.101.42.3] said: 550 5.7.1 Unfortunately, messages from [203.0.113.7] weren't sent. Please contact your Internet service provider since part of their network is on our block list (S3150). (in reply to MAIL FROM command)
The full reply is kept in --list: Microsoft's S3150 code names the reason (the sending IP is on their block list), which is the thing to search for or paste into a ticket.
2. Since when? (qshape)
postsleuth shape --queue-file testdata/queue.json
SHAPE deferred queue by recipient domain, age in minutes
T 5 10 20 40 80 160 320 640 1280 1280+
TOTAL 205 0 0 0 9 22 45 80 12 21 16
gmail.com 141 0 0 0 7 19 39 73 3 0 0
outlook.com 23 0 0 0 0 0 1 4 6 8 4
oldcorp.example 14 0 0 0 0 0 0 0 1 5 8
hotmail.com 11 0 0 0 0 0 0 1 2 6 2
googlemail.com 9 0 0 0 0 2 5 2 0 0 0
acme-typo.example 4 0 0 0 0 0 0 0 0 2 2
smallbiz.example 3 0 0 0 2 1 0 0 0 0 0
Columns are message age in minutes, as in Postfix's qshape. Read it left to right: Gmail's deferrals fill the 40 to 640 minute columns and nothing older, so the throttling started within the last eleven hours or so and new mail is still being caught by it. oldcorp.example is all in the right-hand columns: that destination has been unreachable for most of a day, and those messages will expire at maximal_queue_lifetime unless something changes. Run shape --by sender when you suspect one compromised account is flooding the queue; in this fixture every message comes from example.com, so it doesn't apply.
3. What the log says
The queue only shows what is still stuck. The log shows what already happened, including bounces and mail that was delivered:
postsleuth log --log testdata/mail.log --since all
LOG testdata/mail.log · 792 sent · 125 deferred attempts · 26 bounced · 1 expired · 30 rejected
! still failing: gmail.com (82), outlook.com (38), oldcorp.example (5)
✗ bounce 14 blocklist 550 5.7.1 Unfortunately, messages from [ip] weren't sent. Please cont… · outlook.com
✗ bounce 12 recipient_unknown 554 5.1.1 <addr>: Recipient address rejected: User unknown · yahoo.com
✗ deferral 82 rate_limit 421-4.7.28 Gmail has detected an unusual rate of unsolicited mail ori… · gmail.com
✗ deferral 38 blocklist 550 5.7.1 Unfortunately, messages from [ip] weren't sent. Please cont… · outlook.com
✗ deferral 5 timeout connect to mx.oldcorp.example[ip]: Connection timed out · oldcorp.example
✗ reject 30 relay_denied 554 5.7.1 <addr>: Relay access denied
domain sent deferred bounced last
gmail.com 355 82 0 deferred
outlook.com 30 38 14 deferred
yahoo.com 91 0 12 sent
oldcorp.example 0 5 0 deferred
fastmail.com 103 0 0 sent
proton.me 97 0 0 sent
qq.com 90 0 0 sent
example.com 26 0 0 sent
Two things the queue could not tell us. First, Gmail is still accepting most mail (355 sent against 82 deferred attempts), which fits rate limiting by volume rather than a hard block. Second, Outlook has bounced 14 messages outright and its last attempt was a deferral: the blocklist is costing real mail. The 30 relay_denied rejects are smtpd refusing outside clients that tried to relay, which is the server doing its job, but a sudden jump in that number is worth noticing.
On a real host, leave out --log: postsleuth reads maillog_file from postconf, then /var/log/mail.log or /var/log/maillog, then journald. Syslog, RFC 3339 and journald timestamps and .gz rotations all work.
4. Follow one message
When a user asks about one specific mail, trace it by queue ID or address. A bounce pulls in the non-delivery notice Postfix generated:
postsleuth trace 85C1D13765 --log testdata/mail.log
85C1D13765 bounced
Oct 11 09:11:44 mail postfix/smtpd[3005]: 85C1D13765: client=app1.internal[10.0.0.5]
Oct 11 09:11:44 mail postfix/cleanup[2671]: 85C1D13765: message-id=<ab8c2ecd20@example.com>
Oct 11 09:11:45 mail postfix/qmgr[1201]: 85C1D13765: from=<noreply@example.com>, size=5120, nrcpt=1 (queue active)
Oct 11 09:11:46 mail postfix/smtp[2757]: 85C1D13765: to=<old.user8@yahoo.com>, relay=mta7.am0.yahoodns.net[67.195.204.72]:25, delay=1.2, delays=0.1/0/0.6/0.5, dsn=5.1.1, status=bounced (host mta7.am0.yahoodns.net[67.195.204.72] said: 554 5.1.1 <old.user@yahoo.com>: Recipient address rejected: User unknown (in reply to RCPT TO command))
Oct 11 09:11:46 mail postfix/bounce[3301]: 85C1D13765: sender non-delivery notification: B4EE96B1C7
Oct 11 09:11:47 mail postfix/qmgr[1201]: 85C1D13765: removed
B4EE96B1C7 sent (bounce notice for 85C1D13765)
Oct 11 09:11:46 mail postfix/qmgr[1201]: B4EE96B1C7: from=<>, size=7200, nrcpt=1 (queue active)
Oct 11 09:11:47 mail postfix/local[4410]: B4EE96B1C7: to=<noreply@example.com>, relay=local, delay=0.1, delays=0/0/0/0.1, dsn=2.0.0, status=sent (delivered to mailbox)
Oct 11 09:11:47 mail postfix/qmgr[1201]: B4EE96B1C7: removed
5. All of it at once
postsleuth doctor prints the queue summary, the qshape, the log summary and the DNS checks for your sending domain (MX, SPF with its 10-lookup limit, DKIM, DMARC, PTR/FCrDNS), then a result line. It exits 1 when it finds a problem, so it can run from cron or a monitoring check. DNS is skipped here because the fixture's domains are not real:
postsleuth doctor --queue-file testdata/queue.json --log testdata/mail.log --since all --no-dns
echo $? # 1
The output is sections 1–3 in one report, ending with:
RESULT 6 problem(s), 4 warning(s)
Against a real Postfix
CI builds a Debian Postfix 3.7 container whose transport map sends retry.test to a deferring retry: transport, bounce.test to error:, and timeout.test to 192.0.2.1, an address that never answers. It sends six messages and runs postsleuth from outside the container with --wrap "docker exec -i postsleuth-it", exactly as you would against docker-mailserver or mailcow. This is mailq in that container:
-Queue ID- --Size-- ----Arrival Time---- -Sender/Recipient-------
8CFA98C2D24 284 Sun Oct 11 23:24:29 app@example.test
(Too many messages from your IP, rate limit exceeded, try again later)
user1@retry.test
BB3F58C2D39 284 Sun Oct 11 23:24:29 app@example.test
(connect to 192.0.2.1[192.0.2.1]:25: Connection timed out)
ceo@timeout.test
9D3388C2D2E 284 Sun Oct 11 23:24:29 app@example.test
(Too many messages from your IP, rate limit exceeded, try again later)
user2@retry.test
and this is postsleuth doctor on the same container, from the CI run for commit fd6a2f8 (2026-10-11):
postsleuth doctor · mail.example.test · 2026-10-11 23:24 UTC
QUEUE 3 messages · deferred 3 · oldest 0m (8CFA98C2D24)
✗ 2 rate_limit retry.test
Too many messages from your IP, rate limit exceeded, try again later
→ The receiver is throttling you: lower smtp_destination_concurrency_limit / add
smtp_destination_rate_delay for that domain, and check your sending reputation.
✗ 1 timeout timeout.test
connect to ip[ip]: Connection timed out
→ Connections to port 25 time out: many providers block outbound port 25, so test with nc -vz MX 25
and use a relayhost if blocked.
SHAPE deferred queue by recipient domain, age in minutes
T 5 10 20 40 80 160 320 640 1280 1280+
TOTAL 3 3 0 0 0 0 0 0 0 0 0
retry.test 2 2 0 0 0 0 0 0 0 0 0
timeout.test 1 1 0 0 0 0 0 0 0 0 0
LOG docker exec -i postsleuth-it cat /var/log/postfix.log · 1 sent · 3 deferred attempts · 4 bounced · 0 expired · 0 rejected
! still failing: retry.test (2), timeout.test (1)
✗ bounce 2 dns Host or domain name not found. Name service error for name=example.te… · example.test
✗ bounce 1 dns Host or domain name not found. Name service error for name=nxdomain.i… · nxdomain.invalid
✗ bounce 1 recipient_unknown Recipient address rejected: User unknown in virtual mailbox table · bounce.test
✗ deferral 2 rate_limit Too many messages from your IP, rate limit exceeded, try again later · retry.test
✗ deferral 1 timeout connect to ip[ip]: Connection timed out · timeout.test
RESULT 1 problem(s), 7 warning(s)
Note what doctor adds beyond mailq: the deferrals are classified and grouped, and the log section shows bounces that have already left the queue, including the double bounce for example.test (the sender domain has no DNS in the container, so the non-delivery notices bounce too). The same run then checks that delete without --allow-mutations and delete ALL are both refused.
Acting on it
The categories map to different fixes, and most of them are not "flush the queue":
- rate_limit (Gmail 4.7.28): don't flush. A burst of retries makes it worse. Lower
smtp_destination_concurrency_limitor add asmtp_destination_rate_delayfor that transport, check SPF/DKIM/DMARC alignment and what triggered the volume, then let backoff work. - blocklist (Outlook S3150): the IP has a reputation problem. Fix the cause first (open relay, compromised account, missing PTR), then use Microsoft's delisting form and SNDS.
- timeout: test
nc -vz <mx> 25from the host. Many clouds block outbound port 25; if so, use a relayhost. - dns: usually a typo in the recipient domain; tell the sender rather than waiting for the queue lifetime.
When you do need to change the queue, the commands exist but stay locked until you ask for them:
$ postsleuth delete 78715BBD26
postsleuth: delete changes the mail queue; re-run with --allow-mutations if you mean it
$ postsleuth delete ALL --allow-mutations
postsleuth: invalid queue ID "ALL"
For AI agents
postsleuth mcp exposes the same reads as MCP tools over stdio, so Claude, Cursor or another MCP client can answer "why is mail to Outlook stuck?" on a host you point it at (locally, over --wrap "ssh mx sudo", or into a container). The queue-changing tools are not even listed unless the server is started with --allow-mutations, and each call then needs confirm: true. Setup snippets are in the docs.
If postsleuth puts one of your real deferral texts in category other, please open an issue with the text (addresses removed). That is how the classifier gets better.