# How to validate an email in Node.js (without fooling yourself)

_2026-07-20 · Boundstone (https://boundstone.io/blog/validate-email-nodejs)_


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.

```javascript
// 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:

```javascript
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:

```javascript
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:

```javascript
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:

```json
"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:

```javascript
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](/blog/validate-email-python). Want to eyeball a single address without writing any code? The free keyless [email validator tool](/tools/email-validator). Full field reference lives in the [docs](/docs).
