Проверка адресов электронной почты в PHP, правильный путь

Validate an E-Mail Address with PHP, the Right Way
June 1st, 2007 by Douglas Lovell in Webmaster

Документ IETF, RFC 3696, “Application Techniques for Checking and Transformation of Names”, написанный Джоном Кленсином, предлагает некоторые примеры правильных e-mail адресов, которые, тем не менее, отвергаются большинством валидаторов, используемых в PHP-коде.
Адреса: Abc\@def@example.com, customer/department=shipping@example.com и !def!xyz%abc@example.com с точки зрения RFC все являются правильными. Одна из широко распространенных функций проверки, содержащая шаблон:

"^[_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.

Listing 1 показывает пример из PHP Dev Shed (www.devshed.com/c/a/PHP/Email-Address-Verification-with-PHP/2). Этот код содержит по крайней мере три ошибки. Он не поддерживает валидные символы, такие как %. Во-вторых, он разделяет имя пользователя и доменное имя по символу @. Правильный адрес вроде Abc\@def@example.com обрушит этот код. В-третьих, он не понимает формат DNS-записей для хостов. Хосты с А записью должны иметь возможность принимать почту, при этом не обязательно имея МХ запись.

Listing 1. Неправильная проверка E-mail адресов 

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;

Одно из более приемлемых решений пришло из блога Дейва Чайлда с ilovejackdaniels.com. Оно показано в Listing 2 (www.ilovejackdaniels.com/php/email-address-validation). Дейв не только любит старое американское виски, но также проделал некоторую домашнюю работу: прочитал RFC 2822, где и нашел правильные диапазоны символов для почтовых адресов. Около 50 человек прокомментировали это решение на сайте, они внесли несколько изменений, которые были добавлены в первоначальный вариант. Однако ошибкой этой коллективной разработки является тот факт, что она не приемлет экранированные символы, такие как \@, в составе имени пользователя. Она отвергнет адреса, содержащие более чем одно вхождение @, поэтому не получится разбивать адрес с использованием разделителя("@", $email). В этом коде прикладывается масса усилий к тому, чтобы проверить правильность длины каждого компонента, а может быть нужно было использовать функции разрешения доменного имени?

Listing 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 “Domain Implementation and Specification”, RFC 2234 “ABNF for Syntax Specifications”, RFC 2821 “Simple Mail Transfer Protocol”, RFC 2822 “Internet Message Format”, в дополнение к представленному ранее RFC 3696, все содержат информацию, касающуюся проверки правильности e-mail адресов. RFC 2822 - развитие RFC 822 “Standard for ARPA Internet Text Messages” и он делает прежний документ неактуальным.

Требования к правильным адресам:

1. Адрес содержит локальную часть и домен, разделенные символом @ (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. Доменное имя должно быть разрешаемым с помощью А или МХ DNS-записи (RFC 2821 3.6).

Стандартное соглашение имеет в виду, что символы имени должны поддерживать семибитные кодировки, то есть в реальности это ASCII-символы и никаких многобайтных кодировок.

Разработка правильного валидатора e-mail адресов

Да, целая куча требований! Хотя большая их часть относится к локальной части имени. Значит имеет смысл вначале разделить строку адреса на локальное и доменное имя. Требования 2-5 касаются локального имени и 6-10 - доменного.

Поскольку адреса Abc\@def@example.com и "Abc@def"@example.com - правильные, разделитель вроде $split = explode("@", $email) будет работать не всегда. Можно, конечно, заменить их по ходу, типа $cleanat = str_replace("\\@", ""); но этот хак не отследит патологических случаев вроде Abc\\@example.com. К счастью, такая эскейп-последовательность около @ не поддерживается в доменной части. Таким образом, разделителем должно быть последнее вхождение @. Для поиска последнего вхождения воспользуемся strrpos.

Listing 3 предлагает лучший метод для разделения адреса. strrpos возвращает булев тип, то есть false, если в строке адреса вообще нет @.

Listing 3. Splitting the Local Part and Domain

$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
}

Резонно стартовать с более простых проверок, например если требования длины частей не выполнены, нет смысла делать более сложные проверочные операции.

Listing 4. Length Tests for Local Part and Domain

$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+)*), только валидные символы. Вторая форма более проста, чем первая, с нее и имеет смысл начать. Нет смысла проверять закавыченные формы, если не прошел проверку набор символов. Отдельную проблему представляет \@. Эта форма допускает дублирование слешей для получения слеша в интерпретированном варианте. Поэтому придется допускать \\\\\@ и отвергать \\\\@.

Возможно написать регулярное выражение для поиска необычных последовательностей слешей до не-слеш символа. Это возможно, но не круто. Факт, что обратный слеш предваряет эскейп-последовательность в регулярном выражении, но и в PHP строках тоже. Иными словами, интерпретированный слеш соответствует четырем слешам в регулярном выражении.

Решение - сразу разбить последовательности слешей на пары. str_replace выполнит работу. Listing 5 показывает тест для локальной части.

Listing 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 привлекательны, потому что они сравнительно просты для понимания и не требуют повторения групп символов. Тест: почему описатель группы символов требует два бэк-слеша перед прямым слешем и один бэк-слеш перед кавычкой?

Один недостаток внешнего теста в том, что он пропустит локальную часть, содержащую точки где угодно в имени. Требование номер 2 гласит, что точки не должны быть в начале и конце и не могут появляться рядом по двое и больше. Мы могли бы расширить выражение в форму ^(a+(\.a+)+)$, где а эквивалентно (\\\\.|[A-Za-z0-9!#%&`_=\\/$\'*+?^{}|~-]). Но это опять-таки не круто. Лучше где-нибудь вставить простую проверку, как показано в Листинге 6.

Listing 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, но как было отмечено выше, наше решение доверит большую часть работы функции разрешения имен.

Код в Listing 7 убеждается в том, что все представленные символы допустимы и что точки не ходят парами. Далее обратимся к DNS для проверки наличия записей А и МХ. А записи будут искаться только в том случае, если поиск МХ ничего не дал. Код из Листинга 4 проверит длину доменной части адреса.

Listing 7. Domain Checks

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 содержит ряд каверзных примеров адресов, которые должны преодолеть проверку.

Listing 8. Test the e-mail validation function.

<?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";
}
?>

Listing 9 содержит полный функционал для проверки адресов электронной почты. Код не настолько чёток и сжат как многие другие функции. И он несомненно не является однострочным. Однако он прост для чтения и понимания и он корректно пропускает кого следует и кого следует отвергает. Функции выстроены в соответствии с их относительным весом - простые проверки впереди, требующие регулярных выражений и тем более - работы с DNS, соответственно, в конце.

Listing 9. A Complete E-mail Validation Function

/**
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, будьте готовы к тому, что они не пройдут проверку на легитимность в каких-нибудь интернет-магазинах.

Дуглас Ловелл, инженер-программист из IBM Research, редактор сайта iac52.org.

Назад