msmtp 配置 Outlook / O365 邮箱的 OAuth2 认证

 

我的 WSL 日常使用 msmtp 作为 MTA,它通过 starttls 连接到 Outlook 邮箱的 SMTP 服务器(smtp-mail.outlook.com)。此前 Outlook 的安全策略强制要求多因素认证(MFA),但允许使用应用密码,因此我一直在使用传统的 GPG 加密应用密码的方法。但今天我尝试发送邮件时,得到了以下的错误:

535 5.7.139 Authentication unsuccessful, basic authentication is disabled. [SI2P153CA0032.APCP153.PROD.OUTLOOK.COM 2024-09-25T08:14:40.271Z 08DCDD1F9EAD5AFB]

在 TUNA 群中为此询问群友,获得了微软的公告链接。简而言之,从 2024/9/16 开始,Outlook 的服务器不再支持传统的 SMTP AUTH 方式进行认证,而必须使用 OAuth2。

对于邮件客户端来说,OAuth2 有两种常用的认证方式,bearer token 或者 SASL XOAUTH2;Outlook 使用后者,Gmail 是前者。msmtp 两种都支持,只要对应修改 auth 选项即可。然而,msmtp 和其他大多数命令行 MTA 一样,只能使用而不能管理 OAuth2 token(也就是说,用户需要负责获取和定期刷新)。因此,需要借助第三方工具来管理 OAuth 的状态。

Arch Wiki 的 msmtp 页面推荐使用的工具是 oama,这是一个 Haskell 写的小工具,可以用在 msmtp 的 password-eval 中。然而,使用起来遇到了一些棘手的问题。

具体来说,oama 需要自己配置 OAuth 使用的 client_id 和 client_secret。网上能公开获取到的几个 client_id 都需要配置对应的回调 URL(尽管其实最终客户端可以不真的通过回调获取 code,直接在最后的 302 请求中截获即可;但如果不按配置填写,根本无法进入授权流程),如 ThunderBird 的要求 http://localhost:8080 或者 https://localhost。但 oama 的配置只支持形如 http://127.0.0.1[:port] 的格式,其他格式要么报无法解析的错误,要么无法启动 Web Server listen。我完全不会 Haskell,这个修不来。后续又找到了别的 client_id,但发现无法使用个人的 Outlook 账号登录,只能用组织账号(Microsoft 365),这显然也不是我想要的。

于是我只能选择在 Office 365 中创建了一个 App,配上所需的几个权限(用 OAuth 术语来说叫 scope),但发现微软接受的本地回调只能以 http://localhost 开头,看来 oama 是没法用了。又经过一番搜索,我找到了 mutt 带的 mutt_oauth2.py。把这个文件复制出来,修改其中的 client 相关配置,然后执行:

user@~:$ python3 ~/.local/bin/mutt_oauth2.py -t ~/.local/var/email/outlook.gpg --authorize
OAuth2 registration: microsoft
Preferred OAuth2 flow ("authcode" or "localhostauthcode" or "devicecode"): localhostauthcode
Account e-mail address: [email protected]

后续在给出的链接完成认证即可。如果脚本报告 POP3 鉴权失败,很可能是 Outlook 的设置中没有打开 POP3 服务。最后,修改 msmtp 的配置文件:

account outlook
host smtp-mail.outlook.com
from [email protected]
user [email protected]
tls_starttls on
auth xoauth2
passwordeval python3 ~/.local/bin/mutt_oauth2.py ~/.local/var/outlook.gpg

在每次发送邮件时,mutt_oauth2.py 会检查 token 的可用性,并在需要时刷新。如果 refresh token 也过期了,则会自动重新发起授权。这样,我又可以愉快地使用 mailgit-sendmail 等工具了。

就在我以为完事大吉的时候,还是遇到了新的问题(但和 Outlook 没有关系):如果同时调用 gpg 和 msmtp(如 foo | gpg --clear-sign | msmtp [email protected]),则只有第一个 gpg 可以正确调用 pinentry 来使用智能卡进行签名,而 msmtp 的 passwordeval 脚本调用的 gpg 无法正常触发 pinentry,会卡在 gpg --decrypt 中。尝试使用 shell substitution 也遇到了同样的问题(msmtp [email protected] <(foo | gpg --clear-sign))。似乎是因为我使用了 gpg-agent,而上述命令中都有两个 gpg 进程同时启动,就会有一个无法连接到 agent。目前我只能通过每次只执行一个 gpg 来绕过问题。

附录

为方便起见,公开我的 OAuth Application 配置(由于只在本地使用,公开 secret 没有太大的安全风险):

  • 名称:msmtp OAuth
  • Client ID: 1ba11cc8-c6d1-4ae6-bd88-6becf878f8df
  • Client Secret: lBm8Q~_IfyNpFUZ6KydTc4QHjLl1IwcCxFhxqa7n(过期日:2026/9/24)
  • Redirect URI: http://localhost(经测试,可以增加任意端口号)
  • Scope: IMAP.AccessAsUser.All, POP.AccessAsUser.All, SMTP.Send, User.Read,此外 offline_access 会由客户端额外请求

说明:上述配置仅供测试,我不以任何形式保证其可用性或对此负任何责任。如选择使用,您需要承担任何可能的后果。

在 Microsoft Entra 平台上配置 OAuth App 时,我还遇到了一个坑点:如果授权时指定的 tenatecommon,那么 signInAudience 必须设置成 AzureADandPersonalMicrosoftAccount 或者 AzureADMyOrg(此选项会导致被标记成 Consumer 的 Outlook 用户无法登录,只有组织用户可以登录)。我原来设定的是 PersonalMicrosoftAccount,但会引发授权的 invalid_request 错误。