邮件退信处理与VERP:营销信发出去却没到

我三年前写过一篇教程,讲怎么给自己的博客加一个邮件订阅功能。代码就是最朴素的那种:用户提交邮箱,系统调 mail() 发一封确认信,点链接激活。上线之后一切正常,直到半年后有用户说「我点了订阅但从来没收到确认邮件」,我才发现后台有一批用户永远卡在未激活状态。

查下去才发现,那些邮件不是没发出去——是我的服务器一直在往对方服务器发,对方一直在拒收,而我从头到尾没看过一眼退信。

邮件这东西跟其他协议最大的区别在于:发送成功不等于送达成功。你的 MTA 把信交给对方服务器,返回了 250 接受,这叫「已投递」;对方之后可以静默丢弃、可以投到垃圾箱、可以退回。这一整套回执机制就叫退信(bounce),而管理退信的机制叫 VERP。这篇文章把我踩过的坑和后来搭的一套退信处理流程写清楚。

退信到底是什么

严格来说,退信是一封「由邮件系统自动生成、发回给发件人」的邮件,正文里包含原始邮件的一部分,标题和正文里有机器可读的状态码。它跟你自己写的业务邮件不同,它是 MTA 之间的通信。

信封发件人和信头发件人是两个人

这是理解退信的第一道门槛。一封邮件有两个「发件人」:

  • 信封发件人(Envelope From / Return-Path):SMTP 协议层 MAIL FROM: 后面的那个地址。退信会发到这个地址。用户看不到它。
  • 信头发件人(Header From):邮件正文里那个 From:,用户在邮件列表里看到的就是它。

两个可以不一样。这个特性看起来没什么用,但它是 VERP 的基础。

硬退和软退

退信按原因分两类,处理方式完全不同:

硬退(Hard Bounce),DSN 状态码以 5. 开头。意思是永久性失败,重试没有意义。典型原因:邮箱不存在(5.1.1)、域名不存在(5.1.2)、对方明确拒收(5.7.1,通常因为你的 IP 在黑名单上)。

软退(Soft Bounce),DSN 状态码以 4. 开头。意思是暂时性失败,过一段时间重试可能成功。典型原因:对方邮箱满了(4.2.2)、对方服务器临时故障(4.4.1)、连接超时、被灰名单延迟。

这个区分极其重要,因为它决定你的地址列表怎么维护:硬退一次就该标记失效,软退要容忍多次重试。如果把所有退信都当硬退处理,一次网络抖动就能把你半个订阅列表删掉;反过来把所有退信都当软退,你会一直给已经不存在的邮箱发信,把发送信誉拖垮。

退信有两种到账方式

同步退信(SMTP 层的拒绝):对方在 SMTP 会话里直接返回 5xx,你的 MTA 当场就知道失败,会立刻在队列里标记并生成退信。这种最快、最可靠。

异步退信(DSN 邮件):对方先接受(返回 250),之后发现无法投递(比如对方内部转发到一个不存在的地址),再生成一封 DSN 邮件发回给你的信封发件人。这种退信要过几分钟到几天才到,而且你必须真的去读那个信箱,否则永远不会知道。

我那次的事故就是第二种。所有信都被对方接受了,退信发到了 noreply@ 这个从没人看的信箱里,几百封堆在那里。

VERP:让退信能自动归因

VERP 全称 Variable Envelope Return Path,可变信封返回路径。想法非常简单:给每一封信一个独一无二的信封发件人地址,这样退信回来的时候,你从收件地址就能知道是哪封信、发给谁的。

为什么需要它

假设你给 500 个订阅者群发一封周报,信封发件人统一是 bounce@example.com。有一个人的邮箱失效了,退信回到这个地址。现在问题来了:你怎么知道是这 500 个人里的哪一个失效了?

你只能去解析退信正文里附带的那段原始邮件头,从里面扒出 To: 地址。这条路可行,但极其脆弱:不同 MTA 的退信格式五花八门,有的附完整原信,有的只附头部,有的把原信编码成 base64,有的干脆只给一个 message-id。解析器要写几十种分支,还经常出错。

VERP 把这个问题彻底解决了。信封发件人写成这样:

bounce+u8731=a1b2c3d4@mail.example.com

其中 u8731 是用户 ID,a1b2c3d4 是这次发送的批次哈希。退信回到这个地址,你的 MTA 配置一个通配符收信规则,把整个地址交给处理脚本,脚本按 + 后面的部分切分,直接得到「哪个用户、哪一批次」。零解析、零歧义。

为什么用等号不用加号

地址里那个 = 不是笔误。很多邮件系统(尤其是老的 qmail 系)用 - 做分隔,但 - 在邮箱地址里本来就合法,会造成歧义。= 在 RFC 允许的 atext 字符里,但极少出现在真实的本地部分中,所以冲突概率几乎为零。这是 VERP 的经典写法,Postfix 和绝大多数邮件服务商都支持。

VERP 的代价

用 VERP 有个明显的副作用:每封信的信封发件人都不同,邮件的去重和缓存效率会下降。更麻烦的是,某些反垃圾系统会把「同一域名大量不同的信封发件人」当成钓鱼特征。所以实践中要评估:如果你只是给几百个人发周报,VERP 值得;如果你一天发十万封营销邮件,可能要用「每个批次一个地址」而不是「每封信一个地址」。

另外 VERP 会让 SPF 检查稍微复杂一点点,因为 SPF 是对信封域名做校验的。只要所有 VERP 地址都在同一个域名下(像我上面写的 mail.example.com),SPF 记录覆盖这个域名就没事。

在 Postfix 上把 VERP 跑起来

Postfix 原生支持 VERP,配置不复杂,但有几个地方容易踩坑。

开启 VERP 支持

# /etc/postfix/main.cf
recipient_delimiter = +
enable_long_queue_ids = yes

recipient_delimiter = + 是必须的,它告诉 Postfix 把 + 后面的部分理解为「地址扩展」,而不是本地部分的一部分。

发信时指定信封发件人

如果你是用 PHP 的 mail(),信封发件人由 additional_params 的 -f 指定:

mail($to, $subject, $body, $headers, '-fbounce+u8731=a1b2c3d4@mail.example.com');

如果用 PHPMailer 或者 SwiftMailer,有专门的 Sender 或者 envelope sender 设置。用 sendmail 命令则是 -f 参数。

这里有个大坑:PHP 的 mail() 第五个参数在很多共享主机上被 disable_functions 或者 mail.add_x_header 限制,传了也没用,信封发件人仍然是默认的那个。判断办法是收一封信看 Return-Path 头,那是最终生效的信封发件人。

配置通配符收信

退信回到 bounce+xxx@mail.example.com,你需要让这个域名下的所有地址都能收。两种做法:

做法一,用 catch-all 虚拟别名:

# /etc/postfix/virtual
@mail.example.com   bounce-handler@localhost

然后 postmap /etc/postfix/virtual 并 reload。这样任何发给这个域名的邮件都会进本地 bounce-handler 用户。

做法二,用 transport 表把它们指向一个脚本:

# /etc/postfix/transport
mail.example.com   verp-bounce:

# /etc/postfix/master.cf
verp-bounce  unix  -  n  n  -  -  pipe
  flags=F user=www-data argv=/usr/local/bin/verp-handler.sh ${recipient}

我推荐第二种,因为它把整个流程闭环在邮件系统内,不需要一个真实的邮箱账号,也不会有「信箱满了」这种滑稽问题。但要注意 flags=F 的含义(以信封发件人身份执行),以及脚本要能处理并发——Postfix 会同时拉起多个 pipe 进程。

处理脚本

脚本要做的事很单纯:解析地址、取出用户 ID、查 DSN 状态码、更新数据库。

#!/bin/bash
# /usr/local/bin/verp-handler.sh
RECIPIENT="$1"
USER_ID=$(echo "$RECIPIENT" | sed -E 's/^bounce\+u([0-9]+)=.*/\1/')

MAIL=$(mktemp)
cat > "$MAIL"

# 从退信正文里抽 DSN 状态码
STATUS=$(grep -oE 'Status: [245]\.[0-9]+\.[0-9]+' "$MAIL" | head -1 | awk '{print $2}')
ACTION=$(grep -oE 'Action: (failed|delayed)' "$MAIL" | head -1 | awk '{print $2}')

logger -t verp "user=$USER_ID status=$STATUS action=$ACTION"
rm -f "$MAIL"

真正的处理逻辑在 logger 那行之后:如果 STATUS 以 5 开头,把用户标记为 email_invalid=1;如果是 4 开头且是 delayed,只是记一笔,不动状态。这两个判断一定要分开写。

不用 VERP 也能做:解析退信信箱

如果你的站用的是第三方 SMTP 服务(阿里云邮件推送、腾讯云、SendGrid 之类),它们通常不让你自定义信封发件人,VERP 这条路走不通。这时候只能老老实实解析退信。

退信的可解析部分

一份标准的 DSN(RFC 3464)邮件里有三个关键段:

  • Final-Recipient: 失败的收件人地址
  • Action: failed / delayed / delivered
  • Status: 状态码,形如 5.1.1
  • Diagnostic-Code: 对方服务器的原文说明,这段最有价值,里面通常写着「user unknown」或者「mailbox full」

但现实里有一半的退信不符合标准:Gmail 和 Outlook 的退信格式相对规范,国内一些企业邮局的退信就是从原信里摘一段再拼一句中文说明。所以解析器必须有兜底:先从原文里找 Final-Recipient,找不到再用正则从正文里捞邮箱地址,再找不到就把整封退信存下来人工看。

退信信箱的维护规则

退信信箱是必须有人管的。我给自己定的三条规则:

  • 不要用 noreply@ 做信封发件人。这是最常见的错误,noreply 信箱通常设为自动丢弃,退信全被吞掉。
  • 退信信箱要单独一个账号,跟客服信箱分开,否则人一多就混了。
  • 每天跑一次处理,用 cron 或者 IMAP IDLE,不要等到出问题才去看。

退信之外的三个反垃圾前提

讲到这里必须补一句:退信处理是「事后」的措施,如果发送信誉本身就不好,退信会源源不断。真正决定你的信能不能进收件箱的是三样东西。

SPF、DKIM、DMARC

SPF 是一个 TXT 记录,声明哪些 IP 有权用你的域名发信。DKIM 是一个用私钥签名的机制,接收方用公钥验签,证明信没被篡改。DMARC 建立在两者之上,声明「如果 SPF 和 DKIM 都失败,你该怎么处理这封信」。

三个的配置顺序是 SPF → DKIM → DMARC,从易到难。DMARC 建议从 p=none 开始,先收报告观察一个月,确认所有合法发信源都在报告里,再逐步升级到 quarantine 和 reject。直接上 p=reject 是新手自杀式操作,会把你自己其他系统的信全挡掉。

PTR 反向解析

很多收件方(尤其国内的 QQ 邮箱、163)会检查发信 IP 有没有 PTR 记录,而且要求正向解析的结果跟 PTR 一致。云主机的 IP 默认没有 PTR,要去控制台申请。这一条不满足,你的信几乎百分百进垃圾箱。

发信频率与内容

刚上线的 IP 和新域名,收件方会给很低的「信任额度」,一天发几百封就开始拒。这时候要做的是控量爬坡:第一周每天几十封,第二周几百封,慢慢把信誉养起来。同时邮件内容里别放短链接、别用大量图片、别在标题里用「免费」「中奖」这类词。

我最后搭的那套流程

回到开头那个事故,最后的解决方案是这样的:

每天凌晨两点跑一个脚本,用 IMAP 连到退信信箱,把昨天的退信全部读下来,按 DSN 状态码分类。硬退(5.x)的地址直接标记 email_invalid=1 并且从订阅列表里移出,软退(4.x)记录累计次数,超过三次连续软退也标记失效。处理完把邮件移到「已处理」文件夹,保留 90 天以备排查。

同时给发信流程加了一个前置检查:发信前先查用户状态,已经标记失效的直接跳过,不再浪费一次 SMTP 会话和一个可能被拉黑的 IP 信誉分。

这套东西上线之后,投诉率降了,进垃圾箱的比例也降了。但更重要的是我终于知道「信到底送到了没有」——之前那种「发出去了就算成功」的状态,本质上是在摸黑开车。

如果你也在给站点做邮件通知,我建议先做一件最小的事:把信封发件人从 noreply 改成一个真实有人看的信箱,然后把退信读一遍。这一步不做任何技术改造,就能让你看清现在到底有多少信在失败。看完你大概会跟我一样,庆幸自己看了这一眼。

Last modification:October 25th, 2026 at 01:25 pm

Leave a Comment