Валидация 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.comDNS-проверка вручную через 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-ключ выдаётся сразу после регистрации.
