ARTICLE DETAIL

资讯详情

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

邮箱验证不能只靠正则:从RFC 5322到三层校验实战

邮箱验证不能只靠正则:从RFC 5322到三层校验实战 开始用了太多年的“邮箱正则”直到有个用户在注册页面被拦在门外我才开始认真看RFC 5322。那次的场景现在还记得很清楚对方填的邮箱是usertagcompany.com前端校验直接提示“邮箱格式不正确”。用户很困惑说自己在Gmail里一直这样用为什么你们不让填。我查了一下代码发现那个号称“最强邮箱正则”的表达式里压根没考虑加号。那次之后我彻底把邮箱验证从“抄一段正则”改成了“分层验证”也才开始认真理解RFC 5322到底规范了什么。这篇文章就把这条路上的经验整理一遍RFC 5322里的邮箱格式究竟有哪些反直觉的规则为什么不能靠一个正则走天下以及一套能落地的三层校验方案格式校验、域名MX检查、投递验证该怎么设计。适合正在写注册登录、用户中心、营销触达相关功能的朋友也适合被邮箱格式折腾过的新手。1. 为什么邮箱验证看着简单做起来全是坑1.1 一个简单问题背后的复杂度邮箱地址的基础结构就一句话左边一个 local-part右边一个 domain中间用隔开。看起来非常简单凡是学过几天正则的人都能写出一个“能用”的版本。但真正的问题藏在细节里。RFC 5322对左右两部分的约束完全不是同一个量级左边允许的字符种类比想象中多得多右边却有着严格的标签、连字符和长度限制。更麻烦的是标准里还允许一堆“在理论中合法、在实践中几乎没人用”的写法比如加引号的本地部分、带注释的地址、方括号包裹的IP域名这些写法如果照单全收会让校验器变得极其宽松反而失去意义。结果就是网上流传的所谓“最严正则”多数是在两个极端之间摇摆——要么过于宽松放过一堆垃圾输入要么过于严格误杀了合法的真实邮箱。这些年刷到过不少“史上最强邮箱正则”实际测试下来没几个能同时处理好a.bcexample.com、firstname.middlenamesub.domain.co.uk和john smithexample.com这三类地址。1.2 从RFC 822到RFC 5322规范演进的思路聊RFC 5322之前值得简单提一下它的来历。这个标准是互联网邮件格式规范的最新一版前身是RFC 822和RFC 2822。RFC 822是1982年发布的定义了邮件的头部结构和地址格式2001年RFC 2822接棒把很多含糊不清的地方整理了一轮2008年RFC 5322又在2822基础上做了细节修正和澄清一直用到现在。这套标准当初设计的出发点不是给Web表单当校验规则而是为了定义“邮件报文在互联网上怎么编码、怎么解析”。所以里面大量语法规则是给邮件客户端、邮件服务器这些底层程序看的并不适合直接拿来做用户输入的格式判断。这也是为什么现在业内普遍不推荐“照抄RFC正则”的原因——标准本身是解析规范不是准入规范。但理解它仍然很有价值。知道了哪些写法在语法上合法、实际中又几乎不会出现写业务代码时心里就有底了不会写出“本地部分不能有特殊符号”这种完全错误的东西。1.3 合法和能收到邮件是两码事在动手写校验逻辑之前先想清楚一个关键问题我们做邮箱验证到底在验证什么如果目标只是防止用户输入abc、这类完全不靠谱的内容那么格式校验就够了。但在真实业务里我们需要的是“确保这个邮箱是用户自己可控的、将来能触达的”。这就不是格式校验能解决的问题了。一个典型的例子aaaexample.com这个地址完全符合RFC 5322的语法example.com这个域名是保留给文档示例用的根本收不到任何邮件。再比如userneversuchdomain-xyz123.com格式完全合法但这个域名可能根本不存在。如果注册流程只做了格式校验这些垃圾地址就全都放进来了。所以我在项目中把邮箱验证拆成了三层层级校验内容能拦截的问题成本第一层格式校验明显乱输入、缺少、非法字符低毫秒级第二层域名与MX记录检查域名不存在、没有邮件服务低网络请求约几十毫秒第三层投递验证发送验证邮件邮箱不存在、无法接收邮件、非本人所有高依赖邮件送达链路这三层各司其职前端做第一层后端做第二层和第三层。后面我会分别拆解每层的实现细节。2. 拆解RFC 5322的格式细节知道规则才能写出靠谱校验2.1 本地部分远比你想的灵活先看左边的 local-part。RFC 5322中定义了一套叫atext的字符集解释一下就是英文字母大小写、数字以及!#$%*-/?^_{|}~ 这一整串特殊符号都允许出现在本地部分中。也就是说下面这些地址在语法层面都是合法的usertagexample.com加号别名很常见obrienexample.com单引号meyou域名没有点语法上来讲是被允许的user_nameexample.com下划线某些老系统会拦这种地址但实际是合法的john smithexample.com用双引号包裹里面可以带空格还有一个容易被忽略的细节本地部分中点号的使用有讲究。点号可以作为分隔符但不能出现在本地部分的开头或结尾也不能连续出现。user..nameexample.com就是不合法的。至于有很多老正则特别喜欢的“只能由字母数字组成”之类规则放到RFC 5322面前全都不成立。有一个真实案例某创业公司的注册系统不允许用户填写含的邮箱导致不少从Outlook、Gmail过来的用户注册失败客服工作量蹭蹭往上涨。这事后来在技术圈里被当反面教材讨论过不少次。2.2 域名部分标签、连字符和长度限制再看右边的 domain。RFC 5322规定域名部分必须是一串由点号分隔的标签每个标签只能由字母、数字和连字符组成而且连字符不能出现在标签的首尾。比如-example.com不合法example-.com也不合法。从实际校验的角度看域名部分要比本地部分严格得多。正常的邮箱域名至少会有一个点比如gmail.com、outlook.com、company.com.cn。虽然RFC 5322在语法上并没有强制要求域名部分必须有点但在真实投递场景里——也就是SMTP投递时——没有顶级域的地址基本上是不可能正常路由的。这里还有一个经常被忽视的坑域名部分的标签长度上限是63个字符整个域名的长度上限是255个字符。如果把本地部分和域名部分加起来一个邮箱地址的总长度在实测中不应超过254个字符。这个254的限制不是RFC 5322提出来的而是RFC 5321SMTP协议标准给出的限制在实战中我们应当同时遵守这两个标准。2.3 那些标准允许但业务不支持的边缘语法RFC 5322里还有一些更加边缘的写法了解它们可以帮助你避免在讨论中被带偏注释格式标准允许在地址中插入括号注释比如johnexample.com (约翰)这种写法主要是给人类阅读用的在实际投递前会被剥掉。域名直接写IPuser[192.0.2.1]这种用方括号包一个IP地址的写法也是合法语法但在互联网邮件里基本没有应用场景。quoted-string用双引号包裹的本地部分理论上可以包含空格甚至包含比如johndoeexample.com。SMTP服务器对各种特殊处理的支持参差不齐业务系统没必要给自己找这种麻烦。我的建议是了解这些边缘语法是为了做决策时心里有数而不是为了让校验器去兼容它们。实际上绝大多数学术级完美的RFC正则在业务场景里反而是负担。邮件生态是一个极其庞大且复杂的系统能做和该做是两码事。3. 三层校验实战方案从格式到投递一步步落地3.1 第一层格式校验写一个不误杀的校验器第一层格式校验的目标非常明确用最低成本拦住明显无效的输入。它不是去追求100%符合RFC 5322而是拒绝那些“怎么看都不像邮箱”的内容。我自己在Python项目里用的是一种“宽松但不过界”的方案用正则做基础结构判断先看是否只有一个、本地部分是否为空、域名是否为空然后再按解析器去拆解。Python标准库里的email.utils.parseaddr可以帮我们做解析但它的行为偏向“尽量解析”对异常输入容忍度很高所以还得搭配额外的长度和字符检查。下面是一个比较常见的实现思路import re from email.utils import parseaddr # 简化的 ASCII 宽松校验 # 本地部分允许RFC 5322的atext字符点不能连续或首尾 # 域名部分字母数字加连字符点分标签至少有一个点 LOOSE_EMAIL_RE re.compile( r^(?Plocal[A-Za-z0-9!#$%*/?^_{|}~-] r(?:\.[A-Za-z0-9!#$%*/?^_{|}~-])*) r r(?Pdomain[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_valid_email_format(email: str) - bool: # 先做基础的空值和长度检查 if not email or len(email) 254: return False email email.strip() if email.count() ! 1: return False # 使用标准库解析防御异常输入 _, addr parseaddr(email) if not addr or addr ! email: # parseaddr 可能会剥离注释和空格如果结果不一致则拒绝 return False match LOOSE_EMAIL_RE.fullmatch(addr) if not match: return False # 本地部分长度上限是 64 个字符 local match.group(local) if len(local) 64: return False return True这串正则和网上一大堆“看起来很吓人的完整RFC 5322实现”相比写法和规则都温和得多。它做的事情是本地部分允许atext字符且点号不连续、不首尾出现域名部分至少有一个点、每个标签合法。这套规则能正确放行usertagexample.com、first.lastsub.example.co.uk也能拦住abc、、user..nameexample.com这些明显非法的输入。注意一点不要试图在前端用一个巨大的正则去完成所有判断。前端做这层校验只是为了更快地给用户反馈真正的拦截必须落到后端。任何跑到后端的请求都应该重新校验一遍绝不信任前端传来的任何结果。3.2 第二层域名与MX记录检查筛掉不存在的域名格式校验通过后第二层是检查域名是否存在、有没有配置邮件服务。这一步用的核心手段是DNS查询先查MX记录邮件交换记录如果没有MX记录再做一次A记录查询兜底。看起来很简单但有一个细节要注意DNS查询是有成本的如果每个注册请求都做一次同步DNS查询不仅慢还可能被上游DNS服务器限流。所以实际项目中我会加一层带过期时间的本地缓存比如10分钟同一个域名只查一次。用Python的dnspython库可以这样实现import dns.resolver def check_email_domain(domain: str) - dict: domain domain.strip().lower().rstrip(.) result { domain: domain, has_mx: False, has_a: False, mx_list: [], error: None, } if not domain: result[error] empty_domain return result try: mx_answers dns.resolver.resolve(domain, MX, lifetime5) # 按优先级排序优先级数值越小越靠前 records [(int(r.preference), str(r.exchange).rstrip(.)) for r in mx_answers] records.sort() result[has_mx] True result[mx_list] [r[1] for r in records] except dns.resolver.NXDOMAIN: result[error] domain_not_found return result except dns.resolver.NoAnswer: # 没有 MX 记录时用 A 记录兜底 try: a_answers dns.resolver.resolve(domain, A, lifetime5) result[has_a] True except Exception: result[error] no_mail_exchange except Exception as e: result[error] fdns_error: {e} return result用的时候注意example.com这类保留域名虽然能查到MX记录实际上查不到但有相当一部分垃圾邮箱注册者在填写时用的是example.com、test.com这类看起来合理的域名MX查询能帮你拦下一部分。不过也有一部分邮箱服务商尤其是某些小众的邮件服务可能只配置了SPF记录而没有完整MX记录所以has_mxFalse时不建议直接拒绝所有请求而是根据业务容忍度来决定是放行还是要求用户换一个邮箱。这里有一个实战中比较容易忽略的点MX记录优先级。同一域名可以有多条MX记录数值越小优先级越高。发邮件时邮件服务器会先尝试连优先级最高的MX服务器如果连不上才降级到次优先级的服务器。而DNS解析时查询结果的顺序并不一定按优先级排列所以上面代码里用了sort()按优先级排序避免在后续做SMTP探针时选错服务器。3.3 第三层投递验证真正的所有权确认前面两层做好了能拦下大多数垃圾输入但还差最关键的一步确认这个邮箱真的能收到邮件而且邮箱所有者就是当前用户。唯一通用、可靠的办法就是经典的“发送验证邮件点击验证链接”。流程不复杂但有几个细节容易踩坑Token设计验证链接里带的token必须是高熵随机串推荐直接用secrets.token_urlsafe(32)长度至少32字节。不要用自增ID或者基于用户信息的可预测字符串否则被猜到就变成批量激活接口了。过期时间验证链接一般在30分钟左右失效比较合适。时间太长容易被滥用太短用户体验差。过期后允许重新发送但要有频率限制。一次性使用用户点击过一次之后不管成功还是失败token都应该立刻作废。防止同一个token被多次使用也防止验证链接泄露后被别人抢着激活。幂等处理如果用户在验证页重复点击刷新服务端要保证结果一致。用户在多个设备上同时打开验证链接的场景很常见后端的校验逻辑必须处理并发问题。验证邮件的发送本身也是一个工程话题。发件域一定要配置好SPF、DKIM、DMARC这三样东西否则发的验证邮件大概率进垃圾箱甚至被拒收。我见过不少项目验证链接发出去但用户一直说没收到最后排查发现是发件方DNS记录没配邮件在对方服务器那一步就被扔了。这个问题在自建邮件服务器时尤为常见。发送频率限制必须有同一个IP一小时最多发几次、同一个邮箱一天最多发几次、同一封验证邮件的重发间隔至少60秒。否则一旦接口被刷你的发件服务器就会被各大邮件服务商拉黑这个损失比区区几个垃圾注册严重得多。3.4 前端校验应该做到什么程度前端校验的定位不是安全防线而是体验优化。用户正在表单里填写时我们希望尽早提示明显的格式错误而不是等请求发到后端才返回一个“邮箱格式不正确”。最基本的做法就是用HTML5自带的typeemailinput typeemail nameemail required placeholderyouexample.com浏览器会做一轮基础的格式判断但它的实现比较宽松而且不同浏览器行为不完全一致。比如Chrome对typeemail的校验要求域名部分必须有点而某些浏览器则不强制。所以最佳实践是typeemail打底再加一段轻量的JavaScript做补充校验比如判断是否包含空格、本地部分是否超过64字符、本地部分是否以点号开头结尾等在用户失焦时给出友好提示。还有一个小技巧很多用户会把gmail.com错打成gmial.com、把hotmail.com错打成hotmial.com。前端可以在用户输入完邮箱后做一个常见域名的拼写检查如果发现高度相似但不完全匹配的常见域名用“您是不是想输入 xxxgmail.com”这样的提示去引导用户。这个功能不需要很复杂维护一个常见邮箱域名列表做一次Levenshtein距离计算就够了但带来的用户体验提升非常明显。4. 常见问题与排查技巧实录4.1 那些被误判的合法邮箱案例在实际业务中最让我头疼的不是垃圾输入而是合法的真实用户被系统误杀。这类问题往往藏得很深因为测试人员用的都是标准邮箱踩不到边缘case。这里列几个真实发生过的案例含加号的地址usertagexample.com是合法的加号别名在很多邮箱服务里都有。有些正则把漏掉了直接拒绝。带撇号的地址obrienexample.com是合法的但一些校验规则会把单引号视为非法字符。下划线地址user_nameexample.com完全合法某些老系统也爱拦这个。纯数字本地部分123456qq.com是QQ邮箱的常规用法很多正则却要求本地部分必须包含字母这个规则纯属没事找事。子域名邮箱usermail.company.com这类地址在企业邮箱里很常见校验时必须保证域名部分支持多级标签。对照下来最稳妥的方案其实就是我在3.1节给出的那种宽松正则不试图理解邮箱的“业务含义”只做最基本的语法判断。宁可放过一些垃圾输入也尽量不要误杀真实用户——垃圾输入后面还有MX检查和投递验证兜底。4.2 SMTP探针验证能用但别滥用有一种比发验证邮件更轻量的“伪投递验证”方式SMTP探针。原理是直接连接目标邮箱的MX服务器依次发送EHLO、MAIL FROM、RCPT TO三条SMTP命令通过服务器返回的状态码判断邮箱是否存在。如果RCPT TO返回250说明该地址被接受返回550之类的5xx错误则说明服务器认为该地址不存在。这个方法确实能拦截掉一大半不存在的邮箱但我不推荐在生产环境里频繁使用原因是风险大于收益反垃圾策略邮件服务器对频繁的SMTP探测非常敏感。连续对同一个域名做几十次探针你的服务器IP大概率会被对方拉黑到时候正常的业务邮件也发不出去了。延迟高且不稳定如果一个域名的MX服务器在国外一次探针可能需要好几秒。如果想探测多个MX服务器用户体验会被拖垮。结果不可靠很多服务器不管邮箱是否存在对所有RCPT TO都统一返回250用来防垃圾邮件。这时候你的探针就失去了意义。我的经验是SMTP探针最多用于“注册后异步清洗一批可疑地址”这种低频场景不适合放在注册流程的同步链路上。如果是用户主动提交的邮箱还是老老实实发投递验证邮件成本虽然高一点但拿到的结论是确凿的。4.3 国际化邮箱IDN与大小写处理再聊两个常见的处理难点国际化域名和大小写。国际化域名IDN指的是用非ASCII字符构成的域名比如中文域名中国移动.cn这种形式。DNS系统不支持直接在查询中使用中文字符所以必须先把域名转成punycode编码也就是转成xn--开头的ASCII形式再去查MX记录。Python里可以用idna库或标准库的codecs.encode(domain, idna)来做转换。大小写的问题则更有意思。RFC 5322规定本地部分理论上是区分大小写的但在真实世界里除了一些极老的邮件服务没有任何主流邮箱会把UserGmail.com和usergmail.com当成不同地址。而且Gmail还更进一步它完全忽略本地部分里的点号first.lastgmail.com和firstlastgmail.com是同一个账号。所以在业务系统里处理邮箱时建议在保存前统一做标准化处理域名部分全部转小写整个邮箱地址去首尾空格。对本地部分是否转小写取决于你的业务策略——如果你希望阻止用户用相同邮箱重复注册那就转小写后再判断唯一性如果希望允许同名不同邮箱那就不转但要清楚这可能会被某些用户利用来批量注册。5. 给项目落地的实操建议5.1 语言社区中成熟的校验库优先于手写手写邮箱校验规则的过程是学习和排查的好体验但到了生产环境我还是建议优先使用经过大量项目验证的成熟库。它们的优势不是“完全符合RFC 5322”而是经过了多年真实用户输入的检验在“过于严格”和“过于宽松”之间找到了一个比较合理的平衡点。Pythonemail-validator是目前比较稳的选择里面针对真实世界做了一些修正比如允许userlocalhost这类地址但你可以配置禁止它。Node.jsvalidator.isEmail()是生态里用得最多的方案轻量且选项丰富。JavaApache Commons Validator或hutool的Validator.isEmail()都可以核心逻辑大同小异。Go标准库net/mail.ParseAddress是一个解析器不是校验器但它对结构的要求比较清楚配合自定义长度和标签检查也能用。不过即便是成熟库也不要忘了那层MX检查。很多库只做格式校验完全不关心域名是否存在。5.2 缓存、限流与校验失败日志邮箱验证看着是个小功能但要写得健壮还涉及三个运维层面的东西。第一DNS缓存。每次校验都去查一次DNS太浪费了加上解析超时时间控制在2-5秒缓存10-30分钟可以有效降低上游DNS服务器的压力也能让接口响应时间更可控。第二限流与防滥用。不管是注册触发验证邮件还是重发验证邮件都要做限流。按IP限流按邮箱限流按设备指纹限流三层一起上。否则一旦被脚本刷对方的损失只是换一批代理IP你的损失是发件域名被邮箱服务商拉黑。第三校验失败分类日志。建议把校验失败原因分成几个可枚举的类别格式错误、域名不存在、无MX记录、验证链接过期、token无效、重复注册等。这样看监控图表时可以快速定位问题是在哪个环节也能判断是垃圾注册攻击还是真的有用户在登录时遇到了困难。5.3 我在实际项目中的最终方案经过这些年的反复调整我现在做用户系统时的邮箱验证方案基本长这样前端typeemail 轻量自定义检查 常见域名拼写提示。后端先用宽松正则做格式判断再用dnspython查MX记录带10分钟缓存。注册流程不做同步SMTP探针直接发送带token的验证邮件30分钟过期点击后激活账号。数据入库前域名转小写本地部分按业务需要决定是否转小写邮箱唯一性判断统一用标准化后的值。异步清洗每晚跑一次离线任务对未激活的注册邮箱做SMTP探针清洗清掉一批高危的临时邮箱和无效地址。这个方案不是最严谨的但它效果稳定、没有性能隐患也不会误杀真实用户。我在实际项目中最大的体会是邮箱验证的目标不是“证明一个地址符合规范”而是“证明这个地址属于当前的人类用户”。想清楚这个定位之后很多纠结自然就消失了——不要试图用一段正则去解决所有问题把格式检查、域名检查、所有权确认这三步分开做每一层都用最简单可靠的方式解决自己那一个问题整体的稳定性反而会好很多。最后再分享一个小技巧如果你们的产品支持用户用邮箱注册后修改邮箱那么新邮箱的验证规则可以比注册时更严格一些老系统如果已经有了大量历史数据随时在线上收紧规则反而容易引发存量用户发邮件失败的问题。遇到这种情况新老用户分开做校验要比一刀切改代码稳得多。
返回列表