数友社区 · 帖子

一次永久删信事故:为什么"删除"是 IMAP 里最危险的三个字

这不是一篇"我们修了个 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 只有三条路,每一条都保证"信进了回收站",任何一条失败都不做不可恢复的事:

  1. 知道回收站名 + 服务端支持 MOVE ⇒ UID MOVE 一步搬进去;
  2. 不支持 MOVE ⇒ 先 UID COPY 进回收站(这一步才是"进回收站"的保证),再打 \Deleted;能 UID EXPUNGE (只清这一封)就顺手清掉原件,拿不到 UIDPLUS 就留着**——回收站里已经有一份,列表那侧按 \Deleted 过滤,用户看不见;
  3. 连回收站在哪儿都定位不到 ⇒ 拒绝删除并如实报错。

第三条是这次最重要的决定:宁可"没删",也不做收不回来的删除。用户会看到一句"找不到回收站,出于安全没有删除",而不是一声不响地把信弄没。

配套还有一个小修:回收站名要问服务器(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 条路。

六、六条可以带走的经验

  1. EXPUNGE ≠ 删除你点的那一封,它是"清空本文件夹所有待删标记"。要"只清一封",必须 UID EXPUNGE(UIDPLUS)。
  2. Ok(()) 不代表服务端做了事:它可能只代表"字节写进 socket 了"。结束码要读响应流读到底才算数。
  3. 协议里的名字要按协议编码(IMAP 的 modified UTF-7 只是其中一例)。中文名原样发出去,有的服务端容忍、有的直接废连接 —— 行为不一致才是最难查的部分。
  4. 不要猜特殊文件夹的名字,问服务器(LIST 的特殊用途标记)。
  5. 失败要往"不可恢复"的反方向倒:找不到回收站时,正确的默认是"不删",而不是"换个更狠的办法删掉"。
  6. 本地复现不了的 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)。那名用户的信找不回来了 —— 这篇是它留下的东西。

📥 存为 ShuyoNote 笔记

评论 0

还没有回复,来说两句吧。

板块:实战工作流

← 返回首页