ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

邮箱格式校验:从正则到RFC 5322的分层验证实践

邮箱格式校验:从正则到RFC 5322的分层验证实践 1. 内容整体设计与思路拆解1.1 为什么不能信网上流传的邮箱正则我最早做邮箱校验的时候跟大多数人一样直接打开搜索引擎找一条所谓“万能邮箱正则”复制粘贴到项目里就完事。直到某天生产环境里收到一个投诉用户说自己的邮箱地址是对的但系统一直提示“格式不正确”。我去翻了一下日志发现被拦下来的地址长这样mary.oneilexample.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.lastexample.com.不能出现在开头、结尾也不能连续出现比如.abcexample.com、abc..defexample.com都不合法。引号字符串形式比如john smithexample.com只要在双引号内空格、、.、括号、单引号都可以出现。注释CFWS比如john(comment)example.com这种写法在 RFC 5322 里允许存在但在真实业务里几乎没人这么填。你如果严格按标准去支持会发现很多地址“合法但没意义”。所以做邮箱校验的第一步是先明确你的产品到底需要多严格。你是做一个“表单不让用户瞎填”的前端校验还是要做一个“能解析任意合法邮箱地址”的底层模块这两个需求对应的做法完全不同。1.3 验证的目标分级语法、投递、所有权我用过一段时间之后把邮箱验证拆成三个层级。强烈建议你也这么做因为这三件事的代价和技术方案完全不同。第一层是语法验证。只需要判断字符串是否符合基本的邮箱格式比如有没有、左右两边是否为空、域名部分基本是否合理。这层用简单正则就能完成目的是拦截用户手滑输入的错误比如abc#example.com、zhangsan。第二层是投递验证。检查域名是否存在、有没有配置 MX 记录、SMTP 服务器接不接受这个收件人。这层要发起网络请求可能遇到超时、临时失败、被对方拒收等各种情况。它的作用是提前发现像usernonexistent-domain.com这样的地址但代价是慢而且部分邮箱服务器会对 SMTP 探测有风控。第三层是所有权验证。给这个地址发一封邮件里面放验证码或者带签名链接用户主动点击确认。这是最稳妥的方式也是注册流程里最常见的做法。你可以在点击验证链接的时候再做一次邮箱格式校验确保存储进数据库的地址始终是规范格式。明白了这三层你再去设计代码就不会为“这个正则到底要写多长”这种问题纠结。每一层有每一层适合的工具把工具用对地方比追求一条万能正则有意义得多。2. 核心细节解析与实操要点2.1 RFC 5322 关键语法元素拆解如果要认真做语法层验证就得了解 RFC 5322 里那些基础语法元素的含义。这里我用比较通俗的方式解释几个重点。Addr-spec 结构地址的核心结构addr-spec local-part domain本地部分和域名部分用分隔。到这里大家都没疑问但接下来就有细节了。Local-part 的两种形态第一种是dot-atom也就是一串由.分隔的原子字符。典型的john john.doe johntag加号是常见的子地址写法很多邮箱服务商支持比如 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 地址进行常规收发。注释CFWSCFWS 是“comments and folding whitespace”的缩写意思是在地址的任意位置可以插入注释和空白换行。典型写法johnexample.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加上maxlength254只是前端防护后端必须要再做一次校验。前端可以被绕过但后端校验才是安全底线。2.3 常用正则方案的取舍我调研过不少项目里的邮箱正则也自己梳理过几套按复杂度可以分成三档。第一档简单过滤正则这套适合前端表单的即时提醒作用是挡住明显的手误/^[^\s][^\s]\.[^\s]$/它只检查三件事有且只有一个、前后至少一个字符、域名部分至少包含一个点。优点是代码短、不容易误伤缺点是合法邮箱可能被拦比如johnlocalhost这类内网地址非法邮箱也可能放行比如abcdef这种没有顶级域名的地址。第二档常见格式正则这套适合大多数 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 smithexample.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有一个坑它非常宽容对很多格式错误的东西也会尝试猜测并返回一个可用地址。比如johnexample这种缺顶级域名的输入它也可能解析成功。所以这里必须再加一道域名判断。第三步域名判断。我的做法是先解析出域名然后用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.doeexample.com合法放行最常见的正常格式john..doeexample.com非法拦截连续点号RFC 不允许.johnexample.com非法拦截本地部分以点开头john..doeexample.com合法可放行引号内点号无限制johnlocalhost语法合法建议拦截无点号不适合公网注册场景johnexample常见正则认为非法建议拦截虽然 RFC 5322 语法上允许单标签域名但现实中无法投递john[192.168.1.1]合法建议拦截公网产品没有实用价值john-example.com非法拦截域名标签不能以连字符开头johnexample-.com非法拦截域名标签不能以连字符结尾johnexa_mple.com非法拦截域名不允许下划线特例见下文verylong 250字符 example.com超长拦截超 254 字符上线这里有两个特例要说明一下。一个是johnexample这种单标签域名RFC 5322 语法允许现实中通常无法收信。如果你做的是内网系统允许johnlocalhost是合理的如果是公网注册直接拦截没毛病。另一个是域名里的下划线理论上域名不允许但不少内网系统和企业自建邮件服务实际上会用到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 解析慢或网络策略限制加超时和缓存超时按“不确定”处理正则放行了abb只是单个标签产品层判定需要包含一个点号的域名结构4.2 国际化和 Unicode 邮箱的坑有不少朋友问支持中文邮箱地址吗严格来说支持国际邮箱地址的标准是 RFC 6531SMTPUTF8它允许在邮箱地址中使用 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.lastnamecompany.com被拒。后来排查发现问题出在正则里对.段的长度限制写得太死把 63 个字符写成了 20。很多长域名的最后一级或二级标签会超过这个长度虽然日常少见但碰上了就是 100% 的阻断。教训正则里的数字限制一定要对照 RFC 1035 的域名标签长度限制63 字符来写不要凭感觉定。第二个坑是数据库里给邮箱字段加了唯一索引但没有做大小写和头尾空格的清洗。结果同一个人用UserExample.com和userexample.com注册了两个账号。后来我把入库前统一转小写的逻辑补上了同时提醒前端在输入框上做blur事件的时候就直接 trim。第三个坑是域名 MX 检查的超时设得太短。当时为了让注册接口的响应时间可控把 DNS 查询超时设成了 1 秒结果在部分网络环境较慢的地区正常用户也会因为“域名验证失败”被拦。后来改成超时不算失败只算“不确定”让用户继续走验证邮件流程注册成功率立刻恢复了。这三个坑有一个共同点都是“为了校验而校验”忘了校验的最终目的是帮用户完成操作而不是挡住所有非法输入。语法校验可以严格但在用户体验和安全之间要找平衡点不能一刀切。就我个人的实战体会来说邮箱验证的真正常态是前端用简单正则即时反馈后端做分层的格式、域名判断注册流程用验证邮件做最终确认之后遇到退信再异步标记。这套组合既不复杂又能覆盖绝大多数真实场景。你照着这个思路去落代码会比在网上找一条“完美正则”靠谱得多。
返回列表