How to Verify an Email Address in Node.js
Published · April 2026Checking if an email address is valid goes beyond regex. A proper check involves DNS lookups, SMTP handshakes, and cross-referencing disposable domain lists. Here's how to do it from scratch in Node.js, and when it makes sense to just use an API instead.
§01Step 1: Syntax validation
Regex-based email validation is a rabbit hole. The RFC 5322 grammar is famously baroque. The spec-complete regex is thousands of characters long and accepts addresses no real mail server would ever deliver to. You don't need it.
A simple pattern that covers the 99% case is fine. The real validation happens at the SMTP step; syntax is just a cheap filter to reject obvious garbage before you spend time on DNS.
function isValidSyntax(email) {
// Good enough for 99% of cases. The real validation is the SMTP check.
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
}
If you prefer an even cheaper check: require an @, a dot in the domain part, and a minimum length. Don't over-invest here. The only way to truly know if a mailbox exists is to ask the mail server.
§02Step 2: DNS / MX record lookup
MX records tell you which server is responsible for receiving mail for a domain. If a domain has no MX records, it can't receive email. Full stop. Node's built-in dns module handles the lookup with no dependencies.
import { promises as dns } from 'dns';
async function getMxRecords(domain) {
try {
const records = await dns.resolveMx(domain);
// Sort by priority, lowest number = highest priority
return records.sort((a, b) => a.priority - b.priority);
} catch (err) {
return []; // No MX records = domain doesn't receive email
}
}
const mx = await getMxRecords('gmail.com');
console.log(mx);
// [{ exchange: 'gmail-smtp-in.l.google.com', priority: 5 }, ...]
If the result is empty, the address is definitely invalid and you can stop here. Otherwise you'll want the highest-priority host (lowest number) for the next step.
§03Step 3: SMTP handshake
This is the core of real email verification. You open a TCP connection to the mail server on port 25, introduce yourself with EHLO, declare a sender with MAIL FROM, and then ask the server whether it would accept a message for the address with RCPT TO. The response code to RCPT TO tells you if the mailbox exists.
You never actually send a message. You disconnect before the DATA command. No mail is delivered, no inbox is touched.
import net from 'net';
function verifySmtp(mxHost, email) {
return new Promise((resolve, reject) => {
const socket = net.createConnection(25, mxHost);
let step = 0;
let response = '';
socket.setTimeout(10000); // 10 second timeout
socket.on('data', (data) => {
response = data.toString();
if (step === 0 && response.startsWith('220')) {
// Server greeting received, send EHLO
socket.write('EHLO checkmail.dev\r\n');
step++;
} else if (step === 1 && response.startsWith('250')) {
// EHLO accepted, send MAIL FROM
socket.write('MAIL FROM:<verify@checkmail.dev>\r\n');
step++;
} else if (step === 2 && response.startsWith('250')) {
// MAIL FROM accepted, send RCPT TO (this is the actual check)
socket.write(`RCPT TO:<${email}>\r\n`);
step++;
} else if (step === 3) {
// This response tells us if the mailbox exists
const valid = response.startsWith('250');
socket.write('QUIT\r\n');
socket.end();
resolve({
valid,
code: parseInt(response.substring(0, 3)),
response: response.trim()
});
}
});
socket.on('timeout', () => {
socket.destroy();
reject(new Error('SMTP connection timed out'));
});
socket.on('error', (err) => {
reject(err);
});
});
}
The response codes you care about:
- 250: mailbox exists, message would be accepted.
- 550: mailbox doesn't exist, or the server is rejecting for another policy reason.
- 452 / 451: greylisting or rate limiting. Not a real answer. Try again later.
Now the caveats that make this approach painful in production:
- Port 25 is blocked by most cloud providers. AWS blocks it by default and rarely approves unblock requests. GCP and Azure block it too. This is the biggest practical barrier to building your own verification. You can't run this code on EC2, Lambda, Cloud Run, Vercel, Railway, or any serverless platform without jumping through hoops.
- Catch-all servers return 250 for any address, even nonexistent ones. You can detect these by probing with a random address like
definitely-not-real-abc123@domain.com. If that returns 250, the domain is catch-all and your real result is unreliable. - Rate limiting. Blast a mail server with hundreds of
RCPT TOchecks per minute and you'll get blocked or greylisted. You need global per-domain rate limits with backoff. - IP reputation. Repeatedly connecting to mail servers on port 25 without ever sending email is a recognizable pattern. Your IP can land on a blocklist (Spamhaus, Barracuda, UCEPROTECT) if you aren't careful about volume and cadence.
§04Step 4: Disposable domain detection
Disposable email services (Mailinator, Guerrilla Mail, 10MinuteMail, and thousands of smaller ones) technically pass every check above. The domain has MX records, the server accepts mail, the mailbox exists. But the user walks away from it ten minutes later, so you probably want to flag these before they land in your database.
// Use a community-maintained list
// https://github.com/disposable-email-domains/disposable-email-domains
import disposableDomains from './disposable-domains.json' with { type: 'json' };
const disposableSet = new Set(disposableDomains);
function isDisposable(email) {
const domain = email.split('@')[1].toLowerCase();
return disposableSet.has(domain);
}
There are 10,000+ known disposable domains and the list changes every week. Pinning a snapshot works for a few months, then silently stops catching new services. Maintenance is ongoing.
§05Step 5: Putting it all together
Chain the steps in order of cost: cheap checks first, expensive network calls last. Bail out as soon as you have a definitive answer.
async function verifyEmail(email) {
// 1. Syntax check
if (!isValidSyntax(email)) {
return { email, status: 'invalid', reason: 'bad_syntax' };
}
const domain = email.split('@')[1].toLowerCase();
// 2. Disposable check
if (isDisposable(email)) {
return { email, status: 'disposable' };
}
// 3. MX lookup
const mxRecords = await getMxRecords(domain);
if (mxRecords.length === 0) {
return { email, status: 'invalid', reason: 'no_mx_records' };
}
// 4. SMTP verification
try {
const result = await verifySmtp(mxRecords[0].exchange, email);
return {
email,
status: result.valid ? 'valid' : 'invalid',
mx_host: mxRecords[0].exchange,
smtp_code: result.code
};
} catch (err) {
return { email, status: 'unknown', reason: err.message };
}
}
Drop this into a file, run it against your test addresses, and, assuming your network allows outbound port 25, you now have a working email verifier.
§06Why this is harder than it looks
The code above works on your laptop. At any real scale, the operational problems are where the difficulty actually lives.
1. Port 25 access. Most cloud providers block outbound port 25. AWS requires a special request to unblock it and frequently rejects applications. You can't run this code on a standard EC2 instance, Lambda, Cloud Run, Vercel, Railway, Fly.io, or any serverless platform. You end up renting a VPS from a provider that permits port 25, and then you're also responsible for its uptime.
2. IP reputation management. If you run verification from a single IP, mail servers will start blocking you after a few thousand checks. Production verification services rotate across dozens or hundreds of IPs, warm them up gradually, and monitor blocklist status continuously.
3. Greylisting and rate limiting. Mail servers intentionally delay or reject connections from unknown senders on first contact. Handling retries, exponential backoff, and per-domain concurrency limits adds real complexity. A naive implementation just reports half its results as "unknown" and gives up.
4. Catch-all detection. Roughly one in five corporate domains accepts mail for any local part. Detecting this reliably requires probing with random addresses on every new domain, caching the result, and marking verdicts as uncertain when a domain is catch-all. Skip this and your "valid" rate becomes meaningless for corporate targets.
5. Keeping disposable lists current. New disposable email services launch every week. Maintaining an up-to-date list means monitoring community sources, deduplicating, and shipping updates regularly.
6. Caching and deduplication. At scale, you don't want to hit the same mail server for the same address twice in a day. A cache layer with sensible TTLs (short for negative results, longer for positive) cuts load dramatically but adds another moving part.
§07The alternative: use an API
If you don't want to deal with port 25 restrictions, IP rotation, catch-all probes, and rate limiting, an email verification API handles all of this for you. CheckMail runs the same checks described above (syntax, DNS, SMTP handshake, disposable detection, catch-all detection) in a single request.
const response = await fetch(
'https://api.checkmail.dev/v1/verify?email=john@example.com',
{ headers: { Authorization: `Bearer ${process.env.CM_KEY}` } }
);
const result = await response.json();
console.log(result.status); // 'valid', 'invalid', 'catch_all', 'disposable', 'unknown'
The full response:
{
"email": "john@example.com",
"status": "valid",
"checks": {
"syntax": true,
"mx_found": true,
"smtp_valid": true,
"catch_all": false,
"disposable": false,
"role_based": false,
"free_provider": false
},
"suggestion": null,
"mx_host": "aspmx.l.google.com",
"cached": false,
"ms": 1243
}
Start with 100 free credits. $2 for 1,000 checks after that. Credits never expire. Read the docs or grab an API key.
Questions? mail@checkmail.dev