Backend

授权码没有问题,但电脑名含中文导致发信登录失败

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 时,才提示“授权码错误”。

总结

这个问题给了我们两点工程上的教训:

  1. 注意错误提示是否与原因对应: 在业务层给底层异常包装成面向用户的提示时,要确认异常的真实来源。一句自信但错误的提示,比晦涩的调用栈更容易让用户和排查者走偏;
  2. 留意请求中“不在输入框里的数据”: 一个请求里除了表单字段,还会带上操作系统的主机名、语言环境、时区等信息。遇到跨平台、跨账号的偶发故障时,可以先检查宿主环境。

评论