Para validar una dirección de correo electrónico en Flask ejecutas tres
comprobaciones, no una — y Flask por sí mismo no incluye ninguna, porque Flask no
tiene una capa de formularios propia. Lo que tiene es un ecosistema: WTForms
(normalmente a través de Flask-WTF) para el manejo de formularios, y el paquete
email-validator en el que WTForms se apoya. Entre ambos responden las dos
primeras de las tres preguntas de la validación — ¿tiene la dirección la forma
correcta y puede su dominio recibir correo? — pero nunca la tercera: ¿existe
realmente el buzón? La validación de verdad tiene capas: la sintaxis, luego una
consulta DNS y después un sondeo del buzón por SMTP. Esta guía construye cada
capa como un validador de WTForms que puedes incorporar a un formulario de
Flask-WTF o a una ruta JSON sencilla, y muestra dónde toma el relevo una API de
verificación. Continúa donde lo deja la guía de Python,
de modo que el razonamiento sobre DNS y SMTP que allí se explica se traslada
intacto.
La respuesta rápida#
Usa el validador Email() de WTForms para la sintaxis, su opción
check_deliverability=True (o dnspython) para la consulta MX, y una API de
verificación para la comprobación del buzón por SMTP — encadenadas de lo más
barato a lo más caro para que cada capa haga cortocircuito antes de que se
ejecute la siguiente. No abras conexiones SMTP desde tu proceso de Flask para
sondear buzones: el puerto 25 saliente está bloqueado en la mayoría de los hosts,
y la respuesta depende de la reputación de la IP emisora y del greylisting que no
querrás reimplementar. Cada capa descarta direcciones de forma más barata que la
anterior; solo la API puede dar una por válida. Si este proceso es nuevo para
ti, qué es la verificación de correo explica
primero los términos.
Capa 1: validación de formato con el validador Email de WTForms#
WTForms se encarga de la capa uno, y no la implementa a mano.
wtforms.validators.Email delega en el paquete email-validator, así que
declarar un validador Email() en un campo te da gratis una comprobación de
sintaxis real, derivada del RFC — no una expresión regular:
from flask_wtf import FlaskForm
from wtforms import StringField
from wtforms.validators import DataRequired, Email
class SignupForm(FlaskForm):
email = StringField("Email", validators=[DataRequired(), Email()])
El validador Email() necesita que el paquete esté instalado (pip install email-validator,
o pip install wtforms[email]); sin él, WTForms lanza un error en el momento de la
validación para avisarte. Por dentro llama a email_validator.validate_email, que
también puedes usar por separado — fuera de un formulario, o en una capa de
servicio — para obtener el mismo veredicto como valor booleano:
from email_validator import validate_email, EmailNotValidError
def is_valid_syntax(email: str) -> bool:
try:
validate_email(email, check_deliverability=False)
return True
except EmailNotValidError:
return False
Ten claro exactamente qué te aporta esto. Con check_deliverability=False la
comprobación lee solo la forma — nunca resuelve el DNS, nunca abre un socket y
no tiene ni idea de si example.com existe. definitely-fake@gmail.com pasa.
info@company-that-folded.com pasa. typo@gmial.com pasa. Las tres son
inentregables, y ningún validador que solo lea la cadena te lo dirá jamás, por la
misma razón por la que
la validación de correo con regex falla:
sintaxis y entregabilidad son preguntas distintas. Una es un hecho sobre la
cadena de texto; la otra es un hecho sobre internet.
Capa 2: ¿acepta el dominio correo?#
Un dominio sin registros MX no puede aceptar correo para nadie, así que una sola
consulta DNS elimina los dominios muertos, los nombres de empresa mal escritos y
los TLD inventados. Aquí la pila de Flask hace algo que la de Django no hace de
serie: email-validator puede ejecutar la consulta por ti. Activa
check_deliverability y resolverá los registros MX del dominio (recurriendo a
A/AAAA como alternativa) con dnspython, lanzando
EmailUndeliverableError — una subclase de EmailNotValidError — cuando el
dominio no puede recibir correo. En un formulario, eso es una única opción en el
validador que ya tienes:
email = StringField("Email", validators=[DataRequired(), Email(check_deliverability=True)])
Ese único flag fusiona las capas uno y dos en un solo validador. Por separado, la misma llamada devuelve un objeto de dirección normalizada cuando el dominio pasa la comprobación:
from email_validator import validate_email, EmailNotValidError
def has_mail_route(email: str) -> bool:
try:
validate_email(email, check_deliverability=True) # syntax + live MX lookup
return True
except EmailNotValidError:
return False
Si prefieres ejecutar tú mismo la comprobación DNS — para cachear resultados,
apuntar a tu propio resolver o poner un timeout ajustado — baja directamente a
dnspython:
import dns.resolver # pip install dnspython
def domain_has_mx(domain: str) -> bool:
try:
return len(dns.resolver.resolve(domain, "MX")) > 0
except (dns.resolver.NXDOMAIN, dns.resolver.NoAnswer, dns.resolver.NoNameservers):
return False
Capa 3: ¿existe realmente el buzón?#
Las capas uno y dos solo pueden descartar una dirección. Un dominio puede
publicar registros MX perfectos y aun así no tener ningún buzón en la dirección
que tienes entre manos — noreply-9f2x@gmail.com es sintaxis válida en una ruta
de correo activa, y sigue siendo un buzón que nunca se creó. Confirmar un buzón
concreto implica la conversación de entrega SMTP: conectar al servidor de correo,
emitir RCPT TO, leer la respuesta y desconectar antes de enviar nada.
En principio podrías programar eso desde Flask con smtplib. En la práctica no
deberías ejecutarlo desde tu servidor de aplicación: la mayoría de los hosts en la
nube bloquean el puerto 25 saliente, la respuesta depende de la reputación de la
IP desde la que conectas, y los servidores receptores aplican greylisting y
limitan la tasa de los remitentes desconocidos — así que un sondeo que funciona
en una prueba local falla silenciosamente, o te acaba en una lista negra, en
producción. Cómo funciona la verificación de correo
recorre el proceso completo, dominios catch-all incluidos. Esta es la capa que
vale la pena delegar.
El endpoint de verificación de Qualisend ejecuta todo el proceso — sintaxis, DNS
y el sondeo del buzón por SMTP — desde una infraestructura con reputación
gestionada y devuelve un veredicto. Llámalo con requests, leyendo tu clave
desde el entorno:
import os
import requests # pip install requests
QUALISEND_VERIFY_URL = "https://api.qualisend.com/v1/verify"
def verify(email: str) -> dict:
res = requests.post(
QUALISEND_VERIFY_URL,
headers={"Authorization": f"Bearer {os.environ['QUALISEND_API_KEY']}"},
json={"email": email},
timeout=10,
)
res.raise_for_status()
return res.json()["result"] # {"status", "score", "reason", "sub_flags"}
La respuesta es un sobre { "result": { ... } }. Lee result["status"] —
deliverable, risky, undeliverable o unknown — junto con un score, un
reason y sub_flags para rasgos como direcciones de rol o desechables.
Consulta la referencia de la API para conocer la forma exacta de
los campos; aquí solo necesitas el estado para decidir. Rechazar
undeliverable es el valor por defecto seguro; también podrías rechazar
risky, o guardar el score y dejarla pasar — eso es una decisión de política,
y la guía de registro serverless
explica cómo de estricto ser en el punto de recogida.
Uniendo las capas para validar una dirección de correo en Flask#
El lugar idiomático para añadir una comprobación por campo en Flask-WTF es un
método en línea validate_<nombredelcampo> en el formulario. WTForms añade ese
método a la cadena de validadores del campo y lo ejecuta después de los
validadores de la lista, de modo que, para cuando se dispara,
Email(check_deliverability=True) ya ha resuelto la sintaxis y la consulta MX.
Protege con field.errors para que el viaje de ida y vuelta a la API solo ocurra
con las direcciones que superaron ambas capas más baratas:
from flask_wtf import FlaskForm
from wtforms import StringField
from wtforms.validators import DataRequired, Email, ValidationError
class SignupForm(FlaskForm):
email = StringField("Email", validators=[
DataRequired(),
Email(check_deliverability=True), # layers 1 + 2
])
def validate_email(self, field): # layer 3 — runs after the list above
if field.errors: # syntax or MX already failed
return # don't spend an API credit
if verify(field.data)["status"] == "undeliverable":
raise ValidationError("We couldn't confirm a mailbox at this address.")
Esa protección con field.errors es todo el truco. Una dirección mal formada o
un dominio muerto registra un error en la cadena antes de que se ejecute el
método en línea, así que la protección detiene la llamada de verificación en las
direcciones que nunca tuvieron oportunidad — validar en el orden equivocado, o
saltarse la protección, gasta un crédito en cada errata. En una vista,
validate_on_submit ejecuta todo el proceso antes de que se ejecute el cuerpo de
tu handler:
@app.route("/signup", methods=["POST"])
def signup():
form = SignupForm()
if form.validate_on_submit():
# every layer passed — safe to persist
create_account(form.email.data)
return redirect(url_for("welcome"))
return render_template("signup.html", form=form), 400
Una cosa que decidir de antemano: qué pasa cuando la propia llamada a la API
falla. Un timeout de red o una excepción de requests dentro de
validate_email no debería devolver un 500 en la petición ni entregarle a un
cliente real un error que no puede arreglar. Captura
requests.RequestException por separado de ValidationError, y trata un
servicio inalcanzable como unknown en lugar de undeliverable — deja pasar el
registro y vuelve a verificar la dirección más tarde, en vez de bloquear el alta
por una caída transitoria. Las capas de sintaxis y MX ya se ejecutaron, así que
solo estás suavizando la capa que depende de la red.
No todas las aplicaciones de Flask usan WTForms. Para una API JSON, las mismas
tres capas se leen directamente en una ruta, devolviendo un 400 en una
dirección incorrecta en lugar de lanzar ValidationError:
from flask import Flask, request, jsonify
from email_validator import validate_email, EmailNotValidError
app = Flask(__name__)
@app.post("/api/signup")
def api_signup():
email = (request.get_json(silent=True) or {}).get("email", "")
try:
info = validate_email(email, check_deliverability=True) # layers 1 + 2
except EmailNotValidError as exc:
return jsonify(error=str(exc)), 400
if verify(info.normalized)["status"] == "undeliverable": # layer 3
return jsonify(error="Email address appears undeliverable."), 400
# ... create the account
return jsonify(ok=True), 201
info.normalized es la dirección canonicalizada que email-validator devuelve,
que es lo que deberías guardar. Esta es la misma estructura de tres capas que
encontrarás en las versiones de Node.js y
Python de esta guía — es la disposición en
capas, no el framework, lo que hace que la validación funcione. Cuando estés
eligiendo qué servicio de verificación respalda la capa tres, la
comparativa de APIs alinea las
opciones.
Preguntas frecuentes#
¿Basta con el validador Email de WTForms para validar una dirección de correo?#
Para la sintaxis, sí — Email() delega en el paquete email-validator y es una
comprobación de primera capa mejor que cualquier expresión regular artesanal.
Pero, por defecto, valida la forma, no la entregabilidad. Añade
check_deliverability=True y también ejecutará una consulta MX en vivo, que
descarta los dominios muertos; aun así, nunca contacta con el buzón, de modo que
un resultado positivo significa «el dominio acepta correo», no «esta dirección
existe». Combínalo con una comprobación del buzón por SMTP antes de fiarte de la
dirección.
¿Cómo añado un validador de correo personalizado en Flask-WTF?#
Dos opciones idiomáticas. Para una comprobación puntual, añade un método en línea
llamado validate_<nombredelcampo> al formulario — por ejemplo,
validate_email(self, field) — y lanza wtforms.validators.ValidationError
cuando el valor no sea válido; WTForms lo ejecuta después de los validadores
enumerados en el campo. Para una regla reutilizable, escribe un invocable que
reciba (form, field) y lance el mismo error, y luego pásalo en la lista
validators del campo. Cualquiera de las dos es el lugar donde encaja la llamada
de verificación de Qualisend.
¿Puedo comprobar si un buzón existe en Flask sin una API?#
En parte. email-validator con check_deliverability=True (o dnspython
directamente) confirma que el dominio acepta correo, lo que descarta gratis los
dominios muertos y no necesita nada más allá de una pequeña dependencia. Confirmar
el buzón implica una conversación SMTP que puedes intentar con smtplib, pero
que no deberías ejecutar desde el servidor de tu aplicación — el puerto 25 está
bloqueado por todas partes y el resultado depende de la reputación de tu IP. Esa
es la capa que un servicio de verificación existe para gestionar.
¿Debería validar los correos en el registro o al limpiar una lista?#
Ambas cosas, con distinta profundidad. Ejecuta las capas de sintaxis y MX de forma síncrona en el formulario o la ruta — son lo bastante rápidas como para bloquear la petición y dar feedback instantáneo — y actúa allí también según el veredicto de la API. Reserva la verificación por lotes, más pesada, para la limpieza de listas y las tareas de back-office, donde la latencia no importa y puedes procesar direcciones en bloque en lugar de un envío de formulario cada vez.
¿Listo para añadir la capa SMTP? Introduce una dirección aprobada por Email()
en el comprobador de correo gratuito para ver cómo una
cadena válida para WTForms vuelve como undeliverable, y luego conecta el mismo
veredicto a tu formulario con la referencia de la API — con
ejemplos para copiar y pegar incluidos.