All posts

Email Validation in a Node.js Backend: Zod, Express and Bulk Lists

··8 min read

To validate email in a Node.js backend, trim the input, check syntax in your request schema with validator.isEmail (zod's default z.email() rejects internationalised addresses), then look up the domain's MX records with a cached Resolver and reject only on a definite "no mail here". Store the trimmed address, not a normalised one. For lists, look up each domain once, not each address.

This post is about wiring validation into a real server. The three validation layers themselves, including the SMTP probe, are covered in validating email in JavaScript and Node.js. Everything here was run on Node.js 22.17 with zod 4.6.5, validator 13.15.35 and Express 5.2.1 on 6 October 2026.

Syntax in the request schema: zod or validator?

Zod's default email check is strict by design, so it rejects some real addresses; validator.isEmail is the better rule for a signup form.

Zod 4's API docs describe z.email() as using "a comparatively strict email regex designed to validate normal email addresses containing common characters. It's roughly equivalent to the rules enforced by Gmail." Its migration guide adds that the older z.string().email() form still works but is "now deprecated". You can swap the pattern through z.regexes, which includes html5Email, rfc5322Email and unicodeEmail.

We ran the same inputs through each option:

Input z.email() z.regexes.unicodeEmail validator.isEmail
o'brien@example.ie valid valid valid
john..doe@gmail.com rejected valid rejected
jane@localhost rejected valid rejected
user@münchen.de rejected valid valid
josé@example.com rejected valid valid
65 as + @gmail.com valid rejected rejected
" jane@gmail.com " rejected rejected rejected

No zod pattern got every row right. The default turns away international customers and lets through a local part longer than the 64 characters RFC 5321 allows. unicodeEmail fixes the first problem by accepting almost anything. validator.isEmail got all of them right, so the schema below uses zod for structure and trimming and validator for the email rule itself:

import validator from "validator";
import { z } from "zod";

const Signup = z.object({
  email: z.string().trim().max(254).refine((v) => validator.isEmail(v), "Invalid email"),
});

.trim() runs before the check, so the trailing space that autofill adds no longer fails a real customer. Do not replace any of this with a regex of your own; the regex post shows how badly the popular ones do.

Do not store the normalised address

validator.normalizeEmail rewrites addresses using provider-specific rules, so use it for spotting duplicates, never as the address you save.

The validator.js README lists normalizeEmail as a sanitizer, says it "doesn't validate that the input is an email", and documents defaults that include all_lowercase, gmail_remove_dots, gmail_remove_subaddress and gmail_convert_googlemaildotcom, all true, with similar subaddress-removal defaults for Outlook.com, Yahoo and iCloud. In our test:

validator.normalizeEmail("Jane.Doe+news@GoogleMail.com"); // "janedoe@gmail.com"

That is the same Gmail mailbox, so it is a good key for "has this person signed up before?" It is a bad value to send to. The user may have chosen +news to filter your mail, and the rules are provider guesses: they only apply where the provider really ignores dots and tags. Save the trimmed address in one column and, if you need it, the normalised key in another. The trade-offs of the dedupe rules are in plus addressing and free-trial abuse.

Cache MX lookups per domain

Look up a domain once and reuse the answer, and share in-flight lookups so a burst of signups does not send a burst of queries.

The Node.js DNS docs make no caching promise for the resolve* functions, and a Resolver defaults to four tries per name server, so set a short timeout as well. This module returns ok, invalid or unknown and caches the promise, not just the result:

import { Resolver } from "node:dns/promises";
import { domainToASCII } from "node:url";

const resolver = new Resolver({ timeout: 3000, tries: 2 });
const TTL_MS = { ok: 60 * 60_000, invalid: 60 * 60_000, unknown: 60_000 };
const cache = new Map();

export function checkMailDomain(rawDomain) {
  const domain = domainToASCII(rawDomain.toLowerCase());
  if (!domain) return Promise.resolve({ status: "invalid", reason: "bad domain" });

  const hit = cache.get(domain);
  if (hit && hit.expires > Date.now()) return hit.result;

  const result = lookup(domain);
  cache.set(domain, { result, expires: Date.now() + TTL_MS.unknown });
  result.then((r) => cache.set(domain, { result, expires: Date.now() + TTL_MS[r.status] }));
  return result;
}

async function lookup(domain) {
  try {
    const mx = await resolver.resolveMx(domain);
    if (mx.length === 1 && mx[0].exchange === "") return { status: "invalid", reason: "null MX" };
    mx.sort((a, b) => a.priority - b.priority);
    return { status: "ok", mx: mx[0].exchange };
  } catch (err) {
    if (err.code === "ENOTFOUND") return { status: "invalid", reason: "domain not found" };
    if (err.code !== "ENODATA") return { status: "unknown", reason: err.code };
  }
  try {
    await resolver.resolve4(domain);
    return { status: "ok", mx: domain, note: "no MX, A record only" };
  } catch (err) {
    if (err.code === "ENODATA" || err.code === "ENOTFOUND") return { status: "invalid", reason: "no MX or A record" };
    return { status: "unknown", reason: err.code };
  }
}

Design notes:

  • unknown is cached for a minute, definite answers for an hour. A DNS timeout should be retried soon; a dead domain will not come back to life in the next few minutes.
  • The pending promise goes into the cache immediately. Fifty concurrent calls for protonmail.com resolved together in 173 ms, sharing one lookup.
  • Repeats are effectively free. First lookups took 13 ms (yahoo.com), 33 ms (gmail.com) and 80 ms (outlook.com) on our connection; the cached repeats took about 0.02 ms.
  • A single MX with an empty exchange is a null MX, the way example.com says it accepts no mail. Node returns it without throwing, so it has to be checked explicitly.
  • domainToASCII converts münchen.de to xn--mnchen-3ya.de before the query.

For a long-running process, cap the Map size or swap it for an LRU cache so a flood of junk domains cannot grow memory without limit.

Wiring it into an Express route

Reject on a syntax failure or a definite DNS "no", and accept on unknown, so your resolver having a bad moment never costs you a signup.

import express from "express";
import { checkMailDomain } from "./mailDomain.mjs";

const app = express();
app.use(express.json());

app.post("/signup", async (req, res) => {
  const parsed = Signup.safeParse(req.body);
  if (!parsed.success) return res.status(422).json({ error: "Enter a valid email address." });

  const { email } = parsed.data;
  const domain = email.slice(email.lastIndexOf("@") + 1);
  const dns = await checkMailDomain(domain);
  if (dns.status === "invalid") {
    return res.status(422).json({ error: `${domain} does not accept email. Check for a typo.` });
  }

  // dns.status is "ok" or "unknown": accept, and record which so the address can be rechecked.
  res.status(201).json({ email, domainCheck: dns.status });
});

app.listen(3000);

Results from curl against the running server:

Request body email Response
" jane@gmail.com " 201, saved as jane@gmail.com, domainCheck: ok
john..doe@gmail.com 422, invalid address
user@münchen.de 201, domainCheck: ok
jane@example.com 422, "example.com does not accept email"
jane@nonexistent-domain-sv-test-9a8b7c.com 422, domain does not accept email

Express 5's error-handling guide says handlers that return a Promise call next() automatically when they reject, but checkMailDomain never rejects: every DNS error becomes unknown. That is deliberate. One gap remains: jane@gmial.com is accepted, because gmial.com has an A record and can legally receive mail. Catching it needs a "did you mean gmail.com?" prompt, covered in catching email typos at signup.

Checking a whole list

Validate syntax per row, look up each unique domain once with a concurrency limit, then apply the answer to every row on that domain.

A list of 5,000 addresses rarely has 5,000 domains. This script reads one address per line, with an optional email header, and writes email,status,reason:

import { createReadStream, createWriteStream } from "node:fs";
import { createInterface } from "node:readline";
import validator from "validator";
import { checkMailDomain } from "./mailDomain.mjs";

const [input, output] = process.argv.slice(2);
const CONCURRENCY = 10;
const csv = (value) => `"${String(value).replaceAll('"', '""')}"`;

const rows = [];
const lines = createInterface({ input: createReadStream(input), crlfDelay: Infinity });
for await (const line of lines) {
  const email = line.trim();
  if (email && email.toLowerCase() !== "email") rows.push(email);
}

const domains = [...new Set(
  rows.filter((e) => validator.isEmail(e)).map((e) => e.slice(e.lastIndexOf("@") + 1).toLowerCase())
)];
const byDomain = new Map();
let next = 0;
async function worker() {
  while (next < domains.length) {
    const domain = domains[next++];
    byDomain.set(domain, await checkMailDomain(domain));
  }
}
await Promise.all(Array.from({ length: CONCURRENCY }, worker));

const out = createWriteStream(output);
out.write("email,status,reason\n");
for (const email of rows) {
  const dns = validator.isEmail(email)
    ? byDomain.get(email.slice(email.lastIndexOf("@") + 1).toLowerCase())
    : { status: "invalid", reason: "syntax" };
  out.write(`${csv(email)},${dns.status},${csv(dns.reason ?? "")}\n`);
}
out.end();
console.log(`${rows.length} rows, ${domains.length} unique domains looked up`);

node checkList.mjs list.csv out.csv on a 5,002-row test file printed "5002 rows, 10 unique domains looked up" and finished in 0.25 seconds. The csv() helper escapes quotes in the output the way RFC 4180 expects. The input side is deliberately simple: if your file has several columns or quoted fields, use a CSV parser rather than splitting lines.

What this does not tell you

Everything above proves the address is well formed and the domain takes mail; none of it proves the mailbox exists. nobody-here-12345@gmail.com gets ok from every function in this post.

The mailbox answer needs an SMTP conversation from a host with outbound port 25 open, plus catch-all detection, which the JavaScript post walks through. For an existing list, the output CSV above is a reasonable input to a verifier: SimpleVerifier takes CSV uploads of up to 100,000 rows and returns valid, risky and invalid separately. To spot-check a single address first, use the free email verifier.

The short version

  • Trim, then use validator.isEmail inside your zod schema. Zod's default rejects international addresses and its unicode pattern is too loose.
  • Store the trimmed address. Use normalizeEmail only as a duplicate key.
  • Cache MX lookups per domain, cache the promise, and keep DNS failures as unknown.
  • Reject only definite failures on signup: bad syntax, null MX, a domain that does not exist.
  • For lists, look up domains, not addresses.

Common questions

Does zod's z.email() accept international email addresses?

Not by default. In Zod 4 the default pattern is a deliberately strict, roughly Gmail-style regex, and in our test it rejected user@münchen.de and josé@example.com. z.regexes.unicodeEmail accepts them but is loose enough to accept john..doe@gmail.com and jane@localhost, so pairing zod with validator.isEmail is the safer option.

Should I store the result of validator.normalizeEmail?

No. normalizeEmail is a sanitizer whose defaults remove dots and +tags from Gmail addresses and lower-case everything, so Jane.Doe+news@GoogleMail.com becomes janedoe@gmail.com. That is useful as a duplicate-detection key, but store and send to the address the user typed, trimmed.

Does Node.js cache DNS lookups made with dns.resolveMx?

The Node.js DNS documentation makes no promise of caching for the resolve functions, which send a query over the network. If you check many addresses, cache results per domain yourself. In our test a cached repeat took about 0.02 ms against 13 to 80 ms for the first lookup.

How do I validate a large email list in Node.js efficiently?

Check syntax per address, then look up each unique domain once with a small concurrency limit and apply the answer to every address on that domain. Our 5,002-row test file had 10 unique domains, so it needed 10 DNS lookups and finished in about a quarter of a second. The mailbox-level check is a separate, slower step.

Verify unlimited addresses for $29.99/month

Real SMTP mailbox checks. No credits, no per-email fees.

Get Started