mxprobe-core 0.1.0 → 0.2.1
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.
- package/README.md +6 -0
- package/dist/address.d.ts +17 -0
- package/dist/address.d.ts.map +1 -0
- package/dist/address.js +14 -0
- package/dist/address.js.map +1 -0
- package/dist/api.d.ts +39 -0
- package/dist/api.d.ts.map +1 -0
- package/dist/api.js +2 -0
- package/dist/api.js.map +1 -0
- package/dist/dns.d.ts +23 -0
- package/dist/dns.d.ts.map +1 -0
- package/dist/dns.js +97 -0
- package/dist/dns.js.map +1 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +26 -0
- package/dist/index.js.map +1 -0
- package/dist/options.d.ts +29 -0
- package/dist/options.d.ts.map +1 -0
- package/dist/options.js +24 -0
- package/dist/options.js.map +1 -0
- package/dist/smtp.d.ts +23 -0
- package/dist/smtp.d.ts.map +1 -0
- package/dist/smtp.js +143 -0
- package/dist/smtp.js.map +1 -0
- package/dist/types.d.ts +36 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +8 -0
- package/dist/types.js.map +1 -0
- package/dist/util.d.ts +6 -0
- package/dist/util.d.ts.map +1 -0
- package/dist/util.js +21 -0
- package/dist/util.js.map +1 -0
- package/dist/verifier.d.ts +26 -0
- package/dist/verifier.d.ts.map +1 -0
- package/dist/verifier.js +113 -0
- package/dist/verifier.js.map +1 -0
- package/package.json +17 -5
- package/src/address.ts +29 -0
- package/src/api.ts +45 -0
- package/src/dns.ts +118 -0
- package/src/index.ts +30 -0
- package/src/options.ts +51 -0
- package/src/smtp.ts +172 -0
- package/src/types.ts +48 -0
- package/src/util.ts +21 -0
- package/src/verifier.ts +138 -0
- package/src/index.mjs +0 -408
package/dist/util.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"util.js","sourceRoot":"","sources":["../src/util.ts"],"names":[],"mappings":"AAAA,4DAA4D;AAC5D,MAAM,UAAU,WAAW,CAAI,OAAmB,EAAE,KAAa,EAAE,EAAU;IAC3E,IAAI,KAAiC,CAAC;IACtC,MAAM,OAAO,GAAG,IAAI,OAAO,CAAQ,CAAC,CAAC,EAAE,MAAM,EAAE,EAAE;QAC/C,KAAK,GAAG,UAAU,CAAC,GAAG,EAAE,CAAC,MAAM,CAAC,IAAI,KAAK,CAAC,GAAG,KAAK,qBAAqB,EAAE,KAAK,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC;IACxF,CAAC,CAAC,CAAC;IACH,OAAO,OAAO,CAAC,IAAI,CAAC,CAAC,OAAO,EAAE,OAAO,CAAC,CAAC,CAAC,OAAO,CAAC,GAAG,EAAE,CAAC,YAAY,CAAC,KAAK,CAAC,CAAC,CAAC;AAC7E,CAAC;AAED,iFAAiF;AACjF,MAAM,UAAU,SAAS,CAAC,GAAY;IACpC,IAAI,OAAO,GAAG,KAAK,QAAQ,IAAI,GAAG,KAAK,IAAI,IAAI,MAAM,IAAI,GAAG,EAAE,CAAC;QAC7D,MAAM,IAAI,GAAI,GAA0B,CAAC,IAAI,CAAC;QAC9C,IAAI,OAAO,IAAI,KAAK,QAAQ;YAAE,OAAO,IAAI,CAAC;IAC5C,CAAC;IACD,OAAO,SAAS,CAAC;AACnB,CAAC;AAED,MAAM,UAAU,YAAY,CAAC,GAAY;IACvC,OAAO,GAAG,YAAY,KAAK,CAAC,CAAC,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC;AAC1D,CAAC"}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { type ResolvedOptions, type VerifierOptions } from "./options.ts";
|
|
2
|
+
import type { Action, Summary, Verdict, VerifyResult } from "./types.ts";
|
|
3
|
+
export declare const ACTIONS: Readonly<Record<Verdict, Action>>;
|
|
4
|
+
export declare const VERDICTS: readonly Verdict[];
|
|
5
|
+
export interface VerifierState {
|
|
6
|
+
/** True once a probe proved that port 25 is blocked (with autoDisableSmtp). */
|
|
7
|
+
smtpDown: boolean;
|
|
8
|
+
smtpDownWhy: string | null;
|
|
9
|
+
}
|
|
10
|
+
export interface Verifier {
|
|
11
|
+
verify(email: string): Promise<VerifyResult>;
|
|
12
|
+
verifyBatch(emails: readonly string[]): Promise<VerifyResult[]>;
|
|
13
|
+
readonly state: VerifierState;
|
|
14
|
+
readonly options: ResolvedOptions;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* A verifier with shared limits. The hosted API keeps one for its lifetime so
|
|
18
|
+
* the SMTP concurrency cap holds across requests; the CLI makes one per run.
|
|
19
|
+
*/
|
|
20
|
+
export declare function createVerifier(opts?: VerifierOptions): Verifier;
|
|
21
|
+
/** One-shot helpers. Each call gets its own limits. */
|
|
22
|
+
export declare function verify(email: string, opts?: VerifierOptions): Promise<VerifyResult>;
|
|
23
|
+
export declare function verifyBatch(emails: readonly string[], opts?: VerifierOptions): Promise<VerifyResult[]>;
|
|
24
|
+
/** Summary counts for a batch: { send, hold, kill, total }. */
|
|
25
|
+
export declare function summarize(results: readonly Pick<VerifyResult, "action">[]): Summary;
|
|
26
|
+
//# sourceMappingURL=verifier.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"verifier.d.ts","sourceRoot":"","sources":["../src/verifier.ts"],"names":[],"mappings":"AAIA,OAAO,EAAkB,KAAK,eAAe,EAAE,KAAK,eAAe,EAAE,MAAM,cAAc,CAAC;AAE1F,OAAO,KAAK,EAAE,MAAM,EAAU,OAAO,EAAE,OAAO,EAAE,YAAY,EAAE,MAAM,YAAY,CAAC;AAGjF,eAAO,MAAM,OAAO,EAAE,QAAQ,CAAC,MAAM,CAAC,OAAO,EAAE,MAAM,CAAC,CAA6D,CAAC;AACpH,eAAO,MAAM,QAAQ,EAAE,SAAS,OAAO,EAA0C,CAAC;AAyClF,MAAM,WAAW,aAAa;IAC5B,+EAA+E;IAC/E,QAAQ,EAAE,OAAO,CAAC;IAClB,WAAW,EAAE,MAAM,GAAG,IAAI,CAAC;CAC5B;AAED,MAAM,WAAW,QAAQ;IACvB,MAAM,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,CAAC,CAAC;IAC7C,WAAW,CAAC,MAAM,EAAE,SAAS,MAAM,EAAE,GAAG,OAAO,CAAC,YAAY,EAAE,CAAC,CAAC;IAChE,QAAQ,CAAC,KAAK,EAAE,aAAa,CAAC;IAC9B,QAAQ,CAAC,OAAO,EAAE,eAAe,CAAC;CACnC;AAcD;;;GAGG;AACH,wBAAgB,cAAc,CAAC,IAAI,GAAE,eAAoB,GAAG,QAAQ,CAyCnE;AAED,uDAAuD;AACvD,wBAAgB,MAAM,CAAC,KAAK,EAAE,MAAM,EAAE,IAAI,GAAE,eAAoB,GAAG,OAAO,CAAC,YAAY,CAAC,CAEvF;AAED,wBAAgB,WAAW,CAAC,MAAM,EAAE,SAAS,MAAM,EAAE,EAAE,IAAI,GAAE,eAAoB,GAAG,OAAO,CAAC,YAAY,EAAE,CAAC,CAE1G;AAED,+DAA+D;AAC/D,wBAAgB,SAAS,CAAC,OAAO,EAAE,SAAS,IAAI,CAAC,YAAY,EAAE,QAAQ,CAAC,EAAE,GAAG,OAAO,CAInF"}
|
package/dist/verifier.js
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
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.js";
|
|
4
|
+
import { checkDomain } from "./dns.js";
|
|
5
|
+
import { resolveOptions } from "./options.js";
|
|
6
|
+
import { PORT_BLOCKED, probeMailbox } from "./smtp.js";
|
|
7
|
+
import { errorMessage } from "./util.js";
|
|
8
|
+
export const ACTIONS = Object.freeze({ OK: "send", WEAK: "hold", DEAD: "kill" });
|
|
9
|
+
export const VERDICTS = Object.freeze(["OK", "WEAK", "DEAD"]);
|
|
10
|
+
const RANK = { OK: 0, WEAK: 1, DEAD: 2 };
|
|
11
|
+
function makeSemaphore(n) {
|
|
12
|
+
let active = 0;
|
|
13
|
+
const queue = [];
|
|
14
|
+
const next = () => {
|
|
15
|
+
if (active >= n)
|
|
16
|
+
return;
|
|
17
|
+
const run = queue.shift();
|
|
18
|
+
if (!run)
|
|
19
|
+
return;
|
|
20
|
+
active++;
|
|
21
|
+
run();
|
|
22
|
+
};
|
|
23
|
+
return (fn) => new Promise((res, rej) => {
|
|
24
|
+
queue.push(() => fn()
|
|
25
|
+
.then(res, rej)
|
|
26
|
+
.finally(() => {
|
|
27
|
+
active--;
|
|
28
|
+
next();
|
|
29
|
+
}));
|
|
30
|
+
next();
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
function result(email, verdict, reason, checks) {
|
|
34
|
+
return {
|
|
35
|
+
email,
|
|
36
|
+
action: ACTIONS[verdict],
|
|
37
|
+
verdict,
|
|
38
|
+
reason,
|
|
39
|
+
checks: { syntax: true, mx: null, smtp: "skipped", catch_all: null, ...checks },
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
// Try the primary MX, then the secondary when the primary cannot be reached at all.
|
|
43
|
+
async function probeWithFallback(email, domain, mxHosts, o) {
|
|
44
|
+
let last = null;
|
|
45
|
+
for (const { host, ip } of mxHosts) {
|
|
46
|
+
const probe = await probeMailbox(email, domain, host, { ...o, connectHost: ip ?? host });
|
|
47
|
+
if (probe.smtp !== "unreachable")
|
|
48
|
+
return probe;
|
|
49
|
+
last = probe;
|
|
50
|
+
if (probe.connectError !== undefined && PORT_BLOCKED.has(probe.connectError))
|
|
51
|
+
return probe;
|
|
52
|
+
}
|
|
53
|
+
return last ?? { verdict: "WEAK", reason: "no MX host to probe", smtp: "unreachable", catchAll: null };
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* A verifier with shared limits. The hosted API keeps one for its lifetime so
|
|
57
|
+
* the SMTP concurrency cap holds across requests; the CLI makes one per run.
|
|
58
|
+
*/
|
|
59
|
+
export function createVerifier(opts = {}) {
|
|
60
|
+
const o = resolveOptions(opts);
|
|
61
|
+
const state = { smtpDown: false, smtpDownWhy: null };
|
|
62
|
+
const dnsLimit = makeSemaphore(o.dnsConcurrency);
|
|
63
|
+
const smtpLimit = makeSemaphore(o.smtpConcurrency);
|
|
64
|
+
async function verify(raw) {
|
|
65
|
+
const parsed = parseAddress(raw);
|
|
66
|
+
if (parsed.error !== undefined)
|
|
67
|
+
return result(parsed.email, "DEAD", parsed.error, { syntax: false });
|
|
68
|
+
const { email, domain } = parsed;
|
|
69
|
+
try {
|
|
70
|
+
const d = await dnsLimit(() => checkDomain(domain, o));
|
|
71
|
+
if (d.verdict === "DEAD")
|
|
72
|
+
return result(email, "DEAD", d.reason, { mx: null });
|
|
73
|
+
if (!o.smtp) {
|
|
74
|
+
const note = d.verdict === "OK" ? "; mailbox not probed" : "";
|
|
75
|
+
return result(email, d.verdict, `${d.reason}${note}`, { mx: d.mx });
|
|
76
|
+
}
|
|
77
|
+
if (state.smtpDown) {
|
|
78
|
+
return result(email, d.verdict, `${d.reason}; SMTP tier off for this run (${state.smtpDownWhy})`, { mx: d.mx, smtp: "unreachable" });
|
|
79
|
+
}
|
|
80
|
+
const probe = await smtpLimit(() => probeWithFallback(email, domain, d.mxHosts, o));
|
|
81
|
+
if (probe.connectError !== undefined && o.autoDisableSmtp && PORT_BLOCKED.has(probe.connectError)) {
|
|
82
|
+
state.smtpDown = true;
|
|
83
|
+
state.smtpDownWhy = `${probe.connectError} on ${d.mx}:${o.port}`;
|
|
84
|
+
return result(email, d.verdict, `${d.reason}; SMTP tier off for this run (${probe.connectError})`, { mx: d.mx, smtp: "unreachable" });
|
|
85
|
+
}
|
|
86
|
+
const verdict = RANK[probe.verdict] >= RANK[d.verdict] ? probe.verdict : d.verdict;
|
|
87
|
+
const reason = d.verdict === "WEAK" && probe.verdict === "OK" ? `${probe.reason}; ${d.reason}` : probe.reason;
|
|
88
|
+
return result(email, verdict, reason, { mx: d.mx, smtp: probe.smtp, catch_all: probe.catchAll });
|
|
89
|
+
}
|
|
90
|
+
catch (err) {
|
|
91
|
+
return result(email, "WEAK", `check did not finish (${errorMessage(err)})`, {});
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
function verifyBatch(emails) {
|
|
95
|
+
return Promise.all(emails.map((e) => verify(e)));
|
|
96
|
+
}
|
|
97
|
+
return { verify, verifyBatch, state, options: o };
|
|
98
|
+
}
|
|
99
|
+
/** One-shot helpers. Each call gets its own limits. */
|
|
100
|
+
export function verify(email, opts = {}) {
|
|
101
|
+
return createVerifier(opts).verify(email);
|
|
102
|
+
}
|
|
103
|
+
export function verifyBatch(emails, opts = {}) {
|
|
104
|
+
return createVerifier(opts).verifyBatch(emails);
|
|
105
|
+
}
|
|
106
|
+
/** Summary counts for a batch: { send, hold, kill, total }. */
|
|
107
|
+
export function summarize(results) {
|
|
108
|
+
const s = { send: 0, hold: 0, kill: 0, total: results.length };
|
|
109
|
+
for (const r of results)
|
|
110
|
+
s[r.action]++;
|
|
111
|
+
return s;
|
|
112
|
+
}
|
|
113
|
+
//# sourceMappingURL=verifier.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"verifier.js","sourceRoot":"","sources":["../src/verifier.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,14 +1,19 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mxprobe-core",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "The MX Probe engine: DNS tier, SMTP probe and the send / hold / kill verdict contract. Zero dependencies.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
7
|
-
"main": "./
|
|
7
|
+
"main": "./dist/index.js",
|
|
8
8
|
"exports": {
|
|
9
|
-
".":
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"default": "./dist/index.js"
|
|
12
|
+
}
|
|
10
13
|
},
|
|
11
14
|
"files": [
|
|
15
|
+
"dist",
|
|
16
|
+
"!dist/.tsbuildinfo",
|
|
12
17
|
"src",
|
|
13
18
|
"README.md"
|
|
14
19
|
],
|
|
@@ -30,8 +35,15 @@
|
|
|
30
35
|
"ai-agent",
|
|
31
36
|
"mcp"
|
|
32
37
|
],
|
|
38
|
+
"devDependencies": {
|
|
39
|
+
"@types/node": "^24",
|
|
40
|
+
"typescript": "^7.0.2"
|
|
41
|
+
},
|
|
42
|
+
"types": "./dist/index.d.ts",
|
|
33
43
|
"scripts": {
|
|
34
|
-
"
|
|
35
|
-
"
|
|
44
|
+
"build": "tsc -b",
|
|
45
|
+
"typecheck": "tsc -p tsconfig.test.json",
|
|
46
|
+
"test": "node --test test/*.test.ts",
|
|
47
|
+
"test:live": "MXPROBE_LIVE=1 node --test test/*.live.ts"
|
|
36
48
|
}
|
|
37
49
|
}
|
package/src/address.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export interface ParsedAddress {
|
|
2
|
+
/** The address with the domain lowercased and the trailing dot removed. */
|
|
3
|
+
email: string;
|
|
4
|
+
local: string;
|
|
5
|
+
domain: string;
|
|
6
|
+
error?: undefined;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface AddressError {
|
|
10
|
+
email: string;
|
|
11
|
+
error: string;
|
|
12
|
+
local?: undefined;
|
|
13
|
+
domain?: undefined;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export type ParseResult = ParsedAddress | AddressError;
|
|
17
|
+
|
|
18
|
+
/** Split an address into local part and domain, or return { email, error }. */
|
|
19
|
+
export function parseAddress(raw: unknown): ParseResult {
|
|
20
|
+
const email = String(raw ?? "").trim();
|
|
21
|
+
const m = email.match(/^([^\s@]+)@([^\s@]+\.[^\s@]+)$/);
|
|
22
|
+
if (!m || m[1] === undefined || m[2] === undefined) return { email, error: "not an email address" };
|
|
23
|
+
const local = m[1];
|
|
24
|
+
const domain = m[2].toLowerCase().replace(/\.$/, "");
|
|
25
|
+
if (local.length > 64 || domain.length > 253 || /\.\./.test(domain) || /[^a-z0-9.-]/.test(domain)) {
|
|
26
|
+
return { email, error: "malformed address" };
|
|
27
|
+
}
|
|
28
|
+
return { email: `${local}@${domain}`, local, domain };
|
|
29
|
+
}
|
package/src/api.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// The hosted API's wire types. The server returns them, the `mxprobe` client
|
|
2
|
+
// expects them. Types only: nothing here runs.
|
|
3
|
+
import type { Summary, VerifyResult } from "./types.ts";
|
|
4
|
+
|
|
5
|
+
/** Every error response: { error, message } plus route-specific extras. */
|
|
6
|
+
export interface ApiErrorBody {
|
|
7
|
+
error: string;
|
|
8
|
+
message: string;
|
|
9
|
+
retry_after_seconds?: number;
|
|
10
|
+
credits_left?: number;
|
|
11
|
+
needed?: number;
|
|
12
|
+
checkout_hint?: string;
|
|
13
|
+
max?: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface SignupResponse {
|
|
17
|
+
api_key: string;
|
|
18
|
+
email: string;
|
|
19
|
+
credits: number;
|
|
20
|
+
mailed: boolean;
|
|
21
|
+
message: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface VerifyResponse {
|
|
25
|
+
results: VerifyResult[];
|
|
26
|
+
summary: Summary;
|
|
27
|
+
credits_used: number;
|
|
28
|
+
credits_left: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface BalanceResponse {
|
|
32
|
+
email: string;
|
|
33
|
+
credits: number;
|
|
34
|
+
checks_total: number;
|
|
35
|
+
created_at: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface CheckoutResponse {
|
|
39
|
+
url: string;
|
|
40
|
+
credits: number;
|
|
41
|
+
amount_usd: number;
|
|
42
|
+
packs: number;
|
|
43
|
+
session_id: string;
|
|
44
|
+
message: string;
|
|
45
|
+
}
|
package/src/dns.ts
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// Tier 1: DNS. Free and local. Finds domains that cannot receive mail: no MX
|
|
2
|
+
// and a web host behind the A record, a null MX, an MX that does not resolve,
|
|
3
|
+
// a domain that does not exist, a parking host as MX.
|
|
4
|
+
import { resolveOptions, type VerifierOptions } from "./options.ts";
|
|
5
|
+
import type { MxRecord, Resolver, Verdict } from "./types.ts";
|
|
6
|
+
import { errorCode, errorMessage, withTimeout } from "./util.ts";
|
|
7
|
+
|
|
8
|
+
// MX hosts that forward rather than hold mail. They bounce when the forward
|
|
9
|
+
// target is dead, and some refuse relays outright.
|
|
10
|
+
const FORWARDER_MX: readonly RegExp[] = [
|
|
11
|
+
/registrar-servers\.com$/i, // Namecheap eforward1..5
|
|
12
|
+
/improvmx\.com$/i,
|
|
13
|
+
/forwardemail\.net$/i,
|
|
14
|
+
/mx\.cloudflare\.net$/i, // Cloudflare Email Routing route1..3
|
|
15
|
+
/fwd\d*\.porkbun\.com$/i,
|
|
16
|
+
/mailforward\./i,
|
|
17
|
+
/forwardmx\./i,
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
// Hosts that serve web pages, not mail. An MX pointing here times out.
|
|
21
|
+
const WEBHOST_MX: readonly RegExp[] = [/pixie\.porkbun\.com$/i, /parkingcrew\./i, /sedoparking\./i, /bodis\./i, /above\.com$/i];
|
|
22
|
+
|
|
23
|
+
export interface MxHost {
|
|
24
|
+
host: string;
|
|
25
|
+
/** Resolved for the primary; null for the secondary, which is resolved on demand. */
|
|
26
|
+
ip: string | null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface DomainCheck {
|
|
30
|
+
verdict: Verdict;
|
|
31
|
+
reason: string;
|
|
32
|
+
/** The primary MX host name, or null when the domain is DEAD. */
|
|
33
|
+
mx: string | null;
|
|
34
|
+
/** The hosts to try in order. Empty when DEAD. */
|
|
35
|
+
mxHosts: MxHost[];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface Addresses {
|
|
39
|
+
v4: string[];
|
|
40
|
+
v6: string[];
|
|
41
|
+
all: string[];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const isNullMx = (mx: readonly MxRecord[]): boolean => mx.length === 1 && mx[0]?.exchange === "" && mx[0]?.priority === 0;
|
|
45
|
+
|
|
46
|
+
async function lookupAny(resolver: Resolver, host: string): Promise<Addresses> {
|
|
47
|
+
const [v4, v6] = await Promise.all([resolver.resolve4(host).catch(() => [] as string[]), resolver.resolve6(host).catch(() => [] as string[])]);
|
|
48
|
+
return { v4, v6, all: [...v4, ...v6] };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function reverseName(resolver: Resolver, ip: string, ms: number): Promise<string | null> {
|
|
52
|
+
try {
|
|
53
|
+
const names = await withTimeout(resolver.reverse(ip), "reverse", ms);
|
|
54
|
+
return names[0] ?? null;
|
|
55
|
+
} catch {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Name the web host behind an A record from its IP and reverse name. */
|
|
61
|
+
export function labelWebHost(ip: string, ptr: string | null): string {
|
|
62
|
+
const p = (ptr ?? "").toLowerCase();
|
|
63
|
+
if (ip === "75.2.60.5" || ip === "99.83.190.102" || p.includes("netlify")) return "Netlify";
|
|
64
|
+
if (ip.startsWith("76.76.21.") || ip.startsWith("216.198.79.") || p.includes("vercel")) return "Vercel";
|
|
65
|
+
if (ip.startsWith("104.21.") || ip.startsWith("172.67.") || ip.startsWith("188.114.9") || p.includes("cloudflare")) return "Cloudflare";
|
|
66
|
+
if (p.includes("amazonaws") || p.includes("awsglobalaccelerator")) return "AWS";
|
|
67
|
+
if (p.includes("github")) return "GitHub Pages";
|
|
68
|
+
if (p.includes("squarespace") || p.includes("wixdns") || p.includes("webflow")) return "site builder";
|
|
69
|
+
return ptr ?? "unknown host";
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const dead = (reason: string): DomainCheck => ({ verdict: "DEAD", reason, mx: null, mxHosts: [] });
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The DNS tier for one domain. Returns the verdict, the primary MX host name
|
|
76
|
+
* (null when the domain is DEAD) and the sorted list of hosts to try.
|
|
77
|
+
*/
|
|
78
|
+
export async function checkDomain(domain: string, opts: VerifierOptions = {}): Promise<DomainCheck> {
|
|
79
|
+
const o = resolveOptions(opts);
|
|
80
|
+
const { resolver } = o;
|
|
81
|
+
let mx: MxRecord[];
|
|
82
|
+
try {
|
|
83
|
+
mx = await withTimeout(resolver.resolveMx(domain), "MX", o.dnsTimeoutMs);
|
|
84
|
+
} catch (err) {
|
|
85
|
+
const code = errorCode(err) ?? "";
|
|
86
|
+
if (code === "ENOTFOUND") return dead("domain does not exist (NXDOMAIN)");
|
|
87
|
+
if (code !== "ENODATA" && !/timed out/.test(errorMessage(err))) return dead(`MX lookup failed (${code || errorMessage(err)})`);
|
|
88
|
+
mx = [];
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (mx.length === 0) {
|
|
92
|
+
const ips = await withTimeout(lookupAny(resolver, domain), "A/AAAA", o.dnsTimeoutMs);
|
|
93
|
+
const ip = ips.all[0];
|
|
94
|
+
if (ip === undefined) return dead("no MX and no A/AAAA record");
|
|
95
|
+
const host = labelWebHost(ip, await reverseName(resolver, ip, o.dnsTimeoutMs));
|
|
96
|
+
return dead(`no MX record; mail falls back to the A record ${ip} (${host}), which does not take mail`);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (isNullMx(mx)) return dead("null MX (RFC 7505): the domain accepts no mail");
|
|
100
|
+
|
|
101
|
+
const sorted = [...mx].sort((x, y) => x.priority - y.priority).map((r) => r.exchange.replace(/\.$/, "").toLowerCase());
|
|
102
|
+
const primary = sorted[0] as string;
|
|
103
|
+
const secondary = sorted[1];
|
|
104
|
+
|
|
105
|
+
if (WEBHOST_MX.some((re) => re.test(primary))) return dead(`MX ${primary} is a web or parking host, not a mail server`);
|
|
106
|
+
|
|
107
|
+
const primaryIps = await withTimeout(lookupAny(resolver, primary), "MX host", o.dnsTimeoutMs);
|
|
108
|
+
const primaryIp = primaryIps.v4[0] ?? primaryIps.v6[0];
|
|
109
|
+
if (primaryIp === undefined) return dead(`MX host ${primary} does not resolve`);
|
|
110
|
+
|
|
111
|
+
const mxHosts: MxHost[] = [{ host: primary, ip: primaryIp }];
|
|
112
|
+
if (secondary !== undefined && secondary !== primary) mxHosts.push({ host: secondary, ip: null });
|
|
113
|
+
|
|
114
|
+
if (FORWARDER_MX.some((re) => re.test(primary))) {
|
|
115
|
+
return { verdict: "WEAK", reason: `MX ${primary} is a forwarder; it bounces when the forward target is dead`, mx: primary, mxHosts };
|
|
116
|
+
}
|
|
117
|
+
return { verdict: "OK", reason: `MX ${primary}`, mx: primary, mxHosts };
|
|
118
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
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 { createRequire } from "node:module";
|
|
19
|
+
|
|
20
|
+
/** The package version, read from package.json so a bump is one edit there. */
|
|
21
|
+
export const VERSION: string = (createRequire(import.meta.url)("../package.json") as { version: string }).version;
|
|
22
|
+
|
|
23
|
+
export { parseAddress, type AddressError, type ParsedAddress, type ParseResult } from "./address.ts";
|
|
24
|
+
export { checkDomain, labelWebHost, type DomainCheck, type MxHost } from "./dns.ts";
|
|
25
|
+
export { DEFAULTS, resolveOptions, type ResolvedOptions, type VerifierOptions } from "./options.ts";
|
|
26
|
+
export { probeMailbox, PORT_BLOCKED, type ProbeOptions, type ProbeResult, type ProbeSmtp } from "./smtp.ts";
|
|
27
|
+
export { ACTIONS, VERDICTS, createVerifier, summarize, verify, verifyBatch, type Verifier, type VerifierState } from "./verifier.ts";
|
|
28
|
+
export { errorMessage } from "./util.ts";
|
|
29
|
+
export type { Action, Checks, MxRecord, Resolver, SmtpCheck, Summary, Verdict, VerifyResult } from "./types.ts";
|
|
30
|
+
export type { ApiErrorBody, BalanceResponse, CheckoutResponse, SignupResponse, VerifyResponse } from "./api.ts";
|
package/src/options.ts
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { promises as dnsPromises } from "node:dns";
|
|
2
|
+
import type { Resolver } from "./types.ts";
|
|
3
|
+
|
|
4
|
+
export interface VerifierOptions {
|
|
5
|
+
/** Run the SMTP probe after the DNS tier. Needs outbound port 25. */
|
|
6
|
+
smtp?: boolean;
|
|
7
|
+
dnsTimeoutMs?: number;
|
|
8
|
+
smtpTimeoutMs?: number;
|
|
9
|
+
smtpConcurrency?: number;
|
|
10
|
+
dnsConcurrency?: number;
|
|
11
|
+
/** The name given in EHLO. Its reverse DNS should match the probing IP. */
|
|
12
|
+
helo?: string;
|
|
13
|
+
/** The MAIL FROM address. */
|
|
14
|
+
from?: string;
|
|
15
|
+
port?: number;
|
|
16
|
+
/** Connect here instead of the MX host, for tests against a local server. */
|
|
17
|
+
hostOverride?: string | null;
|
|
18
|
+
/**
|
|
19
|
+
* When true, an ECONNREFUSED / EHOSTUNREACH / ENETUNREACH on a probe turns
|
|
20
|
+
* the SMTP tier off for the rest of the run: the network blocks port 25.
|
|
21
|
+
* The hosted API sets this to false because its port is proven by a health
|
|
22
|
+
* check, and one refusing MX must not switch the tier off for everyone.
|
|
23
|
+
*/
|
|
24
|
+
autoDisableSmtp?: boolean;
|
|
25
|
+
resolver?: Resolver;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export type ResolvedOptions = Required<VerifierOptions>;
|
|
29
|
+
|
|
30
|
+
export const DEFAULTS: Readonly<ResolvedOptions> = Object.freeze({
|
|
31
|
+
smtp: false,
|
|
32
|
+
dnsTimeoutMs: 8000,
|
|
33
|
+
smtpTimeoutMs: 12000,
|
|
34
|
+
smtpConcurrency: 3,
|
|
35
|
+
dnsConcurrency: 20,
|
|
36
|
+
helo: "probe.mxprobe.dev",
|
|
37
|
+
from: "probe@mxprobe.dev",
|
|
38
|
+
port: 25,
|
|
39
|
+
hostOverride: null,
|
|
40
|
+
autoDisableSmtp: true,
|
|
41
|
+
resolver: dnsPromises,
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
/** Defaults under the caller's options. An explicit `undefined` keeps the default. */
|
|
45
|
+
export function resolveOptions(opts: VerifierOptions = {}): ResolvedOptions {
|
|
46
|
+
const out: Record<string, unknown> = { ...DEFAULTS };
|
|
47
|
+
for (const [key, value] of Object.entries(opts)) {
|
|
48
|
+
if (value !== undefined) out[key] = value;
|
|
49
|
+
}
|
|
50
|
+
return out as ResolvedOptions;
|
|
51
|
+
}
|
package/src/smtp.ts
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
// Tier 2: the SMTP probe. Connects to the MX on port 25, says EHLO and MAIL
|
|
2
|
+
// FROM, asks RCPT TO for the address and for a random local part (the
|
|
3
|
+
// catch-all test), then QUITs. No message is ever sent. Needs outbound port 25.
|
|
4
|
+
import net from "node:net";
|
|
5
|
+
import { randomBytes } from "node:crypto";
|
|
6
|
+
import { resolveOptions, type VerifierOptions } from "./options.ts";
|
|
7
|
+
import type { SmtpCheck, Verdict } from "./types.ts";
|
|
8
|
+
import { errorCode, errorMessage, withTimeout } from "./util.ts";
|
|
9
|
+
|
|
10
|
+
// Reply text that means "this mailbox does not exist", as opposed to "we do
|
|
11
|
+
// not like you" (5.7.x) or "not now" (4xx).
|
|
12
|
+
const NO_SUCH_MAILBOX =
|
|
13
|
+
/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;
|
|
14
|
+
|
|
15
|
+
/** Connect errors that mean the network blocks port 25, not that one MX is down. */
|
|
16
|
+
export const PORT_BLOCKED: ReadonlySet<string> = new Set(["ECONNREFUSED", "EHOSTUNREACH", "ENETUNREACH"]);
|
|
17
|
+
|
|
18
|
+
export type ProbeSmtp = Exclude<SmtpCheck, "skipped">;
|
|
19
|
+
|
|
20
|
+
export interface ProbeResult {
|
|
21
|
+
verdict: Verdict;
|
|
22
|
+
reason: string;
|
|
23
|
+
smtp: ProbeSmtp;
|
|
24
|
+
catchAll: boolean | null;
|
|
25
|
+
/** The connect error code when smtp is `unreachable`. */
|
|
26
|
+
connectError?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface ProbeOptions extends VerifierOptions {
|
|
30
|
+
/** Connect to this address (the resolved MX IP) while naming mxHost in the reason. */
|
|
31
|
+
connectHost?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface Reply {
|
|
35
|
+
code: number;
|
|
36
|
+
/** All lines of a multi-line reply, joined with " | ". */
|
|
37
|
+
text: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
interface SmtpSession {
|
|
41
|
+
read(): Promise<Reply>;
|
|
42
|
+
send(cmd: string): Promise<Reply>;
|
|
43
|
+
close(): void;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
interface Waiter {
|
|
47
|
+
resolve(reply: Reply): void;
|
|
48
|
+
reject(err: Error): void;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const codedError = (message: string, code: string): NodeJS.ErrnoException => Object.assign(new Error(message), { code });
|
|
52
|
+
|
|
53
|
+
// A line-oriented SMTP client: connect, then read() a reply or send(cmd) and
|
|
54
|
+
// read its reply. Multi-line replies (250-... 250 ...) come back as one.
|
|
55
|
+
function connectSmtp(host: string, port: number, timeoutMs: number): Promise<SmtpSession> {
|
|
56
|
+
return new Promise((resolve, reject) => {
|
|
57
|
+
const socket = net.createConnection({ host, port });
|
|
58
|
+
socket.setEncoding("utf8");
|
|
59
|
+
socket.setTimeout(timeoutMs);
|
|
60
|
+
let buffer = "";
|
|
61
|
+
let lines: string[] = [];
|
|
62
|
+
const queued: Reply[] = [];
|
|
63
|
+
let waiter: Waiter | null = null;
|
|
64
|
+
let dead: Error | null = null;
|
|
65
|
+
let connected = false;
|
|
66
|
+
|
|
67
|
+
const fail = (err: Error): void => {
|
|
68
|
+
dead = dead ?? err;
|
|
69
|
+
if (waiter) {
|
|
70
|
+
const w = waiter;
|
|
71
|
+
waiter = null;
|
|
72
|
+
w.reject(err);
|
|
73
|
+
}
|
|
74
|
+
if (!connected) reject(err);
|
|
75
|
+
socket.destroy();
|
|
76
|
+
};
|
|
77
|
+
socket.on("timeout", () => fail(codedError("SMTP timeout", "ETIMEDOUT")));
|
|
78
|
+
socket.on("error", fail);
|
|
79
|
+
socket.on("close", () => fail(codedError("connection closed", "ECLOSED")));
|
|
80
|
+
socket.on("data", (chunk: string) => {
|
|
81
|
+
buffer += chunk;
|
|
82
|
+
let idx: number;
|
|
83
|
+
while ((idx = buffer.indexOf("\n")) !== -1) {
|
|
84
|
+
const line = buffer.slice(0, idx).replace(/\r$/, "");
|
|
85
|
+
buffer = buffer.slice(idx + 1);
|
|
86
|
+
lines.push(line);
|
|
87
|
+
if (/^\d{3}( |$)/.test(line)) {
|
|
88
|
+
const reply: Reply = { code: Number(line.slice(0, 3)), text: lines.join(" | ") };
|
|
89
|
+
lines = [];
|
|
90
|
+
if (waiter) {
|
|
91
|
+
const w = waiter;
|
|
92
|
+
waiter = null;
|
|
93
|
+
w.resolve(reply);
|
|
94
|
+
} else {
|
|
95
|
+
queued.push(reply);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
const read = (): Promise<Reply> =>
|
|
101
|
+
new Promise((res, rej) => {
|
|
102
|
+
const next = queued.shift();
|
|
103
|
+
if (next) return res(next);
|
|
104
|
+
if (dead) return rej(dead);
|
|
105
|
+
waiter = { resolve: res, reject: rej };
|
|
106
|
+
});
|
|
107
|
+
const send = (cmd: string): Promise<Reply> => {
|
|
108
|
+
if (dead) return Promise.reject(dead);
|
|
109
|
+
socket.write(`${cmd}\r\n`);
|
|
110
|
+
return read();
|
|
111
|
+
};
|
|
112
|
+
socket.once("connect", () => {
|
|
113
|
+
connected = true;
|
|
114
|
+
resolve({ read, send, close: () => socket.destroy() });
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const firstLine = (reply: Reply): string => reply.text.split(" | ")[0] ?? "";
|
|
120
|
+
const refused = (reason: string): ProbeResult => ({ verdict: "WEAK", reason, smtp: "refused", catchAll: null });
|
|
121
|
+
const deferred = (reason: string): ProbeResult => ({ verdict: "WEAK", reason, smtp: "deferred", catchAll: null });
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Probe one mailbox on one MX. `smtp` is accepted | rejected | refused |
|
|
125
|
+
* deferred | unreachable | dropped. Never sends DATA.
|
|
126
|
+
*/
|
|
127
|
+
export async function probeMailbox(email: string, domain: string, mxHost: string, opts: ProbeOptions = {}): Promise<ProbeResult> {
|
|
128
|
+
const o = resolveOptions(opts);
|
|
129
|
+
const target = o.hostOverride ?? opts.connectHost ?? mxHost;
|
|
130
|
+
let s: SmtpSession;
|
|
131
|
+
try {
|
|
132
|
+
s = await withTimeout(connectSmtp(target, o.port, o.smtpTimeoutMs), "connect", o.smtpTimeoutMs);
|
|
133
|
+
} catch (err) {
|
|
134
|
+
const code = errorCode(err) ?? "EUNKNOWN";
|
|
135
|
+
return { verdict: "WEAK", reason: `cannot connect to ${mxHost}:${o.port} (${code})`, smtp: "unreachable", catchAll: null, connectError: code };
|
|
136
|
+
}
|
|
137
|
+
try {
|
|
138
|
+
const banner = await s.read();
|
|
139
|
+
if (banner.code !== 220) return refused(`${mxHost} greeted with ${firstLine(banner)}`);
|
|
140
|
+
let r = await s.send(`EHLO ${o.helo}`);
|
|
141
|
+
if (r.code !== 250) {
|
|
142
|
+
r = await s.send(`HELO ${o.helo}`);
|
|
143
|
+
if (r.code !== 250) return refused(`${mxHost} refused HELO (${firstLine(r)})`);
|
|
144
|
+
}
|
|
145
|
+
r = await s.send(`MAIL FROM:<${o.from}>`);
|
|
146
|
+
if (r.code !== 250) {
|
|
147
|
+
return r.code >= 500 ? refused(`${mxHost} refused the sender (${firstLine(r)})`) : deferred(`${mxHost} deferred the sender (${firstLine(r)})`);
|
|
148
|
+
}
|
|
149
|
+
r = await s.send(`RCPT TO:<${email}>`);
|
|
150
|
+
let result: ProbeResult;
|
|
151
|
+
if (r.code === 250 || r.code === 251) {
|
|
152
|
+
const random = `${randomBytes(6).toString("hex")}-probe@${domain}`;
|
|
153
|
+
const c = await s.send(`RCPT TO:<${random}>`);
|
|
154
|
+
result =
|
|
155
|
+
c.code === 250 || c.code === 251
|
|
156
|
+
? { verdict: "WEAK", reason: `${domain} is catch-all: ${mxHost} accepts any local part, so the mailbox cannot be proven`, smtp: "accepted", catchAll: true }
|
|
157
|
+
: { verdict: "OK", reason: `mailbox accepted by ${mxHost}`, smtp: "accepted", catchAll: false };
|
|
158
|
+
} else if (r.code >= 500 && NO_SUCH_MAILBOX.test(r.text)) {
|
|
159
|
+
result = { verdict: "DEAD", reason: `${mxHost} says the mailbox does not exist (${firstLine(r)})`, smtp: "rejected", catchAll: null };
|
|
160
|
+
} else if (r.code >= 500) {
|
|
161
|
+
result = refused(`${mxHost} refused the probe, not the mailbox (${firstLine(r)})`);
|
|
162
|
+
} else {
|
|
163
|
+
result = deferred(`${mxHost} deferred (${firstLine(r)})`);
|
|
164
|
+
}
|
|
165
|
+
await s.send("QUIT").catch(() => {});
|
|
166
|
+
return result;
|
|
167
|
+
} catch (err) {
|
|
168
|
+
return { verdict: "WEAK", reason: `${mxHost} dropped the session (${errorCode(err) ?? errorMessage(err)})`, smtp: "dropped", catchAll: null };
|
|
169
|
+
} finally {
|
|
170
|
+
s.close();
|
|
171
|
+
}
|
|
172
|
+
}
|