Skip to content
Inizia con 100 crediti di verifica gratuiti
Qualisend
Tutti gli articoli
Ingegneria / 17 giugno 2026

Come validare un indirizzo email in Python

5 min di lettura

Qualisend team
Come validare un indirizzo email in Python

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.

La tua reputazione, protetta.

Pulisci la tua prima lista in pochi minuti. 100 crediti gratuiti, senza carta di credito.

Inizia