Validar una dirección de correo electrónico en Python significa tres comprobaciones distintas, y la que muestran la mayoría de los tutoriales —una expresión regular— es la menos útil de todas. La validación de verdad es por capas: sintaxis, luego una consulta DNS y después una prueba de buzón por SMTP. La biblioteca estándar de Python y una pequeña dependencia cubren las dos primeras; la tercera es un problema de red que conviene delegar. Esta guía construye cada capa con código funcional.
La respuesta rápida#
Usa una expresión regular permisiva (o email.utils.parseaddr) para la
sintaxis, dnspython para la consulta MX y una API de verificación para la
comprobación de buzón por SMTP: primero lo más barato, cortocircuitando en
cuanto una de ellas sea decisiva. No recurras a smtplib para sondear buzones
desde tu aplicación: el puerto 25 de salida está bloqueado en la mayoría de los
hosts, y el resultado depende de la reputación de la IP de envío y del
greylisting que no querrás reimplementar.
Capa 1: sintaxis#
Mantenla permisiva: detecta erratas, no reimplementes el RFC 5322 (que no ayudaría de todos modos):
import re
SYNTAX = re.compile(r'^[^\s@"]+(?:\.[^\s@"]+)*@[^\s@.]+(?:\.[^\s@.]+)+$')
def is_valid_syntax(email: str) -> bool:
return isinstance(email, str) and len(email) <= 320 and SYNTAX.match(email) is not None
is_valid_syntax("jane@example.com") # True
is_valid_syntax("not-an-email") # False
is_valid_syntax("a@@b.com") # False
El email.utils.parseaddr de la biblioteca estándar analiza una dirección,
pero es indulgente por diseño —parseaddr("nonsense") devuelve
('', 'nonsense') sin lanzar ninguna excepción—, así que es un analizador, no
un validador. Una expresión regular corta es más clara para una puerta de
sintaxis de sí/no.
Capa 2: ¿puede el dominio recibir correo?#
Un dominio sin registros MX no puede aceptar correo para nadie. dnspython los
resuelve en un par de líneas:
import dns.resolver # pip install dnspython
def has_mail_route(domain: str) -> bool:
try:
answers = dns.resolver.resolve(domain, "MX")
return len(answers) > 0
except (dns.resolver.NXDOMAIN, dns.resolver.NoAnswer, dns.resolver.NoNameservers):
return False
has_mail_route("gmail.com") # True
has_mail_route("company-that-folded.com") # False
Esta única consulta elimina dominios muertos y TLD inventados sin coste alguno.
Si quieres tener en cuenta los dominios que aceptan correo en un registro A sin
MX, recurre a resolver A/AAAA cuando el conjunto MX esté vacío, pero una
comprobación MX cubre la inmensa mayoría de las direcciones reales.
Capa 3: ¿existe realmente el buzón?#
Las capas 1 y 2 solo pueden descartar una dirección. Confirmar un buzón
implica la conversación de entrega SMTP y, aunque smtplib puede abrir una,
ejecutarla desde tu aplicación es una mala idea: el puerto 25 está bloqueado de
forma generalizada, la respuesta depende de la reputación de la IP desde la que
conectas, y los servidores receptores aplican greylisting y limitan la tasa a
los desconocidos. Delega esta capa.
El POST /verify de Qualisend ejecuta el proceso completo. Las
comprobaciones locales se devuelven de inmediato con la prueba SMTP en cola:
import os
import requests # pip install requests
BASE = "https://app.qualisend.com/api/v1"
HEADERS = {"Authorization": f"Bearer {os.environ['QUALISEND_API_KEY']}"}
def verify(email: str) -> dict:
res = requests.post(f"{BASE}/verify", headers=HEADERS, json={"email": email}, timeout=10)
res.raise_for_status()
return res.json()
Para la validación de registro en tiempo real, el result inmediato suele
bastar para actuar. Cuando necesites el veredicto confirmado por SMTP, sondea el
trabajo hasta que la prueba termine:
import time
def verify_and_wait(email: str, tries: int = 10, delay: float = 1.5):
job_id = verify(email)["job_id"]
for _ in range(tries):
res = requests.get(f"{BASE}/jobs/{job_id}",
params={"include": "results"}, headers=HEADERS, timeout=10)
job = res.json()
if job["status"] == "completed":
return job["results"][0] # {"status": ..., "score": ..., "reason": ...}
time.sleep(delay)
return None # still processing — treat as unknown, retry later
Verificar una lista por lotes#
No recorras verify() sobre un CSV dirección a dirección: eso es una ida y
vuelta HTTP y un trabajo por fila. Envía la lista completa a /verify en una
sola petición, recibe un único job_id y sondea una vez para todo el lote. El
array results vuelve en el orden de envío:
def verify_bulk(emails: list[str], tries: int = 60, delay: float = 2.0):
res = requests.post(f"{BASE}/verify", headers=HEADERS,
json={"emails": emails}, timeout=30)
res.raise_for_status()
job_id = res.json()["job_id"]
for _ in range(tries):
job = requests.get(f"{BASE}/jobs/{job_id}",
params={"include": "results"}, headers=HEADERS,
timeout=30).json()
if job["status"] == "completed":
return dict(zip(emails, job["results"]))
time.sleep(delay) # back off; large lists take longer than a single probe
return None
Un lote de miles puede tardar minutos porque cada prueba SMTP es una
conversación de red aparte, así que amplía tries/delay para las listas más
grandes en lugar de martillear el endpoint de sondeo. Ejecuta primero las dos
capas locales para descartar la basura evidente antes de gastar créditos:
filtra con is_valid_syntax y has_mail_route, y envía solo las
supervivientes.
Notas sobre async y pydantic#
requests es bloqueante, así que en una aplicación asyncio envuelve la
llamada con asyncio.to_thread(verify, email) o cámbiate a
httpx.AsyncClient para lanzar las llamadas de verificación de forma
concurrente; dnspython incluye un resolutor asíncrono (dns.asyncresolver)
por la misma razón. Si ya usas pydantic, su tipo EmailStr encaja de forma
limpia como Capa 1: valida la sintaxis cuando se construye un modelo, pero se
queda ahí; nunca toca el DNS ni el buzón, así que mantén la comprobación MX y la
API de verificación por detrás de él.
Uniendo las capas#
Primero lo más barato, y para en cuanto tengas una respuesta:
def validate_email(email: str) -> dict:
if not is_valid_syntax(email):
return {"status": "undeliverable", "reason": "invalid_email"}
domain = email.rsplit("@", 1)[1]
if not has_mail_route(domain):
return {"status": "undeliverable", "reason": "invalid_domain"}
return verify(email)["result"] # deliverable | risky | undeliverable | unknown
Las dos capas locales no cuestan nada y atrapan casi toda la basura al instante; la API se ejecuta solo sobre las direcciones que merecen la ida y vuelta. Consulta cómo funciona la verificación de correo para entender por qué las etapas se ordenan así.
Preguntas frecuentes#
¿Puedo verificar un correo en Python solo con la biblioteca estándar?#
Hasta cierto punto. re se encarga de la sintaxis y, con socket, puedes
intentar una consulta DNS, pero no hay un resolutor MX integrado (de ahí
dnspython), y confirmar un buzón requiere una prueba SMTP que no deberías
ejecutar desde tu aplicación. La biblioteca estándar te da la sintaxis y una
comprobación aproximada del dominio; la capa del buzón necesita dnspython más
un servicio de verificación.
¿Por qué no usar smtplib para comprobar si un buzón existe?#
smtplib puede abrir la conversación SMTP, pero ejecutarlo desde el servidor de
tu aplicación es poco fiable y arriesgado: la mayoría de los hosts bloquean el
puerto 25 de salida, la respuesta depende de la reputación de tu IP de envío y
sondear con cierto volumen hace que te limiten la tasa o te incluyan en listas
de bloqueo. Una API de verificación ejecuta la prueba desde una infraestructura
con reputación gestionada y diseñada para ello.
¿Es dnspython la biblioteca adecuada para las consultas MX?#
Sí, es el kit de herramientas DNS estándar y bien mantenido para Python, y
resuelve los registros MX de forma limpia. Instálalo con pip install dnspython
y captura NXDOMAIN, NoAnswer y NoNameservers para tratar una ruta de
correo ausente como no entregable.
¿EmailStr de pydantic valida por completo una dirección de correo?#
No. EmailStr (respaldado por el paquete email-validator) solo comprueba que
una cadena es sintácticamente una dirección válida cuando se construye tu
modelo. No confirma que el dominio tenga registros MX ni que el buzón exista,
así que se corresponde exactamente con la Capa 1. Úsalo como puerta de sintaxis
en tus modelos de pydantic y, después, ejecuta la consulta DNS y la API de
verificación sobre las direcciones que pasen. Además normaliza la dirección
(pasa el dominio a minúsculas), lo cual resulta útil antes de deduplicar una
lista.
¿Listo para añadir la capa SMTP? El plan gratuito incluye 100
créditos que ejecutan el proceso completo, y la
referencia de la API tiene los endpoints completos de /verify y
/jobs con ejemplos listos para copiar y pegar en siete lenguajes.