How to validate an email in Node.js (without fooling yourself)
Regex proves the shape, dns.resolveMx proves the domain can receive mail, and a maintained list proves the rest — three layers, and Node hands you two of them for free.
Contents
You want to validate an email in Node.js, and the internet is happy to hand you a 200-character regex and call it finished. It isn't — but not for the reason you'd guess. The regex isn't too weak; it's answering a different question than the one you actually care about. "Is this email valid?" is really three questions stacked on top of each other, and Node's standard library answers two of them without a single dependency.
What "valid" is actually asking
Pull the word apart and it splits into layers:
- Shape — is the string even email-shaped? (
a@b.com, nota@@b.) - Deliverability, roughly — does the domain publish a mail server that could receive the message?
- Reputation — is it a throwaway burner inbox, or a
sales@alias you'd rather keep out of a drip campaign?
Regex answers the first. DNS answers the second. Only the third needs data that doesn't live on your disk. Do them in that order and stop the moment you have what you need.
Layer 1: shape (a regex, or validator.isEmail)
A strict regex tells you the string could be an email. That's all — and it's still worth doing first because it's instant and rejects the obvious garbage before you spend a DNS round-trip on it.
// WHATWG HTML living-standard email regex — practical, not "RFC 5322 complete"
const EMAIL_RE =
/^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/;
EMAIL_RE.test('you@example.com'); // true
EMAIL_RE.test('you@@example'); // false
Don't want to own a regex? The validator package does the same job and is well tested:
import validator from 'validator';
validator.isEmail('you@example.com'); // true
validator.isEmail('nope@'); // false
The email-validator package exposes a single validate(email) function if a one-function dependency is all you want. Either way, resist the urge to gold-plate the pattern. A regex that "handles every RFC edge case" still can't tell you the mailbox exists — you're polishing the wrong layer.
Layer 2: can the domain receive mail? (dns.promises.resolveMx)
This is the layer most tutorials skip, and it's the one that catches real mistakes — user@gmial.com, dead domains, test@test. If a domain publishes an MX (mail exchange) record, mail servers know where to deliver its email. If it publishes none, nothing you send will land. Node checks this for you, no library required:
import { promises as dns } from 'node:dns';
async function domainCanReceiveMail(email) {
const domain = email.split('@')[1];
try {
const mx = await dns.resolveMx(domain);
return mx.length > 0;
} catch (err) {
if (err.code === 'ENOTFOUND' || err.code === 'ENODATA') return false;
throw err; // a real DNS failure — don't swallow it as "invalid"
}
}
await domainCanReceiveMail('you@gmail.com'); // true
Be honest about what this proves: an MX record means the domain can accept mail. It does not prove the specific mailbox exists — that requires opening an SMTP conversation, which is a different, slower, and far less reliable thing.
And here's the part the rest of the internet won't tell you: for a lot of applications, this is the finish line. Shape plus a live MX check catches typos, expired domains, and made-up addresses at signup. If you don't need to know whether an address is disposable or a role account, stop here. No dependency, no API, no key. Really.
Layer 3: disposable and role accounts (this one needs a list)
DIY runs out at exactly one point. There's no DNS query for "is mailinator.com a burner?" or "is this a support@ alias?" Those answers live in maintained lists — and lists rot the moment you copy them into your repo. That's the layer where a service earns its keep, because keeping the list current is the whole job.
Boundstone's email endpoint runs syntax, a live MX check, a disposable-domain list, and a role-account list in a single call, behind one consistent contract:
const res = await fetch('https://api.boundstone.io/v1/verify/email', {
method: 'POST',
headers: {
Authorization: 'Bearer bs_live_YOUR_KEY',
'Content-Type': 'application/json',
},
body: JSON.stringify({ email: 'you@example.com' }),
});
const data = await res.json();
// { valid_syntax, domain, mx_found, disposable, role_account, free_provider, checks }
The field that matters most is checks, because it tells you what the response did not check:
"checks": {
"performed": ["syntax", "mx", "disposable_list", "role_list"],
"not_performed": ["smtp_mailbox", "catch_all"]
}
smtp_mailbox and catch_all sit in not_performed on purpose. Verifying a specific mailbox means opening an SMTP conversation with the receiving server — slow, rate-limited, and increasingly answered with a polite "sure, that exists" by providers that accept everything (a catch-all). Boundstone does not do SMTP mailbox verification or catch-all detection, and it says so in the payload rather than dressing up a valid_syntax: true as proof the inbox is real. You also get a free_provider flag (gmail, outlook, and friends) — though note that one is a returned field, not a member of checks.performed.
That not_performed list is the reason to trust a valid. Anyone who hands you a clean result without telling you what they skipped is rounding up.
Putting it together
Layer the checks so cheap ones short-circuit the expensive ones:
async function validateEmail(email) {
if (!EMAIL_RE.test(email)) return { ok: false, reason: 'bad_syntax' };
if (!(await domainCanReceiveMail(email))) return { ok: false, reason: 'no_mx' };
return { ok: true }; // enough for most apps — add the API call only if you need disposable/role
}
The short version
- Shape: a regex or
validator.isEmail. Instant, cheap, proves nothing about deliverability. - Domain:
dns.promises.resolveMx. Built in, free, catches dead domains and typos. If you don't need disposable/role checks, this is your finish line — genuinely. - Disposable / role: needs a maintained list, so call the API. Boundstone reports
disposable,role_account, andfree_provider, and stays honest aboutsmtp_mailboxandcatch_allby leaving them innot_performed. - Trust a "valid" only as far as its
not_performedlist lets you. Nobody verifies the mailbox without SMTP; anyone who won't tell you what they skipped is guessing on your behalf.
Doing the same thing in Python? The three layers are identical: validate an email in Python. Want to eyeball a single address without writing any code? The free keyless email validator tool. Full field reference lives in the docs.