
上周帮同事看一个跑了半年的发信脚本控制台里突然甩出来一大坨SMTPDataError: (550, 5.7.60 SMTP; Client does not have permissions to send as this sender)代码一行没改前一天还发得好好的。这种昨天能用今天不行的毛病最烦人因为你会下意识去怀疑自己写的代码结果折腾半天发现是服务端的权限策略变了。这篇就把这类 SMTP 发信报错从里到外捋一遍550和5.7.60分别代表什么、认证身份和发件人身份为什么会打架、Python 的 smtplib 里哪两个参数最容易写错、服务端要怎么补权限以及一套可以直接照着跑的排查流程。不管你是刚接手中继发信的新手还是写过一堆通知脚本的老手看完至少能省掉半天瞎试的时间。1. 报错信息逐字拆解550 与 5.7.60 分别指什么1.1 三个身份认证账号、信封发件人、头部发件人想彻底搞懂这个报错先得接受一个反直觉的事实一封邮件在传输过程中发件人不止一个。很多人以为From字段就是发件人其实它只是给人看的显示信息服务器真正拿去校验的是另外两个东西。第一个是认证身份也就是你login()时用的那个用户名服务器靠它确认你是谁。第二个是信封发件人envelope sender对应 SMTP 协议里的MAIL FROM命令它决定了退信往哪儿退类似纸质信件的寄件人地址栏。第三个才是头部发件人也就是邮件正文头里的From:字段收件人打开邮件看到的就是它。这三个身份在协议层是彼此独立的字段完全可以不一致。而微软系Exchange Online、Microsoft 365 邮箱的服务器有一条非常明确的规矩头部 From 必须等于你的认证账号或者你的认证账号对该地址拥有发送为Send As或代表发送Send on Behalf权限。一旦不满足它就回你550 5.7.60。所以这条报错的字面意思翻译过来就是你登录用的这个账号没资格冒用你写在 From 里的那个地址。理解了这一层很多看似玄学的问题就有解释了。为什么同一份代码换台机器就能跑因为换的那台机器上配置的发件地址刚好和登录账号一致。为什么用网页版邮箱发同样的内容没事因为网页版登录的就是那个地址本身三个身份天然对齐。1.2 5.7.60 出现的位置与它的孪生兄弟先解释数字本身。550是 SMTP 的基本状态码属于 5xx 永久性失败这一类意思是这事没戏别重试了——它和 4xx 的临时失败有本质区别4xx 会提示你稍后重试5xx 说明重试一百遍也是同样结果。5.7.60则是增强状态码Enhanced Status Code格式是X.Y.ZX表示类别5还是永久失败Y表示主体7在 RFC 3463 里被划为安全与策略相关Z是具体细节编号60是微软自己扩展出来的。所以这串数字读起来就是一条永久性的策略拒绝具体原因是发件人权限不足。跟它容易混淆的是另外几个报错码含义和 5.7.60 的区别550 5.7.1中继被拒绝 / 拒绝投递通常是 IP 或域名没被授权中继550 5.7.60无权以此发件人身份发送认证通过了但发件地址没权限535 5.7.3认证失败账号密码或应用密码错误压根没登上554 5.7.1内容或地址被策略拦截垃圾邮件判定、地址黑名单等关键是区分535和550 5.7.60。535是门都没进去550是门进去了但你没资格用这个身份说话。排错方向完全相反前者查密码和应用专用密码后者查发件地址和授权关系。我见过不止一次有人拿着 5.7.60 去反复改密码改到账号被锁定也没用。还有个细节值得留意微软的报错文本在不同入口略有差异有的是Client does not have permissions to send as this sender有的是Client does not have permissions to send on behalf of this sender。前者对应发送为权限缺失后者对应代表发送权限缺失处理路径不一样看日志时别一眼扫过。1.3 什么样的场景最容易撞上从经验上看撞上 5.7.60 的人大致集中在这几类情况里业务系统用统一账号发通知但 From 想显示成客服邮箱运维给共享邮箱配了自动回复脚本开发者本地调试时随手填了个noreply地址还有人是从别的邮件服务商迁移过来习惯性地把登录账号和发件地址分开写。这几类场景有个共同点都希望用 A 账号登录以 B 身份发信。这个诉求在协议上是合理且被支持的只是要在服务端显式授权。如果你所在的环境是自建邮件服务器比如 Postfix、Exchange 本地部署策略可能松得多甚至默认放行一旦迁到云邮箱默认就是收紧的。所以代码没改突然报错最常见的原因是服务商调整了默认策略或者管理员改了发送权限。2. 四类高频触发场景先对号入座再动手2.1 From 头写着别名登录用的却是主账号这是占比最高的一类。典型代码长这样登录名是robotcompany.com但msg[From]写成了servicecompany.com。在 Gmail 里这叫以其他地址发送需要提前在设置里添加并验证该别名在微软系邮箱里需要给机器人账号授予对servicecompany.com的发送为权限。两者都不是改代码能解决的。判断方法很简单把msg[From]临时改成登录账号本身如果立刻就能发出去那基本可以确认是别名权限问题。这一招我用过很多次五分钟就能把方向定下来比翻文档快得多。2.2 共享邮箱与邮件组Send As 与 Send on Behalf 的区别企业内部常见共享邮箱比如supportcompany.com多人共用。这里有两个容易搞混的权限发送为Send As发出的邮件 From 直接显示共享邮箱地址看不出是谁发的适合对外统一形象。代表发送Send on BehalfFrom 显示共享邮箱Sender 显示实际发送人收件人能看到某某代表某某。这两种权限在服务端是两个独立开关授了其中一个不等于有另一个。报错文本里出现send as就补 Send As出现send on behalf of就补 Send on Behalf。我踩过的坑是给同事授了 Send on Behalf但脚本把 From 和 Sender 都写成了共享邮箱服务器按 Send As 的规则去校验照样拒绝。后来把Sender头去掉、或者老老实实补 Send As 权限问题才消失。2.3 客户端认证被整体关闭另一类容易被忽略认证根本没成功但你看到的不是 535。某些环境下服务端允许匿名提交比如内网中继认证环节被跳过可权限校验依然执行于是直接抛出 5.7.60。这种情况在把内网脚本搬到云上时特别常见。排查办法是打开 SMTP 会话日志看有没有AUTH那段交互成功。如果没有先解决认证通道问题。微软系租户还有个专门开关叫 SMTP AUTH客户端提交新建租户里默认对多数邮箱是关闭的需要在管理端显式启用。管理员改过安全策略之后昨天能发今天不能发多半就是它。2.4 smtplib 里两个容易写错的位置Python 标准库smtplib有两个发信方法参数含义很容易记混sendmail(from_addr, to_addrs, msg)第一个参数是信封发件人会作为MAIL FROM发给服务器如果msg里的From头跟它不一致有些服务器会以头部为准做校验有些以信封为准行为不统一。send_message(msg)会从msg里自动推导信封发件人看似省事但也意味着你没法单独控制信封值。我个人的习惯是两个发信方法都用但是把 From 头、信封发件人、登录账号三者写成同一个变量从源头上消灭不一致的可能。这个习惯帮我省掉了至少七八次莫名其妙的退信。3. 从最小复现到逐层定位的完整实操3.1 先写一个能稳定复现的 20 行脚本排查这类问题最忌讳在业务代码里改来改去。正确做法是抽一个最小用例出来把变量降到最少。下面这段我常用来做验证跑通了再往业务里搬import smtplib from email.message import EmailMessage from email.utils import formatdate SMTP_HOST smtp.example.com SMTP_PORT 587 LOGIN_USER robotexample.com # 认证身份 LOGIN_PASS your-app-password # 应用专用密码或授权码 FROM_ADDR serviceexample.com # 想显示的发件地址 msg EmailMessage() msg[From] FROM_ADDR msg[To] someoneexample.org msg[Subject] permission probe msg[Date] formatdate(localtimeTrue) msg.set_content(hello, this is a probe.) with smtplib.SMTP(SMTP_HOST, SMTP_PORT, timeout15) as s: s.set_debuglevel(1) # 关键打印完整会话 s.ehlo() s.starttls() s.ehlo() s.login(LOGIN_USER, LOGIN_PASS) s.send_message(msg) # 信封发件人由 From 头推导 print(sent ok)注意这段代码里LOGIN_USER和FROM_ADDR是故意写成不一样的两个值目的就是复现问题。如果它报 5.7.60那么接下来所有动作都围绕这两个变量做实验先把FROM_ADDR改成跟LOGIN_USER一样如果通了方向就锁定了。端口和加密方式也顺手确认一下587 走 STARTTLS是提交端口的主流选择465 走隐式 TLS25 端口多数云服务商已经封禁了对外提交。用错端口往往表现为连接超时而不是权限报错但混在一起排会非常乱。3.2 打开 debuglevel看懂 SMTP 会话set_debuglevel(1)会把这轮会话的每一行命令和响应都打出来这是定位权限问题最值钱的一步。一段典型的输出大致长这样send: ehlo [192.168.1.20]\r\n reply: b250-SMTP.EXAMPLE.COM\r\n reply: b250-AUTH LOGIN XOAUTH2\r\n reply: b250 OK\r\n send: AUTH LOGIN ...\r\n reply: b235 2.7.0 Authentication successful\r\n send: MAIL FROM:serviceexample.com\r\n reply: b550 5.7.60 SMTP; Client does not have permissions...\r\n读这段日志要看三点。第一AUTH有没有返回 235返回 235 说明身份认证是成功的那问题一定出在权限而不是密码上。第二MAIL FROM后面跟的地址是不是你预期的那个send_message自动推导时有可能会带上显示名或多余括号导致比对失败。第三服务器在EHLO响应里列出了哪些认证机制如果只有AUTH LOGIN没有XOAUTH2说明这环境还没开放更现代的授权方式你就只能走应用专用密码那条路。把这段日志贴出来再去问管理员或者搜资料效率比干巴巴描述我发不了邮件高十倍。3.3 让三个身份对齐的三种改法拿到日志基本就能确定改哪边了下面是三种常见改法按侵入性从低到高排列第一种改 From 头让它等于登录账号。侵入性最低改一行代码就行。代价是对外显示的发件地址变成了机器人账号客户看到会觉得奇怪。如果业务上可以接受这是最省事的方案。第二种给认证账号补发送权限。代码不动去服务端授予robotexample.com对serviceexample.com的发送为权限。这样 From 可以继续显示成客服地址收件人也看不出破绽。这是长期方案适合对外发信的场景。第三种给目标地址单独开一个登录账号。直接用serviceexample.com登录发信三个身份天然一致不需要任何额外授权。这种方法在共享邮箱场景下特别干净缺点是账号数量会变多密码管理要跟上。选哪种取决于你的业务约束。我一般优先推荐第二种因为它把权限这件事显式化出了问题一目了然最怕的是有人为了图快在生产代码里塞了个字符串替换把发件人硬改成登录账号结果几个月后没人记得为什么收件人看到的是个奇怪的地址。3.4 服务端授权把权限补到该补的地方服务端怎么补权限取决于你用的是哪套邮件系统但思路是一致的找到那个认证账号给它对目标发件地址的发送权。如果是微软系的云邮箱管理端通常用 PowerShell 完成大致形态是这样# 给 robot 账号授予对共享邮箱的“发送为”权限 Add-RecipientPermission -Identity serviceexample.com -Trustee robotexample.com -AccessRights SendAs -Confirm:$false # 确认结果 Get-RecipientPermission -Identity serviceexample.com | Format-Table Trustee, AccessRights执行完别急着发信先跑一遍查询命令确认AccessRights里确实出现了SendAs。权限在服务端往往有缓存等几分钟或者重新建立连接再试。我遇到过授予成功但立刻测试仍失败的情况等了大约十分钟再跑就通了不是配置错是同步还没完成。如果是自建的中继或网关则要看它的发件人重写规则sender rewriting和中继白名单。很多网关默认会把信封发件人改写成某个统一地址从外面看像是权限问题实际上是重写规则覆盖了你的设置。另外提醒一句如果你们环境启用了更严格的授权机制比如基于令牌的授权发信脚本可能要用短期令牌而不是固定密码。这类机制一般由平台方提供接入文档照着申请即可但要注意令牌有效期定时任务里要做好续期否则每天早上第一封邮件失败、后面又好了这种规律性故障就是令牌过期的典型表现。4. 排查速查表与几个反直觉的细节4.1 症状到原因的速查表排查时间长了我把常见症状和成因整理成了下面这张表遇到问题先扫一眼比从零开始想快得多症状大概率原因优先动作换 From 为登录账号后立即成功别名未授权服务端补发送为权限AUTH未返回 235直接报 5.7.60认证通道未启用管理端开启客户端提交或改用应用专用密码内网正常搬到云上后失败云服务默认收紧策略检查租户级发送权限昨天能发今天不能发管理员改了策略或令牌过期查变更记录与令牌有效期只有个别收件人报错收件人域策略或地址黑名单换地址验证方向不同邮件的 From 正常但显示某某代表Sender 头被自动填充去掉 Sender 头或补 Send As表格里最后一行值得单独说。有些库在发信时会自动补一个Sender头一旦出现这个头收件端就理解为代表发送视觉上会显示成robotexample.com 代表 serviceexample.com。如果你的业务要求对外只显示统一地址就得把这个头清掉同时确保服务端授的是发送为而不是代表发送。4.2 容易被忽略的五个点第一个是应用专用密码与登录密码不是一回事。很多邮箱服务要求第三方客户端使用单独的授权码直接填登录密码会认证失败。认证失败本该报 535但有些服务商为了安全不区分错误类型统一模糊处理让人误以为是权限问题。第二个是发件地址的显示名会干扰比对。From: 客服 serviceexample.com和From: serviceexample.com在某些服务器上被解析的结果不同。我踩过这个坑日志里MAIL FROM带上了尖括号和显示名服务器比对失败。解决办法是构造EmailMessage时只赋值纯地址需要显示名时用email.utils.formataddr显式拼。第三个是大小写和域名后缀。地址比对通常不区分大小写但如果你的域名有多个写法比如带不带子域服务器可能认作两个不同身份。统一从配置中心取别在代码里手写字符串。第四个是连接池与长连接的副作用。为了性能有些脚本会复用同一个 SMTP 连接发多封不同的邮件。如果这些邮件的 From 各不相同而连接是在某次认证下建立的后面几封就会被判权限不足。要么一个连接只发同源邮件要么每封重新认证。第五个是退避策略用错了地方。5xx 类错误重试没有意义但很多现成的重试库不区分 4xx 和 5xx一概重试三次。结果日志里刷满同样的报错真正的问题被淹没。正确做法是捕获SMTPDataError并解析状态码5xx 直接告警4xx 才走重试。import smtplib try: with smtplib.SMTP(host, port, timeout15) as s: s.starttls() s.login(user, password) s.send_message(msg) except smtplib.SMTPDataError as e: code e.smtp_code # 550 detail e.smtp_error # b5.7.60 ... if code 500: # 永久失败不要重试直接告警 alert(fpermanent failure: {code} {detail}) else: raise这段代码的意义在于把该重试的和不该重试的分开。权限类问题重试一万次也不会变好反而会把日志和警报系统搞垮。5. 从救火到稳态发信链路的长期维护建议5.1 把发件人策略固化下来解决一次 5.7.60 不难难的是别让它反复出现。我的做法是把发件人策略写进配置而不是散落在代码里。具体来说配置文件里明确三项认证账号、信封发件人、头部发件人并且允许它们指向同一个值。启动时做一次校验如果发现认证账号和头部发件人不一致就打一条明确的警告日志提示需要发送为权限。这个校验看起来多余实际上非常有用。团队里新人接手时看到警告就知道有坑不用等到生产环境报错才发现。我更推荐的做法是直接在配置里区分同源发送和代理发送两种模式前者三个身份一致后者要求显式声明目标地址并记录授权依据。把隐式约定变成显式配置是这类问题最有效的根治手段。另外发件地址最好收拢到少数几个别每个业务模块自己定义。地址一多权限关系就变成一张复杂图出问题时要查半天。5.2 监控与降级发信失败最怕的不是报错而是没报错——邮件悄悄发不出去业务方过了一周才发现。所以除了修权限还得给发信链路加上轻量监控统计每日发送量与失败率失败率超标就告警对关键业务邮件做抽样回执验证确保真的到得了对方邮箱。降级策略也值得提前设计。比如主通道因权限问题不可用时能不能自动切到备用通道备用通道的发件地址通常不一样权限关系也不同需要提前配好并定期演练。我见过太多团队写了切换逻辑但从没测过真出事时一按开关又是新的报错。还有一点个人体会权限这东西是会变的人员离职、组织架构调整、安全策略升级都可能悄悄把某个授权摘掉。所以发信脚本最好定期比如每周一次做一次自检用最小用例发一封到内部邮箱验证三条链路是否都通。成本几乎为零但能让你在业务方投诉之前先发现问题。我在实际维护中总结出的经验是把 5.7.60 当成一个权限声明缺失的信号来看待而不是一个单纯的代码 bug。它提醒你这套发信链路里有一层授权关系没写清楚。补上这一层以后同类报错基本就告别了。至于那些临时把发件人改成登录账号的应急改动记得回头补一条注释说明原因和后续计划不然半年后接手的人又得把这段路重走一遍。