这不是一篇"我们修了个 bug"的公告。这是一次真的把用户邮件永久删掉的事故复盘: 协议里几个容易想当然的地方、一个"看起来成功了"的假信号、以及我们事后为它留下的六条判据。如果你写过任何跟 IMAP / 邮箱 / 同步相关的代码,这篇能帮你少踩一次。
一、现场
用户报障只有一句话:
「下午我用客户端删除了
zhaizy@qq.com的上百封邮件。」 过了一会儿:「已删除是空的。」
「已删除」是 QQ 邮箱的回收站。按常理,客户端删信 = 进回收站,用户随时能捞回来。但那次回收站里一封都没有 —— 因为它们不是"被移进回收站",而是被就地永久清掉了。IMAP 协议层面没有撤销入口,那批信找不回来。
这是我们的错。下面把它拆开讲。
二、"删除"在 IMAP 里其实有三种完全不同的结局
大多数人脑子里的"删除"是一个动作;在 IMAP 里它是三个,而且危险程度差一个数量级:
| 做法 | 用户看到什么 | 能不能恢复 |
|---|---|---|
| `UID MOVE <回收站>` | 信从收件箱消失,出现在回收站 | ✅ 能捞回来 |
| `UID STORE +FLAGS (\Deleted)` | 只是打了个"待删"标记,信还在 | ✅ 取消标记即可 |
| `EXPUNGE` | 信立刻、永久消失 | ❌ 不可恢复 |
关键在第三条,也是整起事故的核心:
EXPUNGE清掉的不是"你点名的那一封",而是"这个文件夹里所有带\Deleted标记的信"。
也就是说,你只想删第 3 封,但只要这个文件夹里还有别的信已经被(你、别的客户端、或服务端自己)标过 \Deleted,它们会跟着一起被永久清掉。协议里"只清指定那一封"的能力叫 UID EXPUNGE(RFC 4315,需要服务端支持 UIDPLUS 能力)—— 名字只差三个字母,语义差着"一封"和"一整箱"。
三、事故链:四步走到最坏结局
第一步:回收站的名字是"猜"的。 旧代码里写死了五个候选名(Trash / Deleted Messages / Deleted Items / 垃圾箱 / 已删除),逐个试 UID MOVE,全失败就走兜底。
第二步:中文名不能原样发出去。 IMAP 协议规定文件夹名必须是 modified UTF-7(RFC 3501 §5.1.3)。旧代码把 垃圾箱 / 已删除 按原文发出去 —— 对某些服务端(如阿里云企业邮)这是非法字节流,它会当场废掉那条连接、不回任何响应。于是"逐个试候选名"把连接赔在了半路,后面的兜底全落在一条死连接上。
第三步:出现了"假成功"。 uid_store(...).await.is_ok() 只证明"命令写进了 socket"——连接已经断了它照样返回 Ok。界面拿到这个假的成功计数,把行删掉;用户一刷新,信全回来了。(这就是早前那句报障「批量删除以后,重新拉取后依然出现」的真身。)
第四步:兜底是最狠的那条。 五个候选名都没中之后,代码打上 \Deleted 然后调用 session.expunge() —— 整箱 EXPUNGE。而在 QQ 上:它的回收站既不在候选名里,连接又没被废掉(QQ 容忍了那串非法字节),于是这一步真的执行了,而且完全绕过回收站。
四步里没有任何一步"看起来像灾难":每一步都是一个看起来合理的"兜底"。
四、修法:删信路径里不许再出现"就地永久删除"
新版的 delete_one 只有三条路,每一条都保证"信进了回收站",任何一条失败都不做不可恢复的事:
- 知道回收站名 + 服务端支持
MOVE⇒UID MOVE一步搬进去; - 不支持 MOVE ⇒ 先
UID COPY进回收站(这一步才是"进回收站"的保证),再打\Deleted;能UID EXPUNGE(只清这一封)就顺手清掉原件,拿不到UIDPLUS就留着**——回收站里已经有一份,列表那侧按\Deleted过滤,用户看不见; - 连回收站在哪儿都定位不到 ⇒ 拒绝删除并如实报错。
第三条是这次最重要的决定:宁可"没删",也不做收不回来的删除。用户会看到一句"找不到回收站,出于安全没有删除",而不是一声不响地把信弄没。
配套还有一个小修:回收站名要问服务器(LIST 返回里的 \Trash 特殊用途标记 + 常见叫法,中文名按 UTF-7 编码后比),而不是猜。
五、怎么证明它真的修好了
改完代码只是开始 —— 这类 bug 最麻烦的地方是本地复现不了:它依赖"某个服务端对某个非法字节流的具体反应"。所以我们用了三层:
① 源码哨兵。 一条测试直接扫 delete_one 的源码:
assert!(!body.contains(".expunge()")); // 整箱 EXPUNGE 一旦回来,判据当场变红
assert!( body.contains("uid_copy")); // 必须保留"先 COPY 进回收站"这一步
写法很土,但对付的是"有人为了省事又把兜底加回来"——它比注释硬。
② 真账号探针。 一个 #[ignore] 的探针,往真邮箱 APPEND 一封自己造的 shuyo-probe-<时间戳>,调真的那条删除函数,然后分三处核对:不在收件箱、在回收站里、跑完自己收拾干净。线上读数:
PROBE account=zhaizy@qq.com uid=9770 moved=Ok(1) in_inbox=false
PROBE trash=Some("Deleted Messages") in_trash=true
探针还顺手替我们清掉了前几次失败跑留下的 9 封垃圾信 —— 探针必须自己收拾自己的垃圾,否则它会成为用户邮箱里的新问题。
③ 变异测试。 把"整箱 EXPUNGE"和两条装饰性 CSS 分别改回去,确认对应判据真的会红。绿一次不算数,"改坏了会红"才算数。
顺带挖出的两个协议脾气(都是探针跑出来的)
- QQ 的
SEARCH索引看不见APPEND进去的信:APPEND 明明回了[APPENDUID … 9769]、UID FETCH 9769立刻拿得到,而UID SEARCH SUBJECT "shuyo-probe-"过 60 秒仍是空。所以探针改成"按序号尾段 FETCH 再按主题认领"——便宜,且不依赖那个索引。 - QQ 同时支持
MOVE与UIDPLUS(CAPABILITY实测)—— 也就是说修好之后,它走的是最干净的第 1 条路。
六、六条可以带走的经验
EXPUNGE≠ 删除你点的那一封,它是"清空本文件夹所有待删标记"。要"只清一封",必须UID EXPUNGE(UIDPLUS)。Ok(())不代表服务端做了事:它可能只代表"字节写进 socket 了"。结束码要读响应流读到底才算数。- 协议里的名字要按协议编码(IMAP 的 modified UTF-7 只是其中一例)。中文名原样发出去,有的服务端容忍、有的直接废连接 —— 行为不一致才是最难查的部分。
- 不要猜特殊文件夹的名字,问服务器(
LIST的特殊用途标记)。 - 失败要往"不可恢复"的反方向倒:找不到回收站时,正确的默认是"不删",而不是"换个更狠的办法删掉"。
- 本地复现不了的 bug,用真账号探针 + 源码哨兵钉住。它们不光抓这次的 bug,还抓"下次有人把它改回来"。
七、给使用者的三句话
- 别的客户端也可能这样:只要它的"删除"在服务端不支持
MOVE/UIDPLUS时走了EXPUNGE兜底,就可能出现"删掉即永久"。 - 回收站不是万能的:它只在「被搬进去」时存在;"标记删除 + 整箱清空"这条路会绕过它。
- 看到"永久删除 / 立即清空"这类字样,先确认自己在删什么,尤其是能一次勾很多封的时候。
本文的事实都来自 ShuyoNote 1.91.12 那次修复与它留下的判据(src-tauri/src/email.rs::delete_one、email::tests::delete_path_never_does_a_mailbox_wide_expunge、probe_batch_delete_round_trip_on_a_real_account)。那名用户的信找不回来了 —— 这篇是它留下的东西。
评论 0
还没有回复,来说两句吧。