Validar um endereço de e-mail no Node.js são três tarefas com um nome só. A maioria dos tutoriais mostra uma regex e para por aí, o que verifica a grafia e chama isso de validação. A validação de verdade é feita em camadas: uma verificação de sintaxe barata, uma consulta de DNS e um teste SMTP da caixa postal — e o Node já entrega as duas primeiras prontas. Este guia constrói cada camada com código funcional e mostra onde uma API de verificação assume o controle.
A resposta curta#
Use uma regex permissiva para a sintaxe, node:dns/promises para a consulta de
MX e uma API de verificação para a checagem SMTP da caixa postal — nessa ordem,
da mais barata para a mais cara, encerrando assim que uma delas for decisiva. Não
tente abrir conexões SMTP a partir do servidor da sua aplicação: a porta 25 é
bloqueada na maioria dos hosts e, mesmo onde não é, a checagem depende da
reputação do IP de envio e do tratamento de greylisting
que você não vai querer construir.
Camada 1: sintaxe#
A regex pertence a este lugar e a nenhum outro. Mantenha-a permissiva — o objetivo é pegar erros de digitação na entrada, não reimplementar a RFC 5322 (o que não ajuda mesmo):
const SYNTAX = /^[^\s@."]+(?:\.[^\s@."]+)*@[^\s@.]+(?:\.[^\s@.]+)+$/;
export function isValidSyntax(email) {
return typeof email === "string" && email.length <= 320 && SYNTAX.test(email);
}
Passar aqui significa "vale a pena checar", não "válido". Toda camada seguinte pressupõe que a sintaxe já está sã.
Camada 2: o domínio consegue receber e-mail?#
É aqui que o Node justifica sua existência. O módulo nativo node:dns/promises
resolve registros MX sem nenhuma dependência, e um domínio sem rota de e-mail não
pode aceitar mensagens para ninguém — então essa única consulta elimina domínios
mortos, nomes de empresa escritos errado e TLDs inventados:
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
Alguns domínios aceitam e-mail em um registro A sem MX (MX implícito). Se você
quiser respeitar esse caso extremo, faça um fallback para dns.resolve4 quando o
resolveMx vier vazio — mas, para a esmagadora maioria dos endereços reais, uma
checagem de MX é o filtro certo.
Camada 3: a caixa postal existe de verdade?#
As camadas 1 e 2 só conseguem descartar um endereço. Confirmar uma caixa postal
significa a conversa de entrega SMTP — RCPT TO, ler a resposta, desconectar
antes de enviar qualquer coisa. Em princípio você pode fazer isso com o módulo
net. Na prática, não deveria: a maioria dos provedores de nuvem bloqueia a porta
25 de saída, a resposta depende da reputação do IP de onde você conecta, e os
servidores aplicam greylisting e limitam a taxa de remetentes desconhecidos. Essa
é a camada que vale a pena delegar.
O POST /verify da Qualisend roda o pipeline completo e devolve um
veredito. As checagens locais voltam imediatamente com o teste SMTP na fila:
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
}
}
Para validação de cadastro ao vivo, o result imediato geralmente já basta para
agir — rejeite undeliverable, ofereça a correção do did_you_mean, sinalize
disposable. Quando result.smtp for "pending" e você precisar do
veredito confirmado por SMTP, faça polling do job a partir de uma tarefa em
segundo plano, e não dentro do handler da requisição, até o teste terminar:
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
}
Duas coisas derrubam o fetch ingênuo acima assim que ele encontra tráfego real.
Primeiro, um upstream lento não deveria travar o seu processo Node — envolva a
requisição em um timeout com AbortController para que ela falhe rápido em vez de
bloquear o event loop. Segundo, um 429 (limite de taxa atingido) ou um 5xx é
transitório e vale a pena tentar de novo, ao passo que um 4xx como o 400
significa que o payload está errado e repetir não vai adiantar — então separe os
dois:
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);
}
}
Em um formulário de cadastro, não bloqueie o usuário por isso: se a chamada
estourar o timeout ou devolver 402, 429 ou um 5xx, deixe-o passar e
verifique o endereço depois.
Mantenha a chave de API em process.env, nunca no bundle — essa chamada pertence
ao lado servidor da sua aplicação Node, não a nada que você envie para o
navegador.
Para a limpeza de listas, você está verificando milhares de endereços, não um de
cada vez. Não faça um loop com await (serial e lento) nem dispare todos de uma
vez (você vai estourar o limite de taxa). Envie um número limitado de requisições
concorrentes com Promise.allSettled para que um endereço rejeitado não afunde o
lote inteiro:
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;
}
Junte as entradas rejected, dê um respiro e tente de novo só essas — os
vereditos fulfilled já estão prontos. O limite de taxa vale por workspace e
por minuto e é compartilhado por todas as chaves dele (60 requisições no Free,
120 no Pro, 300 no Enterprise), então uma lista longa ainda vai receber 429
desse jeito. Para uma lista inteira, envie um único job POST /verify/bulk com
{ text, expected_count } e percorra os vereditos com
GET /jobs/{id}?include=results.
Juntando as camadas#
Da mais barata para a mais cara, parando assim que você tiver uma resposta:
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
}
As duas camadas locais não custam nada e pegam a maior parte do lixo na hora; a camada da API roda apenas nos endereços que valem a ida e volta pela rede. Essa ordenação é o truque todo — veja como funciona a verificação de e-mail para entender por que cada etapa fica onde fica.
Perguntas frequentes
01Uma biblioteca como validator.js ou email-validator é suficiente?
Essas bibliotecas fazem bem a camada 1 - sintaxe - e são uma ótima alternativa a uma regex feita na mão. Mas são verificadoras de sintaxe: elas não resolvem registros MX nem confirmam uma caixa postal, então um validator.isEmail() aprovado ainda significa "parece um e-mail", não "será entregue". Combine-as com as camadas de DNS e SMTP acima.
02Consigo verificar se um e-mail existe no Node sem uma API?
Em parte. O node:dns/promises confirma que o domínio aceita e-mail, o que descarta domínios mortos de graça. Confirmar a caixa postal exige um teste SMTP, que você pode tentar com o módulo net, mas não deveria rodar a partir do servidor da sua aplicação - a porta 25 é amplamente bloqueada e o resultado depende da reputação do seu IP. Essa é a camada que um serviço de verificação existe para cuidar.
03Devo validar e-mails de forma síncrona no cadastro?
Faça as camadas instantâneas de forma síncrona - sintaxe e MX são rápidas o suficiente para bloquear a requisição e dar feedback imediato ao usuário. Trate o resultado do SMTP como a resposta mais lenta: aja com base no veredito local imediato no cadastro e use o resultado completo confirmado por SMTP para a limpeza de listas. O guia de cadastro serverless mostra o padrão de ponta a ponta.
04Devo armazenar em cache os resultados de verificação para evitar checar o mesmo endereço de novo?
Sim. Cada chamada de verificação é uma ida e volta pela rede e um crédito gasto, então não repita para um endereço que você acabou de checar. Use como chave o e-mail normalizado - deixe em minúsculas e remova os espaços primeiro - em um cache de vida curta e reaproveite o veredito por alguns minutos. Um detalhe: não guarde em cache resultados unknown ou ainda pending. Um veredito pending ainda não é definitivo: busque o final em GET /jobs/{job_id} assim que o teste SMTP terminar, em vez de chamar o verify de novo, e tente um unknown de novo mais tarde. Um Map simples basta para um único processo Node; recorra ao Redis só quando você rodar mais de uma instância e precisar que elas compartilhem o cache.
Pronto para adicionar a camada SMTP? O plano gratuito inclui 100
créditos que rodam o pipeline completo, e a referência da API tem
os endpoints /verify e /jobs completos, com exemplos de copiar e colar em
sete linguagens.



