乐于分享
好东西不私藏

Office 365 邮件发信踩坑实录:从密码认证到 OAuth2,再到 Graph API

Office 365 邮件发信踩坑实录:从密码认证到 OAuth2,再到 Graph API
如果你现在还在用"账号+密码"这种最朴素的方式连 Office 365 的 SMTP 发信,趁早看看这篇文章——微软已经明确,基本身份验证(Basic Auth)的 SMTP AUTH 会在 2026 年底默认关闭,2027 下半年彻底下线。这不是危言耸听式的提醒,而是已经写进官方时间表的事情。
这篇文章记录的是我们团队最近给一个 .NET/ABP 项目接入 M365 发信功能时,从"能跑起来"到"跑对了"踩过的几个坑,顺带把 SMTP+OAuth2 和 Graph API 两条路都过一遍,方便你少走弯路。
第一步:搞清楚认证方式的现状
Office 365 的 SMTP 发信,认证方式目前实际能用的只有两条路:
1.
应用密码(App Password):账号开 MFA 之后生成的一串专用密码,配置简单,但只支持"传统按用户 MFA",如果租户用的是安全默认值或条件访问策略来强制 MFA,是看不到"应用密码"这个选项的。
2.
OAuth2:真正的现代身份验证,微软长期推荐方向,配置复杂一些,但不受密码轮换、MFA策略变化影响。
如果只是临时跑通一个小功能,应用密码够用;如果是要长期稳定跑在生产环境的系统,建议直接上 OAuth2,省得以后被迫返工。
第二步:Azure AD 应用注册 + Exchange Online 双重授权
这里最容易踩的坑是:很多人只在 Azure AD 里配了权限,却漏了 Exchange Online 那边的授权,导致报 Client not authenticated to send mail
完整流程分两部分:
Azure AD 侧:
1.
Azure Portal → Microsoft Entra ID → 应用注册 → 新注册,拿到 Client ID 和 Tenant ID
2.
证书和密码 → 新客户端密码,生成 Secret(只显示一次,务必存好)
3.
API 权限 → 添加权限 → Office 365 Exchange Online → 应用程序权限 → 勾选 SMTP.SendAsApp
4.
授予管理员同意(必须全局管理员操作)
Exchange Online 侧(容易漏的部分):
powershell1Install-Module -Name ExchangeOnlineManagement2Connect-ExchangeOnline -UserPrincipalName 管理员账号@yourdomain.com34# 把 Azure 应用"介绍"给 Exchange Online5New-ServicePrincipal -AppId "<Client ID>" -ObjectId "<企业应用的 Object ID>"67# 授权发信邮箱8Add-MailboxPermission -Identity "sender@yourdomain.com" -User "<Client ID>" -AccessRights FullAccess -AutoMapping:$false9Add-RecipientPermission -Identity "sender@yourdomain.com" -Trustee "<Client ID>" -AccessRights SendAs -Confirm:$false
注意这里的 ObjectId 要去"企业应用程序"里找同名应用取值,跟"应用注册"页面的 Object ID 不是一个东西——这个细节很多人会搞混。
权限生效一般要等 5-10 分钟。
第三步:坑一,租户级 SMTP AUTH 总开关
配置全部做完,第一次跑代码,报了这个错:
code15.7.139 Authentication unsuccessful, SmtpClientAuthentication is disabled for the Tenant.
这个错误信息容易让人误以为是认证配错了,但其实认证流程本身是通的(token 拿到了,XOAUTH2 也正常发出去了)。真正原因是租户级别有一个独立于认证方式之外的总开关,专门控制 SMTP AUTH 协议本身能不能用。这个开关是 2019 年 10 月之后新建租户的默认值,新租户默认就是关着的。
查状态:
powershell1Get-TransportConfig | Format-List SmtpClientAuthenticationDisabled
开启(租户级别,影响所有邮箱):
powershell1Set-TransportConfig -SmtpClientAuthenticationDisabled $false
如果只想给单个邮箱开,更安全:
powershell1Set-CASMailbox -Identity sender@yourdomain.com -SmtpClientAuthenticationDisabled $false
这里有个关键点:邮箱级别和租户级别是两个独立开关,租户总开关优先级更高。只改邮箱级别、不改租户级别,照样会报同样的错误,报错信息里会明确写着 for the Tenant 而不是具体邮箱,这是判断该改哪一层的关键线索。
改完之后不会立刻生效,通常要等 15-60 分钟的缓存同步时间,测试的时候别急着重复测。
第四步:坑二,MIME 头编码把邮箱地址也裹进去了
租户开关这关过了之后,认证成功、RCPT TO 也被服务器接受,结果发送阶段又报错:
code1550 5.2.254 InvalidRecipientsException; Recipient ' <liwei@xigua.work>' is not resolved.
这个错误比较隐蔽,因为 SMTP 信封层(RCPT TO)明明返回了 250 Recipient OK。问题出在邮件正文里的 To: 这个 MIME 头。
Python 里如果这样写收件人:
python1message["To"] = f"收件人 <{recipient_email}>"
邮件库在处理包含中文的头字段时,会把整个字符串(包括邮箱地址本身)一起做 Base64 编码,变成类似这样:
code1To: =?utf-8?b?5pS25Lu25Lq6IDxsaXdlaUB4aWd1YS53b3JrPg==?=
Exchange 的解析器打开这个头一看,是一坨编码文本,识别不出里面藏着的合法邮箱地址,直接判定"收件人无法解析"。
正确写法是用 formataddr,只对显示名部分编码,地址部分保持 ASCII 明文:
python1from email.header import Header2from email.utils import formataddr34message["From"] = formataddr((str(Header("发件人""utf-8")), sender_email))5message["To"] = formataddr((str(Header("收件人""utf-8")), recipient_email))
生成的头会变成这样,地址部分清晰可辨:
code1To: =?utf-8?b?5pS25Lu25Lq6?= <liwei@xigua.work>
额外提醒:如果这个错误连续触发几次,微软会主动对发信账号做临时限流(Sender throttled due to continuous invalid recipients errors)。改完代码后不要立刻重测,建议等十几分钟让限流窗口过去,不然即便代码修对了,也可能先撞上限流这道墙,容易误判"还没修好"。
第五步:ABP 框架怎么接
ABP 自带的 IEmailSender 默认实现只支持 Host/Port/UserName/Password 这种基本认证配置,不原生支持 OAuth2。社区已经有正式的 Feature Request,官方回复目前需要自己写自定义实现。
做法是自己实现一份 IEmailSender,内部走 OAuth2 逻辑,然后在模块里替换掉默认注册:
csharp1public class OAuth2SmtpEmailSender : IEmailSenderITransientDependency2{3    // 内部用 MSAL 获取 token,MailKit 发信4    // ...5}67context.Services.Replace(8    ServiceDescriptor.Transient<IEmailSenderOAuth2SmtpEmailSender>()9);
这样一来,ABP 内部所有依赖 IEmailSender 的地方(身份验证邮件、系统通知等)都会自动切换到 OAuth2,不用改调用方代码。
第六步:要不要直接换成 Graph API
SMTP+OAuth2 这套配置完整跑通之后,其实值得考虑一个问题:要不要干脆放弃 SMTP,直接用 Microsoft Graph API 发信
两者的核心区别:
SMTP + OAuth2
Graph API
协议
邮件协议(587端口)
纯 HTTPS REST
Azure AD 权限
SMTP.SendAsAppMail.Send
Exchange Online 额外配置
需要(New-ServicePrincipal 等)
不需要
微软长期态度
过渡方案
官方推荐方向
Graph API 的优势很明显:权限配置完全在 Azure AD 层面搞定,不需要再折腾 Exchange Online PowerShell 那一整套。代码也更简洁:
csharp1using Azure.Identity;2using Microsoft.Graph;3using Microsoft.Graph.Models;45var credential = new ClientSecretCredential(tenantId, clientId, clientSecret);6var graphClient = new GraphServiceClient(credential, new[] { "https://graph.microsoft.com/.default" });78var message = new Message9{10    Subject = "测试邮件",11    Body = new ItemBody { ContentType = BodyType.Html, Content = "<p>Hello</p>" },12    ToRecipients = new List<Recipient>13    {14        new Recipient { EmailAddress = new EmailAddress { Address = "to@example.com" } }15    }16};1718await graphClient.Users["sender@yourdomain.com"]19    .SendMail20    .PostAsync(new SendMailPostRequestBody { Message = message, SaveToSentItems = true });
有两个坑要注意:
• 必须用 Users[邮箱].SendMail,不能用 Me.SendMail——应用程序上下文没有真正登录的用户,调不了 /me 端点
• 权限如果没有拿到管理员同意,token 请求不会报错,但真正调 sendMail 时会返回 403,这个坑排查起来容易走弯路
小结
整个流程走下来,几个关键认知:
1.
应用密码只适合临时/小规模场景,长期方案还是 OAuth2
2.
SMTP AUTH 相关的报错要分清是"认证方式"问题还是"协议总开关"问题——for the Tenant 这个关键词是重要线索
3.
中文邮件头编码一定要用 formataddr,不要把邮箱地址跟显示名一起塞进 Base64
4.
如果是新项目,建议一步到位用 Graph API,省掉 Exchange Online 那一层配置,也更符合微软的长期方向;SMTP+OAuth2 更适合已经有存量 SMTP 代码、不想大改的场景
#CSharp#dotnet #Office365 #GraphAPI