mr-email-checker
v0.1.1
Published
Production-grade email validation for Node.js — syntax, MX records, SMTP mailbox verification, disposable/free-provider detection, custom-domain classification, catch-all detection, typo suggestions and risk scoring.
Maintainers
Readme
Production-grade email validation & verification for Node.js — zero runtime dependencies.
mr-email-checker answers the questions you actually have about an email address before you let it into your database:
- Is the syntax valid? — a practical RFC 5321/5322 subset, IDN/Unicode aware, quoted local parts, sub-addressing (
user+tag@…). - Can the domain receive mail? — real MX lookups with A/AAAA implicit-MX fallback and RFC 7505 null-MX handling.
- Does the mailbox exist? — optional live SMTP
RCPT TOprobe with catch-all detection (no message is ever sent). - Is it a free/public provider? (gmail, yahoo, outlook, proton …) — ~4.5k domains.
- Is it a disposable/temporary address? (mailinator, 10minutemail …) — ~138k domains from merged community lists.
- Is it a purchased/custom mailbox domain? — and who hosts it (Google Workspace, Microsoft 365, Zoho, Fastmail, Proton, Cloudflare Email Routing, …).
- Is it a role account? (
info@,support@,admin@…) - Did they make a typo? — "did you mean
[email protected]?"
Typo detection uses Damerau-Levenshtein distance (a single adjacent-letter swap counts as one edit, matching how people actually mistype) with a tight default threshold of
1. This catches real slips likegmial.com→gmail.comwhile not misfiring on legitimate lookalike domains such asyopmail.com(which is a real disposable provider, not a typo ofhotmail.com). Known providers are never flagged as typos.
Every check folds into a single status verdict (deliverable / risky / undeliverable / unknown), a 0–100 deliverability score and a low/medium/high risk bucket.
Accuracy guarantee: only authoritative DNS answers can mark an address undeliverable. If the lookup itself fails (VPN, firewall, resolver down), the checker retries, falls back to DNS-over-HTTPS (Cloudflare → Google), and — if even that is unreachable — reports
status: "unknown"instead of falsely condemning a real address. Transport failures are never cached.
Install
npm install mr-email-checker
# or
pnpm add mr-email-checker
# or
yarn add mr-email-checkerRequires Node.js ≥ 18. Ships dual ESM + CJS builds and full TypeScript types. No dependencies.
Quick start
import { verifyEmail } from "mr-email-checker";
const report = await verifyEmail("[email protected]");
console.log(report.status); // "deliverable" — the one field to act on
console.log(report.valid); // true
console.log(report.score); // 82
console.log(report.risk); // "low"
console.log(report.domain.kind); // "free"
console.log(report.canonical); // "[email protected]" (gmail dots + tag stripped)CommonJS works exactly the same:
const { verifyEmail } = require("mr-email-checker");What you get back
verifyEmail resolves to an EmailReport:
interface EmailReport {
input: string; // exactly what you passed in
email: string | null; // normalized (lowercased domain, punycoded IDN)
canonical: string | null; // dedupe key (gmail dots removed, +tag stripped)
status: EmailStatus; // "deliverable" | "risky" | "undeliverable" | "unknown"
valid: boolean; // never proven undeliverable (fails open on network errors)
score: number; // 0–100 deliverability confidence
risk: "low" | "medium" | "high";
reasons: ReasonCode[]; // machine-readable signals, e.g. ["free_provider", "role_account"]
syntax: SyntaxResult; // localPart, domain, subaddress, quoted, unicode…
mx: MxResult; // status, records[], usedImplicitMx, error
smtp: SmtpResult; // verdict, code, message, catchAll, latencyMs
domain: DomainInfo; // kind, isFreeProvider, isDisposable, isCustomDomain, mailProvider
isRoleAccount: boolean; // info@, support@, admin@…
didYouMean: TypoSuggestion | null;// { suggestion, domain, distance }
durationMs: number;
}status — the one field to act on
| status | Meaning | Typical action |
| --- | --- | --- |
| deliverable | The domain verifiably accepts mail and nothing disproved the mailbox. | Accept. |
| risky | Mail arrives, but you probably don't want it: disposable domain or catch-all server. | Your call — block disposables, soft-accept catch-alls. |
| undeliverable | Proven bad: invalid syntax, non-existent domain, no mail servers (or null MX), or the mailbox was rejected by SMTP. | Reject. |
| unknown | Verification itself failed (DNS unreachable even over DoH). Not evidence against the address. | Accept and re-verify later, or queue for retry. |
Only authoritative DNS answers produce undeliverable. A resolver that is down, blocked or refusing connections yields unknown — never a false no_mx_records.
Example output
// verifyEmail("[email protected]")
{
"email": "[email protected]",
"status": "deliverable",
"valid": true,
"score": 72,
"risk": "low",
"reasons": ["role_account", "custom_domain"],
"isRoleAccount": true,
"domain": {
"kind": "custom",
"isFreeProvider": false,
"isDisposable": false,
"isCustomDomain": true, // a purchased / self-hosted mailbox domain
"mailProvider": "microsoft-365" // …hosted on Microsoft 365
},
"didYouMean": null
}Verifying the mailbox itself (SMTP)
By default mr-email-checker does not open an SMTP connection — it validates syntax, DNS/MX and classification, which is safe everywhere and fast. To actually probe whether the specific mailbox exists, enable SMTP:
const report = await verifyEmail("[email protected]", {
smtp: {
enabled: true,
from: "[email protected]", // use a real domain you control
heloHost: "yourdomain.com",
timeoutMs: 5000,
detectCatchAll: true, // also probe a random mailbox
},
});
console.log(report.smtp.verdict);
// "deliverable" | "undeliverable" | "catch_all" | "greylisted" | "blocked" | "timeout" | "skipped"The probe performs EHLO → MAIL FROM → RCPT TO and then immediately quits — no email is delivered.
⚠️ Port 25 is blocked outbound on almost every cloud (AWS, GCP, Azure, most PaaS/serverless). When the handshake can't complete you'll get
blockedortimeoutrather than a false negative — the score simply falls back to the DNS-based baseline. Run SMTP probing from an environment with unfiltered port 25, ideally with a warm sending IP and proper reverse DNS, or you'll be rate-limited/greylisted by receivers.
Catch-all domains accept every address, so a mailbox can't be individually confirmed — the verdict becomes catch_all and the score is deliberately capped.
Batch verification
import { verifyEmails } from "mr-email-checker";
const reports = await verifyEmails(
["[email protected]", "[email protected]", "[email protected]"],
{ concurrency: 20 }, // default 10
);DNS results are cached (TTL configurable) so repeated domains in a batch cost one lookup.
Lightweight checks
import { isValidEmail, isValidSyntax } from "mr-email-checker";
isValidSyntax("[email protected]"); // true — synchronous, no network
await isValidEmail("[email protected]"); // boolean — syntax + MXUsing the checks individually
Every stage is exported so you can compose your own pipeline:
import {
checkSyntax, // (input) => SyntaxResult — sync, no network
checkMx, // (domain, opts) => Promise<MxResult>
checkSmtp, // (email, domain, mxRecords, opts) => Promise<SmtpResult>
classifyDomain,// (domain, mx, opts) => DomainInfo — free / disposable / custom
suggestTypo, // (domain, threshold?) => TypoSuggestion | null
canonicalize, // (localPart, domain) => string
} from "mr-email-checker";
const s = checkSyntax("bücher@münchen.de");
// { valid: true, domain: "xn--mnchen-3ya.de", unicode: true, … }You can also reach the raw datasets:
import { getDisposableSet, getFreeProviderSet, BUNDLED_COUNTS, identifyProvider } from "mr-email-checker";
getDisposableSet().has("mailinator.com"); // true
getFreeProviderSet().has("gmail.com"); // true
BUNDLED_COUNTS; // { disposable: 138301, freeProviders: 4463 }
identifyProvider(["acme.mail.protection.outlook.com"]); // "microsoft-365"Options
interface VerifyOptions {
dnsTimeoutMs?: number; // default 5000
smtp?: SmtpOptions; // disabled by default
extraDisposableDomains?: Iterable<string>; // merge with the built-in list
extraFreeProviders?: Iterable<string>;
allowlist?: Iterable<string>; // never treat these as disposable
blocklist?: Iterable<string>; // always treat these as disposable
suggestTypos?: boolean; // default true
typoThreshold?: number; // max Damerau edit distance, default 1
cacheTtlMs?: number; // DNS cache TTL, default 300000 (0 disables)
resolveMx?: (domain: string) => Promise<MxRecord[]>; // inject a custom resolver (DoH, tests)
dohFallback?: boolean; // default true — DoH rescue when the system resolver is unreachable
}
interface SmtpOptions {
enabled?: boolean; // default false
from?: string; // MAIL FROM address
heloHost?: string; // EHLO/HELO hostname
port?: number; // default 25
timeoutMs?: number; // default 5000
detectCatchAll?: boolean; // default true
maxHosts?: number; // MX hosts to try, default 2
}Custom lists & a DoH resolver
DNS-over-HTTPS is already built in as an automatic fallback (
dohFallback, on by default): if the system resolver refuses or drops queries, the lookup is retried via Cloudflare and then Google DoH before anything is reported asunknown. TheresolveMxoption below is only needed when you want to replace the system resolver entirely.
await verifyEmail("[email protected]", {
extraFreeProviders: ["corp.internal"],
blocklist: ["spammydomain.xyz"],
allowlist: ["mailinator.com"], // if you actually want to accept it
resolveMx: async (domain) => {
// e.g. Cloudflare DNS-over-HTTPS instead of the system resolver
const res = await fetch(`https://cloudflare-dns.com/dns-query?name=${domain}&type=MX`, {
headers: { accept: "application/dns-json" },
});
const json = await res.json();
return (json.Answer ?? []).map((a: any) => {
const [priority, exchange] = a.data.split(" ");
return { exchange: exchange.replace(/\.$/, ""), priority: Number(priority) };
});
},
});How the score is built
The score starts from a DNS-backed baseline and adjusts on every signal:
| Signal | Effect |
| ---------------------------------------- | ----------------- |
| Disposable domain | hard floor → 2 |
| No MX / domain doesn't exist | hard floor → 5 |
| DNS unreachable (status: "unknown") | neutral 45 |
| Valid syntax + MX present | baseline 55 |
| Known free provider | +15 |
| Custom/purchased domain | +10 |
| Recognised professional host (Workspace…)| +5 |
| Implicit MX (A-record only) | −5 |
| SMTP deliverable | +35 |
| SMTP undeliverable | hard floor → 8 |
| SMTP catch_all | +5 (unconfirmable)|
| SMTP greylisted | +8 |
| Role account (info@, support@) | −8 |
| Likely typo | −25 |
Buckets: ≥70 low, 40–69 medium, <40 high risk.
The exact weights are pragmatic defaults, not a law of nature. If you need different behaviour, read the raw signals off the report and apply your own policy.
reasons codes
report.reasons is a stable, machine-readable list you can branch on:
invalid_syntax, no_mx_records, no_dns_records, dns_error, disposable_domain, free_provider, custom_domain, role_account, mailbox_exists, mailbox_not_found, catch_all_domain, smtp_greylisted, smtp_blocked, smtp_timeout, smtp_skipped, possible_typo, subaddressed, unicode_local_part, quoted_local_part, and more.
The datasets
The disposable (~138k) and free-provider (~4.5k) lists are merged from public, community-maintained sources:
- disposable-email-domains/disposable-email-domains
- disposable/disposable-email-domains
- willwhite/freemail
They're shipped gzip-compressed and base64-embedded, then inflated lazily with node:zlib on first use — so importing the package is instant and there are still zero runtime dependencies. Major providers (gmail, outlook, proton, …) are force-protected from ever being mislabelled as disposable.
Regenerate them anytime:
node scripts/update-lists.mjsDesign notes & limitations
- No email is ever sent. The SMTP check ends at
RCPT TOand quits. - A network failure is never evidence. Only authoritative DNS answers (NXDOMAIN, no records, null MX) can produce
undeliverable. Resolver/transport errors retry, fall back to DoH, and finally surface asstatus: "unknown"with adns_errorreason — and are never cached. - SMTP is best-effort. Greylisting, catch-all servers, tarpitting and port-25 filtering mean a live mailbox check is a strong signal, not a guarantee. Treat
deliverableas high-confidence andcatch_all/blocked/timeoutas "unknown". - Syntax is a practical subset. It accepts what real MTAs accept and rejects the pathological-but-technically-legal corners (bare IP literals, comments) that never appear in genuine signups.
- This is validation, not authentication. It tells you an address is well-formed and reachable — not that the person controls it. Pair it with a confirmation email for anything security-sensitive.
Development
npm install
npm run build # tsup → dist (ESM + CJS + d.ts)
npm run typecheck # tsc --noEmit
npm test # vitestLicense
MIT © Rohit Tiwari
