ARTICLE DETAIL

资讯详情

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

邮箱校验别再用正则硬扛:从RFC 5322到DNS的三层实战方案

邮箱校验别再用正则硬扛:从RFC 5322到DNS的三层实战方案 不知道你注意过没有很多注册页面就栽在一个小小的邮箱输入框上。有人填了usertaggmail.com被前端提示格式不正确有人填了看起来完全正常的abccompany.co.uk又被后端拒收还有人在产品上线后忽然发现垃圾注册量飙升全是xxxgmail.con这种 typo 域名。我自己早年也干过这事儿从网上抄了一个据说符合 RFC 5322的超长正则结果上线第三天就误杀了一批带号地址的真实用户被客服追着骂。邮箱验证这件事表面上是个正则问题实际上牵扯到语法标准、DNS 探测、投递确认三个层面任何一个层面想做绝对正确都会掉进过度工程化的坑里。这篇东西我打算把 RFC 5322 的语法规则拆开讲清楚再给出一套可以直接抄作业的分层校验方案先做实用级语法校验再做域名与 MX 记录检查最后用验证邮件兜底。方案里包含 Python 和 JavaScript 的可运行代码以及一份必测的边界用例清单。适合正在搭注册系统、写用户导入脚本、或者被邮箱校验 bug 折磨的开发者也适合想弄明白为什么简单正则不够用的产品经理。1. 先聊聊为什么邮箱格式校验这么容易翻车1.1 一个简单的正则几乎每家公司的第一版代码里都有几乎每个新手项目里都会出现这样一行const emailRegex /^[\w.][\w.]\.[a-zA-Z]{2,}$/;这行正则看着简洁其实夹着三个隐患。第一它把、-、#、$这些合法字符全排除了而号恰恰是 Gmail、Outlook 这类邮箱的常用功能——很多人用meshopexample.com做一次性注册你的校验直接把人拒了。第二\w实际等价于[A-Za-z0-9_]这意味着user.nameexample.com里的点倒是放了进来但像user..nameexample.com这种连续点也会被放过去而后者的本地部分在 RFC 语法里是违法的。第三它只匹配那些后缀像域名的字符串于是ab也能通过事后你发验证信还发不过去白白浪费一轮投递资源。这行的背后是一种常见的偷懒思路正则越短越好能堵住 90% 的错误输入就够了。问题在于堵住 90% 之后剩下的 10% 恰恰是用户投诉和垃圾数据的高发区产品越大这 10% 的代价就越昂贵。1.2 翻车案例那些看起来合法却收不到信的地址我见过一次比较典型的翻车发生在某公司做客户数据批量导入的时候。数据里有一批first.lastsub.domain.co.uk这种长域名地址校验规则简单粗暴地把域名部分限制为[a-zA-Z]{2,6}于是.co.uk算两段uk只有 2 个字符倒是过了可另外一个usersomedepartment.companyname.example因为最后一段超过了 6 位直接报错客服那边收到一堆 格式错误 的反馈实际上这些地址全是真实存在的内网邮箱。还有一个更隐蔽的坑Name userexample.com这种格式。很多后端直接拿strpos去找符号然后把Name user当成了邮箱地址的一部分结果注册名单里出现了一堆带尖括号的脏数据。正确的姿势是先用email.utils.parseaddr这类工具把显示名和实际地址拆开再校验实际地址——但很多团队根本不知道还有这么一步。1.3 RFC 5322不是你想的那样它其实不关心能不能收到这里要澄清一个普遍误解。RFC 5322以及它的前身 RFC 822、RFC 2822全名叫 Internet Message Format核心作用是把一封邮件长什么样定义清楚比如头部字段怎么写、地址列表怎么分隔、注释和编码怎么处理。它确实给出了addr-spec也就是local-partdomain的完整语法但这份标准的目的从来不是告诉你这个邮箱能不能收到信——它只规定这个地址在格式上是否被允许出现在邮件头部。所以会出现一个反直觉的结论userlocalhost在 RFC 语法里完全合法它也确实可以在某些本机环境里收到邮件但你绝对不会想把这种地址放进一个面向公网用户的注册系统。反过来完全没有域名的纯字符串在语法上不合法但只要你把它交给 DNS 查一下很多非法格式会顺带暴露出这个域名根本不存在的本质。正确理解 RFC 5322是把语法校验当作第一道闸门而不是最后一道。后面还有两层东西等着你域名的可解析性以及最终的投递确认。2. RFC 5322到底规定了什么把语法规则掰开揉碎2.1 addr-spec的完整语法结构RFC 5322 的 3.4.1 节给出了地址的基本形式addr-spec local-part domain local-part dot-atom / quoted-string / obs-local-part domain dot-atom / domain-literal / obs-domain也就是说邮箱地址被分成两段中间一个隔开。左边叫 local-part本地部分右边叫 domain域名。注意这里的dot-atom和quoted-string是两种完全不同的合法形态这直接影响你该不该用一个简单的正则一竿子打死所有输入。我在实际项目中见过的最蠢的做法是把整个地址当成一串宽松字符去匹配结果把user.nameexample.com拆错了位置。要知道在本地部分也可能出现——只要那个本地部分被引号包起来quoted-string比如johnsmithexample.com就是一个语法上合法的地址。当然这种地址在真实世界里几乎不存在很少有系统会真发信给一个带引号的本地部分但理解它的存在能帮你避免一刀切的冲动。2.2 本地部分的三个身份dot-atom、quoted-string和那些特殊字符先看最常见的dot-atom形式。RFC 5322 定义了atext可打印字符集合atext ALPHA / DIGIT / ! / # / $ / % / / / * / / - / / / / ? / ^ / _ / / { / | / } / ~换句话说只要不在这个集合里的字符都不能出现在不带引号的本地部分里。空格、括号、逗号、分号、冒号这些通通不行。dot-atom本身的规则是若干个atext加上中间可选的单个点号但点号不能出现在开头和结尾也不能连续出现两个点。所以user.name合法.user非法user..name非法user.非法。再说quoted-string。它允许你用一个引号把几乎任意内容包起来包括空格、号、括号等等。规则上合法但现实中几乎没有哪个正常用户会注册一个带引号的邮箱。我的态度很明确语法上可以理解它的存在但实际校验时直接拒绝字符即可不用为此写一套复杂的解析逻辑。需要留意的一点是本地部分按标准是区分大小写的Userexample.com和userexample.com在理论上属于不同的邮箱地址。但现实是几乎所有主流邮件服务商都把本地部分当作大小写不敏感处理大部分人的实际使用习惯也是全小写。所以实践里我建议统一转为小写再比较避免注册和登录时因为大小写不一致引发邮箱已被注册的误判。2.3 域名的约束与看起来合法的陷阱域名部分比本地部分要严格一些。RFC 5322 里的dot-atom形式要求域名由若干标签label构成每个标签由字母、数字或连字符组成但不能以连字符开头或结尾。RFC 1035 还规定了每个标签最长 63 个字符整个域名最长 255 个字符。但光看语法还不够我把它总结为三层陷阱格式合法的域名不一定真实存在。userthis-domain-absolutely-not-exist-12345.com完全符合 RFC 语法但你把验证信发出去只会换来一封退信。真实存在的域名不一定能收邮件的域名。很多域名只是公司官网用来展示的根本没有配置 MX邮件交换记录或者只有 A 记录指向一个不跑邮件服务的服务器。顶级域名后缀一直在扩容。早年大家习惯写[a-zA-Z]{2,6}现在新顶级域名如.xyz、.top、.online有些甚至超过 10 个字符。用后缀长度当判断标准迟早会误杀合法用户。从 RFC 的角度domain-literal这种形式比如user[192.168.1.1]也是合法的但同样真实业务中我们只需要直接把方括号形式拒绝掉。你要记住的核心是RFC 5322 给的是一份语法白名单而不是一份投递可行性白名单语法校验只是让你把所有肉眼可见的垃圾格式挡在门外仅此而已。3. 正则不是银弹三层校验体系才是正确姿势3.1 第一层实用级语法校验附完整正则与代码市面上流传最广的完整 RFC 5322 正则有将近 200 个字符看起来威风凛凛实际运行速度慢、可读性差而且它接受的某些地址比如带引号的本地部分业务上根本接触不到。我的经验是写一份实用级正则站在 RFC 5322 语法子集上明确告诉团队它不会支持带引号的本地部分、不会支持 domain-literal、不会支持超长地址但覆盖 99.9% 的真实用户绰绰有余。import re from email.utils import parseaddr # atext 字符集合对应 RFC 5322 # 本地部分atext中间允许单个点不能头和尾、不能连续点 _ATEXT r[A-Za-z0-9!#$%*/?^_{|}~-] _LOCAL rf{_ATEXT}(?:\.{_ATEXT})* # 域名每个 label 只能由字母数字和连字符组成不能以连字符开头/结尾 _LABEL r[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])? _DOMAIN rf{_LABEL}(?:\.{_LABEL})* _EMAIL_RE re.compile(rf^{_LOCAL}{_DOMAIN}$) def validate_email_syntax(email: str) - bool: if not email or len(email) 254: return False email email.strip() # parseaddr 能拆出 Name userexample.com 这种带显示名的输入 display_name, parsed_addr parseaddr(email) if display_name or parsed_addr ! email: return False local, domain email.rsplit(, 1) if len(local) 64: return False return bool(_EMAIL_RE.match(email))这段代码里有两个容易被忽略的细节。第一len(email) 254不是随便定的。RFC 5321 规定的传输路径长度限制大概是 256 字符减去尖括号和冒号之后业界普遍采用 254 作为单条地址的上限。第二先用parseaddr拆一遍是为了杜绝张三 zhangsanexample.com这种带有显示名的脏输入被混进去注册。3.2 第二层域名与MX记录检查DNS探测的细节语法校验只能过滤格式错误对usergmail.con这种看着像真的、实际是 typo 域名完全无能为力。这时候需要第二层去 DNS 体系里查询这个域名到底存不存在、有没有配置邮件的传递链路。我通常的做法是先查域的 MX 记录。只要存在 MX 记录就说明该域至少配置了邮件服务器。如果 MX 查询返回空NoAnswer不要急着判死刑。有些小域名只有 A/AAAA 记录根据 RFC 5321 的规定当邮件系统找不到 MX 记录时可以回退到 A 记录尝试投递。import dns.resolver def validate_email_domain(domain: str, timeout: float 3.0) - bool: try: # 优先查 MX try: answers dns.resolver.resolve(domain, MX, lifetimetimeout) return bool(answers) except (dns.resolver.NoAnswer, dns.resolver.NXDOMAIN, dns.resolver.NoNameservers): pass # 回退查 A/AAAA try: dns.resolver.resolve(domain, A, lifetimetimeout) return True except Exception: return False except Exception: return False这里有几处经验之谈。timeout建议给 2 到 3 秒然后在调用层再做一层更粗粒度的超时兜底否则用户注册时会觉得页面卡死了。DNS 查询本身不是免费的一旦你的注册接口遇到瞬时大流量每个请求都做一次完整 DNS 查询会给上游 DNS 服务器带来明显压力所以务必要做缓存后面第 5 节细说。3.3 第三层投递验证与确认邮件语法检查和 MX 查询再叠加依然不能证明这个地址真的属于某个用户。能证明这一点的只有第三层发出验证邮件让用户回来点击确认链接。这层才是整个邮箱验证体系里真正的金标准。无论是开箱即用的邮箱格式 MX SMTP 探测组合还是昂贵且容易踩雷的在线连接对方邮件服务器执行 RCPT TO 验证本质上都只是一个概率过滤器。真正能把所有权确认下来的只有我给你的邮箱发了一封带唯一 token 的邮件你收到并点开了这件事。所以实战中我的建议非常明确注册场景第一层 第二层做实时校验用户体验快注册完成后触发第三层验证邮件没有完成点击确认的用户标记为未验证限制其部分功能。用户导入场景批量跑第一层和第二层过滤明显无效的地址。找回密码/重要通知场景直接走第三层因为除了验证地址本身用户还必须证明自己能收到邮件。4. 实战落地Python与JavaScript的完整实现4.1 Python端用标准库dnspython搭建校验模块把前面两节的内容拼起来就是一个可以直接放进项目里的校验模块。核心逻辑分成两步先语法后域名语法不过直接返回失败不下重手去请求 DNS。# email_verifier.py import re from email.utils import parseaddr import dns.resolver _ATEXT r[A-Za-z0-9!#$%*/?^_{|}~-] _LOCAL rf{_ATEXT}(?:\.{_ATEXT})* _LABEL r[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])? _DOMAIN rf{_LABEL}(?:\.{_LABEL})* _EMAIL_RE re.compile(rf^{_LOCAL}{_DOMAIN}$) def normalize_domain(domain: str) - str: 国际化域名转 punycode例如 例子.中国 - xn--fsqu00a.xn--fiqs8s try: return domain.encode(idna).decode(ascii) except UnicodeError: return domain.lower() def validate_email_syntax(email: str) - bool: if not email or len(email) 254: return False email email.strip() if , in email or in email or in email: return False _, parsed_addr parseaddr(email) if parsed_addr ! email: return False local, domain email.rsplit(, 1) if len(local) 64: return False if not _EMAIL_RE.match(email): return False return True def validate_email_domain(domain: str, timeout: float 3.0) - bool: domain normalize_domain(domain) try: try: answers dns.resolver.resolve(domain, MX, lifetimetimeout) if answers: return True except (dns.resolver.NoAnswer, dns.resolver.NXDOMAIN, dns.resolver.NoNameservers): pass try: dns.resolver.resolve(domain, A, lifetimetimeout) return True except Exception: return False except Exception: return False def validate_email_full(email: str, check_domain: bool True) - dict: email email.strip() result {email: email, valid: False, reason: syntax_invalid} if not validate_email_syntax(email): return result if check_domain: _, domain email.rsplit(, 1) if not validate_email_domain(domain): result[reason] domain_unreachable return result result[valid] True result[reason] ok return result注意我把normalize_domain放在了 DNS 查询之前。不处理国际化域名的话遇到用户例子.中国这种输入DNS 解析直接就会失败然后你还会误以为这是个无效邮箱。Python 标准库的str.encode(idna)已经能完成 punycode 转换不用额外引入第三方包。4.2 前端JavaScript做好第一道闸门前端做校验的作用不是代替后端而是别让用户等一个注定失败的请求。所以前端只需要做语法级别校验千万不要在前端做 DNS 查询——一来浏览器有跨域和隐私限制二来这会让页面加载变慢、还会被恶意脚本滥用。const EMAIL_RE /^[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])?)*$/; function validateEmailInput(email) { if (typeof email ! string || email.length 0) { return false; } const trimmed email.trim(); if (trimmed.length 254) { return false; } const atIndex trimmed.lastIndexOf(); if (atIndex 0 || atIndex trimmed.length - 1) { return false; } const local trimmed.slice(0, atIndex); const domain trimmed.slice(atIndex 1); if (local.length 64 || domain.length 255) { return false; } if (local.startsWith(.) || local.endsWith(.) || local.includes(..)) { return false; } return EMAIL_RE.test(trimmed); }这段代码刻意用lastIndexOf()来找分隔点而不是用split()这样即便本地部分里出现了字符也能按最后一个正确切分。另一个细节是trimmed赋值之后所有判断都用它防止用户手滑在邮箱前后打了空格。4.3 国际化邮箱EAI和IDN域名的正确处理国际化域名IDN是现实问题。用户例子.中国里的域名部分已经被 IDNA 机制转换成 punycode但本地部分的国际化EAIEmail Address Internationalization对应 RFC 6531在主流邮箱服务商那里支持度参差不齐。我的建议是域名部分按 IDNA 转换后做 DNS 校验本地部分如果出现非 ASCII 字符直接放行到语法校验阶段但要同步记录一条告警因为投递成功率不高最好让这类用户走验证邮件流程确认。不要因为很少见就直接拒绝。在国际化业务里这种地址可能是海外员工的真实邮箱你一拒绝第二天就能收到一个为什么你们不让我注册的工单。5. 真实业务场景中的边界情况与取舍5.1 别名、号地址、角色邮箱该收还是该放先看usertaggmail.com。本地部分里的号是 Gmail 的别名功能用于追踪注册来源完全合法。很多年久失修的正则因为只允许字母、数字、点、下划线把号一棒子打死。我的建议是放行号地址但要在产品层面想清楚它带来的副作用——同一个人可以用无数个带不同 tag 的地址注册你的账号如果你的注册逻辑以邮箱作为唯一账号标识那就等于给垃圾注册开了一扇后门。稳妥的做法是展示用号地址可以唯一性判定用去号后的基础地址。再看adminexample.com、infoexample.com这类角色邮箱。语法合法、域名存在甚至 MX 记录都在但你很难验证这个邮箱背后是不是真人。做 B 端产品时角色邮箱往往是采购流程的必经入口建议放行做 C 端社区类产品时如果垃圾注册困扰明显可以考虑单独标记但不要一禁了之。5.2 一次性邮箱识别该不该做一次性邮箱临时邮箱服务如 mailinator、guerrillamail 等是注册防垃圾的经典目标。识别它们通常有两类手段维护一份黑名单域名列表或者接第三方风控服务。我的看法是黑名单域名列表值得在内网维护一份因为这类域名的特点是域名清单相对稳定、但同一个服务商会有多个不同域名后缀用一个定时任务去更新即可。但千万别把黑名单输出成一个非法邮箱列表直接写死在代码里除非你的产品对刷注册零容忍。很多一次性邮箱域名本身也承载真实用户的测试需求一刀切会让你的产品在开发者圈子里口碑变差。更优雅的做法是识别一次性邮箱后不直接拒绝而是额外增加短信验证或人工审核。5.3 性能考量DNS查询的缓存与超时把 DNS 查询直接塞进注册接口的代价比很多人想象中大。一次未命中的递归 DNS 查询可能耗时几十甚至上百毫秒到了高峰时段这个数字还会放大。我的方案是自建一层简单的 TTL 缓存import time from collections import OrderedDict class DomainStatusCache: def __init__(self, ttl: int 3600, maxsize: int 4096): self.ttl ttl self.data OrderedDict() def get(self, domain: str): item self.data.get(domain) if item is None: return None if time.time() - item[ts] self.ttl: self.data.pop(domain, None) return None self.data.move_to_end(domain) return item[value] def set(self, domain: str, value: bool): self.data[domain] {value: value, ts: time.time()} self.data.move_to_end(domain) if len(self.data) self.maxsize: self.data.popitem(lastFalse)缓存的粒度放在域名是否可达上而不是放在具体邮箱上。做一个能用的、可判定是否可收信的域名几小时内的判断结果基本不会变所以 TTL 设置在 3600 秒左右就很合适。另外把缓存从业务进程里抽出来放 Redis 更利于多实例部署但单机项目用这个 OrderedDict 缓存足够了。6. 测试用例清单这些边界你必须覆盖6.1 正向用例列表给出一张可以直接拷贝的测试用例表能帮你在改规则时迅速回归。输入期望结果说明simpleexample.com合法最基础形式first.lastexample.org合法本地部分带点usertaggmail.com合法加号别名user-nameexample.co.uk合法长后缀多级域名user1234567890abcdefexample.com合法64 字符内本地部分ab.co合法语法层短域名新顶级域用户例子.中国域名转换后应通过 IDN 处理国际化域名注意第 6 条说明ab.co在语法上是合法的但把它归为合法不代表最后一步投递能成功否则就不需要第三层验证邮件了。6.2 反向用例列表反向用例比正向用例更能暴露问题输入期望结果说明plainaddress非法缺少missing-localexample.com非法本地部分为空 / 多个user非法域名部分为空.userexample.com非法本地部分以点开头user.example.com非法本地部分以点结尾user..nameexample.com非法本地部分连续两个点user-example.com非法域名标签以连字符开头userexample-.com非法域名标签以连字符结尾userexample..com非法域名部分连续两个点user nameexample.com非法本地部分含空格userexample.com前后含空格合法trim后前段输入处理johnsmithexample.com非法业务拒绝语法上合法但业务不支持user[192.168.1.1]非法业务拒绝domain-literal 业务不支持最后两条一定要在代码注释里写清楚不是语法错误是业务主动不支持的合法形式。这样将来如果有人翻出 RFC 来质疑你能说得清楚为什么拒绝。6.3 回归测试与mock策略域名校验涉及外部 DNS测试时必须把 DNS 查询 mock 掉否则用例会受网络环境影响轻则偶尔超时重则直接红一片。我在 Python 里习惯用unittest.mock.patch把dns.resolver.resolve替换掉from unittest.mock import patch def test_valid_domain_with_mx(): mock_answer object() with patch(dns.resolver.resolve, return_value[mock_answer]) as mock_resolve: assert validate_email_domain(example.com) is True mock_resolve.assert_called_once() def test_invalid_domain_raises_nxdomain(): import dns.resolver with patch(dns.resolver.resolve, side_effectdns.resolver.NXDOMAIN): assert validate_email_domain(not-exist.invalid) is False前端 JavaScript 侧同理把validateEmailInput当作纯函数测即可不需要真的构造 DOM 环境。把这些用例挂进 CI 之后你会发现改邮箱规则时再也不用靠感觉了你的心里会有底气。邮箱验证的真正难点从来不在于写个正则而在于知道自己手里这套校验到底在验证什么语法、可达性、所有权三者分别对应不同的业务目标。语法校验做得再完美也替不了那封确认邮件反之只发确认邮件不做前置过滤拦截垃圾注册的成本也会高得多。在实际项目里我始终贯彻这个原则注册入口用前两层挡垃圾关键操作用第三层保安全一旦遇到拿不准的边界情况宁可多放一个验证邮件也别误杀一个真实用户。
返回列表