Hosted email verification APIs are convenient, but there are reasons to run your own. You might need SMTP probing, which a Cloudflare Worker cannot do because it has no raw TCP. You might have volume that makes per-call pricing awkward, or data rules that keep address checking on your side of the network. The good news is that a basic verifier needs nothing but Node and its standard library.
This walkthrough builds one. It checks syntax, flags disposable and free and role addresses, and resolves MX records over DNS. It runs on zero third-party packages.
The pipeline order matters
The order of checks is not cosmetic. Cheap checks that need no network go first. The one network call, the MX lookup, goes last, and it is wrapped so it can never stall the request.
import dns from 'node:dns';
import { promisify } from 'node:util';
const resolveMx = promisify(dns.resolveMx);
const DISPOSABLE = new Set([
'mailinator.com', 'tempmail.com', '10minutemail.com', 'guerrillamail.com',
]);
const FREE = new Set([
'gmail.com', 'qq.com', '163.com', 'outlook.com', 'yahoo.com', 'icloud.com',
]);
const ROLE = new Set(['info', 'support', 'noreply', 'sales', 'admin', 'help']);
// A practical subset of RFC 5322. Not the full grammar, and that is fine.
const SYNTAX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
function domainOf(email) {
return email.slice(email.indexOf('@') + 1).toLowerCase();
}
function localOf(email) {
return email.slice(0, email.indexOf('@')).toLowerCase();
}
function withTimeout(promise, ms) {
// Resolve (not reject) on timeout so the slow DNS query is abandoned quietly.
return Promise.race([
promise,
new Promise((resolve) => setTimeout(() => resolve({ ok: false, error: 'mx_timeout' }), ms)),
]);
}
async function mxRecords(domain) {
try {
const records = await resolveMx(domain);
return { ok: true, records };
} catch (err) {
// ENOTFOUND / ENODATA both mean the domain has no MX records.
return { ok: false, error: err.code || 'mx_error' };
}
}
async function verify(email) {
if (!SYNTAX.test(email)) {
return { valid: false, deliverable: null, reason: 'invalid_format', score: 0 };
}
const domain = domainOf(email);
const local = localOf(email);
// Local checks first. No network, so no reason to wait.
if (DISPOSABLE.has(domain)) {
return { valid: false, deliverable: false, reason: 'disposable', score: 75 };
}
const free = FREE.has(domain);
const role = ROLE.has(local);
// The only network call, and the only place a hang can happen.
const mx = await withTimeout(mxRecords(domain), 1500);
if (!mx.ok) {
const reason = mx.error === 'mx_timeout' ? 'mx_timeout' : 'no_mx';
return { valid: false, deliverable: null, reason, score: 0, free, role };
}
const score = 100 - (role ? 10 : 0) - (free ? 10 : 0);
return { valid: true, deliverable: true, reason: 'ok', score, free, role, mx };
}
for (const e of ['test@gmail.com', 'foo@mailinator.com', 'info@bigco.com']) {
console.log(e, '=>', JSON.stringify(await verify(e)));
}
Why the timeout resolves instead of rejects
The withTimeout helper resolves with { ok: false, error: 'mx_timeout' } rather than throwing. That is the whole point of the design. A DNS query that stalls or a network that blips is a reason to return inconclusive, not a reason to crash the request or, worse, to mark a good address as dead.
This is the mistake most homegrown verifiers make. They treat "the lookup failed" as "the address is invalid" and reject the signup. Over a long enough run, every flaky network moment becomes a lost user. Returning deliverable: null with reason: mx_timeout keeps the door open: the caller can send a confirmation email and let the user prove the inbox works.
Where SMTP would plug in
If you self-host on a machine with port 25 reach and a reputable IP, you can extend verify with an SMTP probe after the MX step. Open a socket to the lowest-priority exchange, issue MAIL FROM and RCPT TO, and read the reply code. Keep the same shape: any ambiguity becomes null, never false. On a Worker or any TCP-less runtime, leave it out. The MX check alone already removes the bulk of bad addresses.
Running it
Save the file as verify.mjs and run it with Node 18 or newer:
node verify.mjs
On a normal host with working DNS you will see MX records for gmail.com and a disposable reason for the temp-mail domain. The catch-all domain bigco.com returns deliverable: true on the strength of its MX records alone, which is the honest limit of an MX-only check. If you need to confirm the specific inbox, that is the confirmation-email step, not something this script should claim.
The script is a starting point, not a product. The disposable and free lists are tiny on purpose. A production build pulls a few hundred or a few thousand domains from a maintained source and refreshes them on a schedule. The shape of the result, and the discipline of returning null for the unknown, is what keeps it safe as the lists grow.
I built this example against the response contract of MailProbe, whose hosted endpoint returns the same fields (valid, deliverable, reason, score, checks, suggestion) and is at https://mailprobe.kevin-c0319.workers.dev/. The hosted version adds typo suggestions and provider fingerprinting that the script above leaves out, but the pipeline order and the inconclusive-on-failure rule are the same. Run the script locally for control and volume, reach for the API when you want the larger lists and the suggestion logic without maintaining them.
Top comments (0)