Validare un indirizzo email in Python significa fare tre controlli separati, e quello mostrato dalla maggior parte dei tutorial, una regex, è il meno utile dei tre. La vera validazione è a livelli: sintassi, poi una ricerca DNS, poi un test SMTP della casella. La libreria standard di Python e una piccola dipendenza coprono i primi due; il terzo è un problema di rete che conviene delegare. Questa guida costruisce ogni livello con codice funzionante.
In breve#
Usa una regex permissiva (o email.utils.parseaddr) per la sintassi, dnspython per
la ricerca MX e un'API di verifica per il controllo SMTP della casella: si parte dal
più economico e ci si ferma non appena uno è risolutivo. Non ricorrere a smtplib per
testare le caselle dalla tua app: la porta 25 in uscita è bloccata dalla maggior parte
degli host e il risultato dipende dalla reputazione dell'IP di invio e dal
greylisting, cose che non vuoi reimplementare.
Livello 1: sintassi#
Mantieni il controllo permissivo: intercetta gli errori di battitura, non reimplementare la RFC 5322 (che comunque non servirebbe):
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
email.utils.parseaddr della libreria standard è in grado di analizzare un
indirizzo, ma è permissivo per scelta progettuale: parseaddr("nonsense") restituisce
('', 'nonsense') senza sollevare eccezioni, quindi è un parser, non un validatore.
Per un filtro di sintassi sì/no è più chiara una regex breve.
Livello 2: il dominio può ricevere posta?#
Un dominio senza record MX non può accettare posta per nessuno. dnspython li risolve
in un paio di righe:
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
Questa sola ricerca elimina gratis i domini inattivi e i TLD inventati. Se vuoi
accettare i domini che ricevono posta su un record A senza MX, ripiega sulla
risoluzione di A/AAAA quando l'insieme degli MX è vuoto, ma un controllo MX copre
la stragrande maggioranza degli indirizzi reali.
Livello 3: la casella esiste davvero?#
I livelli 1 e 2 possono solo escludere un indirizzo. Confermare una casella richiede
la conversazione SMTP di consegna e, anche se smtplib può aprirne una, eseguirla
dalla tua applicazione è una cattiva idea: la porta 25 è bloccata quasi ovunque, la
risposta dipende dalla reputazione dell'IP da cui ti connetti e i server riceventi
applicano il greylisting e limiti di frequenza agli sconosciuti. Delega questo
livello.
POST /verify di Qualisend esegue l'intera pipeline. I controlli locali
rispondono subito, con il test SMTP messo in coda:
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()
Per la validazione in tempo reale all'iscrizione, di solito il result immediato basta
per decidere. Quando ti serve l'esito confermato via SMTP, controlla prima
result["smtp"]: cached o skipped significa che il risultato è già definitivo, e
solo pending richiede di interrogare il job. Esegui questa interrogazione da un task
in background, non dentro la richiesta:
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
Verificare una lista in blocco#
Non ripetere verify() su un CSV un indirizzo alla volta: significa un round trip HTTP
e un job per ogni riga. Invia l'intera lista a /verify/bulk con una sola richiesta,
ricevi un solo job_id e interrogalo una volta sola per l'intero batch. Le righe duplicate
vengono scartate (e rimborsate) e i risultati arrivano in pagine da 1.000 al massimo,
quindi scorrile e indicizza ogni riga in base alla sua 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 batch di migliaia di indirizzi può richiedere minuti, perché ogni test SMTP è una
conversazione di rete separata, quindi aumenta tries/delay per le liste più grandi
invece di martellare l'endpoint di polling. Esegui per primi i due livelli locali per
eliminare la spazzatura evidente prima di spendere crediti: filtra con
is_valid_syntax e has_mail_route, poi invia solo gli indirizzi rimasti.
Note su async e pydantic#
requests è bloccante, quindi in un'app asyncio racchiudi la chiamata con
asyncio.to_thread(verify, email) oppure passa a httpx.AsyncClient per lanciare le
chiamate di verifica in parallelo; dnspython include un resolver asincrono
(dns.asyncresolver) per lo stesso motivo. Se usi già pydantic, il suo tipo
EmailStr si inserisce senza attriti come Livello 1 (valida la sintassi quando viene
costruito un modello), ma si ferma lì: non tocca mai il DNS né la casella, quindi tieni
il controllo MX e l'API di verifica a valle.
Mettere insieme i livelli#
Parti dal più economico e fermati appena hai una risposta:
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 fallisce la chiamata API stessa (un timeout, 402 per crediti esauriti, 429 per
limite di frequenza o un 5xx), la funzione adotta il fail-open e restituisce
unknown: lascia passare l'iscrizione e riverifica l'indirizzo più tardi.
I due livelli locali non costano nulla e intercettano subito la maggior parte della spazzatura; l'API viene chiamata solo per gli indirizzi che meritano il round trip. Leggi come funziona la verifica email per capire perché le fasi sono in quest'ordine.
Domande frequenti
01Posso verificare un'email in Python usando solo la libreria standard?
Fino a un certo punto. re gestisce la sintassi e con socket puoi tentare una ricerca DNS, ma non c'è un resolver MX integrato (da qui dnspython) e confermare una casella richiede un test SMTP che non dovresti eseguire dalla tua app. La libreria standard ti dà la sintassi e un controllo approssimativo del dominio; il livello della casella richiede dnspython più un servizio di verifica.
02Perché non usare smtplib per controllare se una casella esiste?
smtplib può aprire la conversazione SMTP, ma eseguirlo dal tuo server applicativo è inaffidabile e rischioso: la maggior parte degli host blocca la porta 25 in uscita, la risposta dipende dalla reputazione del tuo IP di invio e fare questi test, a qualsiasi volume, ti espone a limiti di frequenza o a finire in blacklist. Un'API di verifica esegue il test da un'infrastruttura con reputazione gestita, costruita apposta.
03dnspython è la libreria giusta per le ricerche MX?
Sì: è il toolkit DNS standard e ben mantenuto per Python e risolve i record MX in modo pulito. Installalo con pip install dnspython e intercetta NXDOMAIN, NoAnswer e NoNameservers per considerare non recapitabile un indirizzo senza instradamento della posta.
04EmailStr di pydantic valida completamente un indirizzo email?
No: EmailStr (basato sul pacchetto email-validator) controlla solo che una stringa sia sintatticamente un indirizzo valido quando il modello viene costruito. Non conferma che il dominio abbia record MX né che la casella esista, quindi corrisponde esattamente al Livello 1. Usalo come filtro di sintassi nei tuoi modelli pydantic, poi esegui la ricerca DNS e l'API di verifica sugli indirizzi che lo superano. Inoltre normalizza l'indirizzo (mette il dominio in minuscolo), cosa comoda prima di deduplicare una lista.
Vuoi aggiungere il livello SMTP? Il piano Free include 100 crediti con cui eseguire l'intera pipeline, e il riferimento API contiene gli endpoint
completi /verify e /jobs con esempi pronti da copiare e incollare in sette
linguaggi.



