Skip to content
Empieza con 100 créditos de verificación gratis
Qualisend
Todos los artículos
Ingeniería / 17 de junio de 2026

Cómo validar una dirección de correo electrónico en Python

5 min de lectura

Qualisend team
Cómo validar una dirección de correo electrónico en Python

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
is_valid_syntax("a..b@x.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, comprueba primero result["smtp"]: cached o skipped significa que el resultado ya es definitivo, y solo pending requiere sondear el trabajo. Haz ese sondeo desde una tarea en segundo plano, no dentro de la petición:

import time

def verify_and_wait(email: str, tries: int = 10, delay: float = 1.5):
    data = verify(email)
    if data["result"]["smtp"] != "pending":
        return data["result"]  # cached or skipped: already final
    job_id = data["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": ...}
        if job["status"] in ("canceled", "failed"):
            return None
        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/bulk en una sola petición, recibe un único job_id y sondea una vez para todo el lote. Las filas duplicadas se descartan (y se reembolsan) y los resultados llegan en páginas de hasta 1.000, así que recórrelas e indexa cada fila por su email:

def verify_bulk(emails: list[str], tries: int = 60, delay: float = 2.0):
    res = requests.post(f"{BASE}/verify/bulk", headers=HEADERS,
                        json={"text": "\n".join(emails), "expected_count": len(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}", headers=HEADERS, timeout=30).json()
        if job["status"] == "completed":
            return fetch_results(job_id)
        if job["status"] in ("canceled", "failed"):
            return None
        time.sleep(delay)  # back off; large lists take longer than a single probe
    return None

def fetch_results(job_id: str, page_size: int = 1000) -> dict:
    results, offset = {}, 0
    while True:
        page = requests.get(f"{BASE}/jobs/{job_id}", headers=HEADERS, timeout=30,
                            params={"include": "results", "limit": page_size,
                                    "offset": offset}).json()["results"]
        results.update({row["email"]: row for row in page})
        if len(page) < page_size:
            return results
        offset += page_size

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"}
    try:
        return verify(email)["result"]  # deliverable | risky | undeliverable | unknown
    except requests.RequestException:  # timeout, 402, 429, 5xx: fail open
        return {"status": "unknown", "reason": None}  # let it through, re-verify later

Si la propia llamada a la API falla (un tiempo de espera agotado, un 402 por falta de créditos, un 429 por límite de tasa o un 5xx), la función deja pasar la dirección con unknown: acepta el registro y vuelve a verificar la dirección más tarde.

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

01¿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.

02¿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.

03¿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.

04¿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.

Tu reputación, protegida.

Limpia tu primera lista en minutos. 100 créditos gratis, sin tarjeta.

Empezar