postsleuth

Postfix 为什么发不出信?用 postsleuth 排查卡住的邮件

2026-10-12 · postsleuth v0.1.0 English

队列越积越多,用户说发到 Gmail 的信"没收到",mailq 打出来一大屏。本文演示如何用 postsleuth(一个只读的 Postfix 诊断 CLI,同时是 MCP 服务器)从这一大屏里找到原因和下一步。

输出来源说明:第 1–5 节是 postsleuth v0.1.0 在仓库自带样例数据上的真实运行结果(testdata/queue.json 和 testdata/mail.log,由 testdata/gen.py 生成,模拟一台出站中继"糟糕的一天";域名和 IP 都是文档保留段)。"在真实 Postfix 上"一节摘自项目 CI 日志,CI 会在 Docker 里起一个一次性的 Postfix 3.7。下文输出除去掉颜色外没有任何改动。

常见的排查流程

手工排查 Postfix 卡信一般是:mailq | less,挑一个看起来常见的原因,去邮件日志里 grep,再用 sort | uniq -c 计数(但每行地址和 IP 都不同,根本聚不起来),跑 qshape 看是新问题还是老问题,最后 dig 查 SPF、DMARC 的 TXT 记录。postsleuth 读的是同样的东西(postqueue -j、邮件日志、DNS),把归并这一步替你做了。除非启动时加 --allow-mutations,它不会改动队列。

go install github.com/Blackman99/postsleuth/cmd/postsleuth@latest
# 或者从 GitHub Releases 下载静态二进制

1. 按原因归并队列

在邮件服务器上直接运行 postsleuth queue 即可。这里改为读取保存下来的 postqueue -j 输出:

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 封邮件归成了 5 行。归并前会先把延迟原因里的地址、IP 和追踪 ID 去掉,所以只差在收件人上的 150 条 Gmail 延迟会合成一个 rate_limit 组。每组都有分类(共 17 类,从 rate_limit、blocklist 到 ptr、auth、tls)和处理建议。灰名单标为警告(!)而不是问题(✗),因为 Postfix 会自动重试。

要看某一组背后的具体邮件,按分类过滤:

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)

--list 会保留完整的回复。微软的 S3150 代码说明了原因(发信 IP 在其黑名单上),搜索或提工单时就用它。

2. 从什么时候开始的?(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

列是邮件在队列里的时长(分钟),与 Postfix 自带的 qshape 一致。从左往右看:Gmail 的延迟集中在 40 到 640 分钟这几列,没有更老的,说明限速大约是最近十一个小时内开始的,而且新邮件仍在被拦。oldcorp.example 全在右侧几列:这个目的地已经大半天连不上,如果情况不变,这些邮件会在 maximal_queue_lifetime 到期后退回。怀疑某个被盗账号在刷信时用 shape --by sender;这份样例里所有邮件都来自 example.com,所以用不上。

3. 日志里发生了什么

队列只反映还卡着的邮件,日志则记录了已经发生的事,包括退信和已送达的邮件:

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

这里有两点是队列看不出来的。第一,Gmail 仍在接收大部分邮件(成功 355 次,延迟 82 次),符合按发送量限速、而不是彻底封禁的特征。第二,Outlook 已经直接退回了 14 封,最近一次尝试仍是延迟:黑名单已经造成了实际的丢信。30 条 relay_denied 是 smtpd 拒绝了试图借道转发的外部客户端,这是服务器在正常工作,但如果这个数字突然暴涨就值得留意。

在真实服务器上可以省略 --log:postsleuth 会先从 postconf 读取 maillog_file,再依次尝试 /var/log/mail.log、/var/log/maillog 和 journald。syslog、RFC 3339、journald 三种时间格式以及 .gz 轮转文件都支持。

4. 追踪单封邮件

用户问起某一封信时,按队列 ID 或地址追踪。如果邮件被退回,Postfix 生成的退信通知也会一并列出:

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. 一次看全

postsleuth doctor 会依次输出队列摘要、qshape、日志摘要和发信域名的 DNS 检查(MX、SPF 及其 10 次查询上限、DKIM、DMARC、PTR/正反解一致性),最后给出结论行。发现问题时退出码为 1,可以直接放进 cron 或监控检查。由于样例里的域名不是真实域名,这里跳过 DNS:

postsleuth doctor --queue-file testdata/queue.json --log testdata/mail.log --since all --no-dns
echo $?   # 1

输出就是第 1–3 节合在一份报告里,最后一行是:

RESULT  6 problem(s), 4 warning(s)

在真实 Postfix 上

CI 会构建一个 Debian Postfix 3.7 容器,用 transport 表把 retry.test 指向一个只会延迟的 retry: 传输,把 bounce.test 指向 error:,把 timeout.test 指向永远不会应答的 192.0.2.1。然后发送 6 封邮件,在容器外用 --wrap "docker exec -i postsleuth-it" 运行 postsleuth,跟你对 docker-mailserver 或 mailcow 的用法完全一样。容器里的 mailq 是这样的:

-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

同一容器上的 postsleuth doctor(取自提交 fd6a2f8 的 CI 运行,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)

可以看到 doctor 比 mailq 多给了什么:延迟被分类归并;日志部分还列出了已经离开队列的退信,包括 example.test 的二次退信(容器里发信域名没有 DNS,所以退信通知本身也被退回)。同一次运行随后还验证了不带 --allow-mutations 的 delete 和 delete ALL 都会被拒绝。

接下来怎么处理

不同分类对应不同处理方法,大多数都不是"flush 一下队列":

  • rate_limit(Gmail 4.7.28):不要 flush,集中重试只会更糟。为该传输调低 smtp_destination_concurrency_limit 或加上 smtp_destination_rate_delay,检查 SPF/DKIM/DMARC 对齐和发信量异常的原因,然后等退避重试。
  • blocklist(Outlook S3150):IP 信誉有问题。先修复根因(开放转发、被盗账号、缺 PTR),再走微软的解封表单并查看 SNDS。
  • timeout:在服务器上执行 nc -vz <mx> 25。很多云厂商默认封出站 25 端口,这种情况要改用 relayhost。
  • dns:通常是收件域名拼错了,直接告诉发件人,不要等到队列超时。

确实需要改动队列时,命令是有的,但默认锁着:

$ 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"

给 AI 助手用

postsleuth mcp 通过 stdio 把同样的只读能力暴露为 MCP 工具,Claude、Cursor 等 MCP 客户端就能在你指定的服务器上(本机、--wrap "ssh mx sudo" 或容器内)回答"为什么发往 Outlook 的信卡住了?"。修改队列的工具默认根本不出现在工具列表里,只有启动时加 --allow-mutations 才会提供,并且每次调用还要 confirm: true。配置示例见文档。

如果你遇到的真实延迟原因被归到 other,欢迎提 issue 附上原文(去掉地址),分类器就是这样变准的。

github.com/Blackman99/postsleuth · 文档 · MIT