别再乱写邮箱正则了:RFC 5322与分层校验实战
我之前维护过一个用户注册系统,前前后后线上跑了两年多,邮箱校验用的是一条"据说很全面"的正则——长度够长,符号覆盖率够高,还在评论区被不少同行收藏过。直到有一天,客服转来一个用户反馈:注册永远提示"邮箱格式不正确",但这个用户提供的明明是公司邮箱。
我拉出日志一看,那个地址长这样:"John..Doe"@sub.example.co.uk。注意,local 部分用了引号包裹,里面还有两个连续的点。我当时第一反应是"这地址绝对是假的",但按 RFC 5322 标准走一遍之后,发现人家完全合法。那一刻我意识到,我以前写过的所有邮箱校验,都存在同一个根本问题:我用"我认为合理的格式"代替了"标准定义的格式"。
这个事让我彻底重做了一遍邮箱校验模块,也是今天想跟你分享的全部内容。我会从 RFC 5322 的规定讲起,拆解邮箱地址的真实语法结构,再给出可落地的分层校验方案,覆盖 Python、JavaScript 的代码实现,以及我在真实用户数据上踩过的坑。不管你是刚接触后端开发,还是已经在维护注册量百万级的系统,这篇都应该能帮你少走几段弯路。
1. 为什么"看起来没问题"的正则,其实全错了
1.1 常见初版正则的三大盲区
动手写邮箱校验的时候,大多数人第一版长这样:
这个正则很典型:local 部分用了 [\w.+-],domain 部分用了 [\w.-]。看起来覆盖面不低,但它在三个维度上都有硬伤。
第一个盲区是符号覆盖不全。\w 在大多数语言里等价于 [A-Za-z0-9_],可 RFC 5322 规定的 local 部分合法字符 !#$%&'*+-/=?^_\{|}~ 这一大串里,!、#、$、%、&、'、*、/、=、?、^、`` ``、{、|、}、~ 全都进不去。也就是说 o'brien@example.com 这种在英语国家很常见的姓氏邮箱,直接就死在了第一关。
第二个盲区是结构规则缺失。[\w.-]+ 这种写法没有约束点的位置,于是 user@example..com、user..name@example.com、user@.example.com 这些非法地址会在你的正则里畅通无阻。
第三个盲区是长度上限缺失。local 部分上限 64 字符,域名上限 255 字符,完整地址上限 254 字符,这三个硬指标在很多正则里根本没体现。
1.2 网上流传的两条"标准正则",为什么不能直接用
邮箱验证领域流传很广的有两条正则:一条是 naive 版本 /^\S+@\S+$/,一条是 emailregex.com 上标着 "RFC 5322" 的长正则。
naive 版本的问题在于它只做了最粗粒度的切分,a@b 能过,user@@domain 也能过。它适合拿来给用户做输入提醒,不适合做任何需要严谨性的验证。
而 emailregex.com 那条长正则,如下面这样(这还只是"实际可用"的简化版):
它的语法覆盖率已经相当高,问题出在不可维护和性能上。名字、注释、引号字符串、域字面量全堆在一行里,任何一个规则想调整,都等于重写。更麻烦的是,这种嵌套量级大的正则一旦碰上恶意构造的输入,容易触发灾难性回溯,也就是常说的 ReDoS,让你的服务在用户输入上直接卡死。
这不是说正则不能用,而是说它不该是校验逻辑的全部。
1.3 要分清"语法校验"和"投递保证"
还有一个更底层的认知问题:哪怕你的正则 100% 匹配 RFC 5322 的语法,它也只能保证"这个字符串看起来像个邮箱地址",不能保证"这个邮箱真的存在、真的能收到信"。
语法上合法但投递不了的例子一抓一大把:notexist@example.com 格式完全正确,但这个邮箱可能根本不存在;admin@localhost 语法上合法,但除非你跑在内网邮件系统里,否则外网根本没法投递。所以严格来说,邮箱校验从来不是一道正则能解决的事,而是一套需要分层的系统工程。
这个认知,是我后面所有设计的前提。
2. RFC 5322 到底怎么定义邮箱地址
2.1 一分为二:local-part 和 domain
一个邮箱地址 local-part@domain,被 @ 分成两段。RFC 5322 对这两段的语法约束完全不同,理解这个差异是写对校验的前提。
local-part 有两种合法形态。最常用的是 dot-atom:由一串"原子"组成,原子之间用点隔开,点不能在头尾,也不能连续出现。原子里的合法字符包括大小写字母、数字,以及 !#$%&'*+-/=?^_{|}~` 这些特殊符号。
第二种形态是 quoted-string,用双引号把内容包起来。引号内部几乎什么都能放,包括空格、@、连续的点,甚至中文——只要不是裸的控制字符或者转义规则不允许的字节。比如 "john..doe"@example.com 就是合法地址。
domain 部分同样有两种形态。常见的是点分域名,每个 label 只能由字母、数字、连字符组成,label 不能以连字符开头或结尾,长度上限是 63 字符。另一种是 domain-literal,用方括号包起来,比如 john@[192.168.1.1],IPv6 写法是 john@[IPv6:2001:db8::1]。domain-literal 在实际业务里几乎见不到,但 RFC 5322 里它是合法的,这一点很多校验方案都直接忽略了。
2.2 三个硬长度指标
- local-part 最多 64 字符
- domain 最多 255 字符
- 完整地址最多 254 字符(不是 255)
为什么整体是 254?因为 RFC 5321 规定 SMTP 的 Path 长度上限是 256 字符,这个长度包含两端的尖括号 <>,去掉这两个字符就是 254。有些资料会写 "254" 有些写 "256",其实说的不是同一个东西——一个指邮箱地址本身,一个指 SMTP 信封路径。校验长度的时候,我建议直接按邮箱地址 254 来掐。
2.3 容易被忽略的 CFWS 和"该遵守哪个 RFC"
严格按 RFC 5322 走,john(comment)@example.com 这种带注释的形式在语法上也是被允许的,只是 RFC 5321 在实际投递时不允许。如果你想写一个"百分百符合 RFC 5322"的正则,你需要连嵌套注释和折叠空白都处理掉,成本高得离谱,收益却几乎为零。
实践中大家默认遵循的是 RFC 5321 的放宽限制,而不是 RFC 5322 的全部语法。这个判断很重要,它决定了你的校验器该复杂到什么程度。很多时候"过度符合规范"反而是 bug 的来源——你放行了业务上根本没有意义的地址,只为了在技术讨论里显得很懂标准,这不划算。
3. 分层校验架构:格式、解析、投递三关缺一不可
3.1 为什么不能指望一条正则
把校验拆成三层,不是过度设计,而是每一层解决的问题本质不同:
- 第一层:过滤掉"明显不对"的输入,保护后端不被垃圾数据淹没。
- 第二层:按标准语法解析,确认地址结构合法。
- 第三层:验证域名的邮件服务存在,确认"有地方能收"。
三层各司其职,也各有各的失败模式。单层方案的问题在于,它要么过松放进来一堆垃圾,要么过紧误杀真实用户。拿一个极端例子来说:如果有人拿 a@b 来注册,第一层就该拦掉,但如果你只做第三层 DNS 查询,b 这个域名可能真的存在,甚至可能真的配了 MX 记录——语法上它丑得离谱,但在某些内部环境里它确实能收信。分层不是层层设卡,而是让每一层只做它最擅长的事。
3.2 第一层:极轻量的基础过滤
第一层只做几件事,全是 O(n) 操作:
- 非空判断
- 整体长度不超过 254
- 不含空白字符
@有且仅有一个(或者用最后一个@把 local 和 domain 分开)- local 和 domain 都不为空
这层不追求严谨,追求"便宜"。放到 HTTP 接口里,它能在正则引擎启动前挡掉一半以上的垃圾输入。别小看这几行代码,线上大量注册请求是脚本刷的,很多连 @ 都没带,第一层就能把它们全部过滤掉,给后续昂贵的校验省下大量 CPU。
3.3 第二层:交给成熟的解析器
第二层的核心思路是:不要自己写完整正则,用经过大量测试的库。你踩过的坑,维护这些库的人早踩完了。
- Python:
email.utils.parseaddr做粗解析,email-validator做严格校验和规范化 - Node.js:
validator.isEmail提供可配置的语法校验 - Java、Go、PHP 等也都有对应的成熟实现
需要特别说明的是,email.utils.parseaddr 不能单独拿来当校验器。它的设计目标是解析"姓名 <地址>"这种格式的邮件头,对非法地址非常宽容。你拿它做校验,会发现 NotAnEmail 这种输入它也能给你拆出一个邮箱地址来——因为它把整个串当成了显示名。这是标准库和能力库的区别,混用之前一定要搞清楚。
3.4 第三层:DNS 层面的投递性检查
第三层去查 DNS。查询逻辑不复杂:
- 查域名的 MX 记录
- 如果没有 MX,按 RFC 5321 的规则回退查 A/AAAA 记录
- 两条都没有,说明这个域名根本没配置邮件服务
需要注意的是,MX 查询是网络 IO,比前面两层慢几个数量级,所以必须考虑超时、缓存和异步化。短平快的请求里如果每次注册都同步查一次 MX,性能会很难看。我的做法是加一层 TTL 可控的内存缓存,命中的域名不再重复查 DNS。
| 层级 | 目的 | 手段 | 成本 | 失败后果 |
|---|---|---|---|---|
| 第一层 | 拦截明显垃圾输入 | 字符串基础判断 | 极低 | 放行垃圾、占用后续计算 |
| 第二层 | 确认语法合法 | 标准解析库 | 低 | 误杀合法格式或放行畸形地址 |
| 第三层 | 确认域名有邮件服务 | DNS MX/A 查询 | 中 | DNS 抖动导致误杀 |
4. 代码落地:可以直接抄作业的 Python 和 JavaScript 实现
4.1 Python 完整实现
这段代码把三层做了明确切分。validate_email_syntax 做了第一和第二层,check_domain_mx 做第三层。validate_email_full 返回一个可直接接入业务的结构化结果。
注意:
email-validator在check_deliverability=True时本身也会做 MX 检查,但我还是建议把语法和投递性拆开。理由是调用场景不同——注册表单可能需要全量验证,但"找回密码"页可能只希望验证语法,不希望因为临时 DNS 抖动而拒绝用户。
4.2 JavaScript 实现:浏览器端和 Node 端分开处理
浏览器端通常只做第一层和第二层的部分内容,因为浏览器跨域调 DNS 不现实,你也不应该在首页脚本里引入一个巨大的邮箱解析库。
Node 端可以用 validator.js 做更严格的检查:
require_tld: true 会强制域名必须带顶级域,这会拦掉 admin@localhost 这类的内网地址。如果你恰好需要支持内网邮件系统,记得把它设成 false。
4.3 校验函数该返回什么?
我强烈不建议只返回布尔值。原因很简单:当校验失败时,你需要告诉产品、告诉前端、告诉用户"怎么改"。一个 false 什么信息都没有。
正确做法是返回一个结构化结果,至少包含 valid 和 reason 两个字段。reason 可以是枚举字符串,比如 invalid_format、domain_no_mail_server、local_part_too_long。这样前端可以根据 reason 映射不同提示文案,后端也可以据此做埋点统计——到底是用户在乱填,还是你误杀了哪个合法格式。
5. 那些让规则"翻车"的真实地址与排查链路
5.1 先发现"在误杀",才能谈"怎么修"
那封让我重做系统的工单,其实是我后来排查的起点。复盘的时候发现,这类问题有个共同特征:用户被拒绝的不是"输入格式错误",而是"系统静默丢弃"——用户以为自己填错了,但实际上填的是对的。很多产品压根不会去统计"格式错误"里有多少是误杀,所以问题会被藏得很深。
我的建议是:先在注册接口里给每次校验失败加上 reason 埋点,跑至少两周。然后专门看看 invalid_format 分组里有没有高发的合法域名。如果你发现某个域名下的地址大量被拒,基本可以断定你的规则在误杀。千万不要直接去搜"最全邮箱正则"然后替换上线,你只会从一个坑跳进另一个坑。
5.2 五类被误杀的合法地址
| 案例 | 为什么被误杀 | 正确的处理方式 |
|---|---|---|
"John..Doe"@sub.example.co.uk |
检查连续点时不区分引号上下文 | 用支持 quoted-string 的解析器 |
user+tag@example.com |
误把 + 当垃圾字符 |
RFC 中 + 本就合法,别拦 |
o'brien@example.com |
字符白名单没包含 ' |
交给成熟库处理 |
用户@例子.公司 |
正则不支持非 ASCII | 域名转 punycode 后再校验 |
admin@localhost |
语法合法但外网无法投递 | 按业务场景决定是否放行 |
这里面前三类的根源都是一样的:手写白名单和规则。只要真实世界超出你的想象,你就会丢用户。第四类要特殊处理一下:用户@例子.公司 涉及两个层面——域名 例子.公司 必须转成 punycode(xn--fsq.xn--55qx5d)才能做 DNS 查询;而 local 部分 用户 能否保留中文,取决于收发双方是否支持 SMTPUTF8 扩展(RFC 6531)。稳妥做法是:域名转 punycode 后照常校验,local 部分仍然限制为 ASCII 字符,或者明确告诉用户"本系统暂不支持中文邮箱前缀"。
5.3 修复原则:用标准解析库替代字符白名单
处理完这批工单,我把整个校验模块的代码改成:第一层做基础过滤,第二层全部交给 email-validator 和 validator.js,手写的正则全部删除。原因很朴素:标准解析库把 RFC 5322 / RFC 5321 的边界条件都处理过了,包括引号字符串、转义、punycode、CFWS,而这些细节你不可能靠几条正则维护得住。
我还做了一件事:把上面五类地址写成了 CI 里的测试样例。以后任何人想改校验逻辑,先跑一遍这些用例,改挂了就直接发不了 PR。这个习惯后来帮我挡掉了至少三次"好心优化"引入的回归。
6. 从"格式正确"到"正常送达":投递性验证的工程边界
6.1 SMTP 探测为什么看着香却不能用
有些人会想:既然 DNS 都说有 MX 记录了,能不能直接连对方的 SMTP 服务器发一封探测邮件?技术上可以,现实里非常不建议。
原因有三个。第一,大量邮件服务对来自未知 IP 的 SMTP 连接做了限流或封禁,你会被误判成垃圾邮件发送者。第二,RCPT TO 之后对方可能返回 250,也可能返回 252("无法验证但接受投递"),更可能直接返回 451 让你过会儿再试,状态码的含义远没有你想的那么确定。第三,这个操作属于"看起来像投递行为"的灰色操作,轻则被拉黑,重则牵涉到合规问题。所以我的建议是:MX 查询可以做到第三层,SMTP 探测不要碰。
6.2 更值得做的:风险分级而不是一刀切
与其纠结一个邮箱"绝对对错",不如给它算一个风险分。常见的加分项包括:
- 使用一次性邮箱域名:负分,可能需要额外验证
- 角色账户(
info@、admin@、webmaster@):负分,通常不建议作为个人注册邮箱 - 免费邮箱域名:中性偏负,可根据产品定位调整
- 拼写相似(
gmai.com、hotmial.com):可以给出纠错提示,而不是直接拒绝
产品策略上也建议默认宽容:格式不对先给提示而不是直接拒绝,严重的风险信号(比如一次性邮箱)走"发送邮件验证码"的二次风控,而不是劈头盖脸一个报错。毕竟,验证邮箱最终目的是建立联系,而不是刁难用户。
6.3 性能与安全红线
校验接口上线时要盯三件事:正则超时、DNS 查询耗时、缓存击穿。
正则超时在最前面提过,长正则遇上恶意输入可能触发灾难性回溯。Python 的 regex 库支持超时时间,re 库没有,所以如果非要用正则做核心校验,建议要么换库,要么限制输入长度后再进正则。
DNS 查询必须设置超时(我习惯设 2 秒),并且要缓存结果。缓存 key 是域名,value 是"是否有 MX/A 记录",TTL 可以参考 DNS 响应的 TTL 设个上限,比如 10 分钟。万一某个大型邮箱服务在高峰期抖动,DNS 临时解析失败,你的注册成功率会整个塌方。
还有一个隐性问题:注册接口会被人批量调用。每次调用都去查 DNS 会被运营商标记为异常流量,所以缓存之外,注册接口本身也要做频控。这些不是校验算法本身的事,但它们是"校验能稳定跑在生产环境"的前提。
重做邮箱校验那段时间,我把过去两年被丢进"格式错误"分类的注册记录翻出来重新跑了一遍,结果里面差不多 3% 的用户地址其实完全合法。从那以后我养成了一个习惯:任何校验规则上线前,先拿历史用户数据跑一遍回归,看看有多少存量用户会被新规则误杀。这个数字比什么测试用例都诚实。