一封邮件的旅程:三项全 pass,还是进了垃圾箱


「发送」这个词太轻了。按下它的那一刻,我其实只是让 PHP 把一个字符串交给了一台机器;而在这个字符串躺进别人的收件箱之前,它还要经过四台机器、盖上三个章,最后通过一道不由我决定的审核。

这篇文章把这段路走了一遍。用的全是我自己服务器上的真实数据:邮件头、DNS 记录、谷歌发来的聚合报告,以及写这篇文章时现场做的两封投递实验。

一、先把「发送」这个词拆开

9 月 14 日晚上 23:15,我从博客服务器发出一封自检信,收件人是我自己另一个邮箱。这封信后来成了本文的主要证据——它的邮件头里,每一跳都留了签名。

一封信的四段旅程:WordPress、博客服务器、Google 中继、收件方
一封信的四段旅程(真实取值)

四段分别是:WordPress 侧,wp_mail() 把信交给 SMTP 插件;博客服务器,用主账号认证、连上 smtp-relay.gmail.com 的 587 端口,全程 TLS 1.3;Google 中继,替我用自己的域名密钥盖了章,再从 209.85.220.101 这个出口把信投出去;对方收件方,收下它,并决定它究竟进收件箱还是垃圾箱。

三年前我会以为「发送成功」是最后一个环节。事实是:它只是第一个——wp_mail() 返回 true,只代表我把信交给了中继,不代表收件人收到了。

二、三个印章,各管一件事

SPF、DKIM、DMARC 常被并称「三件套」,但它们在做三件不同的事,失败的样子也完全不同:

  • SPF:回答「这台机器有没有资格代表我的域名发信」。它读的是 DNS 里的 TXT 记录,检查的对象是信封上的发件人。
  • DKIM:回答「这封信在路上有没有被动过」。签发时算出正文与头部的哈希、用私钥签名,收件方拿 DNS 里的公钥验签。
  • DMARC:回答「前两项和信纸上署的域名对得上吗,对不上怎么处置」。它不自己验证,它是那个拿主意的。
SPF、DKIM、DMARC 三个印章的分工与实测取值
三个印章,各管一件事(取值均为现场核验)

真正容易被忽略的是对齐这件事:SPF 验的是信封上的域,DKIM 验的是签名里的域,而收件人在屏幕上看到的是信纸上的 From。三者必须指向同一个域,DMARC 才认账。我们用的是宽松模式(adkim=r / aspf=r):同组织域即可,不必逐字一致。

三、读一封真信:证据全在邮件头里

如果你只打算学一件事,就学这个:把邮件头打开。验证结果、每一跳经手人、退信地址,全在里面,比任何第三方检测工具都可信——因为它就是收件方自己写下的记录。

真实邮件头逐行解读:Received 链、DKIM-Signature、Return-Path、Authentication-Results
真实邮件头逐行读(长签名串已截断)

三个最容易读错的地方:Received 链要从下往上读,最下面那行才是真正的发信人;Return-Path 是信封上的发件人,SPF 检查的就是它,也是退信的去处,而 From 只是信纸上的署名;Authentication-Results 是收件方服务器在收信那一刻给出的判决书。

还要说清一个分寸:那一行 dkim=pass 并不是「对方保证你没问题」,它只是「它当时的验签结论」。认证通过,不等于会被放行——这就引出下面两节。

四、谷歌每天都在给我的域名做体检

因为我在 DMARC 记录里写了 rua=mailto:…,谷歌每天会把「谁在用我的域名发信、通过没通过认证」打包成一份 XML 报告发到那个地址。最近这一份的内容是这样:

report_id: 3894261694774059778
窗口: 2026-09-14 00:00 → 23:59 UTC(= 北京时间 09-14 08:00 → 09-15 07:59)
策略: p=none  sp=none  adkim=r  aspf=r  pct=100

209.85.220.69     6 封   dkim=pass  spf=pass  disposition=none
209.85.220.101   48 封   dkim=pass  spf=pass  disposition=none
合计 54 封,全部通过,没有一个陌生来源

两点值得说明。第一,为什么只有两个 IP:因为这一天的信都走 Google 中继出去,出口就这两个,能对上我们自己抓到的邮件头。第二,为什么处置是 none:因为我写的是 p=none,这条策略目前只记账、不处置。

所以它的价值不在「被警告」,而在于这是一个可见性开关。哪天有人冒用这个域名发信,会先在这份日报里现形——而不是等你收到客户的投诉电话。等它连续几周都干净,再考虑把策略收紧到 quarantine 也不迟。

五、那么,为什么三项全 pass 还是进了垃圾箱

那几天里确实有一封信被送进了垃圾箱,而那封信在垃圾箱里显示的状态仍然是 dkim=pass、spf=pass。听上去矛盾,其实一点都不矛盾:认证只负责回答「你是谁」,不负责回答「你是不是好人」。(那封信现在已不在邮箱里,无法复验,这里只能作为当时的一次实测记录。)

投递判定的五道关:认证、信誉、内容、收件人行为、收件方策略
认证只是入场券:一封信的五道关

收件方判断一封信,至少要过五道关:认证、IP 与域名的历史信誉、内容与结构、收件人的行为、以及它自己的最终策略。我们只控制得住第一道,以及第三道的一部分。所以「全部 pass」的正确读法是:你没有在这道关卡被拦下,而不是你一定会被放行。

六、写这篇文章时,现场做的两封实验

为了让上面这节不全是推理,我现场从同一台服务器、用同一套认证、同一个 From 发出了两封信,收件人都是我另一个邮箱:

  • A 信:纯文本,无链接、无图片、无营销词。
  • B 信:HTML,4 个链接,带「限时免费」「立即领取」这类典型紧迫用语。

结果是:两封都进了收件箱。

这个结果说明什么?它至少说明:在认证齐备、域名没有前科的前提下,一些刻意堆上去的「垃圾特征」并不会立刻触发拦截。但它只是一次样本,我不敢拿它反推成「这样写也没事」——真实判定还牵着发送频率、域名年龄、收件人的行为历史。样本不足时,任何结论都是过度解读。

顺便一提,这两封测试信的标题里都写着「取证」两个字,收件人是我自己,没打扰任何人。

七、抄得走的四件事

域名侧的四条记录、四步排查顺序、监控与实测记录
四条记录 + 四步排查 + 三笔实测

如果只想要一份能干活的清单,那就是:三条认证记录(SPF 说明谁有权发信、 DMARC 说明对不上怎么办、DKIM 公钥供人验签,外加一条 MX)+四步排查顺序(查 DNS → 发一封看 Authentication-Results → 翻对方垃圾箱 → 再回来调内容与信誉)。

还有一件顺手记下的事:WordPress 会强制把 PHP 的时区设为 UTC,所以在邮件代码里写 date(),得到的时间会比北京时间少 8 小时。我第一次看到自己发的邮件主题里写着 06:12、而墙上钟明明是 14:12 的时候,愣了三秒才反应过来——要本地时间,得用 current_time()。

八、一封邮件教会我的

写这篇的起因很简单:有人看到谷歌发来的报告,问我「它为什么给我发这个」。我一开始也以为那是某种警告,读懂之后才发现——那是一份我自己订购的日报,只不过我忘了自己订过。

把这件事拉高一点看:邮件大概是互联网上少数还在认真执行「先证明你是谁,再谈内容」的协议。它在门口就要了身份,之后才轮到内容说话。而我们平时写代码、发内容、做产品,往往把顺序搞反了:先急着把话说漂亮,再回头补身份。

认证齐备不等于被信任;但认证不齐备,连被信任的机会都没有。

如果你也在折腾自建站发信,可以顺路看看上一篇《我用一个下午修好了自己的发信身份》,那篇讲的是这条链路上另一头的事:发信别名与身份。


按中国互联网的活法选技术:一份来自实测的选型清单


同一条网线,同一个下午:某个 CDN 域名秒开,另一个 连接超时;Google 的网页打不开,但发往 Google 的 587 端口一路畅通。这不是玄学,是关于「技术选型」最实在的一课——你选的不只是协议和框架,还有它要穿过的那张网。

一、我那天量到的四件事

全部在同一台境外 VPS 上实测(国内出口、无代理):

cdn.jsdelivr.net           200 · 19,644 字节 · 0.28s
cdnjs.cloudflare.com       超时(12s 无响应)
unpkg.com                  超时(12s 无响应)
www.google.com:443         不可达
www.gstatic.com:443        通(0.2s)
smtp-relay.gmail.com:587   通,握手正常
smtp.gmail.com:465         超时
imap.gmail.com:993         超时

四件事:同一类服务,域名之间命运不同;同一个域,端口之间命运不同;「能不能用」是随时间变的地图;以及第四件——这些结论都只能靠本地实测拿到,查文档查不到。

二、不是墙不墙,是「哪一层断了」

把网络想成四道闸门,排查时一层层确认,比一句「被墙了」有用得多:

  • DNS 层:域名能不能解析成正确的地址。那天 www.google.com 解析出一个明显不对的 IPv6 地址,而 smtp-relay.gmail.com 解析到了真实 IP。这是最容易误判的一层——「能 ping 通」不代表解析是对的。
  • TCP 层:端口能不能握手。587 通、465/993 不通,两种邮件端口的差别就落在这里。
  • TLS 层:握手能不能过、证书域名对不对。
  • 应用层:协议对不对(比如 SMTP 的 STARTTLS 与隐式 TLS),认证方式对不对。

三、十条选型心得

  • 静态资源默认自托管。把库文件放进自己的服务器,别让每个访客去第三方 CDN 试运气。
  • 要引 CDN 就准备两个域名。它们同时挂掉的概率远低于单独一个。
  • 字体、图标、地图 SDK、验证码、收款回调,全部本地化兜底。这五样是「页面白屏」的常客。
  • 出海服务一律配回落。降级链不是过度设计,是可用性的基本密度。
  • 发信优先用「放行的端口」。那天能出去的是 587;smtp.gmail.com:465 反而超时。技术方案要顺着网络的缝走,而不是顺着文档走。
  • 域名下不要赌 MX 只有一家。多一条 MX、多一个可用的发信域,都是保险。
  • 部署链路别绑单一网络出口。同一份产物,最好能从两条路推上去。
  • 监控里加一条 DNS 正确性。只监「能不能连」,漏掉「连到了假地址」。
  • 用户在你这里打不开、在邻居那里打得开。多半不是他的问题,是链路选择问题——让他换个入口(多域名),比让他改 DNS 现实。
  • 把「可用性」当变量写进架构。哪天某条缝合上了,代码结构应该允许你换一条,而不是重写。

四、一张「体检表」,建议你也跑一遍

下面前两行确认「解析是不是真的」,第三行看端口,最后一行看证书。把域名换成你自己的服务,跑一次就知道该给哪一环加兜底:

# 1) 解析是否被污染:多问两家公开 DNS,结果应当一致
dig +short @1.1.1.1 your-domain.com A
dig +short @8.8.8.8 your-domain.com A

# 2) 拿到 IP 后,核对它是不是你预期的那个(尤其 CDN 域名)

# 3) 端口连通性:对关键端口逐个做 TCP 握手
nc -vz your-host 443 ; nc -vz your-host 587 ; nc -vz your-host 465

# 4) 证书与域名是否匹配
openssl s_client -connect your-host:443 -servername your-domain.com < /dev/null | openssl x509 -noout -subject -dates

最后一句心得:可用性不是事实,是当前时刻的观测结果。今天能通的端口,明天可能不通;今天秒开的 CDN,明天可能超时。所以别把「能用」写死在代码里——把「怎么换」写进架构里。

我用一个下午修好了自己的发信身份:dsh-email 发送别名补丁实录


先说结论:我给 dsh-email 写了一个「发送别名」补丁,让一个信箱可以用它登录不了的地址发信,在自己的机器上跑通并提了 PR。这篇不聊情怀,只讲清三件事:问题长什么样、为什么不能只靠改配置、以及怎么把风险压到最低。

一、问题:Gmail 的别名,登不上

Google Workspace 里可以给账号加「别名」(send-as alias)。配好之后在 Gmail 网页端切换身份发信一切正常,但换成程序发就不行——因为 IMAP 和 SMTP 的认证只认主账号。用别名去登录,服务器直接回一句:

535 5.7.8 Username and Password not accepted.

而插件里账号的 user 同时承担两个角色:登录名和 From 头。两者绑死,于是要么登不上,要么发出去的邮件署着一个你不想要的地址。

二、为什么不能「配置一下就好」

翻一遍源码:发信处写的是 from: cfg.user,认证处是 auth: { user: cfg.user, pass: cfg.password }——同一个字段喂给两个地方。所以配置文件里再怎么折腾,也变不出「登录用 A、署名用 B」这种组合。

但配置里做不到的事,协议里是允许的:只要登录身份是别名的主人,From 头就可以写别名。这正是 Gmail「以别名发送」的机制。要把它用起来,只需要让程序能把两个身份分开表达。

三、先证明「旧配置不变」,再动代码

改别人的插件,第一件该做的事不是改代码,而是证明老配置在新版下行为不变。我写了个脚本,把最简配置(只有 user、password、imap、smtp)分别指向新版与已发布的旧版,各跑一次真实 send,用本地一次性 SMTP 服务器把信封抓下来对比:

已发布 0.10.7:AUTH user=legacy@fake.test | MAIL FROM:<legacy@fake.test> | From: legacy@fake.test
修复版     :AUTH user=legacy@fake.test | MAIL FROM:<legacy@fake.test> | From: legacy@fake.test

逐项相同,才有资格往下改。这条对照后来固化成了仓库里的回归测试——以后谁再动这块,先撞它。


四、补丁:把两个身份拆开

新增三个可选字段,各管一件事:

  • senderName:From 头的显示名,留空就只发裸地址;
  • authUser:真正的登录账号,缺省回落到 user;
  • authPassword:登录口令,缺省回落到 password。

配置落地后是这样:

user: alias@your-domain.com      # 收件人看到发信地址
senderName: 鲸鱼小姐              # 收件人看到显示名
authUser: owner@your-domain.com  # 真正登录的账号
authPassword: 应用专用密码         # 登录口令

显示名只在含 RFC 5322 特殊字符时才加引号,已自带引号的写法原样透传——不然中文名和 Doe, John 这类名字会互相打架。

验收标准(两条同时满足才算过):① SMTP 认证用的是 authUser;② 收件人看到的 From 头是显示名 + 别名地址,且邮件通过 SPF 与 DKIM 校验。这台机器上的实测:认证身份为主账号,From 头解码后是「鲸鱼小姐 <alias@…>」,收件侧 dkim=pass、spf=pass。


五、路上踩的三个坑

  • YAML 会把账号名 qq 吃成布尔值。写成 accounts: { qq: {} } 时,qq 被解析成 true,账号名消失,报错说「defaultAccount 不存在」。加引号写成 "qq" 才对。
  • 账号级 provider 会被顶层简写盖住。顶层写 provider: qq、账号里写 provider: gmail 时,解析顺序让顶层的主机地址赢了——账号静默连到错误的服务器。规避办法是账号级显式写 imap/smtp 主机,别依赖预设。
  • 从本机直连 Google 中继会被拒。中继只放行你在 Workspace 里登记过的来源地址,动态 IP 的机器会被打发去买咖啡。所以发信改用 smtp.gmail.com:465 + 应用专用密码;服务器侧才走中继。

六、结语

补丁很小,但方法值得复用:先写兼容性对照,再改代码;改完必须有能复现的验收证据。至于署名——从今天起,这个笔名也会出现在一些技术笔记里。写得不好,欢迎拍砖。