Validare un indirizzo email in Node.js significa fare tre lavori sotto un unico nome. La maggior parte dei tutorial ti mostra una regex e si ferma lì: controlla l'ortografia e la chiama validazione. La vera validazione è a livelli: un controllo della sintassi economico, una ricerca DNS e un test SMTP della casella, e Node ti dà i primi due già pronti. Questa guida costruisce ogni livello con codice funzionante e mostra dove subentra un'API di verifica.
In breve#
Usa una regex permissiva per la sintassi, node:dns/promises per la ricerca MX e
un'API di verifica per il controllo SMTP della casella: in quest'ordine, prima il
più economico, fermandoti appena uno dà una risposta definitiva. Non provare ad
aprire connessioni SMTP dal server della tua app: la porta 25 è bloccata dalla
maggior parte degli host e, anche dove non lo è, il controllo dipende dalla
reputazione dell'IP di invio e da una gestione del greylisting
che non vuoi costruire.
Livello 1: sintassi#
La regex serve qui e da nessun'altra parte. Tienila permissiva: l'obiettivo è intercettare gli errori di battitura al momento dell'inserimento, non reimplementare la RFC 5322 (che comunque non aiuta):
const SYNTAX = /^[^\s@."]+(?:\.[^\s@."]+)*@[^\s@.]+(?:\.[^\s@.]+)+$/;
export function isValidSyntax(email) {
return typeof email === "string" && email.length <= 320 && SYNTAX.test(email);
}
Superare questo controllo significa "vale la pena controllarlo", non "valido". Ogni livello successivo dà per scontato che la sintassi sia già corretta.
Livello 2: il dominio può ricevere posta?#
Qui Node dimostra quanto vale. Il modulo integrato node:dns/promises risolve
i record MX senza dipendenze, e un dominio senza instradamento di posta non può
accettare posta per nessuno, quindi questa sola ricerca elimina domini morti, nomi di
aziende scritti male e TLD inventati:
import { resolveMx } from "node:dns/promises";
export async function hasMailRoute(domain) {
try {
const mx = await resolveMx(domain);
return mx.length > 0;
} catch {
// ENOTFOUND / ENODATA → domain doesn't exist or publishes no MX
return false;
}
}
await hasMailRoute("gmail.com"); // true
await hasMailRoute("company-that-folded.com"); // false
Alcuni domini accettano posta su un record A senza MX (MX implicito). Se vuoi
gestire questo caso limite, ripiega su dns.resolve4 quando resolveMx è vuoto,
ma per la stragrande maggioranza degli indirizzi reali il controllo MX è il filtro
giusto.
Livello 3: la casella esiste davvero?#
I livelli 1 e 2 possono solo escludere un indirizzo. Confermare una casella
significa passare per la conversazione SMTP di recapito: RCPT TO, leggere la
risposta, disconnettersi prima di inviare qualsiasi cosa. In teoria puoi farlo con
il modulo net. In pratica non dovresti: la maggior parte dei provider cloud blocca
la porta 25 in uscita, la risposta dipende dalla reputazione dell'IP da cui ti
connetti e i server applicano il greylisting e limitano la frequenza dei mittenti
sconosciuti. Questo è il livello che conviene delegare.
POST /verify di Qualisend esegue l'intera pipeline e restituisce un
esito. I risultati dei controlli locali arrivano subito, con il test SMTP in coda:
const BASE = "https://app.qualisend.com/api/v1";
export async function verify(email) {
const res = await fetch(`${BASE}/verify`, {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.QUALISEND_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ email }),
});
if (!res.ok) throw new Error(`Qualisend responded ${res.status}`);
return res.json();
}
{
"job_id": "6f1c2e0a-9b3d-4a1e-8c77-1a2b3c4d5e6f",
"probe_queued": true,
"result": {
"email": "jane@example.com",
"status": "deliverable",
"score": 95,
"sub_flags": { "role": false, "disposable": false, "free_mail": false, "catch_all": false },
"did_you_mean": null,
"smtp": "pending",
"reason": null
}
}
Per la validazione in tempo reale all'iscrizione, di solito basta il result
immediato per decidere: rifiuta undeliverable, proponi la correzione
did_you_mean, segnala disposable. Quando result.smtp è "pending" e ti serve
l'esito confermato via SMTP, interroga il job da un'attività in background, non
dentro il request handler, finché il test non termina:
export async function verifyAndWait(email, { tries = 10, delayMs = 1500 } = {}) {
const { job_id, result } = await verify(email);
if (result.smtp !== "pending") return result; // cached or skipped: already final
for (let i = 0; i < tries; i++) {
const res = await fetch(`${BASE}/jobs/${job_id}?include=results`, {
headers: { Authorization: `Bearer ${process.env.QUALISEND_API_KEY}` },
});
const job = await res.json();
if (job.status === "completed") return job.results[0]; // { status, score, reason, ... }
if (job.status === "failed" || job.status === "canceled") return null;
await new Promise((r) => setTimeout(r, delayMs));
}
return { ...result, job_id }; // probe still running: poll job_id again later, don't call /verify again
}
Due cose mettono in difficoltà il fetch ingenuo qui sopra quando arriva il traffico
reale. Primo, un upstream lento non deve bloccare il tuo processo Node: racchiudi la
richiesta in un timeout con AbortController, così fallisce subito invece di bloccare
l'event loop. Secondo, un 429 (rate limit) o un 5xx è temporaneo e vale la pena
ritentare, mentre un 4xx come 400 significa che il payload è sbagliato e ritentare
non serve, quindi separa i due casi:
export async function verify(email, { timeoutMs = 4000 } = {}) {
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), timeoutMs);
try {
const res = await fetch(`${BASE}/verify`, {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.QUALISEND_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ email }),
signal: ctrl.signal,
});
if (res.status === 429 || res.status >= 500) {
throw Object.assign(new Error(`retryable ${res.status}`), { retryable: true });
}
if (!res.ok) throw new Error(`Qualisend responded ${res.status}`);
return res.json();
} finally {
clearTimeout(timer);
}
}
In un modulo di iscrizione, lascia passare l'utente in caso di problemi: se la
chiamata va in timeout o restituisce 402, 429 o un 5xx, fallo procedere e
verifica l'indirizzo in un secondo momento.
Tieni la chiave API in process.env, mai nel bundle: questa chiamata va fatta sul
lato server della tua app Node, non in qualcosa che distribuisci al browser.
Per la pulizia delle liste verifichi migliaia di indirizzi, non uno alla volta. Non
usare un ciclo con await (seriale e lento) e non lanciarli tutti insieme (faresti
scattare il rate limit). Invia un numero limitato di richieste simultanee con
Promise.allSettled, così un indirizzo rifiutato non affonda l'intero batch:
export async function verifyMany(emails, { concurrency = 5 } = {}) {
const out = [];
for (let i = 0; i < emails.length; i += concurrency) {
const slice = emails.slice(i, i + concurrency);
const settled = await Promise.allSettled(slice.map((e) => verify(e)));
out.push(...settled); // each: { status: "fulfilled", value } | { status: "rejected", reason }
}
return out;
}
Raccogli le voci rejected, aspetta un po' e ritenta solo quelle: gli esiti
fulfilled sono già pronti. Il rate limit è per workspace al minuto ed è condiviso
da tutte le sue chiavi (60 richieste su Free, 120 su Pro, 300 su Enterprise), quindi
una lista lunga finirà comunque per ricevere dei 429 con questo sistema. Per
un'intera lista, invia invece un unico job POST /verify/bulk con
{ text, expected_count } e scorri gli esiti con GET /jobs/{id}?include=results.
Mettere insieme i livelli#
Prima il più economico, e fermati appena hai una risposta:
export async function validateEmail(email) {
if (!isValidSyntax(email)) return { status: "undeliverable", reason: "invalid_email" };
const domain = email.slice(email.lastIndexOf("@") + 1);
if (!(await hasMailRoute(domain))) return { status: "undeliverable", reason: "invalid_domain" };
const { result } = await verify(email);
return result; // deliverable | risky | undeliverable | unknown, with reason + sub_flags
}
I due livelli locali non costano nulla e intercettano subito la maggior parte degli indirizzi senza valore; il livello API entra in gioco solo per quelli che meritano il round-trip di rete. Tutto il trucco sta in quest'ordine: leggi come funziona la verifica email per capire perché ogni fase si trova dove si trova.
Domande frequenti
01Mi basta una libreria come validator.js o email-validator?
Queste librerie gestiscono bene il livello 1, la sintassi, e sostituiscono degnamente una regex scritta a mano. Ma sono controlli di sintassi: non risolvono i record MX e non confermano una casella, quindi superare validator.isEmail() significa comunque "sembra un'email", non "verrà recapitata". Abbinale ai livelli DNS e SMTP descritti sopra.
02Posso controllare se un'email esiste da Node senza un'API?
In parte. node:dns/promises conferma che il dominio accetta posta, e così escludi gratis i domini morti. Confermare la casella richiede un test SMTP, che puoi tentare con il modulo net ma non dovresti eseguire dal server della tua app: la porta 25 è ampiamente bloccata e il risultato dipende dalla reputazione del tuo IP. Un servizio di verifica esiste proprio per gestire questo livello.
03Devo validare le email in modo sincrono all'iscrizione?
Esegui in modo sincrono i livelli immediati: sintassi e MX sono abbastanza veloci da far attendere la richiesta e dare all'utente un feedback istantaneo. Considera il risultato SMTP come la risposta più lenta: all'iscrizione agisci in base all'esito locale immediato e usa il risultato completo confermato via SMTP per la pulizia delle liste. La guida alle iscrizioni serverless mostra il modello dall'inizio alla fine.
04Devo mettere in cache i risultati di verifica per non ricontrollare lo stesso indirizzo?
Sì. Ogni chiamata di verifica è un round-trip di rete e un credito speso, quindi non ripeterla per un indirizzo appena controllato. Usa come chiave di una cache a breve durata l'email normalizzata (prima in minuscolo e senza spazi) e riutilizza l'esito per qualche minuto. Un'avvertenza: non mettere in cache i risultati unknown o ancora pending. Un esito pending non è ancora definitivo: leggi quello finale da GET /jobs/{job_id} quando il test SMTP termina, invece di richiamare la verifica, e ritenta più tardi un unknown. Per un singolo processo Node basta una semplice Map; passa a Redis solo quando esegui più istanze che devono condividere la cache.
Vuoi aggiungere il livello SMTP? Il piano Free include 100 crediti che
eseguono l'intera pipeline, e il riferimento API contiene gli endpoint
/verify e /jobs completi, con esempi pronti da copiare in sette linguaggi.



