Validar um endereço de e-mail em Python significa três verificações separadas, e a que a maioria dos tutoriais mostra — uma regex — é a menos útil de todas. A validação de verdade é feita em camadas: sintaxe, depois uma consulta de DNS e, então, um teste de caixa postal via SMTP. A biblioteca padrão do Python e uma pequena dependência dão conta das duas primeiras; a terceira é um problema de rede que vale a pena delegar. Este guia constrói cada camada com código funcional.
A resposta curta#
Use uma regex permissiva (ou email.utils.parseaddr) para a sintaxe, o dnspython
para a consulta de MX e uma API de verificação para a checagem de caixa postal via
SMTP — a mais barata primeiro, encerrando o processo assim que uma delas for decisiva.
Não recorra ao smtplib para sondar caixas postais a partir da sua aplicação: a porta
25 de saída fica bloqueada na maioria dos provedores, e o resultado depende da reputação
do IP de envio e do greylisting que você não quer
reimplementar.
Camada 1: sintaxe#
Mantenha-a permissiva — pegue erros de digitação, não reimplemente a RFC 5322 (o que não ajudaria mesmo):
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
O email.utils.parseaddr da biblioteca padrão até analisa um endereço, mas é
tolerante por design — parseaddr("nonsense") retorna ('', 'nonsense') sem
levantar erro — então é um analisador, não um validador. Uma regex curta é mais clara
como barreira de sintaxe do tipo sim/não.
Camada 2: o domínio consegue receber e-mails?#
Um domínio sem registros MX não consegue aceitar e-mail de ninguém. O dnspython
os resolve em algumas linhas:
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
Essa única consulta elimina domínios mortos e TLDs inventados de graça. Se você quiser
contemplar domínios que aceitam e-mail em um registro A sem MX, recorra a resolver
A/AAAA quando o conjunto de MX estiver vazio — mas a verificação de MX cobre a
grande maioria dos endereços reais.
Camada 3: a caixa postal realmente existe?#
As Camadas 1 e 2 só conseguem descartar um endereço. Confirmar uma caixa postal
significa a conversa de entrega via SMTP — e, embora o smtplib consiga abrir uma,
rodá-lo a partir da sua aplicação é uma má ideia: a porta 25 é amplamente bloqueada,
a resposta depende da reputação do IP de onde você se conecta, e os servidores que
recebem aplicam greylisting e limitam a taxa de estranhos. Delegue esta camada.
O POST /verify da Qualisend executa o pipeline completo. As
verificações locais retornam de imediato, com o teste SMTP em fila:
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 validação de cadastro ao vivo, o result imediato costuma ser suficiente para
agir. Quando você precisar do veredito confirmado por SMTP, confira primeiro
result["smtp"]: cached ou skipped significa que o resultado já é definitivo, e só
pending exige fazer polling do job. Faça esse polling em uma tarefa em segundo plano,
não dentro da requisição:
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
Verificando uma lista em massa#
Não faça um loop de verify() sobre um CSV, um endereço por vez — isso é uma ida e
volta HTTP e um job por linha. Envie a lista inteira para o /verify/bulk em uma única
requisição, receba de volta um único job_id e faça polling uma vez para o lote todo.
Linhas duplicadas são descartadas (e reembolsadas) e os resultados vêm em páginas de
até 1.000, então percorra as páginas e indexe cada linha pelo seu 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
Um lote de milhares pode levar minutos, porque cada teste SMTP é uma conversa de rede
separada, então amplie tries/delay para listas maiores em vez de martelar o
endpoint de polling. Rode as duas camadas locais primeiro para descartar o lixo óbvio
antes de gastar créditos — filtre com is_valid_syntax e has_mail_route e, depois,
envie apenas os sobreviventes.
Notas sobre async e pydantic#
O requests é bloqueante, então, em uma aplicação asyncio, envolva a chamada com
asyncio.to_thread(verify, email) ou troque para httpx.AsyncClient para disparar as
chamadas de verificação de forma concorrente; o dnspython traz um resolvedor async
(dns.asyncresolver) pelo mesmo motivo. Se você já usa pydantic, o tipo EmailStr
é um substituto limpo para a Camada 1 — ele valida a sintaxe quando um modelo é
construído —, mas para por aí: nunca toca no DNS nem na caixa postal, então mantenha a
verificação de MX e a API de verificação a jusante dele.
Juntando as camadas#
A mais barata primeiro, parando assim que você tiver uma resposta:
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
Se a própria chamada à API falhar (timeout, 402 por falta de créditos, 429 por
limite de taxa ou um 5xx), a função libera o endereço com unknown: deixe o cadastro
passar e verifique o endereço de novo mais tarde.
As duas camadas locais não custam nada e pegam a maior parte do lixo na hora; a API roda apenas nos endereços que valem a ida e volta. Veja como funciona a verificação de e-mail para entender por que as etapas ficam nesta ordem.
Perguntas frequentes
01Dá para verificar um e-mail em Python usando só a biblioteca padrão?
Até certo ponto. O re resolve a sintaxe e, com socket, você pode tentar o DNS - mas não existe um resolvedor de MX embutido (por isso o dnspython), e confirmar uma caixa postal exige um teste SMTP que você não deveria rodar a partir da sua aplicação. A biblioteca padrão dá conta da sintaxe e de uma verificação de domínio superficial; a camada da caixa postal precisa do dnspython mais um serviço de verificação.
02Por que não usar o smtplib para checar se uma caixa postal existe?
O smtplib consegue abrir a conversa SMTP, mas rodá-lo a partir do servidor da sua aplicação é pouco confiável e arriscado: a maioria dos provedores bloqueia a porta 25 de saída, a resposta depende da reputação do seu IP de envio, e sondar em qualquer volume acaba te levando a limitação de taxa ou à blocklist. Uma API de verificação executa o teste a partir de uma infraestrutura com reputação gerenciada, feita para isso.
03O dnspython é a biblioteca certa para consultas de MX?
Sim - é o kit de ferramentas de DNS padrão e bem mantido para Python, e resolve registros MX de forma limpa. Instale com pip install dnspython e capture NXDOMAIN, NoAnswer e NoNameservers para tratar a ausência de rota de e-mail como não entregável.
04O EmailStr do pydantic valida um endereço de e-mail por completo?
Não - o EmailStr (baseado no pacote email-validator) só verifica se uma string é sintaticamente um endereço válido no momento em que o seu modelo é construído. Ele não confirma se o domínio tem registros MX nem se a caixa postal existe, então corresponde exatamente à Camada 1. Use-o como a barreira de sintaxe nos seus modelos pydantic e, depois, rode a consulta de DNS e a API de verificação nos endereços que passarem. Ele também normaliza o endereço (deixa o domínio em minúsculas), o que ajuda antes de você remover duplicatas de uma lista.
Pronto para adicionar a camada SMTP? O plano gratuito inclui 100 créditos
que rodam o pipeline completo, e a referência da API traz os endpoints
/verify e /jobs completos, com exemplos de copiar e colar em sete linguagens.



