VERIFIED.NET
Guides 路 Email

How to validate email addresses in Node.js

Updated 2026-10-19 路 7 min read

Validating an email address in Node.js takes three cheap checks you can run yourself (syntax, mail server, disposable domain) and one expensive one you usually can't (does the mailbox exist). This guide covers all four, with code for Node 18 and later. No dependencies needed beyond what ships with Node.

1. Syntax: keep it simple

A full RFC 5322 regex is hundreds of characters long and still rejects addresses real mail servers accept. For a signup form, a deliberately loose check is better: one @, something before it, a dot in the domain, no spaces. Anything stricter belongs to the later checks.

javascript
function checkSyntax(address) {
  const value = String(address).trim();
  if (value.length > 254) return null;
  const at = value.lastIndexOf("@");
  if (at < 1 || at === value.length - 1) return null;
  const local = value.slice(0, at);
  const domain = value.slice(at + 1).toLowerCase();
  if (/\s/.test(value) || local.length > 64) return null;
  if (!/^[a-z0-9-]+(\.[a-z0-9-]+)+$/.test(domain)) return null;   // ASCII domains; punycode-encode IDNs first
  return { local, domain, normalized: `${local}@${domain}` };
}

console.log(checkSyntax("Jane.Doe@Example.COM")); // { local: 'Jane.Doe', domain: 'example.com', ... }
console.log(checkSyntax("jane@@example"));        // null

2. Mail server: look up MX records

dns/promises does the lookup. Two rules matter: a domain with no MX record can still receive mail at its A record, and an MX of . (a "null MX") means the domain accepts no mail at all.

javascript
import { resolveMx, resolve4 } from "node:dns/promises";

// MX hosts, [] if the domain takes no mail, null if DNS didn't answer.
async function mailHosts(domain) {
  try {
    const records = await resolveMx(domain);
    return records.sort((a, b) => a.priority - b.priority)
                  .map((r) => r.exchange)
                  .filter((h) => h && h !== ".");
  } catch (err) {
    if (err.code === "ENOTFOUND") return [];              // domain doesn't exist
    if (err.code === "ENODATA") {                         // no MX: try the A record
      try { await resolve4(domain); return [domain]; } catch { return []; }
    }
    return null;                                          // timeout etc.: unknown, not invalid
  }
}

console.log(await mailHosts("gmail.com")); // [ 'gmail-smtp-in.l.google.com', ... ]
console.log(await mailHosts("gmial.con")); // []

3. Disposable domains

The free, community-maintained disposable-email-domains list (CC0) covers close to 10,000 temporary-mail domains. Load it at startup and match subdomains too:

javascript
const LIST_URL = "https://raw.githubusercontent.com/disposable-email-domains/" +
                 "disposable-email-domains/main/disposable_email_blocklist.conf";

const res = await fetch(LIST_URL);
const DISPOSABLE = new Set(
  (await res.text()).split("\n").map((l) => l.trim().toLowerCase()).filter((l) => l && !l.startsWith("#"))
);

function isDisposable(domain) {
  const parts = domain.toLowerCase().split(".");
  for (let i = 0; i < parts.length - 1; i++) {
    if (DISPOSABLE.has(parts.slice(i).join("."))) return true;
  }
  return false;
}

Lists fall behind, because new throwaway domains appear daily. More on detecting disposable addresses.

4. Mailbox existence: why not do it yourself

Libraries that "verify" by connecting to the mail server on port 25 and sending RCPT TO work on a laptop demo and fail in production. Cloud providers block outbound port 25 by default. Mail providers treat many recipient checks without a message as address harvesting and block the IP, which also breaks your own app's emails if they share it. Yahoo and AOL accept every recipient at that stage anyway, catch-all domains say yes to anything, and greylisting says "try again later".

Done well, it needs a dedicated IP with matching reverse DNS, per-domain rate limits, a list of providers that never give real answers, catch-all detection and an automatic shutoff. That's why it's usually a service.

5. Everything in one request

The Verified.net API runs all of the above (plus typo suggestions and role-address detection) and returns a verdict:

javascript
// Returns the result, or null if the service couldn't be reached (fail open).
async function checkEmail(address) {
  try {
    const res = await fetch("https://api.verified.net/v1/email", {
      method: "POST",
      headers: {
        Authorization: `Bearer ${process.env.VERIFIED_KEY}`,
        "Content-Type": "application/json",
      },
      body: JSON.stringify({ address }),
      signal: AbortSignal.timeout(10_000),
    });
    if (!res.ok) return null;          // 402 out of credits, 429 rate limited
    return await res.json();
  } catch {
    return null;
  }
}

const result = await checkEmail("jane.doe@gmial.com");
console.log(result?.recommendation); // { action: 'review', reasons: [ 'possible_typo' ] }
console.log(result?.did_you_mean);   // jane.doe@gmail.com

Branch on recommendation.action (accept, review, reject); reasons explains it. Each check costs 1 credit, invalid syntax is free, and repeats within 7 days are free. All fields: API docs.

6. In an Express signup route

javascript
app.post("/signup", express.json(), async (req, res) => {
  const email = String(req.body.email || "");
  if (!checkSyntax(email)) return res.status(400).json({ error: "Enter a valid email address." });

  const result = await checkEmail(email);
  if (result?.did_you_mean) {
    return res.status(400).json({ error: `Did you mean ${result.did_you_mean}?`, suggestion: result.did_you_mean });
  }
  if (result?.recommendation.action === "reject") {
    return res.status(400).json({ error: "Please use a working, permanent email address." });
  }
  // accept, review, or service unreachable: create the account, send the confirmation email
  await createAccount(email, { needsReview: result?.recommendation.action === "review" });
  res.status(201).json({ ok: true });
});

Tips

  • Validate on the server. A check in the browser is a nice hint, but anyone can skip it.
  • Fail open on timeouts and errors; queue the address for a later check instead.
  • Don't block "review" outright. Role and catch-all addresses are often genuine businesses.
  • Clean old lists in bulk before a campaign with bulk jobs (CSV upload, up to 100,000 rows).

Try it on your own data

Check an address or number now with the free checker, no signup needed, or start a 7-day trial and call the API from your code.

Start free trial API docs