邮件只发了一次,已发送文件夹里为什么会存下两份副本
有用户找我反馈了一个极其离奇的“灵异现象”:
“我用你们软件发了一封邮件,打开网页版邮箱一看,‘已发送’文件夹里竟然有两封一模一样的邮件!但我打电话问对方导师,人家说确实只收到了一封。这到底算发成功了还是发重了?吓得我冷汗直流。”
查了数据库和操作日志:
- SMTP 发送记录只有一条;
- 唯一的
Message-ID只生成过一次; - 收件人的确只被投递了一次。
既然没有重复发送,那这凭空多出来的第二封“已发送邮件”,到底是谁偷偷塞进去的?
SMTP 只管送信,谁来管“存档”?
要解开这个谜题,我们得先科普一个反直觉的冷知识:在经典的邮件体系里,SMTP 协议根本不管“保存已发送副本”这件事!
什么是 SMTP 与 IMAP 的职责划分?
- SMTP(Simple Mail Transfer Protocol): 只负责把信像信鸽一样投递到对方的邮筒,发完就拍拍屁股走人;
- IMAP(Internet Message Access Protocol): 负责你在客户端里查收邮件、翻看收件箱、管理文件夹。
那么平时你在 Outlook、Foxmail 或者网页邮箱里发完信,发件箱里立刻出现的“已发送邮件”是谁放进去的?
这就分成了两大阵营:
- 自带保姆型的服务商(如 Gmail、QQ 邮箱): SMTP 只要收到信,服务端底层会自动把邮件无缝拷贝一份塞进你的“Sent Items”文件夹;
- 甩手掌柜型的服务商(不少高校自建邮箱、企业旧版 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: 用户打开网页端:赫然出现两封一模一样的已发送邮件!双份副本的时间线
- SMTP 接受邮件,真实投递只有一次;
- 客户端秒级轮询查无此信;
- 客户端手动
APPEND补写一份副本;- 服务端异步任务终于完成自动保存;
- “已发送”文件夹内双胞胎诞生。
因为 IMAP APPEND 只是往文件夹里单纯写个文件,根本不会走公网把信重新发一遍,所以对方导师至始至终只收到了一封。
但对于发信的用户来说,一打开邮箱看到两封,心脏病都要吓出来了。
致命的“未见证明”
顺着这个逻辑推下去,很多人第一反应是:“那你把轮询时间改长一点不就行了?等它个十秒钟!”
但仔细推敲后,你会发现这个补丁完全站不住脚:
| 现实场景 | 为什么查询必然后置误判 |
|---|---|
| 文件夹别名错乱 | 中文叫“已发送”,英文叫“Sent”,还有叫“Sent Messages”的,程序查错文件夹直接判定为空 |
| 服务商改写 Message-ID | 某些网关会在中转时替换成自己的 Message-ID,客户端拿本地 ID 永远查不到 |
| 异步索引延迟不可控 | 繁忙期或者大型企业邮箱,发件箱同步延迟几分钟都是常事 |
逻辑学困境:缺席不等于不存在客户端在此时面对的是一个经典哲学难题:“我现在没查到”,绝不等于“服务商未来不会保存”。 你等 5 秒可能是假阴性,你等 30 秒依然可能是假阴性。把超时调长,不仅把用户的发信流程拖得奇慢无比,还依然无法彻底消灭重叠窗口。
如果真等到两份都写进去了,你想写个脚本帮用户删掉一份?更危险! 两封邮件在服务商底层的内部日期、UID、甚至特有标头可能完全不同,误删了服务商的原生记录可能导致后续同步彻底错乱。
砍掉不可靠的自动补写
权衡再三,我们做出了一个决定:彻底剔除发送成功后的 IMAP 自动 APPEND 逻辑。
发信流程发完 SMTP 即算终结,严禁在成功后画蛇添足地连 IMAP 去猜、去搜、去补写。
这个决定背后是一笔极其清楚的权衡账:
- 保留自动补写的代价: 在大量主流邮箱上制造“幽灵双份邮件”,引发用户的剧烈恐慌与信任危机;
- 砍掉自动补写的代价: 极少数不支持自动存箱的远古协议邮箱,用户在发件箱里看不到副本(但我们的客户端本地软件里有完整、详尽的历史发信账本与原文快照)。
对于一个客户端工具来说,宁可“少做一次可能不需要的写入”,也绝不“冒着污染用户远程云端数据的风险去自作聪明”。
总结
在异构系统的对接与兼容设计中,我们经常容易陷入“我想帮用户把一切不完美都填平”的强迫症里。
但分布式与异构协议的残酷之处就在于:你无法在没有强一致性约定的外部黑盒系统上,安全地执行基于“猜测”的补偿。
- 如果外部服务商的行为是异步的、充满不确定性的,基于“即时未观测到”做出的补偿动作,99% 都会演变成画蛇添足的踩踏事故;
- 划清系统职责边界:发信就专心把 SMTP 发好;
- 学会克制,有时不写代码,比写出复杂的同步探测代码更加高明。