mxprobe-core 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/README.md +6 -0
  2. package/dist/address.d.ts +17 -0
  3. package/dist/address.d.ts.map +1 -0
  4. package/dist/address.js +14 -0
  5. package/dist/address.js.map +1 -0
  6. package/dist/api.d.ts +39 -0
  7. package/dist/api.d.ts.map +1 -0
  8. package/dist/api.js +2 -0
  9. package/dist/api.js.map +1 -0
  10. package/dist/dns.d.ts +23 -0
  11. package/dist/dns.d.ts.map +1 -0
  12. package/dist/dns.js +97 -0
  13. package/dist/dns.js.map +1 -0
  14. package/dist/index.d.ts +11 -0
  15. package/dist/index.d.ts.map +1 -0
  16. package/dist/index.js +26 -0
  17. package/dist/index.js.map +1 -0
  18. package/dist/options.d.ts +29 -0
  19. package/dist/options.d.ts.map +1 -0
  20. package/dist/options.js +24 -0
  21. package/dist/options.js.map +1 -0
  22. package/dist/smtp.d.ts +23 -0
  23. package/dist/smtp.d.ts.map +1 -0
  24. package/dist/smtp.js +143 -0
  25. package/dist/smtp.js.map +1 -0
  26. package/dist/types.d.ts +36 -0
  27. package/dist/types.d.ts.map +1 -0
  28. package/dist/types.js +8 -0
  29. package/dist/types.js.map +1 -0
  30. package/dist/util.d.ts +6 -0
  31. package/dist/util.d.ts.map +1 -0
  32. package/dist/util.js +21 -0
  33. package/dist/util.js.map +1 -0
  34. package/dist/verifier.d.ts +26 -0
  35. package/dist/verifier.d.ts.map +1 -0
  36. package/dist/verifier.js +113 -0
  37. package/dist/verifier.js.map +1 -0
  38. package/package.json +17 -5
  39. package/src/address.ts +29 -0
  40. package/src/api.ts +45 -0
  41. package/src/dns.ts +118 -0
  42. package/src/index.ts +30 -0
  43. package/src/options.ts +51 -0
  44. package/src/smtp.ts +172 -0
  45. package/src/types.ts +48 -0
  46. package/src/util.ts +21 -0
  47. package/src/verifier.ts +138 -0
  48. package/src/index.mjs +0 -408
package/src/types.ts ADDED
@@ -0,0 +1,48 @@
1
+ // The verdict contract, the same in the CLI, the MCP server and the API:
2
+ // { email, action, verdict, reason, checks: { syntax, mx, smtp, catch_all } }
3
+ // action = send | hold | kill
4
+ // verdict = OK | WEAK | DEAD
5
+ // `hold` never becomes `kill` on a refusal, a greylist or a catch-all. Only
6
+ // a 5xx that names the mailbox kills.
7
+
8
+ export type Action = "send" | "hold" | "kill";
9
+ export type Verdict = "OK" | "WEAK" | "DEAD";
10
+
11
+ /** What the SMTP tier said. `skipped` on the DNS tier. */
12
+ export type SmtpCheck = "skipped" | "accepted" | "rejected" | "refused" | "deferred" | "unreachable" | "dropped";
13
+
14
+ export interface Checks {
15
+ syntax: boolean;
16
+ mx: string | null;
17
+ smtp: SmtpCheck;
18
+ catch_all: boolean | null;
19
+ }
20
+
21
+ export interface VerifyResult {
22
+ email: string;
23
+ action: Action;
24
+ verdict: Verdict;
25
+ reason: string;
26
+ checks: Checks;
27
+ }
28
+
29
+ /** Summary counts for a batch. */
30
+ export interface Summary {
31
+ send: number;
32
+ hold: number;
33
+ kill: number;
34
+ total: number;
35
+ }
36
+
37
+ export interface MxRecord {
38
+ exchange: string;
39
+ priority: number;
40
+ }
41
+
42
+ /** The part of `node:dns` promises the engine uses. Tests pass a fake. */
43
+ export interface Resolver {
44
+ resolveMx(hostname: string): Promise<MxRecord[]>;
45
+ resolve4(hostname: string): Promise<string[]>;
46
+ resolve6(hostname: string): Promise<string[]>;
47
+ reverse(ip: string): Promise<string[]>;
48
+ }
package/src/util.ts ADDED
@@ -0,0 +1,21 @@
1
+ /** Reject after `ms` with a message that names the step. */
2
+ export function withTimeout<T>(promise: Promise<T>, label: string, ms: number): Promise<T> {
3
+ let timer: NodeJS.Timeout | undefined;
4
+ const timeout = new Promise<never>((_, reject) => {
5
+ timer = setTimeout(() => reject(new Error(`${label}: timed out after ${ms} ms`)), ms);
6
+ });
7
+ return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
8
+ }
9
+
10
+ /** The `code` of a Node error (ENOTFOUND, ECONNREFUSED, ...), else undefined. */
11
+ export function errorCode(err: unknown): string | undefined {
12
+ if (typeof err === "object" && err !== null && "code" in err) {
13
+ const code = (err as { code?: unknown }).code;
14
+ if (typeof code === "string") return code;
15
+ }
16
+ return undefined;
17
+ }
18
+
19
+ export function errorMessage(err: unknown): string {
20
+ return err instanceof Error ? err.message : String(err);
21
+ }
@@ -0,0 +1,138 @@
1
+ // The driver: syntax, then the DNS tier, then (with `smtp`) the probe, with
2
+ // shared concurrency limits and the "port 25 is blocked" switch.
3
+ import { parseAddress } from "./address.ts";
4
+ import { checkDomain, type MxHost } from "./dns.ts";
5
+ import { resolveOptions, type ResolvedOptions, type VerifierOptions } from "./options.ts";
6
+ import { PORT_BLOCKED, probeMailbox, type ProbeResult } from "./smtp.ts";
7
+ import type { Action, Checks, Summary, Verdict, VerifyResult } from "./types.ts";
8
+ import { errorMessage } from "./util.ts";
9
+
10
+ export const ACTIONS: Readonly<Record<Verdict, Action>> = Object.freeze({ OK: "send", WEAK: "hold", DEAD: "kill" });
11
+ export const VERDICTS: readonly Verdict[] = Object.freeze(["OK", "WEAK", "DEAD"]);
12
+
13
+ const RANK: Readonly<Record<Verdict, number>> = { OK: 0, WEAK: 1, DEAD: 2 };
14
+
15
+ type Task<T> = () => Promise<T>;
16
+ type Limiter = <T>(fn: Task<T>) => Promise<T>;
17
+
18
+ function makeSemaphore(n: number): Limiter {
19
+ let active = 0;
20
+ const queue: Array<() => void> = [];
21
+ const next = (): void => {
22
+ if (active >= n) return;
23
+ const run = queue.shift();
24
+ if (!run) return;
25
+ active++;
26
+ run();
27
+ };
28
+ return <T>(fn: Task<T>) =>
29
+ new Promise<T>((res, rej) => {
30
+ queue.push(() =>
31
+ fn()
32
+ .then(res, rej)
33
+ .finally(() => {
34
+ active--;
35
+ next();
36
+ }),
37
+ );
38
+ next();
39
+ });
40
+ }
41
+
42
+ function result(email: string, verdict: Verdict, reason: string, checks: Partial<Checks>): VerifyResult {
43
+ return {
44
+ email,
45
+ action: ACTIONS[verdict],
46
+ verdict,
47
+ reason,
48
+ checks: { syntax: true, mx: null, smtp: "skipped", catch_all: null, ...checks },
49
+ };
50
+ }
51
+
52
+ export interface VerifierState {
53
+ /** True once a probe proved that port 25 is blocked (with autoDisableSmtp). */
54
+ smtpDown: boolean;
55
+ smtpDownWhy: string | null;
56
+ }
57
+
58
+ export interface Verifier {
59
+ verify(email: string): Promise<VerifyResult>;
60
+ verifyBatch(emails: readonly string[]): Promise<VerifyResult[]>;
61
+ readonly state: VerifierState;
62
+ readonly options: ResolvedOptions;
63
+ }
64
+
65
+ // Try the primary MX, then the secondary when the primary cannot be reached at all.
66
+ async function probeWithFallback(email: string, domain: string, mxHosts: readonly MxHost[], o: ResolvedOptions): Promise<ProbeResult> {
67
+ let last: ProbeResult | null = null;
68
+ for (const { host, ip } of mxHosts) {
69
+ const probe = await probeMailbox(email, domain, host, { ...o, connectHost: ip ?? host });
70
+ if (probe.smtp !== "unreachable") return probe;
71
+ last = probe;
72
+ if (probe.connectError !== undefined && PORT_BLOCKED.has(probe.connectError)) return probe;
73
+ }
74
+ return last ?? { verdict: "WEAK", reason: "no MX host to probe", smtp: "unreachable", catchAll: null };
75
+ }
76
+
77
+ /**
78
+ * A verifier with shared limits. The hosted API keeps one for its lifetime so
79
+ * the SMTP concurrency cap holds across requests; the CLI makes one per run.
80
+ */
81
+ export function createVerifier(opts: VerifierOptions = {}): Verifier {
82
+ const o = resolveOptions(opts);
83
+ const state: VerifierState = { smtpDown: false, smtpDownWhy: null };
84
+ const dnsLimit = makeSemaphore(o.dnsConcurrency);
85
+ const smtpLimit = makeSemaphore(o.smtpConcurrency);
86
+
87
+ async function verify(raw: string): Promise<VerifyResult> {
88
+ const parsed = parseAddress(raw);
89
+ if (parsed.error !== undefined) return result(parsed.email, "DEAD", parsed.error, { syntax: false });
90
+ const { email, domain } = parsed;
91
+ try {
92
+ const d = await dnsLimit(() => checkDomain(domain, o));
93
+ if (d.verdict === "DEAD") return result(email, "DEAD", d.reason, { mx: null });
94
+
95
+ if (!o.smtp) {
96
+ const note = d.verdict === "OK" ? "; mailbox not probed" : "";
97
+ return result(email, d.verdict, `${d.reason}${note}`, { mx: d.mx });
98
+ }
99
+ if (state.smtpDown) {
100
+ return result(email, d.verdict, `${d.reason}; SMTP tier off for this run (${state.smtpDownWhy})`, { mx: d.mx, smtp: "unreachable" });
101
+ }
102
+
103
+ const probe = await smtpLimit(() => probeWithFallback(email, domain, d.mxHosts, o));
104
+ if (probe.connectError !== undefined && o.autoDisableSmtp && PORT_BLOCKED.has(probe.connectError)) {
105
+ state.smtpDown = true;
106
+ state.smtpDownWhy = `${probe.connectError} on ${d.mx}:${o.port}`;
107
+ return result(email, d.verdict, `${d.reason}; SMTP tier off for this run (${probe.connectError})`, { mx: d.mx, smtp: "unreachable" });
108
+ }
109
+ const verdict = RANK[probe.verdict] >= RANK[d.verdict] ? probe.verdict : d.verdict;
110
+ const reason = d.verdict === "WEAK" && probe.verdict === "OK" ? `${probe.reason}; ${d.reason}` : probe.reason;
111
+ return result(email, verdict, reason, { mx: d.mx, smtp: probe.smtp, catch_all: probe.catchAll });
112
+ } catch (err) {
113
+ return result(email, "WEAK", `check did not finish (${errorMessage(err)})`, {});
114
+ }
115
+ }
116
+
117
+ function verifyBatch(emails: readonly string[]): Promise<VerifyResult[]> {
118
+ return Promise.all(emails.map((e) => verify(e)));
119
+ }
120
+
121
+ return { verify, verifyBatch, state, options: o };
122
+ }
123
+
124
+ /** One-shot helpers. Each call gets its own limits. */
125
+ export function verify(email: string, opts: VerifierOptions = {}): Promise<VerifyResult> {
126
+ return createVerifier(opts).verify(email);
127
+ }
128
+
129
+ export function verifyBatch(emails: readonly string[], opts: VerifierOptions = {}): Promise<VerifyResult[]> {
130
+ return createVerifier(opts).verifyBatch(emails);
131
+ }
132
+
133
+ /** Summary counts for a batch: { send, hold, kill, total }. */
134
+ export function summarize(results: readonly Pick<VerifyResult, "action">[]): Summary {
135
+ const s: Summary = { send: 0, hold: 0, kill: 0, total: results.length };
136
+ for (const r of results) s[r.action]++;
137
+ return s;
138
+ }
package/src/index.mjs DELETED
@@ -1,408 +0,0 @@
1
- // MX Probe engine. Zero dependencies, Node 20+.
2
- //
3
- // Two tiers, run in order:
4
- // 1. DNS. Free and local. Finds domains that cannot receive mail: no MX and
5
- // a web host behind the A record, a null MX, an MX that does not resolve,
6
- // a domain that does not exist, a parking host as MX.
7
- // 2. SMTP probe. Connects to the MX on port 25, says EHLO and MAIL FROM,
8
- // asks RCPT TO for the address and for a random local part (the catch-all
9
- // test), then QUITs. No message is ever sent. Needs outbound port 25.
10
- //
11
- // The verdict contract, the same in the CLI, the MCP server and the API:
12
- // { email, action, verdict, reason, checks: { syntax, mx, smtp, catch_all } }
13
- // action = send | hold | kill
14
- // verdict = OK | WEAK | DEAD
15
- // `hold` never becomes `kill` on a refusal, a greylist or a catch-all. Only
16
- // a 5xx that names the mailbox kills.
17
-
18
- import { promises as dnsPromises } from "node:dns";
19
- import net from "node:net";
20
- import { randomBytes } from "node:crypto";
21
-
22
- export const VERSION = "0.1.0";
23
- export const ACTIONS = Object.freeze({ OK: "send", WEAK: "hold", DEAD: "kill" });
24
- export const VERDICTS = Object.freeze(["OK", "WEAK", "DEAD"]);
25
-
26
- export const DEFAULTS = Object.freeze({
27
- smtp: false,
28
- dnsTimeoutMs: 8000,
29
- smtpTimeoutMs: 12000,
30
- smtpConcurrency: 3,
31
- dnsConcurrency: 20,
32
- helo: "probe.mxprobe.dev",
33
- from: "probe@mxprobe.dev",
34
- port: 25,
35
- hostOverride: null,
36
- // When true, an ECONNREFUSED / EHOSTUNREACH / ENETUNREACH on a probe turns
37
- // the SMTP tier off for the rest of the run: the network blocks port 25.
38
- // The hosted API sets this to false because its port is proven by a health
39
- // check, and one refusing MX must not switch the tier off for everyone.
40
- autoDisableSmtp: true,
41
- resolver: dnsPromises,
42
- });
43
-
44
- // MX hosts that forward rather than hold mail. They bounce when the forward
45
- // target is dead, and some refuse relays outright.
46
- const FORWARDER_MX = [
47
- /registrar-servers\.com$/i, // Namecheap eforward1..5
48
- /improvmx\.com$/i,
49
- /forwardemail\.net$/i,
50
- /mx\.cloudflare\.net$/i, // Cloudflare Email Routing route1..3
51
- /fwd\d*\.porkbun\.com$/i,
52
- /mailforward\./i,
53
- /forwardmx\./i,
54
- ];
55
-
56
- // Hosts that serve web pages, not mail. An MX pointing here times out.
57
- const WEBHOST_MX = [
58
- /pixie\.porkbun\.com$/i,
59
- /parkingcrew\./i,
60
- /sedoparking\./i,
61
- /bodis\./i,
62
- /above\.com$/i,
63
- ];
64
-
65
- // Reply text that means "this mailbox does not exist", as opposed to "we do
66
- // not like you" (5.7.x) or "not now" (4xx).
67
- const NO_SUCH_MAILBOX =
68
- /5\.1\.[0136]\b|5\.4\.1\b|user unknown|unknown user|does not exist|doesn't exist|no such (user|recipient|mailbox)|not found|not exist|no mailbox|invalid recipient|recipient rejected|recipient address rejected|unrouteable|unknown recipient|not our customer|mailbox unavailable|address rejected|invalid mailbox|unknown address/i;
69
-
70
- const PORT_BLOCKED = new Set(["ECONNREFUSED", "EHOSTUNREACH", "ENETUNREACH"]);
71
-
72
- const isNullMx = (mx) => mx.length === 1 && mx[0].exchange === "" && mx[0].priority === 0;
73
-
74
- function withTimeout(promise, label, ms) {
75
- let timer;
76
- const timeout = new Promise((_, reject) => {
77
- timer = setTimeout(() => reject(new Error(`${label}: timed out after ${ms} ms`)), ms);
78
- });
79
- return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
80
- }
81
-
82
- async function lookupAny(resolver, host) {
83
- const [a, aaaa] = await Promise.all([
84
- resolver.resolve4(host).catch(() => []),
85
- resolver.resolve6(host).catch(() => []),
86
- ]);
87
- return { v4: a, v6: aaaa, all: [...a, ...aaaa] };
88
- }
89
-
90
- async function reverseName(resolver, ip, ms) {
91
- try {
92
- const names = await withTimeout(resolver.reverse(ip), "reverse", ms);
93
- return names[0] ?? null;
94
- } catch {
95
- return null;
96
- }
97
- }
98
-
99
- export function labelWebHost(ip, ptr) {
100
- const p = (ptr ?? "").toLowerCase();
101
- if (ip === "75.2.60.5" || ip === "99.83.190.102" || p.includes("netlify")) return "Netlify";
102
- if (ip.startsWith("76.76.21.") || ip.startsWith("216.198.79.") || p.includes("vercel")) return "Vercel";
103
- if (ip.startsWith("104.21.") || ip.startsWith("172.67.") || ip.startsWith("188.114.9") || p.includes("cloudflare")) return "Cloudflare";
104
- if (p.includes("amazonaws") || p.includes("awsglobalaccelerator")) return "AWS";
105
- if (p.includes("github")) return "GitHub Pages";
106
- if (p.includes("squarespace") || p.includes("wixdns") || p.includes("webflow")) return "site builder";
107
- return ptr ?? "unknown host";
108
- }
109
-
110
- /** Split an address into local part and domain, or return { error }. */
111
- export function parseAddress(raw) {
112
- const email = String(raw ?? "").trim();
113
- const m = email.match(/^([^\s@]+)@([^\s@]+\.[^\s@]+)$/);
114
- if (!m) return { email, error: "not an email address" };
115
- const local = m[1];
116
- const domain = m[2].toLowerCase().replace(/\.$/, "");
117
- if (local.length > 64 || domain.length > 253 || /\.\./.test(domain) || /[^a-z0-9.-]/.test(domain)) {
118
- return { email, error: "malformed address" };
119
- }
120
- return { email: `${local}@${domain}`, local, domain };
121
- }
122
-
123
- // ---------------------------------------------------------------- tier 1: DNS
124
-
125
- /**
126
- * The DNS tier for one domain. Returns { verdict, reason, mx, mxHosts } where
127
- * mx is the primary MX host name (null when the domain is DEAD) and mxHosts is
128
- * the sorted list of { host, ip } to try in order.
129
- */
130
- export async function checkDomain(domain, opts = {}) {
131
- const o = { ...DEFAULTS, ...opts };
132
- const { resolver } = o;
133
- let mx;
134
- try {
135
- mx = await withTimeout(resolver.resolveMx(domain), "MX", o.dnsTimeoutMs);
136
- } catch (err) {
137
- const code = err?.code ?? "";
138
- if (code === "ENOTFOUND") return dead("domain does not exist (NXDOMAIN)");
139
- if (code !== "ENODATA" && !/timed out/.test(err.message)) return dead(`MX lookup failed (${code || err.message})`);
140
- mx = [];
141
- }
142
-
143
- if (mx.length === 0) {
144
- const ips = await withTimeout(lookupAny(resolver, domain), "A/AAAA", o.dnsTimeoutMs);
145
- if (ips.all.length === 0) return dead("no MX and no A/AAAA record");
146
- const ip = ips.all[0];
147
- const host = labelWebHost(ip, await reverseName(resolver, ip, o.dnsTimeoutMs));
148
- return dead(`no MX record; mail falls back to the A record ${ip} (${host}), which does not take mail`);
149
- }
150
-
151
- if (isNullMx(mx)) return dead("null MX (RFC 7505): the domain accepts no mail");
152
-
153
- const sorted = [...mx].sort((x, y) => x.priority - y.priority).map((r) => r.exchange.replace(/\.$/, "").toLowerCase());
154
- const primary = sorted[0];
155
-
156
- if (WEBHOST_MX.some((re) => re.test(primary))) return dead(`MX ${primary} is a web or parking host, not a mail server`);
157
-
158
- const primaryIps = await withTimeout(lookupAny(resolver, primary), "MX host", o.dnsTimeoutMs);
159
- if (primaryIps.all.length === 0) return dead(`MX host ${primary} does not resolve`);
160
-
161
- const mxHosts = [{ host: primary, ip: primaryIps.v4[0] ?? primaryIps.v6[0] }];
162
- if (sorted[1] && sorted[1] !== primary) mxHosts.push({ host: sorted[1], ip: null });
163
-
164
- if (FORWARDER_MX.some((re) => re.test(primary))) {
165
- return { verdict: "WEAK", reason: `MX ${primary} is a forwarder; it bounces when the forward target is dead`, mx: primary, mxHosts };
166
- }
167
- return { verdict: "OK", reason: `MX ${primary}`, mx: primary, mxHosts };
168
-
169
- function dead(reason) {
170
- return { verdict: "DEAD", reason, mx: null, mxHosts: [] };
171
- }
172
- }
173
-
174
- // --------------------------------------------------------- tier 2: SMTP probe
175
-
176
- // A line-oriented SMTP client: connect, then read() a reply or send(cmd) and
177
- // read its reply. Multi-line replies (250-... 250 ...) come back as one.
178
- function connectSmtp(host, port, timeoutMs) {
179
- return new Promise((resolve, reject) => {
180
- const socket = net.createConnection({ host, port });
181
- socket.setEncoding("utf8");
182
- socket.setTimeout(timeoutMs);
183
- let buffer = "";
184
- let lines = [];
185
- const queued = [];
186
- let waiter = null;
187
- let dead = null;
188
- let connected = false;
189
-
190
- const fail = (err) => {
191
- dead = dead ?? err;
192
- if (waiter) {
193
- const w = waiter;
194
- waiter = null;
195
- w.reject(err);
196
- }
197
- if (!connected) reject(err);
198
- socket.destroy();
199
- };
200
- socket.on("timeout", () => fail(Object.assign(new Error("SMTP timeout"), { code: "ETIMEDOUT" })));
201
- socket.on("error", fail);
202
- socket.on("close", () => fail(Object.assign(new Error("connection closed"), { code: "ECLOSED" })));
203
- socket.on("data", (chunk) => {
204
- buffer += chunk;
205
- let idx;
206
- while ((idx = buffer.indexOf("\n")) !== -1) {
207
- const line = buffer.slice(0, idx).replace(/\r$/, "");
208
- buffer = buffer.slice(idx + 1);
209
- lines.push(line);
210
- if (/^\d{3}( |$)/.test(line)) {
211
- const reply = { code: Number(line.slice(0, 3)), text: lines.join(" | ") };
212
- lines = [];
213
- if (waiter) {
214
- const w = waiter;
215
- waiter = null;
216
- w.resolve(reply);
217
- } else {
218
- queued.push(reply);
219
- }
220
- }
221
- }
222
- });
223
- const read = () =>
224
- new Promise((res, rej) => {
225
- if (queued.length) return res(queued.shift());
226
- if (dead) return rej(dead);
227
- waiter = { resolve: res, reject: rej };
228
- });
229
- const send = (cmd) => {
230
- if (dead) return Promise.reject(dead);
231
- socket.write(`${cmd}\r\n`);
232
- return read();
233
- };
234
- socket.once("connect", () => {
235
- connected = true;
236
- resolve({ read, send, close: () => socket.destroy() });
237
- });
238
- });
239
- }
240
-
241
- const firstLine = (reply) => reply.text.split(" | ")[0];
242
-
243
- /**
244
- * Probe one mailbox on one MX. Returns { verdict, reason, smtp, catchAll,
245
- * connectError }. smtp is one of accepted | rejected | refused | deferred |
246
- * unreachable | dropped. Never sends DATA.
247
- */
248
- export async function probeMailbox(email, domain, mxHost, opts = {}) {
249
- const o = { ...DEFAULTS, ...opts };
250
- const target = o.hostOverride ?? o.connectHost ?? mxHost;
251
- let s;
252
- try {
253
- s = await withTimeout(connectSmtp(target, o.port, o.smtpTimeoutMs), "connect", o.smtpTimeoutMs);
254
- } catch (err) {
255
- const code = err.code ?? "EUNKNOWN";
256
- return { verdict: "WEAK", reason: `cannot connect to ${mxHost}:${o.port} (${code})`, smtp: "unreachable", catchAll: null, connectError: code };
257
- }
258
- try {
259
- const banner = await s.read();
260
- if (banner.code !== 220) return refused(`${mxHost} greeted with ${firstLine(banner)}`);
261
- let r = await s.send(`EHLO ${o.helo}`);
262
- if (r.code !== 250) {
263
- r = await s.send(`HELO ${o.helo}`);
264
- if (r.code !== 250) return refused(`${mxHost} refused HELO (${firstLine(r)})`);
265
- }
266
- r = await s.send(`MAIL FROM:<${o.from}>`);
267
- if (r.code !== 250) return r.code >= 500 ? refused(`${mxHost} refused the sender (${firstLine(r)})`) : deferred(`${mxHost} deferred the sender (${firstLine(r)})`);
268
- r = await s.send(`RCPT TO:<${email}>`);
269
- let result;
270
- if (r.code === 250 || r.code === 251) {
271
- const random = `${randomBytes(6).toString("hex")}-probe@${domain}`;
272
- const c = await s.send(`RCPT TO:<${random}>`);
273
- result =
274
- c.code === 250 || c.code === 251
275
- ? { verdict: "WEAK", reason: `${mxHost} is catch-all, it accepts any local part`, smtp: "accepted", catchAll: true }
276
- : { verdict: "OK", reason: `mailbox accepted by ${mxHost}`, smtp: "accepted", catchAll: false };
277
- } else if (r.code >= 500 && NO_SUCH_MAILBOX.test(r.text)) {
278
- result = { verdict: "DEAD", reason: `${mxHost} says the mailbox does not exist (${firstLine(r)})`, smtp: "rejected", catchAll: null };
279
- } else if (r.code >= 500) {
280
- result = refused(`${mxHost} refused the probe, not the mailbox (${firstLine(r)})`);
281
- } else {
282
- result = deferred(`${mxHost} deferred (${firstLine(r)})`);
283
- }
284
- await s.send("QUIT").catch(() => {});
285
- return result;
286
- } catch (err) {
287
- return { verdict: "WEAK", reason: `${mxHost} dropped the session (${err.code ?? err.message})`, smtp: "dropped", catchAll: null };
288
- } finally {
289
- s.close();
290
- }
291
-
292
- function refused(reason) {
293
- return { verdict: "WEAK", reason, smtp: "refused", catchAll: null };
294
- }
295
- function deferred(reason) {
296
- return { verdict: "WEAK", reason, smtp: "deferred", catchAll: null };
297
- }
298
- }
299
-
300
- // ------------------------------------------------------------------- driver
301
-
302
- const RANK = { OK: 0, WEAK: 1, DEAD: 2 };
303
-
304
- function makeSemaphore(n) {
305
- let active = 0;
306
- const queue = [];
307
- const next = () => {
308
- if (active >= n || queue.length === 0) return;
309
- active++;
310
- const { fn, res, rej } = queue.shift();
311
- fn()
312
- .then(res, rej)
313
- .finally(() => {
314
- active--;
315
- next();
316
- });
317
- };
318
- return (fn) =>
319
- new Promise((res, rej) => {
320
- queue.push({ fn, res, rej });
321
- next();
322
- });
323
- }
324
-
325
- function result(email, verdict, reason, checks) {
326
- return {
327
- email,
328
- action: ACTIONS[verdict],
329
- verdict,
330
- reason,
331
- checks: { syntax: true, mx: null, smtp: "skipped", catch_all: null, ...checks },
332
- };
333
- }
334
-
335
- /**
336
- * A verifier with shared limits. The hosted API keeps one for its lifetime so
337
- * the SMTP concurrency cap holds across requests; the CLI makes one per run.
338
- */
339
- export function createVerifier(opts = {}) {
340
- const o = { ...DEFAULTS, ...opts };
341
- const state = { smtpDown: false, smtpDownWhy: null };
342
- const dnsLimit = makeSemaphore(o.dnsConcurrency);
343
- const smtpLimit = makeSemaphore(o.smtpConcurrency);
344
-
345
- async function verify(raw) {
346
- const parsed = parseAddress(raw);
347
- if (parsed.error) return result(parsed.email, "DEAD", parsed.error, { syntax: false });
348
- const { email, domain } = parsed;
349
- try {
350
- const d = await dnsLimit(() => checkDomain(domain, o));
351
- if (d.verdict === "DEAD") return result(email, "DEAD", d.reason, { mx: null });
352
-
353
- if (!o.smtp) {
354
- const note = d.verdict === "OK" ? "; mailbox not probed" : "";
355
- return result(email, d.verdict, `${d.reason}${note}`, { mx: d.mx });
356
- }
357
- if (state.smtpDown) {
358
- return result(email, d.verdict, `${d.reason}; SMTP tier off for this run (${state.smtpDownWhy})`, { mx: d.mx, smtp: "unreachable" });
359
- }
360
-
361
- const probe = await smtpLimit(() => probeWithFallback(email, domain, d.mxHosts, o));
362
- if (probe.connectError && o.autoDisableSmtp && PORT_BLOCKED.has(probe.connectError)) {
363
- state.smtpDown = true;
364
- state.smtpDownWhy = `${probe.connectError} on ${d.mx}:${o.port}`;
365
- return result(email, d.verdict, `${d.reason}; SMTP tier off for this run (${probe.connectError})`, { mx: d.mx, smtp: "unreachable" });
366
- }
367
- const verdict = RANK[probe.verdict] >= RANK[d.verdict] ? probe.verdict : d.verdict;
368
- const reason = d.verdict === "WEAK" && probe.verdict === "OK" ? `${probe.reason}; ${d.reason}` : probe.reason;
369
- return result(email, verdict, reason, { mx: d.mx, smtp: probe.smtp, catch_all: probe.catchAll });
370
- } catch (err) {
371
- return result(email, "WEAK", `check did not finish (${err.message})`, {});
372
- }
373
- }
374
-
375
- async function verifyBatch(emails) {
376
- return Promise.all(emails.map((e) => verify(e)));
377
- }
378
-
379
- return { verify, verifyBatch, state, options: o };
380
- }
381
-
382
- // Try the primary MX, then the secondary when the primary cannot be reached at all.
383
- async function probeWithFallback(email, domain, mxHosts, o) {
384
- let last = null;
385
- for (const { host, ip } of mxHosts) {
386
- const probe = await probeMailbox(email, domain, host, { ...o, connectHost: ip ?? host });
387
- if (probe.smtp !== "unreachable") return probe;
388
- last = probe;
389
- if (PORT_BLOCKED.has(probe.connectError)) return probe;
390
- }
391
- return last;
392
- }
393
-
394
- /** One-shot helpers. Each call gets its own limits. */
395
- export function verify(email, opts = {}) {
396
- return createVerifier(opts).verify(email);
397
- }
398
-
399
- export function verifyBatch(emails, opts = {}) {
400
- return createVerifier(opts).verifyBatch(emails);
401
- }
402
-
403
- /** Summary counts for a batch: { send, hold, kill, total }. */
404
- export function summarize(results) {
405
- const s = { send: 0, hold: 0, kill: 0, total: results.length };
406
- for (const r of results) s[r.action]++;
407
- return s;
408
- }