
无论是注册、订阅、找回密码还是企业后台的成员邀请邮箱地址几乎都是用户进入系统的第一个字段。看起来只是一个字符串真正处理过的人却知道它背后牵连着 RFC 5322、RFC 5321、DNS、SMTP 投递乃至反垃圾策略等多层链路。今天我就围绕“邮箱验证的正确姿势”这个话题把 RFC 5322 标准中与地址校验有关的部分拆开结合实际项目里踩过的坑梳理一套从浏览器到服务端的完整验证思路。这也是给那些还在用一行正则“验证”邮箱的团队一份参考标准里哪些规则值得遵守哪些规则在真实业务中根本不该做强校验。先给一个结论如果你想用一条正则去“完整实现 RFC 5322”趁早放弃。标准里对本地部分的定义会让正则表达式膨胀到无法维护而且现实中能拦截绝大多数“无效邮箱”的往往只是一小部分基础规则。真正严谨的验证靠的是分层不是一条神仙正则。1. 先把口径对齐邮箱校验到底要解决什么1.1 语法正确、域名存在、地址可投递是三个不同的问题我在做技术评审时经常看到团队把“邮箱验真”当成一个接口要做完的事产品经理提的需求是“用户填的邮箱必须有效”结果研发就把市面上能找到的正则都拼在一起。最后效果很不理想要么误杀太多要么该拦的没拦住。要理解这个问题得先把目标拆开。第一个目标是语法正确也就是这个字符串能不能当作一个邮件地址在 RFC 5322 定义的报文头部出现。比如abcexample.com是合法的abcexample按语法也算合法abcexample..com就不合法。第二个目标是域名存在也就是后面的域名有没有 A 记录或 MX 记录没有记录就没法收信。第三个目标是地址可投递也就是这个域名上是否真的存在这个收件人服务器会不会接受这封邮件再悄悄退信。一条正则最多只能解决第一个目标而且解决得还很勉强。域名是否存在得靠 DNS 查询收件人是否存在往往只有真的发一封邮件才知道。1.2 为什么“严格遵循 RFC 5322”本身就是一个伪需求不少技术文章会放一长串号称“符合 RFC 5322”的正则常见版本长达几百个字符。这类正则确实能匹配标准里的很多地址但它带来的边界问题远多于收益。举个例子RFC 5322 允许 domain 段使用domain-literal形式也就是user[192.168.1.1]这种写法。这在协议上是合法的但普通产品里根本不该让用户这么填。还有带注释形式的地址johnexample.com (John)在报文头里也合法但你在网页表单里收到这种输入第一反应应该是“有人往输入框里塞了恶意载荷”而不是乖乖考虑要不要接受。协议合法和生产可用之间有一条宽的河严格遵循协议的大部分代价都花在了你根本不该接受的地址上。所以我个人的建议是别追求“完整实现”而是先确定你的业务到底需要哪个层次的校验然后逐层设计。2. RFC 5322 地址语法中容易被错误实现的细节2.1 地址骨架local-part、 与 domainRFC 5322 里最基本的邮箱地址结构叫addr-spec定义是local-part domain。注意协议原文用的是AT符号也就是两边分别是本地部分和域名部分。本地部分的规范和很多人想象的不一样。现在常用的dot-atom形式允许的可见字符包括大小写字母、数字以及! # $ % * - / ? ^ _{ | } ~和点号。很多人一看到这个清单就晕其实不必背下来只要理解一点、-、_、% 这些字符都是标准允许出现在本地部分里的你的正则如果要照单全收请别把它们误杀。域名部分遵循的是dot-atom加域名的规则也就是一串由点号分隔的 DNS 标签比如example.com。单标签域名在语法上是允许的所以adminlocalhost在 RFC 5322 眼里是合法地址这也是为什么只靠语法规则永远不够——它根本不管这个域名能不能在互联网上找到。2.2 本地部分的“点号陷阱”和 quoted-string本地部分最常见的坑是点号。first.lastexample.com合法这是大部分人知道的。但标点规则里写着dot-atom不能以点号开头也不能以点号结尾还不能出现连续点号所以first..lastexample.com、.firstexample.com、first.example.com都是不符合现行规范的。还有一类特殊形式叫quoted-string也就是把本地部分放进双引号里。john smithexample.com是合法的johnworkexample.com也是合法的。这类地址在真实世界的邮件系统里很少见但偶尔会出现在企业邮箱或邮件的回信地址中。如果你的项目需要兼容这类地址靠正则硬写就会非常痛苦这也是我后面建议使用成熟解析器的重要原因。2.3 显示名称、地址组和注释校验对象其实不只是 addr-spec很多人写邮件校验时忽略了一点RFC 5322 在报文头里出现的形式其实是一个完整的address它可以带显示名比如John Doe johnexample.com也可以在一个To头里用逗号拼出地址列表。这意味着如果你在做邮件头解析或导出邮件地址列表的功能你需要处理的不只是孤零零的addr-spec还包括display-name、尖括号、甚至group语法比如friends: Alice aliceexample.com, Bob bobexample.com;。如果只用一个校验字符串的正则去处理完整的邮件头字段很快就会崩掉。前端表单里用户填的通常只是普通地址所以这个问题常被忽略但只要你会写邮件解析脚本或对接邮件系统就必须把这些结构考虑进去。输入示例按 RFC 5322 语法是否合法说明simpleexample.com合法最典型的地址first.lastexample.com合法点号分隔本地部分first..lastexample.com不合法连续点号.firstexample.com不合法本地部分以点开头first.example.com不合法本地部分以点结尾john smithexample.com合法quoted-stringjohnworkexample.com合法 出现在引号内不会导致歧义userlocalhost合法单标签域名协议语法国允许user[192.168.1.1]合法domain-literal协议允许userexample..com不合法域名段连续点号user nameexample.com不合法本地部分含未转义空格2.4 HTTP 头注入与 RFC 5322 隐含的安全问题聊邮箱验证不能只说格式还要说安全。最常见的攻击手法是在邮箱字段里塞进换行符或额外的头字段比如victimexample.com\nBcc: attackerexample.com。如果后端不校验直接把用户输入拼进邮件头就可能变成邮件头注入漏洞。RFC 5322 本身对头字段的组织方式有明确约束但普通格式校验函数很容易忽略这种行为。所以不管前端怎么校验后端在拼接邮件头之前都必须做一次严格检查把换行、回车和控制字符直接拦掉。这里有一条底线任何进入头字段的用户输入都不允许出现\r、\n和控制字符。这一点比判断地址是否合法还要重要请把代码评审的重点放在这里。3. 落地实践三层校验架构的设计与代码3.1 第一层前端和 HTML5typeemail的边界很多前端工程师的做法是给input加上typeemail就收工。这个做法太弱了吗其实不错。HTML5 规范对typeemail定义了一个相对克制的正则它允许大多数常用地址形式同时不允许空格、中文和明显乱写的输入。它最大的价值是零成本挡住用户随手输入的abc或123而不是帮你验证邮箱存在。要注意的是浏览器端验证只能作为体验优化不能作为安全边界。它在绕过者眼里就是一层透明纸且不同浏览器实现有细微差异。我的建议是前端保留typeemail但不要额外写一堆复杂正则把更严格的判断交给服务端。3.2 第二层服务端用成熟解析器而不是自造轮子服务端校验不要用自己从网上抄来的所谓“终极正则”最好直接使用语言生态里久经考验的实现。在 Python 里一个比较干净的做法是用标准库email.headerregistry里的Address来解析from email.headerregistry import Address def is_syntactically_valid(value: str) - bool: 只做语法层面的地址解析不做 DNS 或投递验证。 if not value or len(value) 320: return False try: Address(addr_specvalue) except (ValueError, IndexError): return False return TrueAddress.addr_spec解析失败会抛出ValueError或IndexError用它判断地址语法比写正则干净得多同时也能正确处理显示名。可以说这是我在 Python 项目里最推荐的入口。在 Node.js 环境里可以借助validator库的isEmail方法。它的实现维护得比较勤默认会拒绝大多数畸形地址也提供allow_display_name、require_display_name等选项。如果你不想引入整个库至少也要拿社区里被反复验证过的正则而不要自己拍脑袋发明一个。Java 里大家习惯用javax.mail.internet.InternetAddress不过这个类在某些版本里对空地址、纯空格地址的宽容度比较高用的时候要额外做空值和长度检查。3.3 第三层投递验证用发送确认邮件证明地址存在语法验证通过后真正决定地址是否可投递的是发送一封带验证链接的邮件。这就是我们常说的 double opt-in 机制用户提交邮箱后系统发一封确认邮件用户点击链接后地址才标记为“已验证”。我见过很多团队为了省这一步去写 MX 记录查询甚至尝试 SMTP 握手来“试探”邮箱是否存在。说实话这类做法在实践中有很多问题全球邮件系统的反垃圾策略越来越严格频繁对陌生域名做 SMTP 探测很容易被对方服务器拉黑而且即使 SMTP 返回 250也不能保证这个地址真实存在因为很多服务器会故意把不存在和存在都返回相同的状态。相比之下一封确认邮件的链接点击是唯一能证明“这个邮箱背后的真人确实可以访问”的信号。所以请把投递验证放到业务流程中去做不要试图在注册接口里同步完成所有验证。4. 实战中容易踩坑的边界IDN、长度、大小写和特殊服务商4.1 IDN 国际化域名与 EAI 中文邮箱到底要不要支持很多产品不做国际化就不会遇到用户例子.中国这类地址。但只要你有一天准备面向海外用户或者业务里有跨境场景就会遇见国际化域名和国际化邮箱地址。国际化域名IDN的处理核心是punycode。用户输入用户例子.中国时规范的做法是把域名部分转成xn--fsqu00a.xn--fiqs8s再走常规的 DNS 和投递流程。如果后端直接把 UTF-8 域名丢给 SMTP很多老系统会直接拒绝或解析失败。这里需要提醒域名部分可以做 IDN 解码再校验但本地部分一旦包含中文字符就意味着对方服务器需要支持 SMTPUTF8 扩展。这个能力在主流邮件服务商那里已经普及但仍有不少私有邮件系统不支持遇到这种用户最好的体验是允许注册、允许发送但如果投递失败则明确告知用户。4.2 地址长度限制网上流传的 320 是一个容易误用的数字关于邮箱地址的长度RFC 5322 本身没有给一个简单粗暴的上限真正涉及长度限制的是传输协议 RFC 5321。RFC 5321 规定本地部分最多 64 字节、域名部分最多 255 字节同时 SMTP 命令中的路径有长度限制。所以网上经常能看到“邮箱地址最长 320 字符”的说法这个数字基本是 64 255 1 简单加出来的严格扣协议还有一些争议。但在实际项目中我不建议用 320 作为前端校验规则也不建议直接在注册页弹窗告诉用户“邮箱过长”。正确的做法是在服务端做一个安全上限比如 320 或 256超出就返回“请输入有效邮箱”防止有人拿超长字符串打你的数据库或日志。这个值只是容量保护不是业务规则不必放到用户界面去解释。4.3 本地部分的大小写敏感性和 标签地址先说结论在绝大多数邮件系统中本地部分的大小写是被忽略的但协议并没有强制所有服务器都这么做。也就是说johnexample.com和Johnexample.com在绝大多数情况下是同一个邮箱但你无法保证地球上所有邮件服务器都遵守这个约定。最稳妥的做法是存储时保留用户输入的大小写但在比对唯一性时全部转成小写发送时按用户输入或统一小写都行因为绝大多数服务器能正确处理。标签地址也是个常见问题比如johnnewsletterexample.com。很多人看到地址里有就觉得是垃圾邮件账号直接拦掉但 Gmail、Outlook 等大型服务商都支持这种子地址。如果你的产品有“同一邮箱不能重复注册”的需求你很可能需要把别名单独处理或者至少不要把这种地址误判为非法。4.4 别把“能解析”当成“能发”很多兜底校验只做了语法解析就放行结果用户用ab这种地址注册等到发送时才在日志里看到一堆退信。这里我建议在首次发送失败后做标记比如退信状态里如果是5.1.1用户不存在或5.1.2域名不存在就把账号的邮箱状态标记为“高风险未验证”。这是很多人容易忽略的治理手段不是只判断一次而是把邮件验证做成一个持续更新的生命周期。5. 我的最终取舍把验证拆成两个阶段比追求完美正则划算得多如果现在让我给一个新项目定方案我会把邮箱验证拆成两个阶段来设计。第一阶段是注册/录入时的“准入校验”语法上使用成熟解析器长度用 320 上限兜底域名只做可选的 IDN 转码不做 MX 查询不做 SMTP 握手。这样能挡住 95% 的明显错误同时不会误杀first.lasttagsubdomain.example.com这类合法地址。用户填完之后前端给出即时提示后端再校验一次保存后状态设为“未验证”。第二阶段是“投递证明”给该邮箱发送一封确认邮件邮件里带有时效性的验证链接用户点击后状态变为“已验证”。对长期不验证、发送被退回、用户主动点击退订链接等事件系统定期更新地址状态。这套机制可以从技术上覆盖绝大多数正常用户又不会因为过度校验把潜在用户挡在门外。我在实际项目里还养成了一个习惯无论第一阶段的语法校验写得多安全发送邮件前都额外做一次原始字符串的安全过滤把\r、\n、NUL 等控制字符直接拒掉。这个习惯救过我很多次尤其是在从旧系统迁移邮件模板、用户输入拼接邮件头的场景里。邮箱验证不是靠一条正则或者一个库就能高枕无忧的它应该是一个可以被持续观察和修正的流程而不是一个永远不变的函数。