早上九点那封没有送到的邮件
站点的评论通知邮件、密码找回邮件、订阅确认邮件,在我这台机器上安静地坏了将近三周,而我完全不知道。直到有一天一位老读者在评论区留言说「你的密码找回好像坏了,我点了三次都没收到」,我才去看服务器的邮件队列。
当时的场景是这样的:mailq 一跑,屏幕上滚出来两千多封积压邮件,全部是「Connection timed out」或「Connection refused」。更讽刺的是,在这三周里,博客本身的邮件通知功能工作正常,PHP 的 mail() 返回的都是 true,postfix 的 status=active,systemctl status postfix 一片绿色。
这就是邮件系统最典型的故障形态:整条链路上的每一环都「看起来正常」,但信就是送不出去,而且没人告诉你。 邮件不像 HTTP,没有 5xx 让你立刻看到;它的失败默认是「延迟重试」,重试到第四天才给你退信,而退信又被送到一个你可能根本不看的本地邮箱。
这篇文章把我后来整理的一套排查与加固办法写清楚:怎么判断队列积压是不是真的,怎么用 postqueue 和日志定位卡在哪一环,为什么自建服务器的邮件几乎注定进垃圾箱,以及退信地址和反弹处理该怎么做。不涉及群发营销——那是另一码事,而且通常会被各家的反垃圾系统直接打死。
先分清三件事:MTA、MUA 与投递
排查邮件问题之前,需要搞清楚你手上的邮件系统由哪几块组成。以 Postfix 为例,一封从网站发出的信会经过这些环节:
- 应用:PHP 的
mail()调用sendmail命令,把信投给本机的 Postfix。这一步失败时mail()返回false,但很多程序(包括不少 CMS 主题的插件)不检查返回值,所以失败被完全吞掉。 - cleanup 与 queue:Postfix 收下后写进
/var/spool/postfix/下的队列目录,此时才真正「接管」这封信。 - smtp 客户端:Postfix 根据收件人域名做 MX 查询,连上对方的 25 端口,把信递过去。
- 对方服务器:接不接受,取决于对方的反垃圾策略——SPF、DKIM、DMARC、IP 信誉、PTR 记录、内容评分,任意一条不过就可能被延迟或拒绝。
最常见的误解是「Postfix 在跑就说明邮件正常」。Postfix 在跑只说明第一、二步没问题,第三、四步可以完全坏掉而 systemctl status 依然一片绿。所以判断邮件是否正常,唯一的可靠指标是队列里有没有积压、日志里有没有 defer,而不是服务状态。
第一步:确认队列是不是真的在积压
三条命令,按信息量从小到大:
# 1) 最简单:看队列里有几封信
mailq | tail -3
# 输出示例:
# -- 2841 Kbytes in 2184 Requests.
# 2) Postfix 自己的计数(更精确,不需要解析文本)
postqueue -p | tail -1
postconf -h queue_directory
# 3) 按「卡住的原因」统计(最有用,Postfix 2.x 起支持)
qshape deferred | head -20
qshape -s deferred | head -20qshape 是我最推荐的一条。它把队列按「收件人域名」和「在队列里待的时长」做成一张矩阵,一眼就能看出问题是「全部卡住」还是「只卡在某几个域名」:
$ qshape deferred
T 5 10 20 40 80 160 320 640 1280 1280+
gmail.com 412 0 2 18 40 90 160 102 0 0 0
qq.com 980 0 0 1 4 12 30 900 33 0 0
163.com 12 0 0 0 0 0 0 12 0 0 0
TOTAL 2184 0 2 19 44 102 190 1054 33 0 0这张表告诉我两件事:qq.com 的信堆积在 320 分钟那一档(说明是「一直重试一直失败」,而不是「刚进来还没发」),gmail.com 的信分散在 160 分钟附近(说明也在重试,但退得比 qq 快)。这种「分域名分时长」的视图,比看总数有用得多——如果只有一两个域名卡,那是对端的反垃圾策略问题;如果全部域名都卡,那是本机的出口或 DNS 问题。
qshape 在 Debian 上属于 postfix 包的一部分,但如果你用的是 postfix 的精简包,可能需要单独安装 postfix-qshape 之类的子包,找不到命令时先查包管理器。
第二步:日志里找真实原因
队列只能告诉你「有多少卡住了」,maillog 才能告诉你「为什么」。Postfix 的日志通常写在 /var/log/mail.log(Debian/Ubuntu)或 /var/log/maillog(RHEL 系),也可以走 journald:
# Debian/Ubuntu
grep 'status=deferred' /var/log/mail.log | tail -20
# systemd 机器
journalctl -u postfix --since='2 hours ago' | grep -E 'deferred|bounced|reject'
# 按收件人过滤,非常有用(调试单个用户的收信问题)
grep 'to=<someone@example.com>' /var/log/mail.log | tail -10Postfix 的日志里,每封信都会经历 status=sent、status=deferred(延迟重试)、status=bounced(退信,分硬退和软退)三种结局之一。看到 deferred 不算坏事——网络抖动是会自愈的,Postfix 默认会重试五天。真正需要处理的是:同一封信反复 defer,原因始终一样。
下面这张表是我整理的「defer 原因 → 真实含义 → 该怎么办」,按我遇到过的频次排序:
| 日志里的关键词 | 真实含义 | 处置方向 |
|---|---|---|
Connection timed out | 连不上对方 25 端口 | 云厂商封了出站 25(最常见),换 587/465 中继 |
Connection refused | 对方主动拒绝,或本机被列入黑名单 | 查 smtp 端口是否被拦,查 IP 黑名单 |
550 5.7.1 ... blocked | 对方反垃圾判定为垃圾邮件 | 内容/发信域/IP 信誉问题,改中继最省事 |
550 relay access denied | 你把它当中继用但没授权 | 配置 mynetworks 或 sasl_auth |
421 4.7.0 Too many connections | 你发得太快,对方限速 | smtp_destination_rate_delay 控制速率 |
no MX record found | 收件人域名没有 MX | 硬退,直接放弃,别浪费重试 |
Host or domain name not found | 本机 DNS 没配好 | 检查 /etc/resolv.conf,见下文 |
其中第一行是我那台机器上真正的原因:云服务商默认封禁出站 25 端口。绝大多数公有云(AWS、GCP、阿里云、腾讯云、Vultr、Hetzner)都对 25 端口做了限制,有的直接封、有的要申请解封、有的只允许连自己的中继服务。telnet 一下立刻就能验证:
# 用 telnet 或 nc 测对外 25 端口是否通
timeout 5 nc -vz gmail-smtp-in.l.google.com 25
# 卡住不动或者 timeout 就是被封了
# 换个端口测同一个目标(对比法)
timeout 5 nc -vz smtp.gmail.com 587如果 25 端口不通而 587 通,结论就是「出站 25 被拦」。这时候不要尝试各种绕过手段(有些云厂商会因为你探测端口而发警告),正确做法是换一个外部 SMTP 中继:用云厂商自己的邮件推送服务、用第三方事务邮件服务,或者用你自己的另一个服务器上开放的 2525/587 端口做中继。
第三步:把应用出口收到一个可观测的地方
定位到问题之后,我做的第一件事不是修 Postfix,而是让「邮件发不出去」这件事变得可见。因为前面三周里,我完全没有收到任何信号。
第一件事:让应用检查返回值。 PHP 的 mail() 返回 false 只代表「本地 sendmail 拒绝」,代表不了「送达」。真正能确认送达的只有中继服务给的 message-id 回执。所以最简单的办法是:把 mail() 换成一个明确失败会抛异常的封装,至少在本地这一步能看到错误:
function send_mail($to, $subject, $body) {
$headers = "From: noreply@example.com\r\n"
. "MIME-Version: 1.0\r\n"
. "Content-Type: text/plain; charset=UTF-8\r\n";
if (!mail($to, $subject, $body, $headers)) {
error_log("MAIL_FAIL to=$to subject=$subject");
return false;
}
error_log("MAIL_SUBMIT to=$to subject=$subject");
return true;
}这两行 error_log 是关键。它们把「提交成功」和「提交失败」都写进 PHP 的 error_log,这样你只要偶尔 grep MAIL_FAIL 就知道有没有坏。我后来还在每日巡检脚本里加了一条:MAIL_FAIL 数量 > 0 就在日报里顶格显示。
第二件事:不要用本机 MTA 直投,改走中继。 这是我最终的选择,也是我现在给所有个人站点的建议。原因很简单:自建 MTA 直投的成功率在 2026 年已经低到没有维护价值了。你需要维护 IP 信誉(新 IP 基本等于垃圾邮件源)、配置并长期校准 SPF/DKIM/DMARC、处理 PTR 记录(很多 VPS 供应商不允许你设置 PTR)、监控各个 ISP 的黑名单……这每一项都是长期维护成本,而收益只是省下一笔极小的中继费用。
Postfix 走中继的配置(以 587 端口 + SASL 为例):
# /etc/postfix/main.cf 末尾追加
relayhost = [smtp.example-provider.com]:587
smtp_sasl_auth_enable = yes
smtp_sasl_password_maps = hash:/etc/postfix/sasl_passwd
smtp_sasl_security_options = noanonymous
smtp_tls_security_level = encrypt
smtp_tls_CAfile = /etc/ssl/certs/ca-certificates.crt# 密码文件,权限必须 600
echo '[smtp.example-provider.com]:587 apikey:xxxxxxxx' > /etc/postfix/sasl_passwd
chmod 600 /etc/postfix/sasl_passwd
postmap /etc/postfix/sasl_passwd
systemctl reload postfix
# 验证:发一封测试信并看日志
echo "test $(date)" | mail -s "postfix relay test" your@mail.com
tail -f /var/log/mail.log这里有一个必须注意的安全细节:/etc/postfix/sasl_passwd 里是明文的 API key,chmod 600 之后只有 root 能读。但 postmap 生成的 sasl_passwd.db 也需要同样的权限——很多人只改了源文件的权限,忘了 .db,结果密码文件实际上是可读的。检查一下:
ls -l /etc/postfix/sasl_passwd*
# -rw------- 1 root root ... sasl_passwd
# -rw-r--r-- 1 root root ... sasl_passwd.db ← 这个权限就漏了
chmod 600 /etc/postfix/sasl_passwd.db第四步:DNS 三件套,以及为什么它们经常不生效
即使走了中继,中继服务通常也会要求你的域名有正确的 SPF/DKIM/DMARC 记录。这三条记录的正确性,是「进收件箱还是进垃圾箱」的主要分水岭。 但它们有一个共同特点:配置起来容易,验证起来麻烦,而且改完之后不生效的原因往往是缓存。
SPF:一条 TXT 记录,声明哪些 IP 有权代表你的域名发信。规则是「一个域名只能有一条 SPF 记录」,多条 SPF 会导致 SPF 校验失败(permerror),这是最常见的配置错误。合并检查:
dig +short TXT example.com | grep -i spf
# 如果输出两行及以上以 "v=spf1" 开头的记录,就是错的,必须合并成一条DKIM:在域名下发布一条公钥记录,你发出的信带上签名,收信方用公钥验证。selector 名字必须和邮件头里的 s= 参数一致,否则查不到记录。验证方法是用一个测试地址发信,把收到的原始邮件头里的 DKIM-Signature 复制出来,找到 s= 和 d=,然后:
# 假设 selector 是 mail,域名是 example.com
dig +short TXT mail._domainkey.example.com
# 应该输出 v=DKIM1; k=rsa; p=MIIBIjANBg...DMARC:一条 _dmarc.example.com 的 TXT 记录,声明「SPF 和 DKIM 都不过时怎么办」。起步阶段用 p=none 只观察不拦截,等确认正常后收紧到 quarantine:
dig +short TXT _dmarc.example.com
# v=DMARC1; p=none; rua=mailto:dmarc@example.com; pct=100rua 那个邮箱一定要填真实可用的,因为它会收到各家的聚合报告,是发现「有谁在伪造你的域名发信」的唯一手段。不过报告是 XML 格式(gzip 压缩),人眼读不了,通常要用第三方服务解析。
这三个记录改完之后,最容易踩的坑是 TTL 没过去。TXT 记录的 TTL 通常是 300~3600 秒,改了之后要等 TTL 过期才全网生效。更隐蔽的是本地 DNS 缓存:你改完之后 dig 本地看到的还是旧的,因为本机 systemd-resolved 或 nscd 缓存了。判断方法是对比两个不同的公共解析器:
dig +short TXT example.com @8.8.8.8
dig +short TXT example.com @1.1.1.1
dig +short TXT example.com @223.5.5.5
# 如果要看权威服务器的原始记录(绕过所有缓存)
dig +short TXT example.com @ns1.your-dns-provider.com如果 8.8.8.8 和 1.1.1.1 都返回新值,只有本机返回旧值,那就是本地缓存问题,resolvectl flush-caches 清一下即可。但如果权威服务器返回的还是旧值,说明你的 DNS 服务商没保存成功——这时候等多久都没用,得回去查记录是否真的写进去了(常见原因是带了引号、或者记录被服务商拆成多段)。
第五步:退信处理,比想象中重要
队列里的信如果不是全部送出去,就会有一部分变成退信(bounce)。退信本身也是一封邮件,默认会发到 MAILER-DAEMON 或你配置的 postmaster 地址。如果那个地址不存在或者没人看,退信就会被丢弃——于是你就永远不会知道「有多少信没送到」。
所以必须保证有一个真人在看的退信地址。Postfix 里可以把它指向你的外部邮箱:
# /etc/postfix/main.cf
# 把 root 和 postmaster 的信转发到真实邮箱
# /etc/aliases
root: you@real-mail.com
postmaster: you@real-mail.com
# 改完执行
newaliases但光有退信还不够,还要能区分硬退和软退:
- 硬退(5xx):收件人不存在、域名不存在、明确拒绝。这类地址应该从你的库里移除,不要反复发——反复发硬退会被收信方判定为「列表卫生差」,进一步拉低你的 IP 信誉。
- 软退(4xx):临时性失败,信箱满了、对端限速、网络问题。这类会重试,但如果同一个地址连续软退超过 3 次,实质上也等同于硬退。
退信的分类不需要自己写解析,Postfix 在日志里已经用 status=bounced + dsn=5.x.x 或 dsn=4.x.x 标出来了:
grep 'status=bounced' /var/log/mail.log | grep -oE 'dsn=[0-9]\.[0-9]\.[0-9]' | sort | uniq -c | sort -rn这条命令输出的是「各退信原因代码出现次数」的排行。如果 dsn=5.1.1(收件人不存在)排第一,说明你的邮件列表里有一批死地址,该清理了;如果 dsn=4.7.1(被对方拒收)排第一,说明是反垃圾问题,改中继比改内容有效。
一个每日巡检脚本
最后把上面这些串成一个可以放进 cron 的巡检脚本。它只回答一个问题:今天的邮件系统是否需要我动手?
#!/bin/bash
# mailcheck.sh —— 每日邮件系统体检,输出一行摘要
LOG=/var/log/mail.log
TODAY=$(date +%F)
# 1) 队列深度(超过 50 封就该关注)
q=$(mailq 2>/dev/null | tail -1 | awk '{print $4}')
[ -z "$q" ] && q=0
# 2) 今天的 deferred 次数
def=$(grep "^$TODAY" "$LOG" 2>/dev/null | grep -c 'status=deferred')
# 3) 今天的硬退次数(5.x.x)
hard=$(grep "^$TODAY" "$LOG" 2>/dev/null | grep 'status=bounced' | grep -c 'dsn=5\.')
# 4) 队列里最老的一封信待了多久(小时)
old=$(find /var/spool/postfix/deferred -type f -printf '%T@\n' 2>/dev/null \
| sort -n | head -1)
if [ -n "$old" ]; then
oldest_h=$(( ( $(date +%s) - ${old%.*} ) / 3600 ))
else
oldest_h=0
fi
# 5) 结论
if [ "$q" -gt 50 ] || [ "$oldest_h" -gt 6 ]; then
verdict="ALERT"
elif [ "$def" -gt 20 ] || [ "$hard" -gt 10 ]; then
verdict="WATCH"
else
verdict="OK"
fi
echo "$TODAY queue=$q deferred=$def hard_bounce=$hard oldest_h=${oldest_h} $verdict"这个脚本有几个刻意的设计:
为什么用 find -printf '%T@' 算最老的一封信,而不是用 qshape? 因为 qshape 的时长分档很粗(5/10/20/40/80/160 分钟…),而「最老的信待了多久」这个数字更直观。如果最老的一封信已经待了 6 小时以上,说明有一个目标一直在失败,基本可以确定不是网络抖动。
为什么要分 OK / WATCH / ALERT 三档? 因为邮件问题的严重程度差异很大。deferred 有 20 次但都在自愈,属正常;队列 60 封但都是刚才几分钟的,也正常。同时用「队列深度」和「最老时长」两个维度交叉判断,才能避免误报。 只有一个维度的时候,我几乎每天都在被误报吵醒。
为什么不用 set -e? 这是老生常谈但必踩的坑:grep -c 在没有匹配时返回退出码 1,在 set -e 下脚本会在这里静默退出,输出半截。
还有一个经常被忽略的检查:验证「从应用到队列」这一段是否通畅。上面的脚本只看 Postfix 侧,如果 PHP 的 mail() 根本没能把信投给 Postfix,队列会显示 0,脚本会报 OK。所以我另外加一条探针:每天早上发一封测试信到一个只用于这个目的的地址,然后检查它有没有到达。 这一步没法自动化验证「到达」,但可以退一步:检查本机日志里有没有那封信的 550 5.1.1 之外的记录。MAIL_FAIL 那条 error_log 也是同一个目的。
三条教训
教训一:会被静默吞掉的失败,都需要专门做探针。 邮件、定时任务、备份、日志轮转——这些功能的共同点是「失败时不报错」。邮件失败退信到一个没人看的信箱,cron 失败只是没有输出,备份失败只有一个非零退出码。它们不是不重要,而是失败没有声音。所以要主动给它们做探针,而不是等用户来告诉你。
教训二:自建 MTA 直投的时代已经结束了。 这不是技术能力问题,是行业规则变了:IP 信誉体系、各家自建的反垃圾模型、越来越严的 DMARC 执行策略,让「一个人维护一台 VPS 的邮件出口」这件事在性价比上完全不成立。花半小时配好中继,比花三天调 SPF 和修 IP 信誉划算得多。省下来的时间该花在内容上。
教训三:先看队列,后看服务状态。 systemctl status postfix 是绿的,不代表邮件正常;只有 mailq 为空、日志里没有持续的 deferred,才叫正常。这个顺序不只适用于邮件——Nginx 的 active (running) 也不代表站点能访问,MySQL 的 active 也不代表查询能跑。服务状态只说明「进程活着」,不能说明「业务通」。判断业务通不通,永远得走一次真实的端到端请求。