mxprobe-core 0.1.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 (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +34 -0
  3. package/package.json +37 -0
  4. package/src/index.mjs +408 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Andriy Chemerynskiy
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,34 @@
1
+ # mxprobe-core
2
+
3
+ The MX Probe engine. Zero dependencies, Node 20+. Two tiers:
4
+
5
+ 1. **DNS.** Free and local. Finds every domain that cannot receive mail: no MX
6
+ and a web host behind the A record, a null MX, an MX that does not
7
+ resolve, a domain that does not exist, a parking host as MX. Forwarder MX
8
+ hosts (Cloudflare Email Routing, Namecheap, ImprovMX) are a `hold`.
9
+ 2. **SMTP probe.** Connects to the MX on port 25, EHLO, MAIL FROM, RCPT TO for
10
+ the address and for a random local part (the catch-all test), QUIT. No
11
+ message is ever sent. Needs outbound port 25, which most laptops and
12
+ clouds block; the hosted API at https://api.mxprobe.dev runs it for you.
13
+
14
+ ```js
15
+ import { verify, verifyBatch, createVerifier } from "mxprobe-core";
16
+
17
+ await verify("hello@example.com");
18
+ // { email, action: "send" | "hold" | "kill", verdict: "OK" | "WEAK" | "DEAD", reason, checks: { syntax, mx, smtp, catch_all } }
19
+
20
+ await verifyBatch(["a@b.com", "c@d.org"], { smtp: true, helo: "probe.example.com", from: "probe@example.com" });
21
+
22
+ const v = createVerifier({ smtp: true, smtpConcurrency: 3, autoDisableSmtp: false }); // shared limits across calls
23
+ ```
24
+
25
+ `checks.smtp` is `skipped` on the DNS tier, else `accepted`, `rejected`,
26
+ `refused`, `deferred`, `unreachable` or `dropped`. Only `rejected` (a 5xx that
27
+ names the mailbox) kills; everything else that is not `accepted` holds.
28
+
29
+ Options (all optional): `smtp`, `helo`, `from`, `port`, `hostOverride`,
30
+ `dnsTimeoutMs`, `smtpTimeoutMs`, `smtpConcurrency`, `dnsConcurrency`,
31
+ `autoDisableSmtp`, `resolver` (an object with `resolveMx`, `resolve4`,
32
+ `resolve6`, `reverse`, for tests).
33
+
34
+ MIT. Part of https://github.com/andrewchmr/mxprobe.
package/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "mxprobe-core",
3
+ "version": "0.1.0",
4
+ "description": "The MX Probe engine: DNS tier, SMTP probe and the send / hold / kill verdict contract. Zero dependencies.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "main": "./src/index.mjs",
8
+ "exports": {
9
+ ".": "./src/index.mjs"
10
+ },
11
+ "files": [
12
+ "src",
13
+ "README.md"
14
+ ],
15
+ "engines": {
16
+ "node": ">=20"
17
+ },
18
+ "repository": {
19
+ "type": "git",
20
+ "url": "https://github.com/andrewchmr/mxprobe",
21
+ "directory": "packages/core"
22
+ },
23
+ "homepage": "https://mxprobe.dev",
24
+ "keywords": [
25
+ "email",
26
+ "verification",
27
+ "mx",
28
+ "smtp",
29
+ "bounce",
30
+ "ai-agent",
31
+ "mcp"
32
+ ],
33
+ "scripts": {
34
+ "test": "node --test test/*.test.mjs",
35
+ "test:live": "MXPROBE_LIVE=1 node --test test/*.live.mjs"
36
+ }
37
+ }
package/src/index.mjs ADDED
@@ -0,0 +1,408 @@
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
+ }