ARTICLE DETAIL

资讯详情

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

邮箱格式验证的正确姿势:从RFC 5322到分层校验

邮箱格式验证的正确姿势:从RFC 5322到分层校验 先讲一个我真实踩过的坑。前年做电商系统注册页的邮箱校验用了网上抄来的正则上线一个多月工单系统里堆了一批“我明明输的是邮箱为什么提示格式错误”的反馈。点开用户提交的内容一看有带号的有带单引号的有用新顶级域名的还有一位老哥用的是userlocalhost这种内部地址。后来我把“邮箱验证”从“一段正则走到黑”改成了“分层校验”这类问题基本绝迹。今天把这套经验整理出来结合 RFC 5322 标准里真正有用的那部分聊聊邮箱格式验证到底应该怎么做。1. 先确认需求邮箱校验要校验到什么程度1.1 四种典型场景与对应深度很多人一上来就问“有没有一个完美的邮箱正则”这种提问方式本身就把问题想歪了。邮箱格式验证不是一道“对或错”的判断题而是一条流水线你决定在哪一环拦截取决于业务场景。我做过的项目基本可以把邮箱校验需求分成四种注册/登录表单这是最常见的场景核心目标是快速把明显不可能是邮箱的输入挡在门外同时对合法邮箱保持最低误杀率。存量数据清洗手里有几十万条历史用户数据要判断哪些邮箱还能用、哪些是脏数据、哪些已经收不到信。这个场景讲究“打分”而不是一刀切。仅做展示与匹配比如在一个后台列表里判断“这列看起来像不像邮箱”宽松处理就行不需要严格到长度限制。内部系统导入导出比如从 Excel 批量导入用户重点是防止空值、防止格式拼接错误而不是纠结某个地址是否符合 RFC。不同场景对应不同深度。我在项目里见过最离谱的做法是拿一个 500 行的“宇宙无敌正则”去校验注册表单结果就是当你把 RFC 5322 允许的所有合法字符都放进正则后你会发现它同时也会把john..doeexample.org这种实际业务里根本不该放进来的地址给放进来。正则写得越“完整”误放行的概率反而越高。1.2 格式校验只是第一关不是最终判决还有一个要事先达成的共识格式校验的目标不是“判断这个邮箱是否存在”而是“判断这个输入符合不符合邮箱的基本形态”。格式通过了只能说明someoneexample.com这个字符串长得像邮箱但example.com这个域名可能压根不存在someone这个用户可能在这个域上不存在这个邮箱的收件箱可能已经满了甚至这个邮箱是个一次性临时邮箱。所以正确的理解是格式校验是流水线上的第一道筛子后面还有 DNS 查询、SMTP 投递、验证码回填等多道工序。不要把第一道筛子设计成终审法官否则要么误杀严重要么漏放严重。2. RFC 5322 到底规定了什么拆开给大家看2.1 地址的骨架local-part domainRFC 5322 是定义互联网邮件消息格式的标准文档其中关于邮箱地址的核心语法可以简化为这样一句话addr-spec local-part domain也就是你每天看到的用户名域名结构。符号左边叫 local-part右边叫 domain。关键点来了RFC 5322 对 local-part 的规定比绝大多数人想象中要宽松得多。很多程序员在写正则时默认“邮箱用户名只能由字母、数字、点、下划线、中划线组成”但标准并不是这么说的。2.2 local-part 真的有那么多“合法怪字符”RFC 5322 规定local-part 可以包含这些 ASCII 字符大小写字母A-Za-z数字0-9可打印的特殊字符! # $ % * - / ? ^ _{ | } ~点.但点不能出现在开头、结尾也不能连续出现除非整个 local-part 用双引号包裹也就是说obrienexample.com里那个单引号usertagexample.com里那个加号都是完全合法的。很多年前用旧正则把这类地址拒之门外的系统后来都陆陆续续被用户吐槽过。更特殊的情况是引用字符串quoted-string形式john..doeexample.com john doeexample.com在双引号内空格、连续点、甚至符号都可以出现。这类地址在 RFC 5322 层面是合法的但在现实业务里几乎不会有人用。我在分层方案里会把这一类地址直接归为“不建议放行”原因后面再说。2.3 domain 部分普通域名和地址字面量domain 部分相对简单常见的是点分域名形式比如example.com每个标签由字母、数字、中划线组成中划线不能出现在标签开头或结尾。这部分和 DNS 域名规则基本一致。但 RFC 5322 还允许一种很少见的“地址字面量”形式user[192.168.1.1]也就是把 IP 地址放在方括号里。这种形式在标准上合法但普通业务要是放行它基本等于给垃圾输入开门。所以我在实际项目中会直接拒绝掉。另外要注意的是RFC 5322 的语法本身对“validators”和“domain-literal”的表述比较细但对普通开发者来说记住“域名部分本质上就是一个合格的 DNS 域名或者一个地址字面量”就够了不需要把标准全文背下来。2.4 长度和大小写两个最容易被忽略的约束长度方面RFC 5322 本身没有明确规定邮箱地址的总长度但在 RFC 5321SMTP 协议里有约束local-part 最多 64 字符domain 最多 255 字符整个邮箱地址包含和尖括号时总共不超过 256 字符。业界普遍把 254 作为不带尖括号时的总长度上限。大小写方面一个常见的误区是“邮箱地址不区分大小写”。严格来说domain 部分不区分大小写EXAMPLE.com和example.com是同一个域。local-part 在标准上区分大小写Userexample.com和userexample.com理论上可能是两个不同的邮箱。但现实世界里绝大多数邮件服务商尤其是大厂都会把 local-part 当作不区分大小写处理。落到工程上我建议的做法是校验时允许大写入库存储时保留用户原始的 local-part但统一把域名部分转成小写唯一性校验时对整个地址做“不区分大小写”的处理。这样既兼容了标准也避免了用户下次登录时因为大小写不一致而找不到账号。3. 各语言下的实现姿势附可直接抄的代码3.1 Python别再把 parseaddr 当验证器Python 标准库里的email.utils.parseaddr经常被人拿来“验证”邮箱地址但它其实是一个解析器不是验证器。它会把not-an-email这种输入原样返回也不会告诉你a..bexample.com是否合法。用它在注册入口做校验等于把大门敞开。Python 3.6 之后我比较推荐用email.headerregistry.Address来做严格的格式解析from email.headerregistry import Address from email.errors import HeaderParseError def is_valid_email_format(email: str) - bool: # 先做长度检查避免异常输入拖垮解析 if not email or len(email) 254: return False # 处理最基础的脏输入首尾去空格 email email.strip() if not email: return False # 不允许地址字面量形式比如 user[192.168.1.1] if email.rstrip().endswith(]): return False try: addr Address(addr_specemail) except (HeaderParseError, ValueError): return False # 回写校验保证解析结果和输入一致避免解析器“修正”非法输入 return addr.addr_spec email这段代码有几个细节值得说明第一Address的解析逻辑比较接近 RFC 5322 的语法定义它会把obrienexample.com当作合法地址也会正确拒绝a..bexample.com这种连续点的形式。第二addr.addr_spec email这个回写判断非常关键因为某些解析器会宽容地把非法输入“修正”成合法形式如果不比较原字符串校验就失效了。第三endswith(])是我额外加的过滤用来挡住user[192.168.1.1]这种标准合法但业务不合理的地址。如果项目不需要引入额外依赖这套标准库方案够用。如果要查询域名 MX 记录那得上dnspython这个放在第 4 节讲。3.2 JavaScriptHTML5 正则是最稳的起点前端做表单校验最推荐的方式是直接用 HTML5 的input typeemail浏览器内置的正则来自 HTML5 规范它的覆盖范围基本对应了“去掉引号字符串形态”的 RFC 5322 地址比绝大多数手写正则要严谨得多。input typeemail required /如果你用的是 React/Vue组件里也可以直接引用这条 HTML5 规范对应的正则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 isValidEmail(value) { return typeof value string value.length 254 EMAIL_RE.test(value.trim()); }这条正则的特点有两个一是 local-part 允许、单引号、%等常见业务字符二是域名部分每个标签限制在 63 字符以内且整体要求必须有点分隔。它不允许userlocalhost也不允许带引号的 local-part这两点在我的分层方案里都合理。我用它挡掉了大概 99% 的“一眼假”输入剩下的交给后端去把关。不要试图在前端写一个“完全符合 RFC”的正则那个东西只适合拿去参加正则大赛不适合放在生产环境里给用户添堵。3.3 JavaInternetAddress 比手写正则靠谱Java 项目里手写正则的出场率非常高但如果你已经在用javax.mail相关依赖直接用它提供的InternetAddress会更省心import javax.mail.internet.InternetAddress; import javax.mail.internet.AddressException; public static boolean isValidEmailFormat(String email) { if (email null || email.length() 254) { return false; } try { InternetAddress address new InternetAddress(email); address.validate(); return true; } catch (AddressException e) { return false; } }InternetAddress.validate()是基于 RFC 822/2822/5322 的语法解析实现的它能正确识别usertagexample.com也会拒绝a..bexample.com。如果项目里没有javax.mail依赖也可以用 Apache Commons Validator 的EmailValidator效果接近但要注意它的实现同样不支持引号字符串形态的 local-part这一点反而符合业务需要。3.4 国际化邮箱IDN 和 Punycode 绕不开一个在中国区业务里很容易被忽略的点用户可能在邮箱里输入中文域名比如用户例子.中国。这种形式对应的标准是 IDN国际化域名在 DNS 层面实际是通过 Punycode 编码成xn--开头的 ASCII 字符串来工作的。如果后端直接拿中文字符去查 DNS大概率查不到结果。所以统一的做法是校验前把域名部分转成 Punycode再走后续逻辑。Python 里可以用idna库import idna def normalize_email_domain(email: str) - str: local, sep, domain email.rpartition() if not sep: return email try: ascii_domain idna.encode(domain).decode(ascii) except idna.IDNAError: return email return f{local}{ascii_domain.lower()}local-part 的国际化问题更复杂。RFC 6531 允许 local-part 使用 UTF-8但实际互通性很差很多邮件服务商仍然只支持 ASCII 的 local-part。所以我在业务里对 local-part 直接限制为非 ASCII 字符不做放行。4. 格式通过之后MX 查询和验证码投递4.1 MX 查询怎么做才不出错格式校验通过只能说明“这个字符串看起来像邮箱”接下来真正有价值的判断是“这个邮箱的域名到底存不存在、有没有邮件服务器”。MX 记录是邮件交换记录它告诉外界“给这个域发邮件该找谁”。用 Python 的dnspython库做 MX 查询很简单import dns.resolver def domain_has_mail_exchanger(domain: str) - bool: try: answers dns.resolver.resolve(domain, MX, lifetime3) return len(answers) 0 except dns.resolver.NXDOMAIN: # 域名不存在 return False except dns.resolver.NoAnswer: # 域名存在但没有 MX 记录需要额外判断见 4.2 return False except dns.resolver.LifetimeTimeout: # DNS 查询超时按“不确定”处理建议放行而不是一票否决 return True这里有一个工程上的关键经验MX 查询超时不应该直接判定为“邮箱非法”。你只是没能在 3 秒内得到 DNS 响应不代表这个域名不能收信。把超时当成“暂不明确允许进入下一步验证码流程”处理比误杀真实用户要稳妥得多。我在前公司就遇到过因为 DNS 解析偶尔超时导致一批用户注册被拒的线上事故。4.2 没有 MX 的域名可能也能收信很多人以为“没有 MX 记录 不能收邮件”这是一个高频误区。实际上RFC 5321 规定如果某个域没有 MX 记录那么邮件系统可以回退到直接使用该域的 A 记录IPv4 地址来投递。也就是说example.com可能只有一条 A 记录指向某台服务器而服务器上的邮件服务完全可以接收发往example.com的邮件。很多小型创业公司、个人自建邮箱就是这样做的。所以在工程实现上我的域名预检逻辑是MX 记录存在判定为“可靠”MX 不存在但 A/AAAA 记录存在判定为“可以尝试投递”MX、A、AAAA 全部不存在判定为“大概率是假邮箱”。def domain_can_receive_mail(domain: str) - bool: for record_type in (MX, A, AAAA): try: answers dns.resolver.resolve(domain, record_type, lifetime3) if len(answers) 0: return True except (dns.resolver.NXDOMAIN, dns.resolver.NoAnswer, dns.resolver.LifetimeTimeout): continue return False这里把LifetimeTimeout也当成“继续尝试”而不是直接返回是因为单个记录类型超时时换一个类型再查可能就有结果。4.3 验证码邮件的投递避坑域名预检之后真正决定“这个邮箱是不是用户的”的是发送验证邮件并等待用户回填验证码。这一步也存在一些容易踩的坑。第一发送频率必须限制。我在项目里见过用公共邮箱服务发的验证码被对方服务商当成垃圾邮件封掉的情况原因就是某个用户反复触发“重新发送”一分钟内发了十几封。实践上可以把发送间隔设为 60 秒单日同一邮箱最多发 10 封超出就报“操作过于频繁”。第二验证码有效期不要太长也别太短。5 到 10 分钟是比较常见的区间太短容易造成用户还没来得及切到邮箱就过期太长则增加被爆破的风险。验证码本身用 6 位数字比较合适。第三邮件模板里一定要带品牌名和用途说明。比如“【某某产品】您的注册验证码是 1234565 分钟内有效”。没有品牌名的纯验证码邮件进垃圾箱的概率会高很多。第四投递失败要记录原因。SMTP 返回 550用户不存在和 552邮箱已满的处理逻辑应该不同。550 可以告诉用户“该邮箱不存在或无法接收邮件”552 则可以提示“对方邮箱已满请换一个”。5. 常见错判与排查技巧速查5.1 被正则误杀的合法邮箱我见过最多的线上反馈几乎都集中在下面这几类“合法但被误杀”的地址用户输入为什么合法哪种正则容易误杀usertagexample.comlocal-part 允许只写了[\w.-]的正则obrienexample.comlocal-part 允许单引号没包含的字符白名单first.lastexample.co.uk多级域名完全合法要求顶级域名正好 2-3 位user_nameexample.com下划线在某些业务里合法只允许-和.的正则用户例子.中国国际化域名合法没做 IDN 处理的后端逻辑关于“顶级域名长度”这条我想多说两句。很多正则喜欢写\.[a-zA-Z]{2,4}这在 2010 年前后还能用但新顶级域名大量开放之后.company、.tech、.online到处都是。我见过一个系统因为正则写了{2,3}导致用户用.company域名注册时被拒这种问题是产品层面不能接受的。5.2 从正则漏网的非法邮箱反过来也有不少非法输入能穿过质量不高的正则a..bexample.comlocal-part 里连续出现两个点RFC 5322 禁止除非引号包裹但很多宽松正则看不出来。.userexample.comlocal-part 以点开头非法。user.example.comlocal-part 以点结尾非法。user-example.com域名标签以中划线开头非法。userexample-.com域名标签以中划线结尾非法。ab域名没有点在公网业务里基本不可用。这些情况在我推荐的 HTML5 风格正则里基本都能拦掉所以不展开讲。5.3 线上排查邮箱问题的实用路径用户报“收不到验证码”这类问题我总结了一条排查路径先看格式校验有没有把用户地址误杀把用户输入原样贴进测试脚本跑一遍格式判断。再看域名预检确认域名是否存在、MX 记录是否正常可以用dig或在线 DNS 工具。然后看发送日志SMTP 返回的错误码是什么、投递耗时多少。最后问用户查一下垃圾箱检查是不是被服务商拦截了。这条路径看起来简单但绝大多数线上问题都能在这四步里定位到。6. 我最终在项目里落地的这套分层验证方案6.1 第一层格式基础校验第一层我用的是各语言内足够成熟的解析器或 HTML5 风格正则规则如下总长度不超过 254 字符local-part 限制为 ASCII允许业务常见字符字母、数字、、-、_、.、等域名部分限制为点分域名每个标签 1-63 字符整体不超过 255 字符拒绝地址字面量形式user[192.168.1.1]拒绝 local-part 以点开头/结尾或连续两个点这一层的目的就是快速过滤处理掉 95% 以上的垃圾输入。6.2 第二层域名与 MX 预检第二层对提取出来的域名做 DNS 查询域名存在且有 MX 记录可靠继续。域名存在无 MX 但有 A/AAAA 记录可以尝试投递继续。域名解析不到NXDOMAIN判定为非法邮箱提示“域名不存在”。DNS 查询超时按“不确定”处理放行进入验证码流程避免误杀。这一层能筛掉相当一部分随手编造的假邮箱。有人会问这样是不是要等 3 秒实操中我加了简单的缓存同一个域名 24 小时内只查一次基本不会拖慢注册流程。6.3 第三层验证码邮件终极判定第三层是发送验证邮件这是目前最可靠的“这个邮箱是否真实存在且属于你”的判定方式。注意点我在 4.3 已经提过限制发送频率、设置验证码有效期、模板带品牌名、记录投递结果。这里额外给一个“抄作业”用的 Python 示例把前三层串起来class EmailValidator: def __init__(self): self._domain_cache {} def is_reachable(self, email: str) - tuple: # 第一层格式 if not is_valid_email_format(email): return False, 邮箱格式不正确 # 第二层域名预检带缓存 domain email.rpartition()[2] if domain in self._domain_cache: reachable self._domain_cache[domain] else: reachable domain_can_receive_mail(domain) self._domain_cache[domain] reachable if not reachable: return False, 该邮箱域名不存在或无法接收邮件 # 第三层由调用方负责发送验证码并校验回填 return True, 可以发送验证码这个类很适合接在注册接口里先同步跑前两层通过后再进入发送验证码的异步流程。6.4 第四层后续风控与数据清洗注册完成之后邮箱验证还没完全结束。后续还有两件事值得做。一是定期清洗存量数据。很多系统里积累了几年甚至十几年的历史邮箱数据其中有大量已经失效的地址。可以用同样的“格式校验 域名/MX 查询”逻辑做一次全量扫描给每条数据打一个邮箱健康度标签比如“正常”“域名已失效”“格式异常”“疑似一次性邮箱”。这样运营在做邮件营销时可以避开高分贝无效地址提升送达率。二是对一次性邮箱做补充风控。格式校验、域名预检、验证码这一套流程拦不住临时邮箱服务因为那些域名确实存在、也真的能收到验证码。如果业务对账号真实度要求高比如涉及补贴、抽奖可以考虑接第三方邮箱风险库或者维护一份高频临时邮箱域名黑名单。最后分享两个小技巧第一无论用哪一层校验都建议在日志里记录“用户原始输入”和“每层校验结果”。邮箱问题排查时没有原始日志几乎等于盲猜有了日志一眼就能看出是哪个环节把用户拦下来的。第二不要迷信“标准正则”也不要完全抛弃它。RFC 5322 的价值是让你理解邮箱地址的边界在哪里比如为什么可以出现在用户名里、为什么域名有单字符顶级域、为什么长度上限不是随便定的。理解这些边界之后你才能设计出一套既贴近标准、又符合真实业务的分层方案。我最后在项目里用的第一层规则并没有完全放行标准里所有合法地址而是做了一次现实化的取舍这个取舍就是整个“正确姿势”的核心。
返回列表