使用 PHP 验证电子邮件地址的正确方法

作者:Douglas Lovell

互联网工程任务组 (IETF) 文档 RFC 3696,“名称检查和转换的应用技术”(作者:John Klensin)列举了几个有效的电子邮件地址,但许多 PHP 验证例程都拒绝这些地址。这些地址:Abc\@def@example.com、customer/department=shipping@example.com 和 !def!xyz%abc@example.com 都是有效的。文献中一种更流行的正则表达式拒绝所有这些地址

"^[_a-z0-9-]+(\.[_a-z0-9-]+)*@[a-z0-9-]+(\.[a-z0-9-]+)
↪*(\.[a-z]{2,3})$"

此正则表达式仅允许下划线 (_)、连字符 (-)、数字和小写字母字符。即使假设有一个预处理步骤将大写字母字符转换为小写字母,该表达式也会拒绝包含有效字符的地址,例如斜杠 (/)、等号 (=)、感叹号 (!) 和百分号 (%)。该表达式还要求最高级域名组件只有两个或三个字符,因此拒绝了有效的域名,例如 .museum。

另一个受欢迎的正则表达式解决方案如下

"^[a-zA-Z0-9_.-]+@[a-zA-Z0-9-]+.[a-zA-Z0-9-.]+$"

此正则表达式拒绝前面段落中的所有有效示例。它确实优雅地允许大写字母字符,并且没有犯假设高级域名名称只有两个或三个字符的错误。它允许无效域名,例如 example..com。

列表 1 显示了来自 PHP Dev Shed 的示例 (www.devshed.com/c/a/PHP/Email-Address-Verification-with-PHP/2)。该代码包含(至少)三个错误。首先,它无法识别许多有效的电子邮件地址字符,例如百分号 (%)。其次,它在 at 符号 (@) 处将电子邮件地址拆分为用户名和域部分。包含带引号的 at 符号的电子邮件地址,例如 Abc\@def@example.com,将破坏此代码。第三,它未能检查主机地址 DNS 记录。具有 A 类型 DNS 条目的主机将接受电子邮件,并且可能不一定发布 MX 类型条目。我并不是在挑 PHP Dev Shed 的作者的毛病。超过 100 名审阅者给了它四星(满分五星)的评分。

列表 1. 不正确的电子邮件验证

function checkEmail($email) {
  if(preg_match("/^([a-zA-Z0-9])+([a-zA-Z0-9\._-])
  ↪*@([a-zA-Z0-9_-])+([a-zA-Z0-9\._-]+)+$/",
               $email)){
    list($username,$domain)=split('@',$email);
    if(!checkdnsrr($domain,'MX')) {
      return false;
    }
    return true;
  }
  return false;

更好的解决方案之一来自 Dave Child 在 ILoveJackDaniel's 的博客 (ilovejackdaniels.com),如列表 2 所示 (www.ilovejackdaniels.com/php/email-address-validation)。Dave 不仅喜欢老式的美国威士忌,他还做了一些功课,阅读了 RFC 2822,并认识到电子邮件用户名中有效的字符的真实范围。大约 50 人在该网站上评论了这个解决方案,包括一些已纳入原始解决方案的更正。在 ILoveJackDaniel's 共同开发的代码中,唯一的主要缺陷是它不允许在用户名中使用带引号的字符,例如 \@。它将拒绝包含多个 at 符号的地址,因此它不会因为使用以下代码拆分用户名和域部分而陷入困境explode("@", $email)。一个主观的批评是,该代码花费大量精力检查域部分的每个组件的长度——最好将精力花在简单地尝试域查找上。其他人可能会欣赏在网络上执行 DNS 查找之前对域进行尽职调查。

列表 2. 来自 ILoveJackDaniel's 的更好示例

function check_email_address($email) {
  // First, we check that there's one @ symbol, 
  // and that the lengths are right.
  if (!ereg("^[^@]{1,64}@[^@]{1,255}$", $email)) {
    // Email invalid because wrong number of characters 
    // in one section or wrong number of @ symbols.
    return false;
  }
  // Split it into sections to make life easier
  $email_array = explode("@", $email);
  $local_array = explode(".", $email_array[0]);
  for ($i = 0; $i < sizeof($local_array); $i++) {
    if
(!ereg("^(([A-Za-z0-9!#$%&'*+/=?^_`{|}~-][A-Za-z0-9!#$%&
↪'*+/=?^_`{|}~\.-]{0,63})|(\"[^(\\|\")]{0,62}\"))$",
$local_array[$i])) {
      return false;
    }
  }
  // Check if domain is IP. If not, 
  // it should be valid domain name
  if (!ereg("^\[?[0-9\.]+\]?$", $email_array[1])) {
    $domain_array = explode(".", $email_array[1]);
    if (sizeof($domain_array) < 2) {
        return false; // Not enough parts to domain
    }
    for ($i = 0; $i < sizeof($domain_array); $i++) {
      if
(!ereg("^(([A-Za-z0-9][A-Za-z0-9-]{0,61}[A-Za-z0-9])|
↪([A-Za-z0-9]+))$",
$domain_array[$i])) {
        return false;
      }
    }
  }
  return true;
}
要求

IETF 文档 RFC 1035 “域实现和规范”、RFC 2234 “语法规范的 ABNF”、RFC 2821 “简单邮件传输协议”、RFC 2822 “互联网消息格式”以及 RFC 3696(前面引用过)都包含与电子邮件地址验证相关的信息。RFC 2822 取代了 RFC 822 “ARPA 互联网文本消息标准”,并使其过时。

以下是电子邮件地址的要求,以及相关的参考文献

  1. 电子邮件地址由本地部分和域组成,两者之间用 at 符号 (@) 字符分隔 (RFC 2822 3.4.1)。

  2. 本地部分可能包含字母和数字字符,以及以下字符:!、#、$、%、&、'、*、+、-、/、=、?、^、_、`、{、|、} 和 ~,可能带有句点分隔符 (.),在内部,但不能在开头、结尾或紧挨着另一个句点分隔符 (RFC 2822 3.2.4)。

  3. 本地部分可能由带引号的字符串组成——即引号 (") 内的任何内容,包括空格 (RFC 2822 3.2.5)。

  4. 带引号的对(例如 \@)是本地部分的有效组成部分,尽管是 RFC 822 中的过时形式 (RFC 2822 4.4)。

  5. 本地部分的最大长度为 64 个字符 (RFC 2821 4.5.3.1)。

  6. 域由句点分隔符分隔的标签组成 (RFC1035 2.3.1)。

  7. 域名标签以字母字符开头,后跟零个或多个字母字符、数字字符或连字符 (-),以字母或数字字符结尾 (RFC 1035 2.3.1)。

  8. 标签的最大长度为 63 个字符 (RFC 1035 2.3.1)。

  9. 域的最大长度为 255 个字符 (RFC 2821 4.5.3.1)。

  10. 域必须是完全限定的,并且可解析为 A 类型或 MX 类型 DNS 地址记录 (RFC 2821 3.6)。

要求编号 4 涵盖了一种现已过时的形式,可以说这种形式是允许的。发布新地址的代理可以合法地禁止它;但是,使用此形式的现有地址仍然是有效地址。

该标准假设使用 7 位字符编码,而不是多字节字符。因此,根据 RFC 2234,“字母”对应于拉丁字母字符范围 a–z 和 A–Z。同样,“数字”指的是数字 0–9。可爱的国际标准 Unicode 字母表不被接受——甚至没有编码为 UTF-8。ASCII 仍然在这里占主导地位。

开发更好的电子邮件验证器

有很多要求!其中大多数与本地部分和域有关。因此,从围绕 at 符号分隔符拆分电子邮件地址开始是有意义的。要求 2-5 适用于本地部分,要求 6-10 适用于域。

at 符号可以在本地名称中转义。例如,Abc\@def@example.com 和 "Abc@def"@example.com。这意味着在 at 符号上使用 explode,$split = explode("@", $email);或另一种类似的技巧来分隔本地和域部分并不总是有效。我们可以尝试删除转义的 at 符号,$cleanat = str_replace("\\@", "");,但这会遗漏病态情况,例如 Abc\\@example.com。幸运的是,域名部分不允许使用此类转义的 at 符号。at 符号的最后一次出现肯定是分隔符。然后,分隔本地和域部分的方法是使用 strrpos 函数来查找电子邮件字符串中最后一个 at 符号。

列表 3 提供了一种更好的方法来拆分电子邮件地址的本地部分和域。如果电子邮件字符串中未出现 at 符号,则 strrpos 的返回类型将是布尔值 false。

列表 3. 拆分本地部分和域

$isValid = true;
$atIndex = strrpos($email, "@");
if (is_bool($atIndex) && !$atIndex)
{
   $isValid = false;
}
else
{
   $domain = substr($email, $atIndex+1);
   $local = substr($email, 0, $atIndex);
   // ... work with domain and local parts
}

让我们从简单的开始。检查本地部分和域的长度很简单。如果这些测试失败,则无需进行更复杂的测试。列表 4 显示了进行长度测试的代码。

列表 4. 本地部分和域的长度测试

$localLen = strlen($local);
$domainLen = strlen($domain);
if ($localLen < 1 || $localLen > 64)
{
   // local part length exceeded
   $isValid = false;
}
else if ($domainLen < 1 || $domainLen > 255)
{
   // domain part length exceeded
   $isValid = false;
}

现在,本地部分有两种形式之一。它可能具有开始和结束引号,并且没有未转义的嵌入引号。本地部分 Doug \"Ace\" L. 就是一个例子。本地部分的第二种形式是 (a+(\.a+)*),其中 a 代表一大堆允许的字符。第二种形式比第一种形式更常见;因此,首先检查第二种形式。在未通过未引用形式后查找引用形式。

使用反斜杠 (\@) 引用的字符会带来问题。这种形式允许将反斜杠字符加倍,以在解释结果中获得反斜杠字符 (\\)。这意味着我们需要检查奇数个反斜杠字符是否引用了非反斜杠字符。我们需要允许 \\\\\@ 并拒绝 \\\\@。

可以编写一个正则表达式来查找非反斜杠字符之前的奇数个反斜杠。这是可能的,但并不美观。由于反斜杠字符是 PHP 字符串中的转义字符,也是正则表达式中的转义字符,因此吸引力进一步降低。我们需要在表示正则表达式的 PHP 字符串中编写四个反斜杠字符,以向正则表达式解释器显示单个反斜杠。

一个更具吸引力的解决方案是在使用正则表达式检查测试字符串之前,简单地从测试字符串中剥离所有成对的反斜杠字符。str_replace 函数非常适合。列表 5 显示了本地部分内容的测试。

列表 5. 有效本地部分内容的局部测试

if (!preg_match('/^(\\\\.|[A-Za-z0-9!#%&`_=\\/$\'*+?^{}|~.-])+$/',
                str_replace("\\\\","",$local)))
{
   // character not valid in local part unless 
   // local part is quoted
   if (!preg_match('/^"(\\\\"|[^"])+"$/', 
                   str_replace("\\\\","",$local)))
   {
      $isValid = false;
   }
}

外部测试中的正则表达式查找允许的或转义的字符序列。如果失败,则内部测试查找转义的引号字符序列或引号对内的任何其他字符。

如果您正在验证作为 POST 数据输入的电子邮件地址(这很可能),则必须小心包含反斜杠 (\)、单引号 (') 或双引号字符 (") 的输入。PHP 可能不会使用额外的反斜杠字符来转义这些字符,无论它们在 POST 数据中何处出现。此行为的名称是 magic_quotes_gpc,其中 gpc 代表 get、post、cookie。您可以让您的代码调用函数 get_magic_quotes_gpc(),并在肯定响应时剥离添加的斜杠。您还可以确保 PHP.ini 文件禁用此“功能”。需要注意的另外两个设置是 magic_quotes_runtime 和 magic_quotes_sybase。

列表 5 中的两个正则表达式很有吸引力,因为它们相对容易理解,并且不需要重复允许的字符组 [A-Za-z0-9!#%&`_=\\/$\'*+?^{}|~.-]。这是一个给您的测试。为什么字符组在正斜杠之前需要两个反斜杠字符,而在单引号之前需要一个反斜杠字符?

列表 5 的外部测试的一个缺陷是,它通过了在字符串中任何位置包含点的本地部分字符串。要求编号 2 规定点不能开始或结束本地部分,并且它们不能连续出现两次或多次。我们可以通过将外部正则表达式扩展为 ^(a+(\.a+)+)$ 形式来解决此问题,其中 a 是 (\\\\.|[A-Za-z0-9!#%&`_=\\/$\'*+?^{}|~-])。我们可以这样做,但这会导致一个冗长、难以阅读、重复的表达式,难以让人相信。更清楚的做法是添加列表 6 中所示的简单检查。

列表 6. 检查本地部分中的点位置。

if ($local[0] == '.' || $local[$localLen-1] == '.')
{
   // local part starts or ends with '.'
   $isValid = false;
}
else if (preg_match('/\\.\\./', $local))
{
   // local part has two consecutive dots
   $isValid = false;
}

本地部分结束了。代码现在检查所有本地部分要求。检查域将完成电子邮件验证。代码可以分别检查域中的所有标签,就像列表 2 中所示的喜欢威士忌的代码一样,但是,正如前面暗示的那样,此处提供的解决方案允许 DNS 检查完成大部分域验证工作。

列表 7 对域部分进行粗略检查,以确保仅包含有效字符,且没有重复的点。它继续进行 MX 和 A 记录的 DNS 查找。仅当 MX 记录检查失败时,它才会检查 A 记录。列表 4 中的代码验证了域值的长度。

列表 7. 域检查

if (!preg_match('/^[A-Za-z0-9\\-\\.]+$/', $domain))
{
   // character not valid in domain part
   $isValid = false;
}
else if (preg_match('/\\.\\./', $domain))
{
   // domain part has two consecutive dots
   $isValid = false;
}
else if (!(checkdnsrr($domain,"MX") || checkdnsrr($domain, "A")))
{
   // domain not found in DNS
   $isValid = false;
}

那么,它好吗?您来决定。但是,最好测试一下逻辑,以确保它至少是正确的。列表 8 包含一系列电子邮件地址测试用例,任何电子邮件验证都应通过这些用例。

列表 8. 测试电子邮件验证功能。

<?php
require("validEmail.php"); // your favorite here

function testEmail($email)
{
  echo $email;
  $pass = validEmail($email);
  if ($pass)
  {
    echo " is valid.\n";
  }
  else
  {
    echo " is not valid.\n";
  }
  return $pass;
}

$pass = true;
echo "All of these should succeed:\n";
$pass &= testEmail("dclo@us.ibm.com");
$pass &= testEmail("abc\\@def@example.com");
$pass &= testEmail("abc\\\\@example.com");
$pass &= testEmail("Fred\\ Bloggs@example.com");
$pass &= testEmail("Joe.\\\\Blow@example.com");
$pass &= testEmail("\"Abc@def\"@example.com");
$pass &= testEmail("\"Fred Bloggs\"@example.com");
$pass &= testEmail("customer/department=shipping@example.com");
$pass &= testEmail("\$A12345@example.com");
$pass &= testEmail("!def!xyz%abc@example.com");
$pass &= testEmail("_somename@example.com");
$pass &= testEmail("user+mailbox@example.com");
$pass &= testEmail("peter.piper@example.com");
$pass &= testEmail("Doug\\ \\\"Ace\\\"\\ Lovell@example.com");
$pass &= testEmail("\"Doug \\\"Ace\\\" L.\"@example.com");
echo "\nAll of these should fail:\n";
$pass &= !testEmail("abc@def@example.com");
$pass &= !testEmail("abc\\\\@def@example.com");
$pass &= !testEmail("abc\\@example.com");
$pass &= !testEmail("@example.com");
$pass &= !testEmail("doug@");
$pass &= !testEmail("\"qu@example.com");
$pass &= !testEmail("ote\"@example.com");
$pass &= !testEmail(".dot@example.com");
$pass &= !testEmail("dot.@example.com");
$pass &= !testEmail("two..dot@example.com");
$pass &= !testEmail("\"Doug \"Ace\" L.\"@example.com");
$pass &= !testEmail("Doug\\ \\\"Ace\\\"\\ L\\.@example.com");
$pass &= !testEmail("hello world@example.com");
$pass &= !testEmail("gatsby@f.sc.ot.t.f.i.tzg.era.l.d.");
echo "\nThe email validation ";
if ($pass)
{
   echo "passes all tests.\n";
}
else
{
   echo "is deficient.\n";
}
?>

请务必运行测试以查看有效和被拒绝的电子邮件地址,PHP 字符串内的双重转义 (\\) 往往会模糊地址。您面临的挑战是将您最喜欢的电子邮件验证代码提交到此测试。请放心,列表 9 中的代码确实通过了!

列表 9 包含用于验证电子邮件地址的完整函数。它不像许多函数那样简洁——当然不是一行代码。但是,它易于阅读和理解,并且可以正确地接受和拒绝许多其他已发布的函数错误地拒绝和接受的电子邮件地址。该函数大致按照成本递增的顺序排列验证测试。特别是,更复杂的正则表达式,当然还有 DNS 查找,都放在最后。

列表 9. 完整的电子邮件验证函数

/**
Validate an email address.
Provide email address (raw input)
Returns true if the email address has the email 
address format and the domain exists.
*/
function validEmail($email)
{
   $isValid = true;
   $atIndex = strrpos($email, "@");
   if (is_bool($atIndex) && !$atIndex)
   {
      $isValid = false;
   }
   else
   {
      $domain = substr($email, $atIndex+1);
      $local = substr($email, 0, $atIndex);
      $localLen = strlen($local);
      $domainLen = strlen($domain);
      if ($localLen < 1 || $localLen > 64)
      {
         // local part length exceeded
         $isValid = false;
      }
      else if ($domainLen < 1 || $domainLen > 255)
      {
         // domain part length exceeded
         $isValid = false;
      }
      else if ($local[0] == '.' || $local[$localLen-1] == '.')
      {
         // local part starts or ends with '.'
         $isValid = false;
      }
      else if (preg_match('/\\.\\./', $local))
      {
         // local part has two consecutive dots
         $isValid = false;
      }
      else if (!preg_match('/^[A-Za-z0-9\\-\\.]+$/', $domain))
      {
         // character not valid in domain part
         $isValid = false;
      }
      else if (preg_match('/\\.\\./', $domain))
      {
         // domain part has two consecutive dots
         $isValid = false;
      }
      else if
(!preg_match('/^(\\\\.|[A-Za-z0-9!#%&`_=\\/$\'*+?^{}|~.-])+$/',
                 str_replace("\\\\","",$local)))
      {
         // character not valid in local part unless 
         // local part is quoted
         if (!preg_match('/^"(\\\\"|[^"])+"$/',
             str_replace("\\\\","",$local)))
         {
            $isValid = false;
         }
      }
      if ($isValid && !(checkdnsrr($domain,"MX") || 
 ↪checkdnsrr($domain,"A")))
      {
         // domain not found in DNS
         $isValid = false;
      }
   }
   return $isValid;
}

广而告之!常见的用法和广泛的草率编码可能会为电子邮件地址建立事实上的标准,该标准比记录的正式标准更具限制性。如果您想愚弄垃圾邮件机器人,请采用像 {^c\@**Dog^}@cartoon.com 这样的电子邮件地址。不幸的是,您也可能会愚弄一些合法的电子商务网站。您认为哪个会更快适应?

Douglas Lovell 是 IBM Research 的软件工程师,《XSL 格式化对象开发者手册》(Sams 出版社出版)的作者,以及 iac52.org 的网站编辑。

加载 Disqus 评论