Sooner or later every app that collects email addresses needs to know whether an address is real. You can get a surprisingly long way without sending a single message. This guide builds four checks in Node, from the cheapest to the most invasive, and it is honest about where each one stops being reliable.
All the snippets are ES modules, so save them as .mjs files and run them on a current Node release.
A short syntax check catches most typos
Skip the giant regex. The full email grammar is enormous and almost nobody needs it. Check the few rules that catch real mistakes: exactly one @, a sensible length, no spaces or brackets in the name, and a domain with at least one dot.
// parse-email.mjs import { domainToASCII } from 'node:url'; export function parseEmail(input) { if (typeof input !== 'string') return null; const address = input.trim(); if (address.length > 254) return null; const parts = address.split('@'); if (parts.length !== 2) return null; const [local, rawDomain] = parts; if (local.length === 0 || local.length > 64) return null; if (/[\s"(),:;<>[\\\]]/.test(local)) return null; if (local.startsWith('.') || local.endsWith('.') || local.includes('..')) return null; // Turns "bücher.example" into the "xn--" form DNS uses; invalid domains become "". const domain = domainToASCII(rawDomain.toLowerCase()); const labels = domain.split('.'); if (domain === '' || labels.length < 2) return null; const labelOk = (label) => /^[a-z0-9-]{1,63}$/.test(label) && !label.startsWith('-') && !label.endsWith('-'); if (!labels.every(labelOk) || /^\d+$/.test(labels.at(-1))) return null; return { local, domain }; } This accepts someone@gmail.com and rejects name@@gmail.com, name@gmail and anything with a space in it. It does not prove the mailbox exists. It only proves the text is shaped like an address.
The domain has to have a mail server, so look it up
An address can be perfectly formatted and still point nowhere. DNS tells you where a domain receives mail, through MX records, and Node ships the lookup in node:dns/promises.
// find-mail-servers.mjs import { resolveMx, resolve4, resolve6 } from 'node:dns/promises'; const NO_ANSWER = new Set(['ENODATA', 'ENOTFOUND']); // "No records" is an answer. Timeouts and server failures are not, so they are rethrown. async function lookup(resolver, domain) { try { return await resolver(domain); } catch (err) { if (NO_ANSWER.has(err.code)) return []; throw err; } } export async function findMailServers(domain) { const mx = await lookup(resolveMx, domain); if (mx.length > 0) { // A lone "." target is a null MX (RFC 7505): the domain refuses all mail. const usable = mx.filter((record) => record.exchange !== '' && record.exchange !== '.'); return usable.sort((a, b) => a.priority - b.priority).map((record) => record.exchange); } // No MX records at all: RFC 5321 says to fall back to the domain's own address records. const [v4, v6] = await Promise.all([lookup(resolve4, domain), lookup(resolve6, domain)]); return v4.length + v6.length > 0 ? [domain] : []; } Two details matter here. Lower priority numbers are tried first, so the list is sorted by them. And a domain with no MX records is not automatically dead: RFC 5321 says a sender should fall back to the domain's A or AAAA record, which is called an implicit MX. A domain that publishes a single null MX has said it accepts no email at all, so the function returns an empty list for it.
Run it against gmail.com and you get back a short list of mail hosts. Run it against a made-up domain and you get an empty list.
Disposable domains are worth flagging at signup
A throwaway inbox passes every check above, because its domain is real and has a mail server. A maintained public list of disposable domains, kept as a plain text file with one domain per line, lets you flag them. Download it on a schedule rather than copying it once, because the list keeps growing.
// is-disposable.mjs import { readFile } from 'node:fs/promises'; // One domain per line, "#" for comments. Refresh this file on a schedule. const text = await readFile(new URL('./disposable-domains.txt', import.meta.url), 'utf8'); const disposable = new Set( text.split('\n').map((line) => line.trim().toLowerCase()).filter((line) => line && !line.startsWith('#')), ); export function isDisposable(domain) { const labels = domain.toLowerCase().split('.'); // Check "mail.throwaway.example", then "throwaway.example". Never the bare TLD. for (let i = 0; i < labels.length - 1; i++) { if (disposable.has(labels.slice(i).join('.'))) return true; } return false; } The loop also matches parent domains, because a service often hands out addresses on many subdomains. Treat a match as a flag, not a verdict. Some people use a throwaway address for ordinary privacy reasons, so decide for your own product whether to block it or just warn.
An SMTP probe asks the mail server directly, with real caveats
The last step is to ask the destination server whether it would accept mail for the address. You connect to a host from the MX lookup, say hello, name a sender, and then name the recipient with RCPT TO. A 250 reply means the server would take the message. A 550 means there is no such mailbox. You then say QUIT before any message body is sent, so nothing is delivered.
// probe-mailbox.mjs import net from 'node:net'; function verdictFor(code) { if (code === 250 || code === 251) return 'accepted'; if (code === 550 || code === 551 || code === 553) return 'rejected'; return 'unknown'; // 4xx (greylisting, rate limits) and anything unusual } export function probeMailbox(host, address, { port = 25, helo, mailFrom, timeoutMs = 10_000 }) { return new Promise((resolve, reject) => { const socket = net.createConnection({ host, port }); socket.setEncoding('utf8'); socket.setTimeout(timeoutMs, () => socket.destroy(new Error('timed out'))); // Replies arrive in order: greeting, EHLO, MAIL FROM, RCPT TO. const commands = [`EHLO ${helo}`, `MAIL FROM:<${mailFrom}>`, `RCPT TO:<${address}>`]; let replies = 0; let buffer = ''; let done = false; function finish(error, result) { if (done) return; done = true; if (error) { socket.destroy(); reject(error); } else { socket.end('QUIT\r\n'); // no message body is ever sent resolve(result); } } socket.on('error', (err) => finish(err)); socket.on('close', () => finish(new Error('connection closed early'))); socket.on('data', (chunk) => { buffer += chunk; const lines = buffer.split('\r\n'); buffer = lines.pop(); // keep a half-received line for (const line of lines) { const match = /^(\d{3})(?: |$)/.exec(line); // "250-..." means more lines follow if (match) onReply(Number(match[1]), line); } }); function onReply(code, text) { if (done) return; replies += 1; const atRcpt = replies === 4; if (atRcpt || code >= 400) { finish(null, { verdict: atRcpt ? verdictFor(code) : 'unknown', code, text }); } else { socket.write(`${commands[replies - 1]}\r\n`); } } }); } Use helo and mailFrom values from a domain you control, and try the function on a mail server you run yourself while you are learning. It is less reliable against the open internet than it looks:
- Many cloud hosts block outbound port 25 by default, so the connection never opens.
- Catch-all domains accept every address at
RCPT TOand sort the mail out later, so a 250 proves nothing about the mailbox. - Greylisting servers answer with a temporary 4xx code the first time they see a sender and expect a real mail server to retry. The function reports that as
unknown. - Rate limits and blocklists arrive quickly, because many probes with no message behind them look like address harvesting.
- That harvesting look can hurt the reputation of the IP address you probe from, and your real outgoing mail may depend on the same address.
So treat the result as three buckets, accepted, rejected and unknown, and be comfortable with the third one.
A hosted service is simpler when you do not want to run a probe
The first three checks are cheap, and they are worth keeping in your own code. The probe is where the ongoing work lives: access to port 25, retries for greylisting, catch-all detection and IP reputation all need care over time.
If you would rather not run that yourself, a hosted email verification service does the probing for you. There are plenty to choose from and many have a free tier. Compare them on how they treat catch-all domains and how often they answer unknown, because those are the cases a plain 250 cannot settle.
Disclosure: I work on Emailchaser, which offers one of these, a free email checker. It runs 3 free checks a day with no signup, and it marks a catch-all domain as risky rather than proven, for the reasons above.
This post was drafted with AI assistance, and the code samples were run and tested before publishing.


