授权码没有问题,但电脑名含中文导致发信登录失败
1434 字约 5 分钟
摘要授权码反复重新生成,输入法切换了好几次,QQ 邮箱和 163 邮箱仍然都登录失败。最后发现原因是 Python 的 SMTP 库在打招呼时,把 Windows 的中文电脑名按 ASCII 编码发送了。
在 Auto Email Sender 的用户群里,有一位同学遇到了一个很让人困惑的问题:
“我今天想发邮件,软件一直提示‘邮箱授权码格式不正确,包含非法字符’。我把 QQ 邮箱的授权码重置了三遍,全是手打的英文字母和数字,绝对没有全角空格!后来我换成 163 邮箱测试,报的是一模一样的错!但我用同一个账号密码测 IMAP 收信,是成功的。这到底是哪里出了问题?”
这类问题很有迷惑性:
- 错误提示明确指向“授权码”;
- 用户在输入框里反复修改,折腾了一上午;
- 换了服务商仍然报错,但同一账号的 IMAP 协议可以正常登录。
如果两家大服务商的 SMTP 都不可用,问题很可能不在远端服务器,而在本地操作系统的某个配置上。
误导人的错误提示
翻看后端的鉴权代码,我找到了这段异常处理:
try:
smtp.login(username, password)
except UnicodeEncodeError:
# 程序员自以为是的“贴心提示”
raise Exception("邮箱授权码格式不正确,可能包含了中文或非 ASCII 字符,请重新输入")这段代码的假设是:很多人复制密码时会带上中文字符或零宽空格,导致发送时抛出 UnicodeEncodeError。
但问题在于,它把 login() 过程中发生的所有编码异常,都归咎于 password 参数。
异常发生的位置,不一定是异常的原因
smtp.login()并不只是发送一句密码。在发送用户名和密码之前,它还会建立连接、进行握手、协商认证方式,甚至会重新执行一次 EHLO。这个过程中任何一步出现编码错误,都会被这段 catch 捕获,并包装成一句与实际原因无关的提示。
另外,IMAP 使用同样的用户名和授权码可以正常登录。也就是说,授权码本身没有问题。
问题一定出在 SMTP 独有、IMAP 不需要的握手步骤上。
登录前的 EHLO 握手
在 SMTP 协议(RFC 5321)中,客户端连接服务器后,在发送账号密码之前,需要先发送一条 EHLO 命令,告诉服务器自己的主机名:
EHLO <client-hostname>例如:“你好,我是 junie-macbook.local,我想使用你们的服务发一封邮件。”
通俗比喻:只懂英文的门卫与带中文名牌的访客为什么电脑名含中文会在登录前就出错? 想象你要拜访一座老式的英国庄园:
- 门口的老门卫(SMTP 服务器)只能识别纯英文字母,即 7 位 ASCII 编码;
- 你准备出示工作证(正确的邮箱和密码);
- 但按照规定,管家(Python 的
smtplib)会先替你递上一张拜访卡,卡上写着你的名字,名字是中文的“小明-笔记本”;- 门卫看到这些不认识的汉字,直接处理失败。
门卫还没有机会检查你的工作证,就在递卡这一步出了错。授权码没有问题,但你连门都进不去。
Python 标准库如何获取主机名?Python 的
smtplib在初始化时,如果没有显式传入local_hostname,就会调用socket.getfqdn(),获取当前操作系统的完整计算机名(FQDN),并用在EHLO命令中发送。
这位用户的 Windows 电脑名称是 小明-笔记本。
有些高校或公司内网的 DHCP 服务器,还会自动给主机名加上带拼音或特殊字符的后缀,比如 小明-PC.local。
SMTP 协议诞生于上世纪 80 年代,它的命令通道要求使用 7 位 ASCII 编码。
当 Python 的 smtplib 试图把包含汉字的计算机名按 ASCII 编码发送时,会抛出如下错误:
UnicodeEncodeError: 'ascii' codec can't encode characters in position 5-6: ordinal not in range(128)外层的 try-catch 接住了这个异常,并在界面上显示:“您的授权码格式不正确!”
所以,出错的并不是用户的密码,而是计算机的名字。
| 诊断维度 | 实际情况 | 错误提示的误导 |
|---|---|---|
| 用户名与密码 | 纯 ASCII 字母数字,格式正确 | 被报为“包含非法字符” |
| IMAP 协议 | 握手时不发送本机名,登录成功 | 让用户怀疑 SMTP 服务器出了问题 |
| 真正原因 | Windows 中文设备名在 EHLO 阶段无法编码 | 隐藏在标准库内部的隐式行为 |
生成一个可以安全发送的 EHLO 主机名
找到原因之后,修复就比较简单了。
我们不再依赖标准库自动获取操作系统的主机名。在建立 SMTP 连接(无论是 SSL 还是 STARTTLS)时,改为自己获取并处理 local_hostname:
def get_safe_ehlo_hostname():
raw_fqdn = socket.getfqdn()
# 1. 尝试将可能含有非 ASCII 的域名转为标准的 Punycode (IDNA)
try:
encoded = raw_fqdn.encode('idna').decode('ascii')
if is_valid_hostname(encoded):
return encoded
except Exception:
pass
# 2. 无法转换,或名称含有非法字符,回退到合法的 IP 地址字面量
local_ip = get_local_ip()
if local_ip:
return f"[{local_ip}]"
# 3. 最后的保底值
return "[127.0.0.1]"为什么回退到 [127.0.0.1] 而不是 localhost?根据 RFC 5321,如果客户端无法提供有效的 FQDN,建议使用方括号包裹的 IPv4 地址字面量,例如
[192.168.1.100]。这样既符合 ASCII 规范,也能通过严格邮件服务器的反垃圾邮件检查。
错误处理也做了分级:
- 用户名或密码含有非法字符:在进入网络层之前,由 Pydantic 进行静态校验并拦截;
- 握手阶段出错:明确提示“本地网络环境或主机名协商异常”;
- 只有服务器返回
535 Authentication failed时,才提示“授权码错误”。
总结
这个问题给了我们两点工程上的教训:
- 注意错误提示是否与原因对应: 在业务层给底层异常包装成面向用户的提示时,要确认异常的真实来源。一句自信但错误的提示,比晦涩的调用栈更容易让用户和排查者走偏;
- 留意请求中“不在输入框里的数据”: 一个请求里除了表单字段,还会带上操作系统的主机名、语言环境、时区等信息。遇到跨平台、跨账号的偶发故障时,可以先检查宿主环境。