1. 内容整体设计与思路拆解
1.1 为什么不能信网上流传的邮箱正则
我最早做邮箱校验的时候,跟大多数人一样,直接打开搜索引擎,找一条所谓“万能邮箱正则”,复制粘贴到项目里就完事。直到某天生产环境里收到一个投诉:用户说自己的邮箱地址是对的,但系统一直提示“格式不正确”。
我去翻了一下日志,发现被拦下来的地址长这样:
"mary.o'neil"@example.org当时那条正则是我从某篇热帖里抄的,大概长这样:
/^[\w.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$/这个正则会直接拒绝上面那个地址。理由很粗暴:'不在[\w.+-]这个字符集里。
问题来了:这个地址到底合不合法?我去查了 RFC 5322,结论是——它完全合法。本地部分(local-part)被双引号包裹,里面可以存在绝大多数 ASCII 字符,包括空格、单引号、括号,甚至是@本身。也就是说,我们一直以来用的那些正则,校验的不是邮箱格式,而是“我见过的邮箱格式”。
这里引出一个核心认知:邮箱格式校验不能只靠一条正则打天下。适当用正则做“快速初筛”没问题,但如果你要处理的是真实用户输入,或者要做对外服务的表单校验,就需要把验证拆成多个层级,不同场景用不同策略。
1.2 先理解 RFC 5322 在规范什么
RFC 5322 是互联网消息格式的标准,全称是“Internet Message Format”,它定义了电子邮件的结构,包括头部字段、正文组织方式,以及邮箱地址的语法。
注意,它规定的是“地址在邮件头里怎么写”,而不是“这个地址一定存在”。这是很多人理解错的地方。RFC 5322 的语法覆盖了地址的“形状”,但它不关心这个域名有没有 MX 记录,更不关心这个邮箱能不能收到信。
具体到邮箱地址,RFC 5322 把地址拆成两大部分:
- 本地部分(local-part):
@左边的部分,理论上是用户名或者邮箱别名。 - 域名部分(domain):
@右边的部分,可以是一个域名,也可以是用方括号包裹的 IP 地址字面量。
这里面有几个关键语法点,绝大多数网上正则都没覆盖:
dot-atom形式,比如first.last@example.com,.不能出现在开头、结尾,也不能连续出现,比如.abc@example.com、abc..def@example.com都不合法。- 引号字符串形式,比如
"john smith"@example.com,只要在双引号内,空格、@、.、括号、单引号都可以出现。 - 注释(CFWS),比如
john@(comment)example.com,这种写法在 RFC 5322 里允许存在,但在真实业务里几乎没人这么填。你如果严格按标准去支持,会发现很多地址“合法但没意义”。
所以做邮箱校验的第一步,是先明确你的产品到底需要多严格。你是做一个“表单不让用户瞎填”的前端校验,还是要做一个“能解析任意合法邮箱地址”的底层模块?这两个需求对应的做法完全不同。
1.3 验证的目标分级:语法、投递、所有权
我用过一段时间之后,把邮箱验证拆成三个层级。强烈建议你也这么做,因为这三件事的代价和技术方案完全不同。
第一层是语法验证。只需要判断字符串是否符合基本的邮箱格式,比如有没有@、@左右两边是否为空、域名部分基本是否合理。这层用简单正则就能完成,目的是拦截用户手滑输入的错误,比如abc#example.com、zhangsan@。
第二层是投递验证。检查域名是否存在、有没有配置 MX 记录、SMTP 服务器接不接受这个收件人。这层要发起网络请求,可能遇到超时、临时失败、被对方拒收等各种情况。它的作用是提前发现像user@nonexistent-domain.com这样的地址,但代价是慢,而且部分邮箱服务器会对 SMTP 探测有风控。
第三层是所有权验证。给这个地址发一封邮件,里面放验证码或者带签名链接,用户主动点击确认。这是最稳妥的方式,也是注册流程里最常见的做法。你可以在点击验证链接的时候再做一次邮箱格式校验,确保存储进数据库的地址始终是规范格式。
明白了这三层,你再去设计代码,就不会为“这个正则到底要写多长”这种问题纠结。每一层有每一层适合的工具,把工具用对地方,比追求一条万能正则有意义得多。
2. 核心细节解析与实操要点
2.1 RFC 5322 关键语法元素拆解
如果要认真做语法层验证,就得了解 RFC 5322 里那些基础语法元素的含义。这里我用比较通俗的方式解释几个重点。
Addr-spec 结构
地址的核心结构:
addr-spec = local-part "@" domain本地部分和域名部分用@分隔。到这里大家都没疑问,但接下来就有细节了。
Local-part 的两种形态
第一种是dot-atom,也就是一串由.分隔的原子字符。典型的:
john john.doe john+tag+加号是常见的子地址写法,很多邮箱服务商支持(比如 Gmail)。注意标准本身并没有要求支持+tag,但它是合法的atext字符,所以不违规。
第二种是quoted-string,用双引号把本地部分包起来:
"john smith" "john..doe" "()" "@"只要在引号内,除了\和"之外的可打印 ASCII 字符基本都允许。这意味着" "(一个空格)加上@example.com都是一个语法合法的邮箱地址——虽然没有任何正常人会这么填。
你如果真想兼容 RFC 5322 的全部语法,处理 quoted-string 是绕不开的。但现实是很多产品直接放弃支持这类地址,因为用户量几乎为零,还容易被人拿来绕校验规则。
Domain 部分
域名部分可以是常规域名,也可以是 IP 地址字面量:
example.com [192.168.1.1] [IPv6:2001:db8::1]在做产品的时候,我建议直接接受[192.168.1.1]这种形式的语法合法性,但在注册场景里直接拦截,因为公网邮箱不可能用纯 IP 地址进行常规收发。
注释(CFWS)
CFWS 是“comments and folding whitespace”的缩写,意思是在地址的任意位置可以插入注释和空白换行。典型写法:
john@example.com (work address) john.doe(注释)@example.com如果你用标准解析器去处理,这些地址都是能解析出实际邮箱地址的。但产品端处理这种东西没有任何收益,反而会让攻击面变大。所以我的建议是:语法标准归标准,产品策略归产品策略。
2.2 长度限制:最容易忽略的硬指标
邮箱格式校验除了正则语法,还有一个硬指标经常被忽略:长度。
RFC 5321(也就是 SMTP 协议标准)规定了邮箱地址的最大长度是 256 个字符。注意这个 256 是“Reverse-path 或 Forward-path 的最大长度”,包含尖括号和 SMTP 命令中的其他字符。所以有一种更保守的说法:邮箱地址本身最好限制在 254 个字符以内。
实际开发中,数据库字段长度往往直接决定你能存多长的邮箱。比如你给email字段设置了 VARCHAR(64),那就算语法校验通过了,超长地址也会在入库的时候报错。更尴尬的是,有些用户在输入框里粘贴一个带空格的长地址,前端正则过了,后端也过了,入库却失败,最后抛出一个不友好的 500 错误。
我的做法是统一用一个常量来定义邮箱最大长度,比如 320 个字符的宽松上限(local-part 64 + @ 1 + domain 255,这是 RFC 5321 对传输参数的理论上限),同时业务层再强制 254 个字符。这样既尊重标准,也避免把标准的上限直接暴露给用户——毕竟没有哪个真人会注册一个几百字符的邮箱。
实操上,在 HTML 表单里给input加上maxlength="254"只是前端防护,后端必须要再做一次校验。前端可以被绕过,但后端校验才是安全底线。
2.3 常用正则方案的取舍
我调研过不少项目里的邮箱正则,也自己梳理过几套,按复杂度可以分成三档。
第一档:简单过滤正则
这套适合前端表单的即时提醒,作用是挡住明显的手误:
/^[^\s@]+@[^\s@]+\.[^\s@]+$/它只检查三件事:有且只有一个@、@前后至少一个字符、域名部分至少包含一个点。优点是代码短、不容易误伤;缺点是合法邮箱可能被拦(比如john@localhost这类内网地址),非法邮箱也可能放行(比如abc@def这种没有顶级域名的地址)。
第二档:常见格式正则
这套适合大多数 Web 应用的注册表单,在简单过滤的基础上加入对域名后缀的常见约束:
/^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/它允许常见的可视字符,对域名做了长度和字符限制,但依然不支持 quoted-string、注释、IP 字面量。这个正则对应的是“人类日常使用场景的绝大多数邮箱”,它有意放弃标准中的边缘情况。如果你做的是注册、订阅、通知这类产品,我推荐从这套起步,而不是去折腾完整标准。
第三档:完整解析器
如果想真正按照 RFC 5322 语法做解析,用正则已经不合适了。正则表达式不适合表达嵌套括号和引号转义这类上下文相关语法,正确做法是写一个解析器,或者直接使用现成的库。
以 Python 为例,标准库email模块提供了email.utils.parseaddr等方法,它能处理 quoted-string、注释这些复杂情况,但要注意它有自己的一套容错逻辑,并不完全等于标准解析器。更接近标准语法解析的是社区库flanker,它基于pyparsing实现了对 RFC 5322 地址语法的解析,接口也很简单:
from flanker.addresslib import address result = address.parse('"john smith"@example.com') print(result) # 能正常解析这类解析器通常还会附带域名后缀和白名单校验,适合做邮件网关、多收件人解析、地址清洗等后台任务。
3. 实操过程与核心环节实现
3.1 分步实现:从零搭一套邮箱校验模块
这里我以 Python 后端为例,展示一个既能兼容常规用户输入、又能在必要时处理 RFC 5322 复杂地址的分层校验方案。整体思路是:先判断格式是否符合“常见格式”,再决定是否走宽松解析,最后做域名检查。
第一步,先做正则初筛。这里的正则就是我们前面说的第二档,它在性能和准确率之间比较平衡:
import re COMMON_EMAIL_RE = re.compile( r"^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+" r"@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?" r"(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$" ) def is_likely_valid_email(address: str) -> bool: if not isinstance(address, str): return False address = address.strip() if len(address) < 3 or len(address) > 254: return False if address.startswith('"') or '(' in address: # 这类地址走宽松解析,不走常规正则 return True return bool(COMMON_EMAIL_RE.match(address))这里我加了一个判断:如果地址以双引号开头或者包含括号,说明它可能是 quoted-string 或带注释的地址,常规正则会误判,因此先放行,交给下一层处理。
第二步,对放行的“复杂地址”做真正的 RFC 5322 语法解析。这里我用email.utils来演示,因为它是标准库,很多项目里已经隐式依赖了它。
from email.utils import parseaddr def validate_by_parser(address: str) -> bool: # parseaddr 返回 (realname, email_address) display_name, parsed = parseaddr(address) # 如果解析失败,parsed 会是空字符串 if not parsed: return False # 解析成功不代表完全一样,比如乱加注释会被 parser 移除 # 这里可以通过比较清洗后的地址是否与原地址“语义等价”来判断 return '@' in parsed不过email.utils.parseaddr有一个坑:它非常宽容,对很多格式错误的东西也会尝试猜测并返回一个可用地址。比如"john@example"这种缺顶级域名的输入,它也可能解析成功。
所以这里必须再加一道域名判断。
第三步,域名判断。我的做法是先解析出域名,然后用dnspython去查 MX 记录。MX 记录存在,说明这个域名至少配置了邮件服务器,是“有可能收信”的域名。
import dns.resolver def has_mx_record(domain: str) -> bool: if not domain or len(domain) > 253: return False try: answers = dns.resolver.resolve(domain, 'MX') return len(answers) > 0 except (dns.resolver.NoAnswer, dns.resolver.NXDOMAIN, dns.resolver.NoNameservers): return False注意一个坑:MX 记录的查询结果可能为空,但域名本身有 A/AAAA 记录,理论上可以接收邮件。RFC 5321 规定,如果目标域名没有 MX 记录,主机会尝试使用 A/AAAA 记录作为隐式 MX。所以严格的实现应该是在没有 MX 时继续查 A 记录。不过在实际业务里,不配置 MX 却希望收到邮件的域名非常少,所以我把“是否可以直接发信”的判断简化为两段式:先查 MX,没有 MX 就查 A 记录。
def has_mail_server(domain: str) -> bool: try: answers = dns.resolver.resolve(domain, 'MX') if len(answers) > 0: return True except dns.resolver.NoAnswer: pass except (dns.resolver.NXDOMAIN, dns.resolver.NoNameservers): return False try: dns.resolver.resolve(domain, 'A') return True except (dns.resolver.NXDOMAIN, dns.resolver.NoAnswer, dns.resolver.NoNameservers): return False这三步合起来,就是一个比“一条正则走天下”稳妥得多的校验链路。你既没有放弃复杂地址的兼容性,也没有为了兼容复杂地址而让所有输入都走宽松解析,从而放走明显乱填的内容。
3.2 用边界用例验证自己的想法
写完校验模块之后,最重要的是拿边界用例做测试。这里我整理了几类典型输入,建议你直接拿去做单元测试。
| 输入 | 语法是否合法 | 是否建议放行 | 原因 |
|---|---|---|---|
john.doe@example.com | 合法 | 放行 | 最常见的正常格式 |
john..doe@example.com | 非法 | 拦截 | 连续点号,RFC 不允许 |
.john@example.com | 非法 | 拦截 | 本地部分以点开头 |
"john..doe"@example.com | 合法 | 可放行 | 引号内点号无限制 |
john@localhost | 语法合法 | 建议拦截 | 无点号,不适合公网注册场景 |
john@example | 常见正则认为非法 | 建议拦截 | 虽然 RFC 5322 语法上允许单标签域名,但现实中无法投递 |
john@[192.168.1.1] | 合法 | 建议拦截 | 公网产品没有实用价值 |
john@-example.com | 非法 | 拦截 | 域名标签不能以连字符开头 |
john@example-.com | 非法 | 拦截 | 域名标签不能以连字符结尾 |
john@exa_mple.com | 非法 | 拦截 | 域名不允许下划线(特例见下文) |
verylong+ 250字符 +@example.com | 超长 | 拦截 | 超 254 字符上线 |
这里有两个特例要说明一下。一个是john@example这种单标签域名,RFC 5322 语法允许,现实中通常无法收信。如果你做的是内网系统,允许john@localhost是合理的;如果是公网注册,直接拦截没毛病。另一个是域名里的下划线,理论上域名不允许,但不少内网系统和企业自建邮件服务实际上会用到mail_example.com这种域名。所以域名校验的严格程度,要根据部署环境决定,不能一刀切。
3.3 彻底模拟 RFC 5322 完整语法的成本
有人可能会问,既然标准放在那里,为什么不用一个完整的 RFC 5322 解析器处理所有情况?我承认,如果写一个严格按照标准实现的解析器,确实能把 quoted-string、CFWS、IP 字面量、转义引号全部处理得明明白白。但要注意两件事。
第一,完整解析不代表友好。你大概率不会想看到注册表单提交了一个合法但极其怪异的邮箱,比如"@"@example.com,然后你还把这个地址存进数据库。从产品角度,这类输入更可能是恶意试探,而不是真实用户。
第二,解析器只能回答“语法对不对”,不能回答“能不能收到信”。你最终还是要靠域名检查和发送验证邮件来解决核心业务问题。所以,在大多数应用场景里,用分层校验替代完整解析是更务实的选择:用简单正则拦截手误,用宽松解析兼容边缘情况,用 DNS 检查过滤明显不存在的域名,最后用验证邮件确认所有权。
这套方案的复杂度可控,每一层都可以独立测试,而且每一层都能在需要的时候单独替换。我后来在好几个项目里都沿用了这个结构,每次改都只动某一层,不会影响整体逻辑。
4. 常见问题与排查技巧实录
4.1 常见问题速查表
下面是这半年里,我陆续在评论区、客户工单和 code review 中收集到的典型问题,整理成速查表。
| 问题现象 | 可能原因 | 解决办法 |
|---|---|---|
| 邮箱带国际域名,正则拦了 | 正则的域名部分只允许 ASCII | 不要盲改正则,先看是否要支持 EAI(见 4.2) |
| 用户从 Excel 复制邮箱,带了隐藏空格 | 字符串前后有不可见空白或\t | 校验前先strip(),同时检查中间是否有异常空白 |
| 同一个邮箱大小写不同,注册了两次 | 本地部分理论区分大小写,但多数服务商忽略 | 业务层统一转小写存储,再做唯一索引 |
| 域名有 MX 记录,但发送仍然退信 | 收件人不存在或邮箱已停用 | MX 检查只是“可能收信”,最终以 SMTP 会话结果为准 |
邮箱包含+tag被其他系统拒绝 | 目标服务不支持子地址 | 前端不要强制禁用+,但要在提示文案里说明 |
| 域名检查超时 | DNS 解析慢或网络策略限制 | 加超时和缓存,超时按“不确定”处理 |
正则放行了a@b | b只是单个标签 | 产品层判定需要包含一个点号的域名结构 |
4.2 国际化和 Unicode 邮箱的坑
有不少朋友问,支持中文邮箱地址吗?严格来说,支持国际邮箱地址的标准是 RFC 6531(SMTPUTF8),它允许在邮箱地址中使用 UTF-8 字符,并且本地部分和域名部分都支持中文字符。比如:
张伟@example.cn这种地址确实存在于现实中,但它的收发要求邮件服务端和发送端都支持 SMTPUTF8,而现在很多老旧的邮件服务器并不支持。
处理策略我分了三种场景:
- 如果你的产品只面向国内市场,且用户群偏年轻,可以考虑支持中文邮箱。但邮箱验证步骤不要只靠格式判断,必须配合验证邮件。
- 如果你的产品面向海外用户,建议保留 ASCII 校验,同时把国际域名(punycode)处理好。也就是把
مثال.إختبار转成xn--mgbh0fb.xn--kgbechtv再存储。 - 如果你的产品是 To B 系统,接收的企业邮箱列表很可能包含一些非标准地址,比如带下划线、单标签域名、甚至空格(带引号)。这种情况下,唯一可靠的办法是先用宽松正则做初筛,然后靠 SMTP 探测或者验证邮件兜底。
从工程上看,我的个人倾向是:语法正则尽量保持 ASCII 校验,因为在注册这个环节,中文邮箱的占比极低,为它付出的兼容代价会拖慢所有正常用户的体验。真到了需要支持的阶段,再单独开一个开关,配合完整解析器和 SMTPUTF8 流程。
4.3 SMTP 层面验证的正确打开方式
很多人做完语法校验后,还想再多做一步“这个邮箱真的存在吗”的验证,于是就想到了 SMTP 验证。原理是先查 MX 记录,然后连上目标邮件服务器的 25 端口,通过HELO、MAIL FROM、RCPT TO几条命令探测对方是否会返回 250 状态码。
这个思路理论上可行,但实际执行中坑很多。
首先,很多邮件服务器(比如 Gmail、Outlook)会拒绝来自动态 IP 或未反向解析 IP 的 SMTP 连接,你连 25 端口都不一定通。其次,即使连上了,对方也可能故意对所有收件人返回 250,用来防止目录枚举攻击。第三,频繁的 SMTP 探测可能让你的 IP 进对方的黑名单,后果是以后正常发信都被拒。
所以我的建议是:不要在注册表单里做 SMTP 验证,尤其是不要在同步链路里做,因为它会大幅增加请求耗时和失败概率。正确的姿势是:如果确实需要做投递验证,把它放到异步任务里,当作“邮箱清洗”的一步,而且要做限流、超时控制和重试退避。
如果你只是想避免用户手滑填错邮箱,性价比更高的方案是发送验证邮件——一次真实的投递尝试,远胜所有格式和 DNS 判断。用户收到的验证链接,本身就是“这个邮箱存在且归你所有”的最强证明。
4.4 我踩过的三个真实坑
最后分享几个我在项目中实际踩过的坑,都是文档里不会写但大概率会遇到的。
第一个坑,是生产环境里曾经放过一条极其严格的正则,导致有用户注册时填的first.middle.lastname@company.com被拒。后来排查发现,问题出在正则里对.段的长度限制写得太死,把 63 个字符写成了 20。很多长域名的最后一级或二级标签会超过这个长度,虽然日常少见,但碰上了就是 100% 的阻断。
教训:正则里的数字限制,一定要对照 RFC 1035 的域名标签长度限制(63 字符)来写,不要凭感觉定。
第二个坑,是数据库里给邮箱字段加了唯一索引,但没有做大小写和头尾空格的清洗。结果同一个人用User@Example.com和user@example.com注册了两个账号。后来我把入库前统一转小写的逻辑补上了,同时提醒前端在输入框上做blur事件的时候就直接 trim。
第三个坑,是域名 MX 检查的超时设得太短。当时为了让注册接口的响应时间可控,把 DNS 查询超时设成了 1 秒,结果在部分网络环境较慢的地区,正常用户也会因为“域名验证失败”被拦。后来改成超时不算失败,只算“不确定”,让用户继续走验证邮件流程,注册成功率立刻恢复了。
这三个坑有一个共同点:都是“为了校验而校验”,忘了校验的最终目的是帮用户完成操作,而不是挡住所有非法输入。语法校验可以严格,但在用户体验和安全之间要找平衡点,不能一刀切。
就我个人的实战体会来说,邮箱验证的真正常态是:前端用简单正则即时反馈,后端做分层的格式、域名判断,注册流程用验证邮件做最终确认,之后遇到退信再异步标记。这套组合既不复杂,又能覆盖绝大多数真实场景。你照着这个思路去落代码,会比在网上找一条“完美正则”靠谱得多。