一位同事最近向我推荐了一篇博文: 论电子邮件正则表达式验证的徒劳性。为了简洁起见,我将在本文中将其称为Futility 。
我承认虽然编写能够成功识别字符串是否符合 Internet 消息标头的 RFC 5322 定义的正则表达式的挑战是一项有趣的挑战,但Futility对于实际程序员来说并不是有用的指南。
这是因为它将 RFC 5322 消息标头与 RFC 5321 地址文字混为一谈;用简单的语言来说,这意味着构成有效 SMTP 电子邮件地址的内容不同于构成有效邮件标头的内容。
这也是因为它促使读者全神贯注于从标准的角度来看理论上可能的边缘情况,但我将证明,“在野外”发生的可能性极小。
本文将扩展这两个断言,讨论电子邮件正则表达式的一些可能用例,并以实用电子邮件正则表达式的注释“食谱”示例作为结尾。
SMTP 用于电子邮件传输的普遍性意味着,作为一个实际问题,如果不仔细阅读相关的 IETF RFC,即 5321,任何电子邮件地址格式的检查都是不完整的。
5322 将电子邮件地址视为简单的通用邮件标头,没有适用特殊情况规则。这意味着括号中的注释是有效的,即使在域名中也是如此。
Futility中引用的测试套件包括 10 个包含注释、变音符号或 Unicode 字符的测试,并表明其中 8 个代表有效的电子邮件地址。
这是不正确的,因为 RFC 5321 明确指出电子邮件地址的域名部分“出于 SMTP 目的而被限制为由从 ASCII 字符集中提取的一系列字母、数字和连字符组成”。
在构建正则表达式的上下文中,很难夸大此约束简化问题的程度,尤其是在确定过长的字符串长度方面。示例的注释将在下面突出显示这一点。
它还暗示了我们将进一步探讨的验证背景下的其他一些实际考虑因素。
根据这两个 RFC,“@”符号左侧的电子邮件地址部分的技术名称是“邮箱”。两个 RFC 都允许在邮箱部分允许使用哪些字符方面有很大的自由度。
唯一重要的实际限制是引号或括号必须平衡,这是在普通正则表达式中验证的真正挑战。
然而,现实世界的邮箱实现再次成为实际程序员应该采用的措施。
通常,付钱给我们的人不赞成将我们 90% 的计费时间用于解决现实生活中可能根本不存在的 10% 的理论边缘案例。
让我们看看主要的电子邮件邮箱提供商、消费者和企业,并考虑他们允许使用哪些类型的电子邮件地址。
对于消费者电子邮件,我做了一些初步研究,使用了从 Twitter 帐户泄露的 5,280,739 个电子邮件地址列表。
基于 1.15 亿个 Twitter 帐户,这为我们提供了 99% 的置信度和 0.055% 的误差幅度,用于整个 Twitter 人口,这将非常代表所有互联网电子邮件地址的一般人口。这是我学到的:
但是,这是四舍五入的 100%。对于那里的琐事爱好者,我还发现:
实际效果是,假设电子邮件地址邮箱仅包含 ASCII 字母数字、点和破折号,则消费者电子邮件的准确度将高于 5 9 的准确度。
对于商业电子邮件,Datanyze报告称有 6,771,269 家公司使用 91 种不同的电子邮件托管解决方案。然而,帕累托分布成立,其中 95.19% 的邮箱由仅 10 家服务提供商托管。
Google 在创建邮箱时只允许使用 ASCII 字母、数字和点。然而,它会在接收电子邮件时接受加号。
仅允许 ASCII 字母、数字和点。
使用 Microsoft 365,并且只允许 ASCII 字母、数字和点。
没有记录。
不幸的是,我们只能确定 82% 的企业,我们不知道这代表了多少个邮箱。然而,我们确实知道,在 Twitter 电子邮件地址中,173,467 个域中只有 400 个拥有超过 100 个单独的电子邮件邮箱。
我相信其余 99% 的域中的大部分都是企业电子邮件地址。
在服务器或域级别的邮箱命名策略方面,我建议以 99% 的置信度和 0.25% 的误差范围将这 237,592 个电子邮件地址视为代表 10 亿个商业电子邮件地址的总体是合理的,给我们当假设电子邮件地址邮箱仅包含 ASCII 字母数字、点和破折号时,接近 3 个 9。
同样,考虑到实用性,让我们考虑在什么情况下我们可能需要以编程方式识别有效的电子邮件地址。
在此用例中,一位潜在的新客户正在尝试创建一个帐户。我们可以考虑两种高级策略。在第一种情况下,我们尝试验证新用户提供的电子邮件地址是否有效并同步进行帐户创建。
您可能不想采用这种方法的原因有两个。第一个是,虽然您可能能够验证电子邮件地址的格式是否有效,但它可能并不存在。
另一个原因是,在任何规模上,同步都是一个危险信号词,这应该导致务实的程序员考虑一个即发即弃的模型,在该模型中,无状态 Web 前端将表单信息传递给微服务或 API,微服务或 API 将通过发送一个唯一链接异步验证电子邮件,这将触发帐户创建过程的完成。
对于通常用于下载白皮书的简单联系表格,接受看起来像有效电子邮件但实际上并非如此的字符串的潜在缺点是,您无法验证是否有效,从而降低了营销数据库的质量。电子邮件地址确实存在。
因此,与表单中输入的字符串的编程验证相比,即发即弃模型再次成为更好的选择。
这将我们引向一般的程序化电子邮件地址识别的真实用例,尤其是正则表达式:匿名化或挖掘大块非结构化文本。
我第一次遇到这个用例是为了协助一位需要将引荐来源日志上传到欺诈检测数据库的安全研究人员。推荐人日志包含在离开公司的围墙花园之前需要匿名的电子邮件地址。
这些是几亿行的文件,一天有几百个文件。 “行”的长度可能接近一千个字符。
遍历一行中的字符,应用复杂的测试(例如,这是行中第一次出现@
吗,它是否是文件名的一部分,例如[email protected]
?)使用循环和标准字符串函数会创建一个不可能大的时间复杂度。
事实上,这家(非常大的)公司的内部开发团队已经宣布这是一项不可能完成的任务。
我写了以下编译后的正则表达式:
search_pattern = re.compile("[a-zA-Z0-9\!\#\$\%\'\*\+\-\^\_\`\{\|\}\~\.]+@|\%40(?!(\w+\.)**(jpg|png))(([\w\-]+\.)+([\w\-]+)))")
并将其放入以下 Python 列表理解中:
results = [(re.sub(search_pattern, "[email protected]", line)) for line in file]
我不记得它有多快,但它很快。我的朋友可以在笔记本电脑上运行它并在几分钟内完成。这是准确的。我们将其计时为 5 9 秒,同时查看假阴性和假阳性。
由于引用日志,我的工作变得有些容易;它们只能包含 URL“合法”字符,因此我能够找出我在回购自述文件中记录的任何冲突。
此外,如果我执行了电子邮件地址分析并确信到达 5 9 的目标所需的全部是 ASCII 字母数字、点和破折号,我本可以使它变得更简单(和更快)。
尽管如此,这是实用性的一个很好的例子,并且确定解决方案的范围以适应要解决的实际问题。
所有编程知识和历史中最伟大的名言之一是伟大的 Ward Cunningham 的告诫,花点时间准确地记住你想要完成的事情,然后问问自己“什么是最简单的可能可行的事情?”
在从大量非结构化文本中解析(并可选地转换)电子邮件地址的用例中,这个解决方案绝对是我能想到的最简单的方法。
就像我在开头所说的那样,我发现构建符合 RFC 5322 的正则表达式的想法很有趣,所以我将向您展示可组合的正则表达式块来处理标准的各个方面,并解释正则表达式如何制定这些规则。最后,我将向您展示组装完成后的样子。
电子邮件地址的结构是:
现在为正则表达式。
^(?<mailbox>(\[a-zA-Z0-9\\+\\!\\#\\$\\%\\&\\'\\\*\\-\\/\\=\\?\\+\\\_\\\{\\}\\|\\\~]|(?<singleDot>(?<!\\.)(?<!^)\\.(?!\\.))|(?<foldedWhiteSpace>\\s?\\&\\#13\\;\\&\\#10\\;.))\{1,64})
首先,我们有^
,它“锚定”字符串开头的第一个字符。如果验证一个应该只包含有效电子邮件的字符串,将使用它。它确保第一个字符是合法的。
如果用例是在更长的字符串中查找电子邮件,则省略锚点。
接下来,我们有(?<mailbox>
。为方便起见,它命名了捕获组。捕获组内部是三个正则表达式块,由备用匹配符号|
分隔,这意味着一个字符可以匹配三个表达式中的任何一个。
编写好的(高性能和可预测的)正则表达式的一部分是确保这三个表达式相互排斥。也就是说,匹配其中一个的子串,肯定不会匹配另外两个。为此,我们使用特定的字符类而不是可怕的.*
。
[a-zA-Z0-9\+\!\#\$\%\&\'\*\-\/\=\?\+\_\{\}\|\~]
第一个备用匹配项是包含在方括号中的字符类,它捕获电子邮件邮箱中除点、“折叠空格”、双引号和括号之外的所有合法 ASCII 字符。
我们排除它们的原因是它们只是有条件地合法,也就是说,关于如何使用它们的规则必须经过验证。我们在接下来的 2 场备用比赛中处理它们。
(?<singleDot>(?<!\.)(?<!^)\.(?!\.))
第一个这样的规则涉及点(句点)。在邮箱中,点只能作为两个合法字符串之间的分隔符,所以连续的两个点是不合法的。
如果有两个连续的点,为了防止匹配,我们使用正则表达式负向后视(?<!\.)
指定如果前面有一个点,则下一个字符(一个点)将不匹配。
可以链接正则表达式环顾四周。在我们到达点(?!^)
之前还有另一个否定的回顾,它强制执行点不能是邮箱的第一个字符的规则。
在点之后,有一个否定的 look_ahead_ _(?!\.)_
,如果它紧跟一个点,这可以防止点被匹配。
(?<foldedWhiteSpace>\s?\&\#13\;\&\#10\;.)
这是一些 RFC 5322 关于允许消息中的多行标题的废话。我敢打赌,在电子邮件地址的历史上,从来没有人认真地创建过一个包含多行邮箱的地址(他们可能只是开玩笑)。
但我正在玩 5322 游戏,所以这里是创建折叠空白作为替代匹配项的 Unicode 字符串。
两个 RFC 都允许使用双引号来包围(或转义)通常是非法的字符。
它们还允许将注释括在括号中,以便它们易于阅读,但在解释地址时不会被邮件传输代理 (MTA) 考虑。
在这两种情况下,字符只有在平衡时才是合法的。这意味着必须有一对字符,一个打开,一个关闭。
我很想写下我已经发现了一个demonstrationem mirabilem ,然而,这可能只在死后才有效。事实上,这在普通正则表达式中并不重要。
我有一种直觉,即“贪婪”正则表达式的递归性质可能会被利用,但是,我不太可能在接下来的几年里投入必要的时间来解决这个问题,所以按照最好的传统,我将其保留作为读者的练习。
{1,64}
真正重要的是邮箱的最大长度:64 个字符。
因此,在我们用最后一个右括号关闭邮箱捕获组之后,我们在大括号之间使用量词来指定我们必须至少匹配我们的任何一个候选项一次,但不超过 64 次。
\s?(?<atSign>(?<!\-)(?<!\.)\@(?!\@))
分隔符块以特殊情况\s?
因为根据Futility,分隔符之前的空格是合法的,我只是相信他们的话。
捕获组的其余部分遵循与singleDot类似的模式;如果前面有一个点或破折号,或者如果紧接着另一个@
,它将不匹配。
在这里,就像在邮箱中一样,我们有 3 个备用匹配项。最后一个在其中嵌套了另外 4 个备用匹配项。
(?<dns>[[:alnum:]]([[:alnum:]\-]{0,63}\.){1,24}[[:alnum:]\-]{1,63}[[:alnum:]])
这不会通过Futility 中的几个测试,但如前所述,它严格遵守具有最终决定权的 RFC 5321。
(?<IPv4>\[((?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\])
这个不用多说。这是一个众所周知且易于使用的 IPv4 地址正则表达式。
(?<IPv6>(?<IPv6Full>(\[IPv6(\:[0-9a-fA-F]{1,4}){8}\]))|(?<IPv6Comp1>\[IPv6\:((([0-9a-fA-F]{1,4})\:){1,3}(\:([0-9a-fA-F]{1,4})){1,5}?\])|\[IPv6\:((([0-9a-fA-F]{1,4})\:){1,5}(\:([0-9a-fA-F]{1,4})){1,3}?\]))|(?<IPv6Comp2>(\[IPv6\:\:(\:[0-9a-fA-F]{1,4}){1,6}\]))|(?<IPv6Comp3>(\[IPv6\:([0-9a-fA-F]{1,4}\:){1,6}\:\]))|(?<IPv6Comp4>(\[IPv6\:\:\:)\])|(?<IPv6v4Full>(\[IPv6(\:[0-9a-fA-F]{1,4}){6}\:((?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3})(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\])|(?<IPv6v4Comp1>\[IPv6\:((([0-9a-fA-F]{1,4})\:){1,3}(\:([0-9a-fA-F]{1,4})){1,5}?(\:((?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?))\])|\[IPv6\:((([0-9a-fA-F]{1,4})\:){1,5}(\:([0-9a-fA-F]{1,4})){1,3}?(\:((?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?))\]))|(?<IPv6v4Comp2>(\[IPv6\:\:(\:[0-9a-fA-F]{1,4}){1,5}(\:((?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?))\]))|(?<IPv6v4Comp3>(\[IPv6\:([0-9a-fA-F]{1,4}\:){1,5}\:(((?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?))\]))|(?<IPv6v4Comp4>(\[IPv6\:\:\:((?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3})(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\]))
我找不到适合 IPv6(和 IPv6v4)地址的正则表达式,所以我自己写了一个,仔细遵循 RFC 5321 中的 Backus/Naur 注释规则。
我不会注释 IPv6 正则表达式的每个子组,但我已经命名了每个子组以便于区分并查看发生了什么。
除了我在 IUPv6Comp1 捕获组中将“左侧”的贪婪匹配与“右侧”的非贪婪匹配相结合的方式外,没有什么特别有趣的。
我已将最终的正则表达式以及来自 Futility 的测试数据保存到Regex101中,并通过我自己的一些 IPv6 测试用例进行了增强。我希望您喜欢这篇文章,并且它被证明对你们中的许多人有用并节省时间。
AZW