uCheckeruChecker
Блог/Верификация
12 мин чтения

Валидация email на Python: библиотеки, примеры кода и подводные камни

Форма регистрации принимает «test@@gmial.con». CRM копит тысячи мёртвых адресов. Рассылки летят в пустоту, а bounce rate ползёт вверх. Знакомая ситуация для любого Python-разработчика, которому поручили «добавить проверку email». В этой статье - конкретные библиотеки, рабочий код и объяснение, почему regex недостаточно.

Три уровня проверки email

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

Синтаксическая проверка убеждается, что строка соответствует формату RFC 5321: есть локальная часть, символ @, допустимый домен. Отсекает явный мусор - пустые строки, пробелы, двойные точки, адреса без домена. Работает мгновенно, не требует сетевых запросов.

DNS-проверка запрашивает MX-записи домена. Если у домена нет почтового сервера, адрес принять некуда. Это отлавливает опечатки в домене (gmial.com, yaho.com) и выдуманные домены.

SMTP-проверка подключается к почтовому серверу и спрашивает, существует ли конкретный ящик. Самый точный способ, но с оговорками: серверы могут врать (catch-all домены принимают всё), блокировать частые запросы, требовать TLS.

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

email-validator: синтаксис + DNS в одной библиотеке

Библиотека email-validator (автор - Joshua Tauberer) стала стандартом де-факто для валидации email в Python-проектах. Она проверяет синтаксис по RFC 5321/5322, нормализует адрес (убирает лишние точки, приводит домен к нижнему регистру), и опционально проверяет DNS.

Установка:

pip install email-validator

Базовый пример - проверка одного адреса с DNS-валидацией:

from email_validator import validate_email, EmailNotValidError

def check_email(address: str) -> dict:
    """Проверяет email: синтаксис + DNS."""
    try:
        result = validate_email(address, check_deliverability=True)
        return {
            "valid": True,
            "normalized": result.normalized,
            "local_part": result.local_part,
            "domain": result.domain,
            "mx": result.mx,
        }
    except EmailNotValidError as e:
        return {"valid": False, "error": str(e)}

# Примеры
print(check_email("User@Example.COM"))
# {'valid': True, 'normalized': 'User@example.com', ...}

print(check_email("bad@@broken"))
# {'valid': False, 'error': 'An @ sign may not ...'}

print(check_email("user@несуществующий-домен.xyz"))
# {'valid': False, 'error': 'The domain name ... does not exist.'}

Параметр check_deliverability=True включает DNS-проверку: библиотека запрашивает MX-записи и A-записи домена. Если ни одна не найдена, адрес считается невалидным. Для форм регистрации, где важна скорость ответа, можно отключить эту проверку и выполнять её асинхронно.

Нормализация - неочевидная, но полезная функция. Библиотека приводит домен к нижнему регистру, конвертирует интернационализированные домены в ASCII-совместимый формат (Punycode) и удаляет лишние точки. Это предотвращает дубликаты в базе: «User@Gmail.com» и «user@gmail.com» становятся одним адресом.

Если нужна только проверка синтаксиса без сетевых запросов (например, в unit-тестах):

result = validate_email(
    "user@example.com",
    check_deliverability=False,
)
print(result.normalized)  # user@example.com

DNS-проверка вручную через dns.resolver

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

pip install dnspython

Функция ниже извлекает MX-записи домена и возвращает их в порядке приоритета. Если MX нет, пробует A-запись - по стандарту RFC 5321 сервер на A-записи тоже может принимать почту:

import dns.resolver

def get_mail_servers(domain: str, timeout: float = 5.0) -> list[str]:
    """Возвращает список почтовых серверов домена."""
    resolver = dns.resolver.Resolver()
    resolver.timeout = timeout
    resolver.lifetime = timeout

    # Пробуем MX-записи
    try:
        mx_records = resolver.resolve(domain, "MX")
        servers = sorted(mx_records, key=lambda r: r.preference)
        return [str(r.exchange).rstrip(".") for r in servers]
    except (dns.resolver.NoAnswer, dns.resolver.NXDOMAIN):
        pass
    except dns.resolver.NoNameservers:
        return []

    # Fallback на A-запись
    try:
        a_records = resolver.resolve(domain, "A")
        return [str(r) for r in a_records]
    except Exception:
        return []

# Пример
print(get_mail_servers("gmail.com"))
# ['gmail-smtp-in.l.google.com', 'alt1.gmail-smtp-in.l.google.com', ...]

print(get_mail_servers("несуществующий-домен.xyz"))
# []

Пустой список - верный признак того, что домен не принимает почту. Но наличие MX-записей ещё не гарантирует, что конкретный ящик существует. Для этого нужен следующий уровень - SMTP.

SMTP-проверка через smtplib

SMTP-валидация имитирует начало отправки письма. Подключаемся к почтовому серверу, отправляем команды EHLO, MAIL FROM, RCPT TO - и по ответу сервера определяем, существует ли ящик. Само письмо не отправляется - соединение закрывается до команды DATA.

import smtplib
import dns.resolver

def smtp_check(email: str, timeout: int = 10) -> dict:
    """Проверяет существование ящика через SMTP.

    Возвращает dict с полями: exists, code, message.
    """
    domain = email.split("@")[-1]

    # 1. Получаем MX-сервер
    try:
        mx = dns.resolver.resolve(domain, "MX")
        mail_server = str(sorted(mx, key=lambda r: r.preference)[0].exchange).rstrip(".")
    except Exception:
        return {"exists": False, "code": 0, "message": "No MX record"}

    # 2. Подключаемся к SMTP
    try:
        smtp = smtplib.SMTP(timeout=timeout)
        smtp.connect(mail_server, 25)
        smtp.ehlo("verify.example.com")
        smtp.mail("check@verify.example.com")
        code, msg = smtp.rcpt(email)
        smtp.quit()

        return {
            "exists": code == 250,
            "code": code,
            "message": msg.decode(),
        }
    except smtplib.SMTPServerDisconnected:
        return {"exists": None, "code": 0, "message": "Server disconnected"}
    except smtplib.SMTPConnectError:
        return {"exists": None, "code": 0, "message": "Connection refused"}
    except Exception as e:
        return {"exists": None, "code": 0, "message": str(e)}

# Пример
result = smtp_check("user@gmail.com")
print(result)
# {'exists': False, 'code': 550, 'message': '5.1.1 ... not found'}

Код ответа 250 означает «ящик существует». 550 - «ящик не найден». 450 или 451 - временная ошибка, стоит повторить позже.

SMTP-проверка - мощный инструмент, но не универсальный. Catch-all серверы отвечают 250 на любой адрес. Gmail и Outlook агрессивно блокируют частые проверки. Для массовой валидации SMTP-подход не масштабируется без ротации IP и серьёзной инфраструктуры.

Подводные камни SMTP-проверки

На первый взгляд SMTP-проверка кажется идеальным решением: спрашиваем сервер напрямую, получаем точный ответ. На практике всё сложнее.

Catch-all домены. Корпоративные серверы часто настроены принимать почту на любой адрес в домене. Команда RCPT TO вернёт 250 для «asdfkjh@company.com» так же, как для «ceo@company.com». Определить catch-all можно, отправив запрос на заведомо несуществующий адрес в том же домене. Если сервер ответит 250, домен - catch-all.

Greylisting. Некоторые серверы намеренно отклоняют первое подключение с незнакомого IP (код 450) и ждут повторной попытки через несколько минут. Настоящие почтовые серверы повторяют - спамеры обычно нет. Для валидатора это означает ложный отрицательный результат при первом запросе.

Rate limiting. Gmail, Outlook, Yahoo ограничивают количество SMTP-подключений с одного IP. После нескольких десятков проверок вы получите временную блокировку. Проверка тысячи адресов с одного сервера займёт часы, а не минуты.

Порт 25 закрыт. Многие облачные провайдеры (AWS, GCP, Azure) блокируют исходящие подключения на порт 25 по умолчанию. Код, который работает на вашем ноутбуке, может не работать в продакшене.

Этика и юридические вопросы. Массовые SMTP-проверки могут выглядеть как сканирование. Не все администраторы отнесутся к этому спокойно. В ряде юрисдикций такие проверки могут попасть под ограничения законодательства о спаме.

Почему regex для email - плохая идея

Соблазн написать r"^[\w.-]+@[\w.-]+\.\w+$" велик. Одна строка, никаких зависимостей, работает «для большинства случаев». Проблема в том, что стандарт email (RFC 5321/5322) допускает конструкции, которые ни один простой regex не покроет.

Полностью RFC-совместимое регулярное выражение занимает несколько килобайт текста. Его невозможно поддерживать, сложно отлаживать, и оно всё равно не проверяет ни DNS, ни SMTP. Адрес user@nonexistent-domain-12345.com пройдёт любой regex.

Рекомендация простая: используйте regex только как первый грубый фильтр (есть@, не пусто), а реальную валидацию делегируйте специализированной библиотеке.

import re

# Грубый regex - только как быстрый pre-filter
BASIC_PATTERN = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")

def quick_filter(email: str) -> bool:
    """Отсекает явный мусор до вызова полной валидации."""
    return bool(BASIC_PATTERN.match(email))

# Примеры
print(quick_filter("user@example.com"))   # True
print(quick_filter("not-an-email"))       # False
print(quick_filter("user@fake.xyz"))      # True - regex не видит проблему

Последний пример показывает ограничение: regex пропустил адрес с несуществующим доменом. Для реальной проверки нужен DNS-запрос.

Полный pipeline: синтаксис + DNS + SMTP

На практике эти три уровня удобно объединить в одну функцию. Каждый последующий этап запускается, только если предыдущий пройден:

from email_validator import validate_email, EmailNotValidError
import dns.resolver
import smtplib

def full_validate(email: str) -> dict:
    """Трёхуровневая валидация: синтаксис, DNS, SMTP."""

    # Уровень 1: синтаксис
    try:
        info = validate_email(email, check_deliverability=False)
        normalized = info.normalized
        domain = info.domain
    except EmailNotValidError as e:
        return {"level": "syntax", "valid": False, "error": str(e)}

    # Уровень 2: DNS
    try:
        mx = dns.resolver.resolve(domain, "MX")
        mail_server = str(
            sorted(mx, key=lambda r: r.preference)[0].exchange
        ).rstrip(".")
    except Exception:
        return {"level": "dns", "valid": False, "error": "No MX record"}

    # Уровень 3: SMTP
    try:
        smtp = smtplib.SMTP(timeout=10)
        smtp.connect(mail_server, 25)
        smtp.ehlo("verify.example.com")
        smtp.mail("check@verify.example.com")
        code, msg = smtp.rcpt(normalized)
        smtp.quit()

        return {
            "level": "smtp",
            "valid": code == 250,
            "code": code,
            "normalized": normalized,
        }
    except Exception as e:
        return {
            "level": "smtp",
            "valid": None,
            "error": f"SMTP error: {e}",
            "normalized": normalized,
        }

# Примеры
print(full_validate("User@Gmail.com"))
# {'level': 'smtp', 'valid': True, 'code': 250, 'normalized': 'User@gmail.com'}

print(full_validate("test@@broken"))
# {'level': 'syntax', 'valid': False, 'error': '...'}

Этот pipeline подходит для разовых проверок и небольших списков. Для пакетной обработки стоит добавить асинхронность ( aiosmtplib вместо smtplib), пул соединений и retry-логику с экспоненциальной задержкой.

Пакетная валидация: когда адресов тысячи

Локальная SMTP-проверка десяти тысяч адресов - задача, которая быстро упирается в ограничения. Серверы блокируют ваш IP, облачные хостинги режут исходящий трафик на порт 25, а каждое подключение занимает секунды.

Для пакетной работы разумнее использовать API. Ниже - пример проверки списка через uChecker API. Загружаем файл, запускаем задачу, периодически опрашиваем статус:

import requests
import time
import os

API_KEY = os.environ["UCHECKER_API_KEY"]
BASE = "https://api.uchecker.net"
HEADERS = {"x-api-key": API_KEY}

def bulk_validate(file_path: str) -> dict:
    """Загружает файл и ждёт результат валидации."""

    # 1. Загружаем файл
    with open(file_path, "rb") as f:
        resp = requests.post(
            f"{BASE}/api/v1/validate/bulk",
            headers=HEADERS,
            files={"file": f},
        )
    task = resp.json()
    task_id = task["task_id"]
    print(f"Task created: {task_id}")

    # 2. Ждём завершения
    while True:
        status = requests.get(
            f"{BASE}/api/v1/tasks/{task_id}",
            headers=HEADERS,
        ).json()

        if status["status"] == "completed":
            break
        if status["status"] == "failed":
            return {"error": status.get("message", "Task failed")}

        print(f"Progress: {status.get('progress', 0)}%")
        time.sleep(5)

    # 3. Забираем результат
    result = requests.get(
        f"{BASE}/api/v1/tasks/{task_id}/result",
        headers=HEADERS,
    ).json()

    return result

# Использование
result = bulk_validate("emails.csv")
print(f"Valid: {result['valid_count']}, Invalid: {result['invalid_count']}")

API берёт на себя ротацию IP, обход greylisting, определение catch-all доменов и десятки других нюансов. Вместо инфраструктуры для SMTP-проверок вы получаете один POST-запрос и готовый результат.

Сравнение подходов

МетодТочностьСкоростьОграничения
RegexНизкая< 1 мсНе проверяет домен и ящик
email-validatorСредняя50-200 мсНе проверяет ящик
SMTP (smtplib)Высокая2-10 секCatch-all, блокировки, порт 25
API (uChecker)Высокая1-3 секТребует API-ключ

Какой подход выбрать

Форма регистрации. Используйте email-validator с check_deliverability=True. Синтаксис + DNS отсекают большинство ошибок, работают быстро и не требуют внешних сервисов.

Очистка существующей базы. Для списков до нескольких сотен адресов - комбинация email-validator + smtplib. Для тысяч и десятков тысяч - API валидатора. Локальная SMTP-проверка на таких объёмах утонет в блокировках и таймаутах.

CI/CD pipeline. Если проверяете email в автотестах или при деплое, достаточно синтаксической проверки без сети: check_deliverability=False. DNS и SMTP-запросы замедлят тесты и сделают их нестабильными.

Реальное время в продакшене. Для real-time проверки при каждом входящем адресе (webhook, API-эндпоинт, Telegram-бот) - API валидатора через HTTP. Один запрос, ответ за секунду, без головной боли с SMTP-инфраструктурой.

Главное правило: чем больше адресов и чем выше требования к точности, тем больше смысла отдать валидацию специализированному сервису. Собственная SMTP-проверка - познавательное упражнение и рабочее решение для малых объёмов. Для продакшена с тысячами адресов она создаёт больше проблем, чем решает.

Проверьте свою базу через uChecker - 30 бесплатных проверок, чтобы оценить качество списка. API-ключ выдаётся сразу после регистрации.

python валидация emailemail-validator pythonпроверка email pythonpython SMTP проверкаpython regex emaildns.resolver pythonвалидация email