postsleuth

Why is Postfix not delivering? Diagnosing stuck mail with postsleuth

2026-10-12 · postsleuth v0.1.0 中文版

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_limit or add a smtp_destination_rate_delay for 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> 25 from 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.

github.com/Blackman99/postsleuth · Docs · MIT