跳至内容
返回博客

Outlook OAuth 漂流记:IMAP 坏了,Graph 万岁

8 分钟阅读

这是一个关于一个 GitHub issue —「Microsoft 账户登录用不了」— 如何演变成注册我们自己的 Azure AD 应用、用 Rust 写出一整套 Microsoft Graph REST API 传输层,并在邮件终于加载出来之前排掉七个连环故障的故事。本该是一次配置修复的事,最后成了 MailVault 与 Microsoft 通信方式的彻底重写。

缺陷报告

两位用户从不同角度报告了同一件事。企业 M365 用户在尝试登录时看到 AADSTS900971: No reply address provided。个人 Outlook.com 用户过了登录界面,却在应用尝试通过 IMAP 连接时撞上 AUTHENTICATE failed。症状相同 — “登录不了” — 但根因完全是两回事。

问题 1:借来的客户端 ID

MailVault 最初的 Microsoft OAuth2 流程用的是 Thunderbird 那个众所周知的公共客户端 ID。这是常见做法 — Thunderbird 在 Microsoft 和 Google 都注册了 OAuth2 应用,好让其他邮件客户端搭个便车。它一直挺好用,直到不好用为止。

对企业 M365 租户来说,Thunderbird 那个应用并没有注册我们的 localhost:19876/callback 跳转地址。Azure AD 要求精确匹配,而 Thunderbird 的应用只列了它自己的跳转地址。修复办法:注册 MailVault 自己的 Azure AD 应用,填上我们确切的跳转地址,配置成公共客户端(不带密钥,仅用 PKCE),同时支持组织账户和个人 Microsoft 账户。

问题 2:Microsoft 把 IMAP 搞坏了

个人 Outlook.com 那边的问题更糟。自 2024 年 12 月起,Microsoft 存在一个影响个人账户 IMAP OAuth2 的服务器端回归问题。OAuth2 流程成功走完之后,XOAUTH2 SASL 握手会失败并报“User is authenticated but not connected.”。这不是客户端的 bug — 是 Microsoft 的 IMAP 服务器在拒绝有效的令牌。Microsoft 支持论坛上那个帖子已经挂了好几个月,仍然看不到修复的迹象。

个人账户上应用专用密码仍然能用,但那就失去了 OAuth2 的意义。我们需要一条完全不同的路。

核选项:Microsoft Graph API

既然 IMAP 是坏的,那就别用 IMAP。Microsoft Graph 是一套 REST API,完全不经过 IMAP 就能直接访问邮箱。我们不再连 outlook.office365.com:993 去说 IMAP 协议,而是带着 Bearer 令牌向 graph.microsoft.com/v1.0/me/messages 发 HTTPS 请求。同一个 OAuth2 令牌,完全不同的传输方式。

搭一套 Graph 传输,意味着几乎要碰应用的每一层:

Rust 后端 — 新增一个 graph.rs 模块,里面的 GraphClient 结构体实现了 list_folders()、list_messages()、get_message()、delete_message()、set_read_status() 和 get_mime_content()。为 Graph 的 JSON 响应定义了数据结构(GraphMessage、GraphMailFolder 等),并带一个转换方法,把 Graph 邮件翻译成我们已有的 EmailHeader 结构体。再加六个新的 Tauri 命令把这一切暴露给前端。

OAuth2 scope 分支 — IMAP 账户需要 IMAP.AccessAsUser.All 和 SMTP.Send 这两个 scope。Graph 账户需要 Mail.ReadWrite 和 Mail.Send。scope 的选择发生在用户点“使用 Microsoft 登录”那一刻,依据是他的邮箱域名是否属于已知的 Microsoft 个人域名(outlook.com、hotmail.com、live.com,以及 12 个地区变体)。

前端 store — Zustand store 里的每一项邮件操作 — 加载邮件头、选中邮件、标记已读/未读、删除、搜索、预取 — 都得加一个 if (isGraphAccount(account)) 分支。Graph 邮件用的是不透明的字符串 ID 而不是整数 UID,所以我们维护了一个映射层(_graphIdMap),给 Graph 邮件分配合成的连续 UID,调用 API 时再翻译回去。

后台流水线 — 负责后台加载邮件头和缓存内容的 AccountPipeline 类,这两个阶段都需要各自的 Graph 路径。加载邮件头改用 graphListMessages 的 skip 式分页,而不是 IMAP 的按页获取。缓存内容则通过 graphCacheMime 下载完整 MIME(这是一个 Rust 命令,一次完成取回原始 .eml、存进 Maildir 并解析),而不是走 IMAP 的 FETCH。

。

实现做完之后,我们部署上去,开始用一个真实的 Outlook.com 账户测试。接下来是一场调试马拉松,每修好一个就冒出下一个问题。

失败 1:“Invalid client secret provided” — 我们的 CI 构建把 MAILVAULT_MS_CLIENT_SECRET 设成了环境变量。代码老老实实地把它发给了 Azure,而 Azure 拒绝了它,因为我们的应用注册的是公共客户端(不允许带密钥)。修复:对 Microsoft 硬编码 client_secret: None — 公共 PKCE 客户端永远不该发送密钥,没有例外。

失败 2:连接测试报“AUTHENTICATE failed” — 解决密钥问题之后,OAuth2 流程顺利走完了。但应用紧接着就去用 IMAP 测试连接 — 正是那个坏掉的协议。addAccount 函数里硬编码了一段 IMAP 连接测试,没有 Graph 分支。修复:Graph 账户改为调用 graphListFolders 来测试,而不是开一个 IMAP 会话。

失败 3:“Could not load email content” — 邮件头通过 Graph 正常加载了,但点开一封邮件来读就失败。useChatBodyLoader 这个 hook — 它在聊天视图和会话视图里逐步加载邮件正文 — 只知道用 IMAP 的 fetchEmailLight 取正文。对 Graph 账户,它需要用 graphGetMessage 配上映射好的 Graph 邮件 ID。修复:加一个 Graph 分支,先查出 Graph ID,再通过 Graph API 获取。

失败 4:邮件加载一次,然后就消失了 — 邮件第一次加载正常,但切走再回来,会话就空了。Graph 分支把邮件正文缓存进了内存里的 LRU 缓存,却从来没往磁盘写 .eml 文件。等 LRU 缓存把它们淘汰掉,内容就没了。修复:用快速的 graphGetMessage JSON 响应显示完邮件之后,在后台发一个 graphCacheMime 调用,下载完整 MIME 并把 .eml 存进 Maildir。

失败 5:点开邮件后转圈不停 — 针对失败 4 的第一版修复把 graphCacheMime 当成了主要的获取方式(下载 MIME、存盘、解析、返回)。这会在显示任何东西之前先把整封原始邮件连同所有附件下载下来。对一封 5 MB 带附件的邮件来说,就是好几秒的转圈。修复:用 graphGetMessage(只带 HTML 正文的快速 JSON)立即显示,再在后台用 graphCacheMime 做磁盘持久化。两阶段加载:立刻显示,延迟落盘。

失败 6:会话视图要折叠再展开才看得到内容 — 会话视图里的邮件一直显示“Could not load email content”,直到手动折叠再展开。这是一个 React 渲染竞态。useChatBodyLoader 这个 hook 把加载好的正文存在一个初始为空的 useRef(new Map()) 里。ThreadEmailItem 组件在渲染过程中同步读取这个 map,而填充它的 useEffect 在首次渲染之后才运行。等正文可用时,组件早就已经渲染出“Could not load.”了。折叠再展开触发了一次重新渲染,这时才读到已经填好的 map。修复:在 hook 主体里(而不是在 useEffect 内)同步检查内存缓存,预先填充这个正文 map。异步的 useEffect 仍然负责获取未缓存的正文,但已缓存的在首次渲染时就能拿到。

失败 7:后台同步仍然在走 IMAP — refreshAllAccounts 函数按定时器刷新所有账户。对非当前账户,它无条件用 IMAP 拉取邮件头,于是 Graph 账户不断报连接错误、刷满日志。其他每条代码路径都改过了,唯独漏了这一条。修复:加一个 isGraphAccount 分支,后台同步改用 graphListFolders + graphListMessages。

我们最终发布了什么

最终结果是:个人 Microsoft 账户(outlook.com、hotmail.com、live.com 及其地区变体)在登录时会被自动识别,并走 Graph API 传输。用户点击「使用 Microsoft 登录」,在浏览器里完成认证,应用就能用了。不涉及 IMAP,也不会再出现「User is authenticated but not connected」这类报错。

企业 M365 账户使用 MailVault 自己的 Azure AD 应用注册,重定向 URI 也配得正确。对于 OAuth2 策略严格的组织,用户可以在账户设置的「高级」区域覆盖客户端 ID 和租户 ID。

Graph 账户支持阅读、删除,以及把邮件标记为已读/未读。发信仍然走 SMTP(配合 OAuth2 令牌工作正常)。邮件正文通过快速 JSON 拉取以便立即显示,完整 MIME 则在后台下载,用于离线访问和附件支持。

教训

最大的收获:当你给邮件客户端加一种新的传输方式时,你加的不是一条代码路径 — 你是在给每一条代码路径分叉。每一个碰网络的函数都得知道该用哪种传输。每个后台任务、每次预取、每个同步定时器、每次连接测试。漏掉任何一个,就会产生一个只有新传输的用户才会遇到的 bug。

第二个收获:两阶段加载值得这份复杂度。在显示任何东西之前先下完整封 MIME,会带来无法接受的延迟。先用 JSON API 快速给出预览,再在后台惰性持久化完整内容,既能让用户立刻得到反馈,也照样把离线归档攒了起来。

第三:对于首次渲染就必须可用的数据,React 的 useRef + useEffect 组合是个陷阱。如果组件在渲染时读一个 ref,而这个 ref 是在 effect 里填充的,那么首次渲染看到的永远是旧数据。把填充逻辑挪到 hook 主体里同步执行(而不是放在 effect 里)就能解决,而且不会多出额外的重新渲染。

有时候「登录用不了」这个 bug 是一次配置修复。有时候它是一整套新的 API 传输层。你没得挑。