О практичности регулярных выражений для обработки адресов электронной почты

О практичности регулярных выражений для обработки адресов электронной почты

3 апреля 2023 г.

Коллега недавно указал мне на сообщение в блоге: On бесполезность проверки регулярных выражений электронной почты. Для краткости в этой статье я буду называть это бесполезностью.

Я признаю, что, хотя задача написания регулярного выражения, которое может успешно определить, соответствует ли строка определению RFC 5322 для заголовка Интернет-сообщения, является занимательной задачей, Futility не является полезным руководством для практического программиста. .

Это связано с тем, что он объединяет заголовки сообщений RFC 5322 с адресными литералами RFC 5321; что на простом языке означает, что то, что составляет действительный адрес электронной почты SMTP, отличается от того, что составляет действительный заголовок сообщения в целом.

Это также потому, что это побуждает читателя увлечься пограничными случаями, которые теоретически возможны с точки зрения стандартов, но которые, как я покажу, имеют бесконечно малую вероятность возникновения «в дикой природе».

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

RFC 5321 заменяет 5322

Универсальность SMTP для передачи электронной почты означает, что на практике ни одно исследование форматирования адресов электронной почты не будет полным без внимательного прочтения соответствующего RFC IETF, которым является 5321.

5322 рассматривает адреса электронной почты как просто общий заголовок сообщения, к которому не применяются особые правила. Это означает, что комментарии, заключенные в круглые скобки, допустимы даже в имени домена.

набор тестов, упомянутый в Futility, включает 10 тестов, содержащих комментарии, диакритические знаки или символы Unicode, и указывает, что 8 из них представляют действительные адреса электронной почты.

Это неверно, поскольку в RFC 5321 прямо указано, что части доменных имен адресов электронной почты «ограничены для целей SMTP и состоят из последовательности букв, цифр и дефисов, взятых из набора символов ASCII. ”

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

Это также подразумевает некоторые другие практические соображения в контексте проверки, которые мы рассмотрим далее.

Имена почтовых ящиков в дикой природе

Согласно обоим документам RFC техническое название части адреса электронной почты слева от символа «@» — «почтовый ящик». Оба RFC допускают значительную свободу выбора символов, допустимых в части почтового ящика.

Единственное существенное практическое ограничение заключается в том, что кавычки или круглые скобки должны быть сбалансированы, что является реальной проблемой для проверки в ванильном регулярном выражении.

Однако реальные реализации почтовых ящиков снова являются мерой, которую должен использовать практический программист.

Как правило, люди, которые платят нам, не одобряют то, что 90 % наших оплачиваемых часов направляются на решение 10 % теоретических пограничных случаев, которые, возможно, вообще не существуют в реальной жизни.

Давайте посмотрим на основных поставщиков почтовых ящиков электронной почты, потребителей и компании и рассмотрим, какие типы адресов электронной почты они разрешают.

Что касается потребительской электронной почты, я провел первичное исследование, используя список из 5 280 739 адресов электронной почты, которые просочились из учетных записей Twitter.

Основываясь на 115 миллионах учетных записей Twitter, это дает нам уровень достоверности 99% с погрешностью 0,055% для всего населения Twitter, что было бы очень репрезентативным для общего населения всех адресов электронной почты в Интернете. Вот что я узнал:

* 82% адресов содержали только буквенно-цифровые символы ASCII,

* 15 % содержали только буквенно-цифровые символы ASCII и точки (точки ASCII) для 97 % всех адресов,

* 3 % содержат только буквы, цифры, точки и тире ASCII, что соответствует 100 % адресов электронной почты.

Однако это округленные 100%. Для любителей мелочей я также нашел:

* 38 адресов с подчеркиванием, что составляет 0,00072% от общего числа

* 27 со знаками плюс для 0,00051% и

* 1 адрес с символами Unicode, представляющими 0,00002% от общего числа.

Чистый эффект заключается в том, что если предположить, что почтовые ящики адресов электронной почты содержат только буквы, цифры, точки и тире ASCII, то точность для потребительских электронных писем будет лучше, чем 59.

Что касается корпоративной электронной почты, Datanyze сообщает, что 6 771 269 компаний используют 91 различное решение для хостинга электронной почты. Однако распределение Парето работает, и 95,19% этих почтовых ящиков размещены всего у 10 поставщиков услуг.

Gmail для бизнеса (доля рынка 34,35 %)

При создании почтового ящика Google разрешает использовать только буквы, цифры и точки ASCII. Однако он будет принимать знак плюса при получении электронной почты.

Microsoft Exchange Online (33,60%)

Допускаются только буквы, цифры и точки ASCII.

Почтовый хостинг GoDaddy (14,71%)

Использует Microsoft 365 и допускает использование только букв, цифр и точек ASCII.

7 дополнительных поставщиков (12,53%)

Не задокументировано.

К сожалению, мы можем быть уверены только в 82% компаний, и мы не знаем, сколько почтовых ящиков это представляет. Однако мы знаем, что из адресов электронной почты Twitter только 400 из 173 467 доменов имели более 100 отдельных почтовых ящиков.

Я считаю, что большинство из 99% оставшихся доменов были корпоративными адресами электронной почты.

Что касается политик именования почтовых ящиков на уровне сервера или домена, я предлагаю разумно принять эти 237 592 адреса электронной почты как представляющие совокупность 1 миллиарда рабочих адресов электронной почты с уровнем достоверности 99% и погрешностью 0,25%, что дает нам близко к 3 9, если предположить, что почтовый ящик адреса электронной почты содержит только буквенно-цифровые символы ASCII, точки и тире.

Случаи использования

Опять же, думая прежде всего о практичности, давайте рассмотрим, при каких обстоятельствах нам может потребоваться программная идентификация действительного адреса электронной почты.

Создание новой учетной записи/регистрация пользователей

В этом случае потенциальный новый клиент пытается создать учетную запись. Есть две высокоуровневые стратегии, которые мы могли бы рассмотреть. В первом случае мы пытаемся убедиться, что адрес электронной почты, который предоставляет новый пользователь, действителен, и синхронно приступаем к созданию учетной записи.

Есть две причины, по которым вы, возможно, не захотите использовать этот подход. Во-первых, хотя вы можете подтвердить, что адрес электронной почты имеет действительную форму, он может, тем не менее, не существовать.

Другая причина заключается в том, что в любом масштабе синхронный является тревожным словом, которое должно заставить прагматичного программиста вместо этого рассмотреть модель «выстрелил и забыл», в которой веб-интерфейс без сохранения состояния передает информацию формы микросервису или API, который будет асинхронно проверить электронное письмо, отправив уникальную ссылку, которая инициирует завершение процесса создания учетной записи.

Контактные формы

В случае простой контактной формы, которая часто используется для загрузки технических документов, потенциальным недостатком принятия строк, которые выглядят как действительный адрес электронной почты, но не являются, является то, что вы снижаете качество своей маркетинговой базы данных, не чтобы проверить, действительно ли существует адрес электронной почты.

Таким образом, еще раз повторю, что модель "выстрелил-забыл" лучше, чем программная проверка строки, введенной в форму.

Анализ журналов рефереров и других больших объемов данных.

Это приводит нас к реальному варианту использования программной идентификации адресов электронной почты в целом и регулярных выражений в частности: анонимизация или анализ больших фрагментов неструктурированного текста.

Я впервые столкнулся с этим вариантом использования, помогая исследователю безопасности, которому нужно было загрузить журналы реферера в базу данных обнаружения мошенничества. Журналы рефералов содержали адреса электронной почты, которые необходимо было анонимизировать, прежде чем покинуть огороженный сад компании.

Это были файлы с сотнями миллионов строк, и это были сотни файлов в день. «Строки» могут иметь длину около тысячи символов.

Итерация символов в строке, применение сложных тестов (например, является ли это первым вхождением @ в строку и является ли оно частью имени файла, такого как imagefile@2x.png< /code>?) использование циклов и стандартных строковых функций создало бы невероятно большую временную сложность.

Фактически, собственная команда разработчиков этой (очень крупной) компании объявила это невыполнимой задачей.

Я написал следующее скомпилированное регулярное выражение:

search_pattern = re.compile("[a-zA-Z0-9!#$%'*+-^_`{|}~.]+@|%40(?!(w+.) **(jpg|png))(([w-]+.)+([w-]+)))")

И поместил его в следующее понимание списка Python:

results = [(re.sub(search_pattern, "redacted@example.com", строка)) для строки в файле]

Я не помню, насколько быстро это было, но это было быстро. Мой друг мог запустить его на ноутбуке и сделать за считанные минуты. Это было точно. Мы зафиксировали его на уровне 5 9 с учетом как ложноотрицательных, так и ложноположительных результатов.

Моя работа была несколько облегчена тем фактом, что журналы рефералов; они могли содержать только «разрешенные» символы URL, поэтому я смог отобразить любые коллизии, которые я задокументировал в репозитории readme.

Кроме того, я мог бы сделать это еще проще (и быстрее), если бы провел анализ адресов электронной почты и с уверенностью узнал, что все, что нужно для достижения цели 5 9, — это буквенно-цифровые символы ASCII, точки и тире.

Тем не менее, это хороший пример практичности и выбора решения в соответствии с реальной проблемой, которую необходимо решить.

Одной из величайших цитат во всей истории и знаниях программирования является указание великого Уорда Каннингема. чтобы на секунду вспомнить, чего именно вы пытаетесь достичь, а затем спросить себя: «Какая самая простая вещь может сработать?»

В случае использования синтаксического анализа (и, возможно, преобразования) адреса электронной почты из большого количества неструктурированного текста это решение было определенно самым простым, что я мог придумать.

Поваренная книга с комментариями

Как я сказал в начале, идея создания регулярного выражения, совместимого с RFC 5322, показалась мне забавной, поэтому я покажу вам составные фрагменты регулярного выражения для работы с различными аспектами стандарта и объясню, как регулярные выражения действуют в соответствии с этой политикой. В конце я покажу вам, как это выглядит в собранном виде.

Структура адреса электронной почты:

  1. Почтовый ящик
  2. Разрешенные символы
  3. Одиночные точки (двойные точки запрещены)
  4. Свернутое пустое пространство (безумие RFC 5322)
  5. (Полное решение для регулярных выражений также должно включать сбалансированные круглые скобки и/или кавычки, но у меня их пока нет. И, возможно, никогда не будет.)
  6. Разделитель (@)
  7. Доменное имя
  8. Стандартные анализируемые домены DNS
  9. Литералы адресов IPv4
  10. Литералы адресов IPv6
  11. IPv6-полный
  12. IPv6-comp (для сжатия)
  13. 1-я форма (2+ 16-битные группы нулей в середине)
  14. 2-я форма (2+ 16-битные группы нулей в начале)
  15. 3-я форма (2 16-битные группы нулей в конце)
  16. 4-я форма (8 16-битных нулевых групп)
  17. IPv6v4-полный
  18. Компьютер IPv6v4 (сжатый)
  19. 1-я форма
  20. 2 форма
  21. 3 класс
  22. 4 класс

Теперь о регулярном выражении.

Почтовый ящик

^(?<почтовый ящик>([a-zA-Z0-9+!#$%&'*-/=?+_{}|~]|(?<singleDot>( ?<!.)(?<!^).(?!.))|(?<foldedWhiteSpace>s?&#13;&#10;.)){1,64})

Во-первых, у нас есть ^, который «привязывает» первый символ к началу строки. Это следует использовать при проверке строки, которая должна содержать только действительный адрес электронной почты. Это гарантирует, что первый символ является допустимым.

Если вместо этого используется вариант использования для поиска сообщения электронной почты в более длинной строке, опустите привязку.

Затем у нас есть (?<mailbox>. Это имя группы захвата для удобства. Внутри захваченной группы находятся три фрагмента регулярного выражения, разделенные символом альтернативного соответствия | что означает, что символ может соответствовать любому из трех выражений.

Часть написания хорошего (эффективного и предсказуемого) регулярного выражения заключается в том, чтобы убедиться, что три выражения являются взаимоисключающими. То есть подстрока, совпадающая с одной, определенно не совпадет ни с одной из двух других. Для этого мы используем определенные классы символов вместо страшного .*.

Безусловно допустимые символы

[a-zA-Z0-9+!#$%&'*-/=?+_{}|~]

Первое альтернативное совпадение — это класс символов, заключенный в квадратные скобки, который содержит все символы ASCII, разрешенные в почтовом ящике электронной почты, кроме точки, «свернутого пробела», двойной кавычки и скобка.

Причина, по которой мы их исключили, заключается в том, что они являются только условно законными, то есть существуют правила их использования, которые должны быть проверены. Мы обработаем их в следующих двух альтернативных матчах.

одна точка

(?<singleDot>(?<!.)(?<!^).(?!.))

Первое такое правило касается точки (точки). В почтовом ящике точка разрешена только в качестве разделителя между двумя строками допустимых символов, поэтому две последовательные точки недопустимы.

Чтобы предотвратить совпадение, если есть две последовательные точки, мы используем регулярное выражение отрицательный просмотр назад (?<!.), который указывает, что следующий символ (точка) не будет соответствует, если перед ним стоит точка.

Осмотр регулярных выражений может быть сцеплен. Прежде чем мы доберемся до точки (?!^), выполняется еще один обратный просмотр, который обеспечивает выполнение правила, согласно которому точка не может быть первым символом почтового ящика.

После точки идет отрицательный look_ahead_ _(?!.)_, который предотвращает сопоставление точки, если за ней сразу следует точка.

сложенное белое пространство

(?<foldedWhiteSpace>s?&#13;&#10;.)

Это какой-то бред RFC 5322 о разрешении многострочных заголовков в сообщениях. Готов поспорить, что в истории почтовых адресов еще не было человека, который всерьез создал адрес с многострочным почтовым ящиком (может, в шутку).

Но я играю в игру 5322, так что вот она, строка символов Юникода, которая создает свернутый пробел в качестве альтернативного совпадения.

Сбалансированные двойные кавычки и скобки

Оба RFC допускают использование двойных кавычек для заключения (или экранирования) символов, которые обычно недопустимы.

Они также позволяют заключать комментарии в круглые скобки, чтобы они были удобочитаемыми, но не учитывались агентом пересылки почты (MTA) при интерпретации адреса.

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

У меня возникает соблазн написать, что я обнаружил демонстрационное чудо, однако это, вероятно, работает только посмертно. Правда в том, что это нетривиально в ванильном регулярном выражении.

У меня есть интуиция, что рекурсивная природа «жадных» регулярных выражений может быть использована с пользой, однако я вряд ли посвятю время, необходимое для решения этой проблемы в течение следующих нескольких лет, и поэтому в лучших традициях я оставлю ее. в качестве упражнения для читателя.

Длина почтового ящика

{1,64}

Что на самом деле имеет значение, так это максимальная длина почтового ящика: 64 символа.

Поэтому после того, как мы закроем группу захвата почтового ящика с помощью закрывающей скобки, мы используем квантификатор между фигурными скобками, чтобы указать, что мы должны сопоставить любой из наших альтернатив по крайней мере один раз и не более 64 раз.< /p>

подписать

s?(?<atSign>(?<!-)(?<!.)@(?!@))

Фрагмент разделителя начинается с специального регистра s? потому что, согласно Futility, допустим пробел непосредственно перед разделителем, и я просто верю им на слово.

Остальная часть группы захвата следует схеме, аналогичной singleDot; он не будет совпадать, если ему предшествует точка или тире или если сразу за ним следует другой @.

Имя домена

Здесь, как и в почтовом ящике, у нас есть 3 альтернативных совпадения. И последний из них вложил в него еще 4 альтернативных совпадения.

Стандартный синтаксический анализ DNS

(?<dns>[[:alnum:]]([[:alnum:]-]{0,63}.){1,24}[[:alnum:]-]{1 ,63}[[:alnum:]])

Это не пройдет несколько тестов в Futility, но, как упоминалось ранее, оно строго соответствует RFC 5321, за которым стоит последнее слово.

IPv4

(?<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

(?<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.

Я не буду аннотировать каждую подгруппу регулярного выражения IPv6, но я назвал каждую подгруппу, чтобы ее было легко разобрать и посмотреть, что происходит.

На самом деле ничего особенно интересного, за исключением, может быть, того, как я объединил жадное сопоставление на «левой» стороне и нежадное сопоставление на «правой» в группе захвата IUPv6Comp1.

Полный Монти

Я сохранил окончательное регулярное выражение вместе с тестовыми данными из Futility и улучшил некоторые собственные тестовые примеры IPv6 в Регулярное выражение101. Я надеюсь, что вам понравилась эта статья, и что она окажется полезной и сэкономит время для многих из вас.

AZW


Оригинал