What we'll build
Most "validate an email in JavaScript" answers stop at a regular expression. A regex tells you an address looks right, but not whether the domain exists, whether it accepts mail, or whether it's a throwaway inbox. In this guide we'll build a validateEmail() function for Node.js that checks all of that and returns one of four statuses:
| Status | Meaning | Typical action |
|---|---|---|
valid | Well-formed, and the domain accepts mail | Accept |
invalid | Bad syntax, no such domain, or no mail server | Reject and ask the user to fix it |
risky | Disposable, a likely typo, a role account, or no MX records | Your call: block, warn, or flag |
unknown | DNS failed temporarily | Accept and re-check later |
You'll need Node.js 22 or later and ES modules ("type": "module" in package.json). The only dependency is a list of disposable domains:
npm install disposable-email-domainsEverything else uses Node's built-in node:dns and node:url modules. Want to see the end result first? Try the free email checker, which runs the same checks.
1. Check the syntax
Start with the cheap check that needs no network. Rather than one giant regular expression, split the address at the last @ and validate each part on its own. That keeps the rules readable and lets you return a useful reason when something's wrong:
import { domainToASCII } from "node:url";
// RFC 5322 "atext": the characters allowed in each dot-separated part of the local part.
const ATOM = /^[a-z0-9!#$%&'*+/=?^_`{|}~-]+$/i;
// A DNS label: letters, digits and hyphens, not starting or ending with a hyphen.
const LABEL = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
export function checkSyntax(input) {
const email = String(input).trim();
if (email.length > 254) return { ok: false, reason: "too_long" };
const at = email.lastIndexOf("@");
if (at < 1 || at === email.length - 1) return { ok: false, reason: "missing_at_or_part" };
const local = email.slice(0, at);
// Lower-cases and converts internationalised domains to punycode ("" if invalid).
const domain = domainToASCII(email.slice(at + 1));
if (Buffer.byteLength(local) > 64) return { ok: false, reason: "local_part_too_long" };
// Splitting on "." also rejects leading, trailing and doubled dots (they create empty atoms).
if (!local.split(".").every((atom) => ATOM.test(atom))) {
return { ok: false, reason: "invalid_local_part" };
}
const labels = domain.split(".");
if (!domain || labels.length < 2 || !labels.every((l) => LABEL.test(l)) || /^\d+$/.test(labels.at(-1))) {
return { ok: false, reason: "invalid_domain" };
}
return { ok: true, local, domain, normalized: `${local.toLowerCase()}@${domain}` };
}A few details are doing important work here:
domainToASCII()fromnode:urllower-cases the domain and converts internationalised domains to punycode (münchen.debecomesxn--mnchen-3ya.de), which is the form DNS needs. It returns an empty string for anything unparseable.- Splitting the local part on dots means
.jane,jane.andja..neall produce an empty segment that fails the check, with no extra rules needed. - Length limits come from RFC 5321: 64 bytes for the local part and 254 characters overall.
- Quoted local parts (
"john doe"@example.com) are technically legal but almost never legitimate in a sign-up form, so this rejects them deliberately.
Only lower-case the local part for comparing addresses (to stop duplicate sign-ups). Store what the user typed, because a few mail servers still treat Jane and jane as different mailboxes.
validator.isEmail() from the popular validator package is a solid alternative to this step. Like this function, it only checks the format, so you still need the steps below.2. Check the domain's MX records
A perfectly formatted address is useless if its domain doesn't exist or doesn't accept mail. Node's dns/promises module can look up the domain's MX (mail exchanger) records, which name the servers that receive its mail:
import { Resolver } from "node:dns/promises";
// A dedicated resolver with a short timeout, so a slow DNS server can't stall a sign-up.
export const resolver = new Resolver({ timeout: 3000, tries: 2 });
export async function checkDomain(domain) {
try {
const mx = await resolver.resolveMx(domain);
// RFC 7505 "null MX": a single record pointing at "." means "this domain accepts no mail".
if (mx.length === 1 && mx[0].exchange === "") {
return { status: "invalid", reason: "domain_rejects_mail" };
}
if (mx.length > 0) {
return { status: "ok", mx: mx.sort((a, b) => a.priority - b.priority) };
}
} catch (err) {
if (err.code === "ENOTFOUND") return { status: "invalid", reason: "domain_not_found" };
// ENODATA = the domain exists but has no MX records; handled below.
// Anything else (ESERVFAIL, ETIMEOUT, ECONNREFUSED…) is temporary – don't blame the user.
if (err.code !== "ENODATA") return { status: "unknown", reason: "dns_error" };
}
// No MX records: SMTP falls back to the domain's own A/AAAA record ("implicit MX").
const hasAddress = await Promise.any([resolver.resolve4(domain), resolver.resolve6(domain)]).then(
() => true,
() => false,
);
return hasAddress
? { status: "risky", reason: "no_mx_records" }
: { status: "invalid", reason: "no_mail_server" };
}The error codes tell you exactly what happened, and they need different handling:
| Result | Meaning | Status |
|---|---|---|
| MX records returned | The domain accepts mail | ok |
One MX with exchange "" | "Null MX" (RFC 7505): the domain explicitly accepts no mail | invalid |
ENOTFOUND | The domain doesn't exist (NXDOMAIN) | invalid |
ENODATA | The domain exists but has no MX records, so fall back to A/AAAA | risky or invalid |
ESERVFAIL, ETIMEOUT, … | DNS trouble, not the user's fault | unknown |
Two things are easy to get wrong. First, use a dedicated Resolver with a short timeout. The default settings can leave a request hanging for a long time if a DNS server is slow. Second, never treat a timeout as "invalid", or you'll turn away real users whenever DNS has a bad moment.
3. Cache DNS lookups
Many of your users share a handful of domains (gmail.com, outlook.com and so on), so there's no need to hit DNS for every sign-up. A small in-memory cache helps a lot:
import { checkDomain } from "./dns-check.js";
const cache = new Map();
const TTL_MS = 60 * 60 * 1000; // 1 hour
// Caches the promise, not the result, so concurrent sign-ups on the same domain share one lookup.
export function checkDomainCached(domain) {
const hit = cache.get(domain);
if (hit && hit.expires > Date.now()) return hit.promise;
const promise = checkDomain(domain);
cache.set(domain, { promise, expires: Date.now() + TTL_MS });
// Don't remember temporary failures.
promise.then((result) => {
if (result.status === "unknown") cache.delete(domain);
});
return promise;
}Caching the promise rather than the result means that if 50 people sign up with the same new domain at once, Node makes one DNS query, not 50. For a long-running server, swap the Map for a bounded cache such as lru-cache so memory can't grow forever.
4. Detect disposable domains
Temporary inboxes from services like Mailinator and Guerrilla Mail pass every check so far: the syntax is fine, and the domains have working mail servers. The only way to catch them is a list. The community-maintained disposable-email-domains package ships over 100,000 of them:
import domains from "disposable-email-domains" with { type: "json" };
const disposable = new Set(domains);
// Checks the domain and each parent, so "x.mailinator.com" matches "mailinator.com".
export function isDisposable(domain) {
const labels = domain.split(".");
for (let i = 0; i < labels.length - 1; i++) {
if (disposable.has(labels.slice(i).join("."))) return true;
}
return false;
}Loading the list into a Set makes each lookup instant. Checking parent domains matters because many disposable services hand out addresses on random subdomains. Keep the package up to date, since new disposable domains appear every week. Read more about how disposable email detection works.
5. Flag role accounts and suggest typo fixes
Two more signals are worth having. Role accounts like info@ and support@ reach a shared inbox, not a person. Typos like gmial.com are one of the most common reasons sign-up emails never arrive:
const ROLE_ACCOUNTS = new Set([
"admin", "billing", "contact", "help", "hello", "info", "marketing", "no-reply",
"noreply", "office", "postmaster", "sales", "support", "team", "webmaster",
]);
export function isRoleAccount(local) {
// Ignore "+tag" sub-addressing: "support+billing" is still "support".
return ROLE_ACCOUNTS.has(local.toLowerCase().split("+")[0]);
}
// Real providers people mistype. Include close neighbours (mail.com, ymail.com, gmx.com…)
// too, or they'd be "corrected" to the provider one letter away.
const POPULAR_DOMAINS = [
"gmail.com", "googlemail.com", "yahoo.com", "ymail.com", "yahoo.co.uk", "hotmail.com",
"hotmail.co.uk", "outlook.com", "live.com", "msn.com", "icloud.com", "me.com", "mac.com",
"aol.com", "mail.com", "gmx.com", "gmx.de", "proton.me", "protonmail.com",
];
// Levenshtein distance where swapping two adjacent letters counts as one edit.
function distance(a, b) {
const d = Array.from({ length: a.length + 1 }, (_, i) => [i, ...Array(b.length).fill(0)]);
for (let j = 1; j <= b.length; j++) d[0][j] = j;
for (let i = 1; i <= a.length; i++) {
for (let j = 1; j <= b.length; j++) {
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
d[i][j] = Math.min(d[i - 1][j] + 1, d[i][j - 1] + 1, d[i - 1][j - 1] + cost);
if (i > 1 && j > 1 && a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1]) {
d[i][j] = Math.min(d[i][j], d[i - 2][j - 2] + 1);
}
}
}
return d[a.length][b.length];
}
// "gmial.com" → "gmail.com"; null when the domain is fine or nothing is close.
export function suggestDomain(domain) {
if (POPULAR_DOMAINS.includes(domain)) return null;
let best = null;
let bestDistance = 3;
for (const candidate of POPULAR_DOMAINS) {
const dist = distance(domain, candidate);
if (dist < bestDistance) [best, bestDistance] = [candidate, dist];
}
return bestDistance <= (domain.length <= 6 ? 1 : 2) ? best : null;
}The distance function is a standard Levenshtein edit distance, plus one extra rule so that swapping two neighbouring letters (gmial → gmail) counts as a single edit. Short domains only get one edit's leeway, because a two-letter difference on me.com is a different domain entirely.
The domain list matters as much as the algorithm. Real providers that sit one letter apart, like mail.com and gmail.com or ymail.com and gmail.com, must all be in the list, or users of one get "corrected" to the other. Even then, some genuine domains will look like typos (mx.com is one edit from me.com). That's why the result is a suggestion you show the user, never an automatic correction.
6. Put it together
Now combine the checks, from cheapest to most expensive, and return a single verdict:
import { checkSyntax } from "./syntax.js";
import { checkDomainCached } from "./dns-cache.js";
import { isDisposable } from "./disposable.js";
import { isRoleAccount, suggestDomain } from "./extras.js";
/**
* Returns { status, reason, email, didYouMean } where status is
* "valid" | "invalid" | "risky" | "unknown".
*/
export async function validateEmail(input) {
const syntax = checkSyntax(input);
if (!syntax.ok) return { status: "invalid", reason: syntax.reason, email: null, didYouMean: null };
const { local, domain, normalized } = syntax;
const suggestion = suggestDomain(domain);
const base = { email: normalized, didYouMean: suggestion ? `${local}@${suggestion}` : null };
const dns = await checkDomainCached(domain);
if (dns.status === "invalid" || dns.status === "unknown") return { ...base, status: dns.status, reason: dns.reason };
// The domain can receive mail. Now look for risk signals, most serious first.
if (isDisposable(domain)) return { ...base, status: "risky", reason: "disposable" };
if (suggestion) return { ...base, status: "risky", reason: "possible_typo" };
if (dns.status === "risky") return { ...base, status: "risky", reason: dns.reason };
if (isRoleAccount(local)) return { ...base, status: "risky", reason: "role_based" };
return { ...base, status: "valid", reason: "mail_server_found" };
}Here's what it returns for a range of inputs:
| Input | status | reason | didYouMean |
|---|---|---|---|
jane@gmail.com | valid | mail_server_found | – |
joe@gmial.com | risky | possible_typo | joe@gmail.com |
test@mailinator.com | risky | disposable | – |
support@acme.io | risky | role_based | – |
x@no-such-domain.example | invalid | domain_not_found | – |
not-an-email | invalid | missing_at_or_part | – |
7. Use it in an Express sign-up route
Here's a typical policy for a sign-up form: reject invalid and disposable addresses, accept everything else, and pass the typo suggestion back so the front end can show "Did you mean …?":
import express from "express";
import { validateEmail } from "./validate-email.js";
const app = express();
app.use(express.json());
app.post("/signup", async (req, res) => {
const result = await validateEmail(req.body?.email ?? "");
if (result.status === "invalid" || result.reason === "disposable") {
return res.status(422).json({
error: result.reason === "disposable"
? "Please use a permanent email address."
: "That email address can't receive mail.",
didYouMean: result.didYouMean,
});
}
// "unknown" means DNS had a hiccup – accept the address and re-check it later.
// await createUser(result.email);
res.status(201).json({ ok: true, email: result.email, didYouMean: result.didYouMean });
});
app.listen(3000);Adjust the policy to your product. A B2B form might also reject free providers, while a newsletter might accept role accounts. Whatever you choose, always validate on the server, because client-side checks can be bypassed.
8. Test it
The syntax check is pure, so it's easy to unit test with Node's built-in test runner. Run it with node --test:
import { test } from "node:test";
import assert from "node:assert/strict";
import { checkSyntax } from "./syntax.js";
test("accepts normal addresses", () => {
for (const email of ["jane@example.com", "jane.doe+news@example.co.uk", "user@münchen.de"]) {
assert.equal(checkSyntax(email).ok, true, email);
}
});
test("rejects malformed addresses", () => {
for (const email of ["", "jane", "@example.com", "jane@", "a..b@example.com", "jane@example", "jane@-example.com"]) {
assert.equal(checkSyntax(email).ok, false, email);
}
});For the DNS checks, the resolver is exported so your tests can point it at a local test DNS server with resolver.setServers(["127.0.0.1:5353"]). That way your test suite doesn't depend on the internet.
What about checking that the mailbox exists?
You could go one step further and use Node's net module to connect to the domain's mail server on port 25, then ask whether a specific mailbox exists (RCPT TO). We don't recommend doing it inside a request handler:
- Many hosting providers and cloud platforms block outbound port 25.
- Gmail, Outlook and other large providers often accept every recipient at that stage, or block verification attempts from unknown IP addresses.
- "Catch-all" domains accept any address, and greylisting deliberately delays unknown senders, so the answer is often inconclusive.
The email validation guide covers SMTP verification in more depth. For most sign-up forms, the checks above plus a confirmation email give you the best balance of accuracy and speed.
Or use an email validation API
Everything above works, but it's code you have to maintain: the disposable list goes stale, the typo dictionary needs tuning, and DNS edge cases keep turning up. The validateemail.io API runs all of these checks in one request, with results cached on Cloudflare's network:
const API = "https://validateemail.io/v1";
const headers = {
Authorization: `Bearer ${process.env.VALIDATEEMAIL_KEY}`,
"Content-Type": "application/json",
};
export async function validateWithApi(email) {
try {
const res = await fetch(`${API}/validate?email=${encodeURIComponent(email)}`, {
headers,
signal: AbortSignal.timeout(5000),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return await res.json();
} catch (err) {
// Never block a sign-up because the validator is slow or unreachable.
console.warn("Email validation unavailable:", err.message);
return { status: "unknown", reason: "validator_unavailable" };
}
}
// Validates any number of addresses, 100 per request.
export async function validateList(emails) {
const results = [];
for (let i = 0; i < emails.length; i += 100) {
const res = await fetch(`${API}/validate/batch`, {
method: "POST",
headers,
body: JSON.stringify({ emails: emails.slice(i, i + 100) }),
signal: AbortSignal.timeout(30000),
});
if (!res.ok) throw new Error(`Batch failed: HTTP ${res.status}`);
results.push(...(await res.json()).results);
}
return results;
}Note the fallback in validateWithApi(): if the API is slow or unreachable, it returns unknown instead of throwing, so a network problem never blocks a sign-up. The response includes the same status, reason and did_you_mean fields used in this guide, plus a confidence score and every individual check.
Frequently asked questions
What is the best regex to validate an email in JavaScript?
For quick client-side feedback, a simple pattern like /^[^\s@]+@[^\s@]+\.[^\s@]+$/ is enough. No regex can tell you whether the domain exists or accepts mail, so on the server combine a structured syntax check with a DNS MX lookup, as shown in this guide.
How do I check if an email domain exists in Node.js?
Use resolveMx() from node:dns/promises. An ENOTFOUND error means the domain doesn't exist, ENODATA means it exists but has no MX records, and a single MX record with an empty exchange is a "null MX" that accepts no mail. Treat timeouts and SERVFAIL as temporary rather than invalid.
Is validator.isEmail() enough to validate an email address?
It's a good syntax check, but it only checks the format. It can't detect non-existent domains, missing mail servers, disposable inboxes or typos like gmial.com, so pair it with DNS and disposable-domain checks.
Can Node.js verify that a mailbox exists without sending an email?
Technically yes, by opening an SMTP connection on port 25 and issuing RCPT TO. In practice, port 25 is often blocked, large providers accept all recipients at that stage, and catch-all domains make the result unreliable. A confirmation email is still the only definitive test.