Точная проверка синтаксиса электронной почты (без серьезного)

Так друг мне показался, как странные и конкретные общие правила синтаксиса электронной почты. Например, письма могут иметь «комментарии». В основном вы можете помещать символы в круглые скобки, которые просто игнорируются. Таким образом, это не только верно, email(this seems extremely redundant)@email.com - это тот же адрес электронной почты, что и [email protected].

Теперь большинство поставщиков электронной почты имеют более простые и простые в работе ограничения (например, только ascii, цифры, точки и тире). Но я подумал, что было бы весело провести время, чтобы следовать точным рекомендациям, насколько я мог. Я не буду описывать все конкретные здесь, так как я (надеюсь) сделал все ясно в самом коде.

Я подробно рассмотрел шрифт всех знаний, Wikipedia для сводки о правилах.

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

"""This module will evaluate whether a string is a valid email or not.

It is based on the criteria laid out in RFC documents, summarised here:
https://en.wikipedia.org/wiki/Email_address#Syntax

Many email providers will restrict these further, but this module is primarily
for testing whether an email is syntactically valid or not.

Calling validate() will run all tests in intelligent order.
Any error found will raise an InvalidEmail error, but this also inherits from
ValueError, so errors can be caught with either of them.

If you're using any other functions, note that some of the tests will return
a modified string for the convenience of how the default tests are structured.
Just calling valid_quotes(string) will work fine, just don't use the assigned
value unless you want the quoted sections removed.
Errors will be raised from the function regardless.

>>> validate("[email protected]")
>>> validate("[email protected]")
>>> validate("[email protected]")
Traceback (most recent call last):
  ...
InvalidEmail: Consecutive periods are not permitted.
>>> validate("[email protected]")
>>> validate("[email protected]")
>>> validate("john.smith(comment)@example.com")
>>> validate("(comment)[email protected]")
>>> validate("(comment)[email protected](comment).com")
>>> validate('"abcdefghixyz"@example.com')
>>> validate('abc."defghi"[email protected]')
Traceback (most recent call last):
  ...
InvalidEmail: Local may neither start nor end with a period.
>>> validate('abc."def<>ghi"[email protected]')
Traceback (most recent call last):
  ...
InvalidEmail: Incorrect double quotes formatting.
>>> validate('abc."def<>ghi"[email protected]')
>>> validate('[email protected][192.168.2.1]')
>>> validate('[email protected][192.168.12.2.1]')
Traceback (most recent call last):
  ...
InvalidEmail: IPv4 domain must have 4 period separated numbers.
>>> validate('[email protected][IPv6:2001:db8::1]')
>>> validate('[email protected](comment)example.com')
"""


import re

from string import ascii_letters, digits


HEX_BASE = 16
MAX_ADDRESS_LEN = 256
MAX_LOCAL_LEN = 64
MAX_DOMAIN_LEN = 253
MAX_DOMAIN_SECTION_LEN = 63

MIN_UTF8_CODE = 128
MAX_UTF8_CODE = 65536
MAX_IPV4_NUM = 256

IPV6_PREFIX = 'IPv6:'
VALID_CHARACTERS = ascii_letters + digits + "!#$%&'*+-/=?^_`{|}~"
EXTENDED_CHARACTERS = VALID_CHARACTERS + r' "(),:;<>@[\]'
DOMAIN_CHARACTERS = ascii_letters + digits + '-.'

# Find quote enclosed sections, but ignore \" patterns.
COMMENT_PATTERN = re.compile(r'\(.*?\)')
QUOTE_PATTERN = re.compile(r'(^(?<!\\)".*?(?<!\\)"$|\.(?<!\\)".*?(?<!\\)"\.)')

class InvalidEmail(ValueError):
    """String is not a valid Email."""

def strip_comments(s):
    """Return s with comments removed.

    Comments in an email address are any characters enclosed in parentheses.
    These are essentially ignored, and do not affect what the address is.

    >>> strip_comments('exam(alammma)[email protected](lectronic)mail.com')
    '[email protected]'"""

    return re.sub(COMMENT_PATTERN, "", s)

def valid_quotes(local):
    """Parse a section of the local part that's in double quotation marks.

    There's an extended range of characters permitted inside double quotes.
    Including: "(),:;<>@[\] and space.
    However " and \ must be escaped by a backslash to be valid.

    >>> valid_quotes('"any special characters <>"')
    ''
    >>> valid_quotes('this."is".quoted')
    'this.quoted'
    >>> valid_quotes('this"wrongly"quoted')
    Traceback (most recent call last):
      ...
    InvalidEmail: Incorrect double quotes formatting.
    >>> valid_quotes('still."wrong"')
    Traceback (most recent call last):
      ...
    InvalidEmail: Incorrect double quotes formatting."""

    quotes = re.findall(QUOTE_PATTERN, local)
    if not quotes and '"' in local:
        raise InvalidEmail("Incorrect double quotes formatting.")

    for quote in quotes:
        if any(char not in EXTENDED_CHARACTERS for char in quote.strip('.')):
            raise InvalidEmail("Invalid characters used in quotes.")

        # Remove valid escape characters, and see if any invalid ones remain
        stripped = quote.replace('\\\\', '').replace('\\"', '"').strip('".')
        if '\\' in stripped:
            raise InvalidEmail('\ must be paired with " or another \.')
        if '"' in stripped:
            raise InvalidEmail('Unescaped " found.')

        # Test if start and end are both periods
        # If so, one of them should be removed to prevent double quote errors
        if quote.endswith('.'):
            quote = quote[:-1]
        local = local.replace(quote, '')
    return local

def valid_period(local):
    """Raise error for invalid period, return local without any periods.

    Raises InvalidEmail if local starts or ends with a period or 
    if local has consecutive periods.

    >>> valid_period('example.email')
    'exampleemail'
    >>> valid_period('.example')
    Traceback (most recent call last):
      ...
    InvalidEmail: Local may neither start nor end with a period."""

    if local.startswith('.') or local.endswith('.'):
        raise InvalidEmail("Local may neither start nor end with a period.")

    if '..' in local:
        raise InvalidEmail("Consecutive periods are not permitted.")

    return local.replace('.', '')

def valid_local_characters(local):
    """Raise error if char isn't in VALID_CHARACTERS or the UTF8 code range"""

    if any(not MIN_UTF8_CODE <= ord(char) <= MAX_UTF8_CODE
           and char not in VALID_CHARACTERS for char in local):
        raise InvalidEmail("Invalid character in local.")

def valid_local(local):
    """Raise error if any syntax rules are broken in the local part."""

    local = valid_quotes(local)
    local = valid_period(local)
    valid_local_characters(local)


def valid_domain_lengths(domain):
    """Raise error if the domain or any section of it is too long.

    >>> valid_domain_lengths('long.' * 52)
    Traceback (most recent call last):
      ...
    InvalidEmail: Domain length must not exceed 253 characters.
    >>> valid_domain_lengths('proper.example.com')"""

    if len(domain.rstrip('.')) > MAX_DOMAIN_LEN:
        raise InvalidEmail("Domain length must not exceed {} characters."
                           .format(MAX_DOMAIN_LEN))

    sections = domain.split('.')
    if any(1 > len(section) > MAX_DOMAIN_SECTION_LEN for section in sections):
        raise InvalidEmail("Invalid section length between domain periods.")

def valid_ipv4(ip):
    """Raise error if ip doesn't match IPv4 syntax rules.

    IPv4 is in the format xxx.xxx.xxx.xxx
    Where each xxx is a number 1 - 256 (with no leading zeroes).

    >>> valid_ipv4('256.12.1.12')
    >>> valid_ipv4('256.12.1.312')
    Traceback (most recent call last):
      ...
    InvalidEmail: IPv4 domain must be numbers 1-256 and periods only"""

    numbers = ip.split('.')
    if len(numbers) != 4:
        raise InvalidEmail("IPv4 domain must have 4 period separated numbers.")
    try:
        if any(0 > int(num) or int(num) > MAX_IPV4_NUM for num in numbers):
            raise InvalidEmail
    except ValueError:
        raise InvalidEmail("IPv4 domain must be numbers 1-256 and periods only")

def valid_ipv6(ip):
    """Raise error if ip doesn't match IPv6 syntax rules.

    IPv6 is in the format xxxx:xxxx::xxxx::xxxx
    Where each xxxx is a hexcode, though they can 0-4 characters inclusive.

    Additionally there can be empty spaces, and codes can be ommitted entirely
    if they are just 0 (or 0000). To accomodate this, validation just checks
    for valid hex codes, and ensures that lengths never exceed max values.
    But no minimums are enforced.

    >>> valid_ipv6('314::ac5:1:bf23:412')
    >>> valid_ipv6('IPv6:314::ac5:1:bf23:412')
    >>> valid_ipv6('314::ac5:1:bf23:412g')
    Traceback (most recent call last):
      ...
    InvalidEmail: Invalid IPv6 domaim: '412g' is invalid hex value.
    >>> valid_ipv6('314::ac5:1:bf23:314::ac5:1:bf23:314::ac5:1:bf23:41241')
    Traceback (most recent call last):
      ...
    InvalidEmail: Invalid IPv6 domain"""

    if ip.startswith(IPV6_PREFIX):
        ip = ip.replace(IPV6_PREFIX, '')
    hex_codes = ip.split(':')
    if len(hex_codes) > 8 or any(len(code) > 4 for code in hex_codes):
        raise InvalidEmail("Invalid IPv6 domain")

    for code in hex_codes:
        try:
            if code:
                int(code, HEX_BASE)
        except ValueError:
            raise InvalidEmail("Invalid IPv6 domaim: '{}' is invalid hex value.".format(code))

def valid_domain_characters(domain):
    """Raise error if any invalid characters are used in domain."""

    if any(char not in DOMAIN_CHARACTERS for char in domain):
        raise InvalidEmail("Invalid character in domain.")

def valid_domain(domain):
    """Raise error if domain is neither a valid domain nor IP.

    Domains (sections after the @) can be either a traditional domain or an IP
    wrapped in square brackets. The IP can be IPv4 or IPv6.
    All these possibilities are accounted for."""

    # Check if it's an IP literal
    if domain.startswith('[') and domain.endswith(']'):
        ip = domain[1:-1]
        if '.' in ip:
            valid_ipv4(ip)
        elif ':' in ip:
            valid_ipv6(ip)
        else:
            raise InvalidEmail("IP domain not in either IPv4 or IPv6 format.")
    else:
        valid_domain_lengths(domain)

def validate(address):
    """Raises an error if address is an invalid email string."""

    try:
        local, domain = strip_comments(address).split('@')
    except ValueError:
        raise InvalidEmail("Address must have one '@' only.")

    if len(local) > MAX_LOCAL_LEN:
        raise InvalidEmail("Only {} characters allowed before the @"
                         .format(MAX_LOCAL_LEN))
    if len(domain) > MAX_ADDRESS_LEN:
        raise InvalidEmail("Only {} characters allowed in address"
                         .format(MAX_ADDRESS_LEN))

    valid_local(strip_comments(local))
    valid_domain(strip_comments(domain))


if __name__ == "__main__":
    import doctest
    doctest.testmod()
    raw_input('>DONE<')
52 голоса | спросил SuperBiasedMan 22 Jpm1000000pmFri, 22 Jan 2016 14:31:57 +030016 2016, 14:31:57

6 ответов


31

"@"@example.com и "\ "@example.com оба не работают, но они действительный .

" "@example.com, но на самом деле он недействителен. *

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

A

* RFC 5322 описывает quoted-string следующим образом:

quoted-string   =   [CFWS]
                    DQUOTE *([FWS] qcontent) [FWS] DQUOTE
                    [CFWS]

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

ответил rhino 22 Jpm1000000pmFri, 22 Jan 2016 23:30:34 +030016 2016, 23:30:34
23

Мне лично сложно скомпрометировать ваш код. На самом деле я очень удивлен отсутствием кода.

Помимо нескольких ошибок PEP8 есть три изменения, которые я бы рекомендовал. Вы удаляете из своих котировок как \\, так и \", но вы делаете это чрезмерно многословным способом:

stripped = quote.replace('\\\\', '').replace('\\"', '"').strip('".')

Вместо этого вы можете использовать re.sub :

stripped = re.sub(r'\\[\\"]', '', quote).strip('".')

Таким образом, вы читаете, что он может одновременно заменять как \\, так и \" для получения большей читаемости. Я не знаю, есть ли разница в производительности.


Я бы добавил еще одну функцию, так как в настоящее время вы будете использовать функцию validate следующим образом:

try:
    validate('')
except:
    # Handle non-valid email
else:
    # Handle valid email

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

if is_valid(''):
    # Handle valid email
else:
    # Handle non-valid email

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


Все ваши функции являются общедоступными, что побуждает меня делать:

import email
email.valid_quotes('[email protected]')

Это должна быть частная функция, что я не должен использовать, и поэтому вы должны называть его _valid_quotes. Хотя он все равно может использоваться таким же образом, теперь это «Python private». И следует, как re.py определяет его функции.

И как @Mathias сказал, что вы также должны добавить __all__.


Кроме вышеуказанных трех пунктов, у вас есть несколько ошибок PEP8, которые вы, возможно, не получили. Но они довольно мелкие:

  1.   

    Функция верхнего уровня верхнего уровня и определения классов с двумя пустыми строками.

  2. У вас слишком много пробелов вокруг вашего импорта, достаточно двух пустых строк (которые все равно будут противоречить PEP8).

  3. Вы не достаточно отступили от двух ошибок в validate.

  4. У вас есть одна строка, которая превышает 79 символов.

ответил Peilonrayz 22 Jpm1000000pmFri, 22 Jan 2016 16:32:03 +030016 2016, 16:32:03
22

Сказано это в чате уже, но @ преуспевает, даже если он не является допустимым адресом электронной почты. Вы должны требовать не менее 1 символа в локальной части и 1 символ в домене.

ответил Pimgd 22 Jpm1000000pmFri, 22 Jan 2016 15:47:57 +030016 2016, 15:47:57
16

Кажется, что ваш модуль должен содержать только validate как «общедоступную» функцию. Вы можете добиться этого, объявив __all__ = ['validate', 'InvalidEmail']. Это повлияет на то, как поддержка pydoc и help встроенного дисплея на вашем модуле (они будут показывать только документацию docstring, исключение и функцию проверки), а также как from the_ultimate_email_validator import * обрабатывается (пропускает только validate и InvalidEmail) в глобальное пространство имен).

Помимо этого, глядя на предполагаемую версию validate, он очень похож на int или связанные с ним встроенные. Таким образом, было бы полезно переименовать его в менее пассивное действие (например, email) и вызвать его так:

valid_address = email(user_input)

Возвращаемое значение может быть лишено комментариев, и любая проблема синтаксического анализа приведет к повышению InvalidEmail тем же способом guess = int(raw_input()) будет повышать ValueError

Говоря об этом возвращаемом значении, я предполагаю, что это будет что-то вроде строк

return '{}@{}'.format(local, domain)

в конце validate, потому что комментарии уже удалены в первой строке функции. Но тогда почему вы вызываете valid_local(strip_comments(local)) и valid_domain(strip_comments(domain)) вместо valid_local(local) и valid_domain(domain)? Кажется, что нет никакого случая, когда комментарии могут быть оставлены в local или domain после удаления всего адреса.

ответил Mathias Ettinger 22 Jpm1000000pmFri, 22 Jan 2016 16:02:17 +030016 2016, 16:02:17
11

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

В вашем методе remove_comments не учитываются вложенные комментарии, которые явно разрешены RFC .

Как и ожидалось, remove_comments("Hello (new) world") возвращает "Hello world", но когда я его запустил, remove_comments("Hello (new (old) ish) world") вернулся 'Hello ish) world'.

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

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

Возможно, вы обнаружите, что это быстро становится неуправляемым, когда вы пытаетесь учесть цитируемые строки и экранированные символы - как бы вы написали свой парсер таким образом, чтобы он анализировал foo"\")"("")@example.com до foo")@example.com? Если вы действительно хотите поразить как можно больше случаев патологического края, я бы посоветовал узнать о формальных языках и парсерах, а затем выкопать библиотеку парсеров для Python, чтобы помочь вам создать свой собственный. Python Wiki перечисляет несколько, а этот, в частности, выглядит довольно неплохо, хотя я сам не пытался его использовать.

ответил ymbirtt 24 Jam1000000amSun, 24 Jan 2016 01:58:21 +030016 2016, 01:58:21
7

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

Кажется, что вы не поддерживаете IPv4-адреса IPv4, сопоставленные IPv6:

validate('[email protected][::FFFF:222.1.41.90]')

(см. http://www.tcpipguide.com/free/t_IPv6IPv4AddressEmbedding-2.htm )

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

validate('[email protected]!^&&%^%#&^%&%$^#%^&%$^%#&^*&^*^%^#$')

И еще хуже:

 validate('[email protected]\n')
 validate('[email protected]\nmp\nle.com\n')
 validate('[email protected]\nX-Spam-Score: 0')

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

ответил Sjoerd Job Postmus 23 Jam1000000amSat, 23 Jan 2016 10:19:04 +030016 2016, 10:19:04

Похожие вопросы

Популярные теги

security × 330linux × 316macos × 2827 × 268performance × 244command-line × 241sql-server × 235joomla-3.x × 222java × 189c++ × 186windows × 180cisco × 168bash × 158c# × 142gmail × 139arduino-uno × 139javascript × 134ssh × 133seo × 132mysql × 132