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": 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 você precisar do veredito confirmado por SMTP, faça polling
do job até o teste terminar:
export async function verifyAndWait(email, { tries = 10, delayMs = 1500 } = {}) {
const { job_id } = await verify(email);
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, ... }
await new Promise((r) => setTimeout(r, delayMs));
}
return null; // still processing — treat as unknown, retry later
}
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 422
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);
}
}
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. Esse mesmo padrão com estrangulamento
mantém você abaixo do limite de taxa do plano sem nenhuma dependência extra.
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#
Uma 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.
Consigo 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.
Devo 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.
Devo 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, porque são justamente os
que vale a pena tentar de novo assim que o teste SMTP terminar. 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.