由John Klensin编写的互联网任务工程组(IETF)文档RFC 3696 “应用程序技术检查和命名转换”给出了多个真实有效的EMAIL地址,但很不幸的是这些地址却被大多数PHP校验程序给拒绝了,地址Abc\@[email protected],customer/[email protected] 和!def!xyz%[email protected]都是有效的,下面是在有关文献中给出的一个非常流行正则表达式,但它拒绝了前面给出的所有EMAIL地址:
^[_a-z0-9-]+(\.[_a-z0-9-]+)*@[a-z0-9-]+(\.[a-z0-9-]+)*(\.[a-z]{2,3})$ |
这个正则表达式只允许下划线(_)和连字符(-),数字和大小写字母。即使在此之前先对字母做了大小写转换,也会拒绝那些包含斜线(/),等号(=),惊叹号(!)和百分号(%)的地址。这个表达式也要求最高级的域组件至少要有2个或3个字符,因此也会拒绝有效的域,如.museum。
另一个受人喜欢的正则表达式是:
^[a-zA-Z0-9_.-]+@[a-zA-Z0-9-]+.[a-zA-Z0-9-.]+$ |
这个正则表达式也会拒绝前面段落给出的有效示例地址,它优雅地解决了大写字母问题,并且当最高级域名只有2个或3个字符也不会报错了,但它又允许无效的域名,如example..com。
清单1显示了一个来自PHP Dev Shed(http://www.devshed.com/c/a/PHP/Email-Address-Verification-with-PHP/2)的示例,这个代码包括(至少)三个错误:
首先,它不能识别出许多有效的email地址字符,如百分号(%)。
第二,它将email地址在@符合处分成用户名和域两部分,如果email地址在符号处包括一个引用,如Abc\@[email protected],将会中断这个代码。
第三,它不能检查主机地址DNS记录,有DNS A记录的主机会接受email地址,不一定非得要MX记录,我没有在PHP Dev Shed上找作者的岔,至少有100位浏览者做了4-5星的评分。
清单1.一个错误的email校验程序
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; |
一个比较好的解决方案来自IloveJackDaniel(http://ilovejackdaniels.com/)的Dave Child的博客,显示在清单2中(http://www.ilovejackdaniels.com/php/email-address-validation)。
Dave不仅喜欢陈年的美国威士忌,而且它也有一个副业,就是阅读RFC 2822,找出在一个email地址用户名有效的字符范围,至少有50人对这个解决方案做了评论,包括一些已经被合并到最初的方案中的纠正,不过这个由大家共同开发的代码仍然有缺陷,主要是它不能允许在用户名中出现转移字符,如\@,并且当不止一个地址符号时也会拒绝,因此就不能使用@来将用户名与域进行划分,个人认为这会扩大检查域名中每个组件影响的范围。
清单2:IloveJackDaniel提供的更好的示例
function check_email_address($email) { //首先,我们检查这里的@符号,然后看其长度是否正确。 if (!ereg("^[^@]{1,64}@[^@]{1,255}$", $email)) { // email无效,因为某个小段中的字符数量错误或@符号的数量错误 return false; } //将其分割成小段 $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; } } //检查域是否是一个IP地址,如果不是,它应该是一个有效的域 if (!ereg("^\[?[0-9\.]+\]?$", $email_array[1])) { $domain_array = explode(".", $email_array[1]); if (sizeof($domain_array) < 2) { return false; // 域长度不够 } 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“语法说明书的扩充BNF:ABNF”,RFC 2821“简单邮件传输协议”,RFC 2822“Internet消息格式”,此外还有RFC 3696(前面已经引用过),这些文档都包括了email地址验证的信息,RFC 2822取代了RFC 822“标准ARPA Internet文本消息”,并使其过时。
下面列出了一个有效email地址的必要条件,并列出了引用的相关文档:
1、一个email地址是有本地部分和域两部分组成的,以@作为它们之间的分界线。(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、域部分是以字母开头的,后面跟0、字母、数字或连字符(-),以字母或数字字符结尾。(RFC 1035 2.3.1)
8、一个域标记的最大字符长度为63。(RFC 1035 2.3.1)
9、整个域部分的最大字符长度为255。(RFC 2821 4.5.3.1)
10、域必须具有完全合格的,可解析的DNS A记录和MX记录。(RFC 2821 3.6)
必要条件编号4包括一个过时的引用,但它是经过论证可以使用的,代理商很可能会屏蔽掉这类地址,但对于现有的这类地址,它们仍然是合法的。
标准采用的是七位字符编码,而不是多位字符,因此,按照RFC 2234的定义,字母要符合Latin字母范围a-z和A-Z,同样,数字指的是0-9,国际标准Unicode字母是不被接受的,即使是以UTF-8进行编码也不行,ASCII码仍然是统治地位。
开发一个更好的email校验器
必要条件实在是太多了!它们中绝大多数都涉及到本地部分和域部分,其中2-5应用到本地部分,6-10应用于域部分。
在本地名称部分还是可以使用@字符的,如Abc\@[email protected]和"Abc@def"@example.com,这就意味着以@标记作为分水岭是一个错误,$split = explode("@", $email);与此类似的技巧也是不行的,我们可以尝试移除@标记,$cleanat = str_replace("\\@", "");,但这样又会错过一些病例,如Abc\\@example.com,幸运的是,在域部分是不运行以@标记作为分水岭的,最后一个@字符必须明确为分隔符,这种分隔本地部分和域部分的方法使用了strrpos函数在email字符串中查找最后一个@字符。
清单3提供了一个更好的分隔email字符串本地和域部分的方法,如果在email字符串中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); // 域和本地部分一起 } |
让我们从简单的开始说起,检查本地部分和域部分的长度非常简单,如果这些测试都失败了,就不用再做更多的测试,清单4显示了测试长度的代码。
清单4:本地部分和域部分长度测试
$localLen = strlen($local); $domainLen = strlen($domain); if ($localLen < 1 || $localLen > 64) { // 本地部分长度超出 $isValid = false; } else if ($domainLen < 1 || $domainLen > 255) { //域部分长度超出 $isValid = false; } |
现在,本地部分有一到两种格式,它可能以嵌入式引用开始或结束,Doug \"Ace\" L.就是一个本地部分的示例,本地部分的另一种格式是(a+(\.a+)*),它使用了大量的标准字符,第二种比第一种更常见,因此要首先检查它,未引用格式失败后请寻求引用格式。
引用字符时使用反斜杠(\@),这种格式允许使用两个反斜杠来在结果中获得一个反斜杠字符(\\),这就意味着我们还得为没有反斜杠的字符串检查反斜杠的数量可能为奇数,我们需要允\\\\\@,拒绝\\\\@。
写一个正则表达式查找在非反斜杠字符前的字符串中反斜杠的奇数值还是可以实现的,虽然可以实现,但并不完美,实际上在PHP和正则表达式中,反斜杠是一个转义字符,在PHP中我们需要写四个反斜杠字符代表正则表达式显示一个单一的反斜杠。
另一个动人的解决方案是在开始检查之前简单地将测试字符串中的反斜杠字符对剥离出去,这里使用了str_replace函数,清单5显示了一个本地部分内容的测试代码。
清单5:校验本地部分内容的局部测试
if (!preg_match('/^(\\\\.|[A-Za-z0-9!#%&`_=\\/$\'*+?^{}|~.-])+$/', str_replace("\\\\","",$local))) { if (!preg_match('/^"(\\\\"|[^"])+"$/', str_replace("\\\\","",$local))) { $isValid = false; } } |
这里的外部测试代码中正则表达式看起来就是一串被接受的字符序列或转移字符,内部测试代码实际上就是一串引用的转移字符或由一对引号括起来的其他字符。
如果你正在校验一个进入POST数据的email地址,你需要注意斜线(\)、单引号(')和双引号(")字符,PHP可能会也可能不会转义这些字符,对应这个行为的是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; } |
将本地部分看作一个包,现在要检查本地部分所有的必要条件,检查域后将完成email地址的校验,代码将会独立地检查域的每一个标记,与清单2中的代码一样,但这个解决办法代表这里允许DNS检查,检查域是否有效。
清单7做了一个粗略的检查,确保域部分只包括可用的字符,无重复的点,然后继续查询DNS A记录和MX记录。仅当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包括了一系列的email地址测试用例。
清单8:email有效性测试函数
<?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("[email protected]"); $pass &= testEmail("abc\\@[email protected]"); $pass &= testEmail("abc\\\\@example.com"); $pass &= testEmail("Fred\\ [email protected]"); $pass &= testEmail("Joe.\\\\[email protected]"); $pass &= testEmail("\"Abc@def\"@example.com"); $pass &= testEmail("\"Fred Bloggs\"@example.com"); $pass &= testEmail("customer/[email protected]"); $pass &= testEmail("\[email protected]"); $pass &= testEmail("!def!xyz%[email protected]"); $pass &= testEmail("[email protected]"); $pass &= testEmail("[email protected]"); $pass &= testEmail("[email protected]"); $pass &= testEmail("Doug\\ \\\"Ace\\\"\\ [email protected]"); $pass &= testEmail("\"Doug \\\"Ace\\\" L.\"@example.com"); echo "\nAll of these should fail:\n"; $pass &= !testEmail("abc@[email protected]"); $pass &= !testEmail("abc\\\\@[email protected]"); $pass &= !testEmail("abc\\@example.com"); $pass &= !testEmail("@example.com"); $pass &= !testEmail("doug@"); $pass &= !testEmail("\"[email protected]"); $pass &= !testEmail("ote\"@example.com"); $pass &= !testEmail("[email protected]"); $pass &= !testEmail("[email protected]"); $pass &= !testEmail("[email protected]"); $pass &= !testEmail("\"Doug \"Ace\" L.\"@example.com"); $pass &= !testEmail("Doug\\ \\\"Ace\\\"\\ L\\[email protected]"); $pass &= !testEmail("hello [email protected]"); $pass &= !testEmail("[email protected]."); echo "\nThe email validation "; if ($pass) { echo "passes all tests.\n"; } else { echo "is deficient.\n"; } ?> |
请务必进行测试,查看email地址是否合法,PHP脚本中的双斜线(\\)转义字符很容易混淆,你编写的email地址校验代码将接受这个测试代码的挑战,确保清单9中的代码测试通过。
清单9中的代码包括一个完整的校验email地址的函数,它不能再做任何简化了,但它仍然保持了简单易懂,易读,容易理解的风格,它能够准确地接受或拒绝email地址,而其他函数可能效果不一样。
清单9:完整的email地址校验函数
** 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这样的邮件地址吧,但这样可能将电子商务网站也一并欺骗了。