Backend

邮件只发了一次,“已发送”文件夹里却有两份副本,原因排查

1575 字约 6 分钟

摘要对方只收到了一封邮件,发件人的“已发送”文件夹里却有两份一模一样的副本。原因是客户端的兜底逻辑和邮箱服务商的异步存档同时写入了同一封邮件。

有用户反馈了一个奇怪的问题:

“我用你们软件发了一封邮件,打开网页版邮箱一看,‘已发送’文件夹里有两封一模一样的邮件!但我打电话问对方导师,人家说确实只收到了一封。这到底算发成功了还是发重了?我吓得不轻。”

我查了数据库和操作日志:

  • SMTP 发送记录只有一条;
  • 唯一的 Message-ID 只生成过一次;
  • 收件人确实只收到了一次投递。

既然没有重复发送,那第二封“已发送邮件”是怎么出现的?

SMTP 只负责投递,不负责存档

要解开这个问题,需要先了解一点:在经典的邮件体系中,SMTP 协议本身不负责保存“已发送”副本。

SMTP 与 IMAP 的分工
  • SMTP(Simple Mail Transfer Protocol): 负责把邮件投递到对方的邮箱,投递完成后任务即结束;
  • IMAP(Internet Message Access Protocol): 负责在客户端收取邮件、浏览收件箱、管理文件夹。

那么,在 Outlook、Foxmail 或网页邮箱里发出邮件后,发件箱中的“已发送”副本是谁写入的?

大致分为两类服务商:

  1. 会自动存档的服务商(如 Gmail、QQ 邮箱): SMTP 收到邮件后,服务端会自动把邮件复制一份放进“Sent Items”文件夹;
  2. 不会自动存档的服务商(部分高校自建邮箱、企业旧版 Exchange): SMTP 只负责发送,不会存档。客户端发送后需要连接 IMAP,执行一条 APPEND 命令,手动把刚发出的邮件写回发件箱。
通俗比喻:两个热心的人同时往档案柜里放备份

为什么只发了一封信,却多出一份存档? 假设你在办公室寄出一份合同:

  • 业务员(邮箱服务器)会自动复印一份,准备放进你的个人档案柜;
  • 你的助理(客户端)并不知道业务员会复印,于是自己也复印了一份。助理先去档案柜看了一眼,里面还没有,于是把复印件放了进去;
  • 紧接着,业务员也拿着他的复印件过来,放了进去。

合同只寄出了一份,档案柜里却被放进了两份一样的副本。

为了照顾第二类服务商,我在发信逻辑中加入了一个兜底步骤:SMTP 投递成功后,程序立即连接 IMAP,用 Message-ID 在“已发送”文件夹中查找。如果连续几次都没有找到,就调用 IMAP APPEND,替用户手动存一份。

这个设计的出发点是对用户负责。

问题出在第一类服务商身上:它们会自动存档,但存档的速度有时比较慢。

时间差导致的重复

还原一下出问题时的时间线:

T1: Auto Email Sender 通过 SMTP 成功投递邮件
T2: 客户端立即连接 IMAP,查询“已发送文件夹”
T3: 服务端正在异步建立索引,客户端查询时没有找到这封邮件
T4: 客户端判断“这个邮箱不会自动存档”,于是调用 IMAP APPEND,写入一份副本
T5: 500 毫秒后,服务端的异步存档任务完成,也把 SMTP 接收的那封邮件写入了“已发送文件夹”
T6: 用户打开网页端,看到两封一模一样的已发送邮件
双份副本的时间线
  1. SMTP 接受邮件,实际投递只有一次;
  2. 客户端在几秒内查询,没有找到这封邮件;
  3. 客户端调用 APPEND,补写了一份副本;
  4. 服务端的异步存档任务完成;
  5. “已发送”文件夹中出现了两份副本。

IMAP APPEND 只是在文件夹里写入一份邮件,不会通过公网重新发送,所以对方导师始终只收到了一封。

但对发信的用户来说,打开邮箱看到两封,确实会让人担心。

为什么不能简单地延长等待时间

有人会想:“那把查询的等待时间加长一点不就行了?多等十秒钟。”

但仔细想,这个补丁站不住脚:

实际场景为什么查询结果会误判
文件夹名称不统一中文叫“已发送”,英文叫“Sent”,还有叫“Sent Messages”的,查错文件夹就会判定为空
服务商改写 Message-ID有些网关在中转时会替换 Message-ID,客户端用本地的 ID 永远查不到
异步索引延迟不稳定高峰期或大型企业邮箱,发件箱同步延迟几分钟很常见
没有查到,不代表服务商不会保存

此时客户端面对的是一个基本问题:“我现在没查到”,不等于“服务商之后不会保存”。 等 5 秒可能得到假阴性,等 30 秒仍然可能得到假阴性。把超时时间调长,只会拖慢用户的发信流程,也无法彻底消除重叠的时间窗口。

如果两份副本都已经写入,再写脚本删掉其中一份呢?这同样有风险。两封邮件在服务商内部的日期、UID,甚至特定的标头都可能不同。误删服务商原生的记录,可能导致后续同步出现错乱。

删除不可靠的自动补写

权衡之后,我们决定:删除发送成功后的 IMAP 自动 APPEND 逻辑。

发信流程在 SMTP 发送成功后即告结束,不再连接 IMAP 去猜测、查找或补写。

这个决定的代价很清楚:

  • 保留自动补写的代价: 在大量主流邮箱中制造“幽灵双份邮件”,引起用户的恐慌和对软件的不信任;
  • 删除自动补写的代价: 少数不支持自动存档的老旧邮箱,用户在网页端看不到副本。不过客户端本地保存了完整的发信记录和原文快照。

对于客户端工具来说,宁可少做一次可能不需要的写入,也不要冒着污染用户云端数据的风险去自作聪明。

总结

在对接异构系统时,很容易想把所有不完善的地方都补齐。

但分布式和异构协议的难点在于:在没有强一致性约定的外部系统上,无法安全地执行基于猜测的补偿操作。

  • 如果外部服务的行为是异步的、不确定的,那么基于“暂时没有观察到”做出的补偿,很可能引发新的问题;
  • 划清职责边界:发信就只负责把 SMTP 发送做好;
  • 有时候不写额外的同步检查代码,比写一套复杂的探测逻辑更稳妥。

评论