← Blog

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, not a@@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, and free_provider, and stays honest about smtp_mailbox and catch_all by leaving them in not_performed.
  • Trust a "valid" only as far as its not_performed list 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.

Frequently asked questions

How do you validate an email address in Node.js?

Start with a syntax check (a regex plus a length cap under 254 characters) to catch typos and malformed addresses, then do a DNS MX lookup to confirm the domain actually has mail servers configured to receive mail. From there you can flag disposable domains, role accounts like info@ or support@, and free providers so you know what kind of address you are dealing with. Just be honest about what each step proves: clean syntax and a valid MX mean the address is well-formed and the domain can accept mail, not that the specific mailbox exists.

Does checking the MX record confirm an email address is real?

No. An MX lookup only confirms the domain has mail servers configured to accept mail; it tells you nothing about whether the specific mailbox exists or will accept a message. Confirming an individual mailbox would require SMTP-level interaction, which Boundstone deliberately does not perform and reports honestly as not_performed (smtp_mailbox and catch_all). That is why a Boundstone result spells out exactly what it checked and what it did not, so you are never fooled into reading domain can receive mail as this person exists.

Can I verify that an email exists without sending an email?

You can get a long way without sending anything: syntax validation, a DNS MX lookup, and disposable, role-account, and free-provider flags all work without touching the mailbox. What you cannot do this way is prove the exact mailbox exists or detect a catch-all domain, since that requires SMTP probing that Boundstone does not do and marks as not_performed. So a valid result in this sense means well-formed and hosted on a mail-accepting domain, which is genuinely useful, but it is not a deliverability guarantee and should not be sold as one.

Thomas Tsui

Founder of Boundstone — building phone, email and IP validation you can actually verify.

One honest API for email, phone and IP — every response lists what it checked and what it didn't claim to. Free tier: 250 credits/month, no card, credits never expire.

More from Boundstone — API documentation · Benchmark methodology · The benchmark series · Buyer's checklist