MailVault 2.4.0:一次稳定性更新
进入 MailVault 2.4.0 的大部分工作都是看不见的。界面几乎一模一样。功能清单也很朴素 — 可配置的发送延迟、往已发送文件夹复制一份、一些设置项的重排。但在引擎盖底下,这个版本主要是在修那类会悄悄侵蚀你对邮件应用信任的 bug:点开一封邮件却显示另一封,陈旧的邮件在账户之间串场,一个连自己都联系不上的后台助手,以及一套可能发布出坏二进制的发布流程。
这是一次稳定性更新。下面是当时哪里坏了,以及我们做了什么。
让我们写下这篇文章的那个 bug
在 Outlook 和 Microsoft 365 账户上,点开一封邮件可能显示完全另一封邮件的内容。列表里的主题和查看器顶部的主题是对上的 — 但正文、发件人和邮件头完全属于另一封。报告不多,也无法按需复现,但第一次见到时相当吓人。
根本原因出在我们把两种邮件协议桥接起来的方式上。经典 IMAP 给每封邮件一个稳定的 UID,在一个邮箱的整个生命周期里都不变。Microsoft Graph API 没有这个东西 — 它给的是不透明的字符串邮件 ID。为了让 MailVault 其余的代码保持统一,我们的 Graph 传输层临时合成了按位置排的 UID:把文件夹索引一遍,依次发 1、2、3 这样的号。
这在顺序不变时是有效的。可是新邮件到达、某个会话移到别的文件夹、服务器端排序差了一毫秒 — 原本指向邮件 A 的位置型 UID 现在指向了邮件 B。邮件头列表没问题,因为我们每次都重新拉取。但用同一个会漂移的 UID 作键的正文缓存,就交出了错误的内容。
修法是架构性的:我们把真实的 Graph 消息 ID 直接写进每一条邮件头,并在正文缓存、内容加载器和各类操作路径中都用它作为规范标识。合成的 UID 仍然存在,用于兼容应用内部那些 IMAP 形状的接口,但它不再是「这到底是哪一封邮件」的事实来源。
跨账户串场
另一类相关的「显示错邮件」bug:从账户 A 切到账户 B,打开 B 里一个恰好为空的文件夹,会短暂看到账户 A 的邮件还留在列表里。
这是缓存失效的问题。切换账户时清掉了一部分邮件状态,但没有全清。排序后的邮件列表、内存中的正文缓存和线程缓存的更新时机略有不同。如果目标文件夹里有新邮件,新数据来得够快,把陈旧条目盖住了。如果目标文件夹是空的,在「空」的结果最终传播过来之前,你看到的就是那份陈旧列表。
修复本身并不光鲜:现在每一处在切换账户或邮箱时清空邮件状态的地方,都会在新账户的数据开始加载之前,原子地把状态全部清干净。不再有清了一半的缓存,也不再有“空文件夹先到”和“上一个账户的邮件才被淘汰”之间的竞态。
找不到自己套接字的沙盒守护进程
MailVault 有一个后台助手 — 一个小小的 sidecar 守护进程,即使主窗口关着也让邮件保持同步。在 macOS 上,应用和守护进程都跑在 App Sandbox 里,这意味着它们各自看到的文件系统视图是不同的。它们需要一个共享的会合点,通过 Unix 域套接字通信。
这个 bug 是:应用用一种方式解析套接字路径,守护进程用另一种方式,而在沙盒构建里,这两条路径指向不同的容器目录。守护进程会启动、绑定套接字,然后就在那儿干等。应用去连,连不上,于是断定守护进程没在跑。
我们现在两边都用 dirs::home_dir() 来解析 ~/.mailvault/mv.sock。在沙箱下,它对两个进程都展开成同一个容器内的相对路径,于是客户端总能找到刚刚由它启动的那个服务。
辅助进程的权限文件也做了对应的清理。守护进程二进制从应用的签名配置里继承了 app-sandbox,而这会让没有自己 Info.plist 的独立二进制崩溃。application-groups 也设着,是早先打算用 App Group IPC 时留下的 — 既然两个进程现在共用同一个容器主目录,它就没必要了。两者都被移除。在我们测试过的每一个 macOS 版本上,包括全新安装的沙箱构建,辅助进程都能干净地启动。
看起来没问题、其实有问题的 entitlements
这是最让人不安的一类 bug:一切看上去都对,而它就是悄无声息地不工作。
macOS 的权限文件允许你用 $(AppIdentifierPrefix) 来引用开发者团队前缀。Xcode 会在把文件交给 codesign 之前展开这个变量。我们的发布流水线不是 Xcode — 它直接调用 codesign — 而事实证明 codesign 不会展开这个变量。于是字面上的 $(AppIdentifierPrefix) 字符串被嵌进了签好名的二进制里。
症状是:应用与守护进程之间的钥匙串共享悄悄失效了。应用能从钥匙串读到凭据,守护进程却看不见 — 因为它请求的访问组标识符是字面的 $(AppIdentifierPrefix).com.mailvault... 字符串,而不是真正带团队前缀的那个标识符。守护进程于是退而去要它根本没有的凭据,然后放弃。
修复方式是加一步 sed 展开:在调用 codesign 之前,把权限文件里的变量替换成真实的团队前缀再写回去。我们把它和已有的版本一致性检查放在一起,这样“乍一看配置没毛病”这一类 bug 就能在 CI 里被拦下来。
Snap 发布的闸门
说到 CI:在 2.4.0 之前,推一个发布 tag 会自动把生成的 snap 发布到 stable 通道。如果在初步冒烟测试之后出了问题 — 有时确实会,因为 Snap 的隔离行为和开发构建不一样,而这些差异往往只有装上之后才显现 — 等我们发现时,坏掉的版本已经上线了。
发布流水线现在停在“snap 已上传”这一步。另有一个单独的 Promote Snaps 工作流负责把某个构建从 edge 频道推到 stable,而且只有在我们从 edge 频道装到一台干净机器上、并验证过基本功能之后才做:添加账户、加载邮件、备份。发布成功时这只多一次点击,发布不成功时回滚就从容得多。
那些更安静的修复
还有一批更小的修复,都属于「别让应用自相矛盾」这个主题。
模块级缓存失效。有几个惰性初始化的模块级缓存 — 比如记忆化的文件夹查找和描述符查找 — 在底层数据变化时没有被清掉。现在会清了,而且统一文件夹缓存有了大小上限,不会无限增长。
重复写描述符。账户激活时会把账户描述符往磁盘上写不止一次,快速切换时甚至会自己和自己抢。重复的写入没有了。
账户激活中的无限递归。activateAccount 里有一条罕见路径 — 缓存未命中之后的描述符恢复兜底 — 如果恢复出来的描述符又触发一次恢复,它就会调回自身。原先靠一个重入保护把它挡住;更干净的修复是彻底去掉这层递归。
Store 架构。我们本来有一组“领域 store”包装层,夹在组件和主 Zustand store 之间,把状态复制成本地副本。它们的初衷是提供更干净的 API,实际上却让每一份状态都有了两个事实来源,还时不时冒出更新顺序的 bug。现在它们变成了薄薄的选择器再导出 — 用法不变,事实来源只剩一个。
后台辅助进程架构。管理后台辅助进程的那段 Rust 代码在一个模块里干了两件事:决定如何启动守护进程(用哪个二进制、带什么参数、在什么沙箱下),以及在它跑起来之后处理 RPC 传输。我们把这两件事拆开,启动策略放进 helper.rs,传输另起一个模块。排查启动失败不必再在请求/响应代码里到处 grep,反过来也一样。用户看到的文案同时也改了措辞 — “系统服务”变成了“后台辅助进程”,这更如实地说明了它到底是什么。
这个版本为什么做了这么久
稳定性版本出奇地难规划。每个修复单看都很小,但它们覆盖的面积极大 — Graph 传输、账户切换、沙盒 IPC、代码签名和发布基础设施全都动过。这些 bug 大多罕见到我们无法按需复现;只能从日志、用户报告和读代码里推理,直到能看清坏状态是怎么产生的,然后写下修复,再等着确认报告不再出现。
回报是那种你不会注意到的变化,直到某天你突然发现那个过去每周咬你一次的 bug 不见了。如果你遇到过上面任何一种情况 — Outlook 上显示错误的邮件内容、切换账户时的陈旧邮件、一个连不上的后台助手 — 2.4.0 就是让它们消失的那个版本。
接下来的清单:做完那个五阶段重构,收尾 v2.4 后续列表上剩下的条目(Hostinger 的 IMAP 单邮箱识别、文件夹切换的边角情况、撰写草稿气泡),并继续把分类器重写推向成型。功能开发照旧 — 只不过是在一个我们信得过的地基上。