mailifier 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.
@@ -0,0 +1,384 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/server.ts
4
+ import { createServer } from "node:http";
5
+
6
+ // src/address.ts
7
+ var LOCAL_RE = /^[a-z0-9!#$%&'*+/=?^_`{|}~.-]+$/;
8
+ var LABEL_RE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/;
9
+ var PUNYCODE_RE = /^xn--[a-z0-9-]+$/;
10
+ function emailSyntaxError(email) {
11
+ const parts = email.split("@");
12
+ if (parts.length !== 2) return "must contain exactly one @";
13
+ const [local, domain] = parts;
14
+ if (!local) return "empty local part";
15
+ if (local.length > 64) return "local part longer than 64 chars";
16
+ if (email.length > 254) return "address longer than 254 chars";
17
+ if (local.startsWith(".") || local.endsWith(".") || local.includes("..")) {
18
+ return "misplaced dot in local part";
19
+ }
20
+ if (!LOCAL_RE.test(local)) return "illegal character in local part";
21
+ if (!validDomain(domain)) return "invalid domain";
22
+ return null;
23
+ }
24
+ function validDomain(domain) {
25
+ if (!domain || domain.length > 253) return false;
26
+ const labels = domain.replace(/\.+$/, "").split(".");
27
+ if (labels.length < 2) return false;
28
+ if (!labels.every((l) => LABEL_RE.test(l) || PUNYCODE_RE.test(l))) return false;
29
+ const tld = labels[labels.length - 1];
30
+ return tld.length >= 2 && (/^[a-z]+$/.test(tld) || PUNYCODE_RE.test(tld));
31
+ }
32
+
33
+ // src/server.ts
34
+ var MAX_BODY = 4096;
35
+ function makeProbeServer(opts) {
36
+ const maxInFlight = opts.maxInFlight ?? 8;
37
+ const log = opts.log ?? (() => {
38
+ });
39
+ let inFlight = 0;
40
+ return createServer(async (req, res) => {
41
+ try {
42
+ if (req.method === "GET" && req.url === "/healthz") {
43
+ const port25 = opts.canary ? await opts.canary() : null;
44
+ return json(res, 200, { ok: true, in_flight: inFlight, port_25: port25 });
45
+ }
46
+ if (req.method !== "POST" || req.url !== "/verify") return json(res, 404, { error: "no" });
47
+ const offered = (req.headers.authorization ?? "").replace(/^Bearer\s+/i, "");
48
+ if (!timingSafeEqual(offered, opts.token)) return json(res, 401, { error: "no" });
49
+ if (inFlight >= maxInFlight) return json(res, 429, { error: "busy" });
50
+ const body = await readJson(req);
51
+ const email = typeof body.email === "string" ? body.email.trim() : "";
52
+ const syntax = email ? emailSyntaxError(email) : "missing email";
53
+ if (syntax) return json(res, 400, { error: syntax });
54
+ if (opts.canary && !await opts.canary()) {
55
+ log("port 25 closed from this host");
56
+ return json(res, 503, { error: "port 25 closed from this host" });
57
+ }
58
+ inFlight += 1;
59
+ try {
60
+ const t0 = Date.now();
61
+ const verdict = await opts.probe.verify(email);
62
+ log(`${verdict.result} ${String(verdict.raw.reason ?? "")} ${Date.now() - t0}ms`);
63
+ return json(res, 200, verdict);
64
+ } finally {
65
+ inFlight -= 1;
66
+ }
67
+ } catch (err) {
68
+ log(`error ${err instanceof Error ? err.name : "Error"}`);
69
+ return json(res, 500, { error: err instanceof Error ? err.name : "error" });
70
+ }
71
+ });
72
+ }
73
+ function json(res, status, payload) {
74
+ const text = JSON.stringify(payload);
75
+ res.writeHead(status, {
76
+ "content-type": "application/json",
77
+ "content-length": Buffer.byteLength(text)
78
+ });
79
+ res.end(text);
80
+ }
81
+ async function readJson(req) {
82
+ let text = "";
83
+ for await (const chunk of req) {
84
+ text += chunk;
85
+ if (text.length > MAX_BODY) throw new Error("body too large");
86
+ }
87
+ try {
88
+ const parsed = JSON.parse(text || "{}");
89
+ return parsed && typeof parsed === "object" ? parsed : {};
90
+ } catch {
91
+ return {};
92
+ }
93
+ }
94
+ function timingSafeEqual(a, b) {
95
+ if (a.length !== b.length || b.length === 0) return false;
96
+ let diff = 0;
97
+ for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
98
+ return diff === 0;
99
+ }
100
+
101
+ // src/smtp.ts
102
+ import { randomBytes } from "node:crypto";
103
+ import { createConnection } from "node:net";
104
+
105
+ // src/dns.ts
106
+ var DOH_ENDPOINT = "https://cloudflare-dns.com/dns-query";
107
+ var TYPE_CODES = { MX: 15, A: 1, AAAA: 28 };
108
+ var NOERROR = 0;
109
+ var NXDOMAIN = 3;
110
+ var DohError = class extends Error {
111
+ name = "DohError";
112
+ };
113
+ var DohStatusError = class extends DohError {
114
+ name = "DohStatusError";
115
+ };
116
+ async function resolve(name, rtype, fetchImpl = fetch) {
117
+ const url = `${DOH_ENDPOINT}?${new URLSearchParams({ name, type: rtype })}`;
118
+ let resp;
119
+ try {
120
+ resp = await fetchImpl(url, {
121
+ headers: { accept: "application/dns-json" },
122
+ signal: AbortSignal.timeout(1e4)
123
+ });
124
+ } catch (err) {
125
+ throw new DohError(
126
+ `resolver unreachable for ${rtype} ${name}: ${err instanceof Error ? err.message : String(err)}`
127
+ );
128
+ }
129
+ if (!resp.ok) throw new DohError(`HTTP ${resp.status} for ${rtype} ${name}`);
130
+ let body;
131
+ try {
132
+ body = await resp.json();
133
+ } catch {
134
+ throw new DohStatusError(`non-JSON response for ${rtype} ${name}`);
135
+ }
136
+ if (body.Status === NXDOMAIN) return [];
137
+ if (body.Status !== NOERROR)
138
+ throw new DohStatusError(`DNS status ${body.Status} for ${rtype} ${name}`);
139
+ return (body.Answer ?? []).filter((a) => a.type === TYPE_CODES[rtype]).map((a) => a.data);
140
+ }
141
+
142
+ // src/smtp.ts
143
+ var SMTP_PORT = 25;
144
+ var CRLF = "\r\n";
145
+ var DEFAULT_TIMEOUT_MS = 12e3;
146
+ var MAX_MX_TRIED = 3;
147
+ var SmtpReply = class extends Error {
148
+ constructor(code, text) {
149
+ super(`${code} ${text}`);
150
+ this.code = code;
151
+ this.text = text;
152
+ this.name = "SmtpReply";
153
+ }
154
+ code;
155
+ text;
156
+ };
157
+ var replyCode = (reply) => Number.parseInt(reply.slice(0, 3), 10);
158
+ var REJECTED_USER = /user unknown|no such user|does not exist|unknown user|recipient rejected|recipient address rejected|invalid recipient|no mailbox|mailbox not found|mailbox unavailable|address rejected/i;
159
+ var replyClass = (code) => Math.floor(code / 100);
160
+ var enhanced = (reply) => /\b([245])\.(\d{1,3})\.(\d{1,3})\b/.exec(reply);
161
+ async function mailHosts(domain, resolver) {
162
+ const mx = (await resolver(domain, "MX")).map((rr) => {
163
+ const [priority, host] = rr.trim().split(/\s+/);
164
+ return { priority: Number(priority), host: (host ?? "").replace(/\.$/, "").toLowerCase() };
165
+ }).filter((r) => r.host !== "" && r.host !== ".").sort((a2, b) => a2.priority - b.priority);
166
+ if (mx.length > 0) return [...new Set(mx.map((r) => r.host))];
167
+ const a = await resolver(domain, "A");
168
+ return a.length > 0 ? [domain] : [];
169
+ }
170
+ async function converse(conv, email, opts, transcript) {
171
+ const step = async (line) => {
172
+ if (line !== null) await conv.write(line);
173
+ const reply = await conv.read();
174
+ const code = replyCode(reply);
175
+ transcript.push({ sent: line, code, reply: reply.slice(0, 200) });
176
+ return { code, reply };
177
+ };
178
+ const expect = async (line, ok) => {
179
+ const r = await step(line);
180
+ if (r.code !== ok) throw new SmtpReply(r.code, r.reply);
181
+ return r;
182
+ };
183
+ await expect(null, 220);
184
+ await expect(`EHLO ${opts.helo}`, 250);
185
+ await expect(`MAIL FROM:<${opts.mailFrom}>`, 250);
186
+ const rcpt = await step(`RCPT TO:<${email}>`);
187
+ let randomAccepted = null;
188
+ if (replyClass(rcpt.code) === 2) {
189
+ const domain = email.slice(email.lastIndexOf("@") + 1);
190
+ const probe2 = await step(`RCPT TO:<${opts.random()}@${domain}>`);
191
+ randomAccepted = replyClass(probe2.code) === 2;
192
+ }
193
+ try {
194
+ await conv.write("QUIT");
195
+ } catch {
196
+ }
197
+ conv.close();
198
+ return { code: rcpt.code, reply: rcpt.reply, randomAccepted };
199
+ }
200
+ function readRcpt(code, reply, randomAccepted) {
201
+ const klass = replyClass(code);
202
+ if (klass === 2) {
203
+ return randomAccepted ? { result: "catch_all", reason: "catch_all" } : { result: "valid", reason: "accepted" };
204
+ }
205
+ const enh = enhanced(reply);
206
+ if (klass === 5) {
207
+ if (enh?.[2] === "1" || REJECTED_USER.test(reply))
208
+ return { result: "invalid", reason: "rejected" };
209
+ return { result: "risky", reason: "blocked" };
210
+ }
211
+ return { result: "risky", reason: "greylisted" };
212
+ }
213
+ async function probeMailbox(email, opts) {
214
+ const resolver = opts.resolver ?? ((n, t) => resolve(n, t));
215
+ const dial = opts.dial ?? dialTcp;
216
+ const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
217
+ const conv = {
218
+ helo: opts.helo,
219
+ mailFrom: opts.mailFrom ?? `postmaster@${opts.helo}`,
220
+ random: opts.random ?? (() => `wren-${randomBytes(6).toString("hex")}`)
221
+ };
222
+ const domain = email.slice(email.lastIndexOf("@") + 1).toLowerCase();
223
+ const hosts = await mailHosts(domain, resolver);
224
+ const transcript = [];
225
+ if (hosts.length === 0)
226
+ return { result: "invalid", reason: "no_mx", mx: null, code: null, transcript };
227
+ let lastReason = "unreachable";
228
+ for (const host of hosts.slice(0, MAX_MX_TRIED)) {
229
+ let session;
230
+ try {
231
+ session = await dial(host, SMTP_PORT, timeoutMs);
232
+ } catch (err) {
233
+ transcript.push({ sent: null, code: 0, reply: `connect ${host}: ${errorName(err)}` });
234
+ lastReason = "unreachable";
235
+ continue;
236
+ }
237
+ try {
238
+ const { code, reply, randomAccepted } = await converse(session, email, conv, transcript);
239
+ return { ...readRcpt(code, reply, randomAccepted), mx: host, code, transcript };
240
+ } catch (err) {
241
+ session.close();
242
+ if (err instanceof SmtpReply) {
243
+ lastReason = replyClass(err.code) === 4 ? "greylisted" : "blocked";
244
+ continue;
245
+ }
246
+ transcript.push({ sent: null, code: 0, reply: `${host}: ${errorName(err)}` });
247
+ lastReason = "unreachable";
248
+ }
249
+ }
250
+ return { result: "risky", reason: lastReason, mx: null, code: null, transcript };
251
+ }
252
+ var errorName = (err) => err instanceof Error ? `${err.name}${err.code ? ` ${err.code}` : ""}` : String(err);
253
+ var dialTcp = (host, port2, timeoutMs) => new Promise((resolveConn, reject) => {
254
+ const socket = createConnection({ host, port: port2 });
255
+ let buffer = "";
256
+ let waiting = null;
257
+ const fail = (err) => {
258
+ if (waiting) {
259
+ waiting.reject(err);
260
+ waiting = null;
261
+ } else reject(err);
262
+ socket.destroy();
263
+ };
264
+ socket.setTimeout(
265
+ timeoutMs,
266
+ () => fail(Object.assign(new Error("smtp timeout"), { code: "ETIMEDOUT" }))
267
+ );
268
+ socket.on("error", fail);
269
+ socket.on("close", () => fail(Object.assign(new Error("closed"), { code: "ECONNRESET" })));
270
+ const pump = () => {
271
+ const lines = buffer.split(CRLF);
272
+ const end = lines.findIndex((l) => /^\d{3} /.test(l) || l.length > 0 && /^\d{3}$/.test(l));
273
+ if (end < 0 || !waiting) return;
274
+ const reply = lines.slice(0, end + 1).join("\n");
275
+ buffer = lines.slice(end + 1).join(CRLF);
276
+ const w = waiting;
277
+ waiting = null;
278
+ w.resolve(reply);
279
+ };
280
+ socket.on("data", (chunk) => {
281
+ buffer += chunk.toString("latin1");
282
+ pump();
283
+ });
284
+ socket.once(
285
+ "connect",
286
+ () => resolveConn({
287
+ read: () => new Promise((res, rej) => {
288
+ waiting = { resolve: res, reject: rej };
289
+ pump();
290
+ }),
291
+ write: (line) => new Promise(
292
+ (res, rej) => socket.write(`${line}${CRLF}`, (err) => err ? rej(err) : res())
293
+ ),
294
+ close: () => {
295
+ socket.removeAllListeners("close");
296
+ socket.end();
297
+ socket.destroy();
298
+ }
299
+ })
300
+ );
301
+ });
302
+ var SmtpProbe = class {
303
+ constructor(opts) {
304
+ this.opts = opts;
305
+ this.gapMs = opts.perHostGapMs ?? 1500;
306
+ this.sleep = opts.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
307
+ }
308
+ opts;
309
+ name = "smtp";
310
+ lastByHost = /* @__PURE__ */ new Map();
311
+ gapMs;
312
+ sleep;
313
+ async verify(email) {
314
+ const domain = email.slice(email.lastIndexOf("@") + 1).toLowerCase();
315
+ const resolver = this.opts.resolver ?? ((n, t) => resolve(n, t));
316
+ const key = (await mailHosts(domain, resolver))[0] ?? domain;
317
+ const previous = this.lastByHost.get(key) ?? Promise.resolve();
318
+ const turn = previous.then(async () => {
319
+ const outcome2 = await probeMailbox(email, { ...this.opts, resolver });
320
+ await this.sleep(this.gapMs);
321
+ return outcome2;
322
+ });
323
+ this.lastByHost.set(
324
+ key,
325
+ turn.then(
326
+ () => void 0,
327
+ () => void 0
328
+ )
329
+ );
330
+ const outcome = await turn;
331
+ return {
332
+ result: outcome.result,
333
+ raw: {
334
+ reason: outcome.reason,
335
+ mx: outcome.mx,
336
+ code: outcome.code,
337
+ helo: this.opts.helo,
338
+ transcript: outcome.transcript
339
+ }
340
+ };
341
+ }
342
+ };
343
+
344
+ // src/main.ts
345
+ var port = Number(process.env.PROBE_PORT ?? 2525);
346
+ var token = process.env.PROBE_TOKEN ?? "";
347
+ var helo = process.env.PROBE_HELO ?? "";
348
+ if (!token || !helo) {
349
+ console.error("PROBE_TOKEN and PROBE_HELO are required");
350
+ process.exit(2);
351
+ }
352
+ var probe = new SmtpProbe({
353
+ helo,
354
+ perHostGapMs: Number(process.env.PROBE_HOST_GAP_MS ?? 1500)
355
+ });
356
+ var canaryHost = process.env.PROBE_CANARY_HOST ?? "gmail-smtp-in.l.google.com";
357
+ var CANARY_TTL_MS = 10 * 6e4;
358
+ var canaryOpenUntil = 0;
359
+ async function port25Open() {
360
+ if (Date.now() < canaryOpenUntil) return true;
361
+ try {
362
+ const c = await dialTcp(canaryHost, SMTP_PORT, 8e3);
363
+ try {
364
+ const banner = await c.read();
365
+ if (!banner.startsWith("220")) return false;
366
+ await c.write("QUIT");
367
+ } finally {
368
+ c.close();
369
+ }
370
+ canaryOpenUntil = Date.now() + CANARY_TTL_MS;
371
+ return true;
372
+ } catch {
373
+ return false;
374
+ }
375
+ }
376
+ var server = makeProbeServer({
377
+ probe,
378
+ token,
379
+ canary: port25Open,
380
+ log: (line) => console.log((/* @__PURE__ */ new Date()).toISOString(), line)
381
+ });
382
+ server.listen(port, () => console.log(`mailifier listening on :${port} as ${helo}`));
383
+ for (const sig of ["SIGINT", "SIGTERM"])
384
+ process.once(sig, () => server.close(() => process.exit(0)));
package/dist/main.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/main.js ADDED
@@ -0,0 +1,55 @@
1
+ #!/usr/bin/env node
2
+ import { makeProbeServer } from "./server.js";
3
+ /**
4
+ * Serve the probe on PROBE_PORT (2525). Env: PROBE_TOKEN (required), PROBE_HELO
5
+ * (required), PROBE_HOST_GAP_MS, PROBE_CANARY_HOST (an MX known to answer; default
6
+ * Google's inbound).
7
+ */
8
+ import { dialTcp, SMTP_PORT, SmtpProbe } from "./smtp.js";
9
+ const port = Number(process.env.PROBE_PORT ?? 2525);
10
+ const token = process.env.PROBE_TOKEN ?? "";
11
+ const helo = process.env.PROBE_HELO ?? "";
12
+ if (!token || !helo) {
13
+ console.error("PROBE_TOKEN and PROBE_HELO are required");
14
+ process.exit(2);
15
+ }
16
+ const probe = new SmtpProbe({
17
+ helo,
18
+ perHostGapMs: Number(process.env.PROBE_HOST_GAP_MS ?? 1500),
19
+ });
20
+ // One banner read from a well-known MX, remembered for ten minutes, says whether port
21
+ // 25 is open from here. Open once = trusted for the window; closed = checked again.
22
+ const canaryHost = process.env.PROBE_CANARY_HOST ?? "gmail-smtp-in.l.google.com";
23
+ const CANARY_TTL_MS = 10 * 60_000;
24
+ let canaryOpenUntil = 0;
25
+ async function port25Open() {
26
+ if (Date.now() < canaryOpenUntil)
27
+ return true;
28
+ try {
29
+ const c = await dialTcp(canaryHost, SMTP_PORT, 8_000);
30
+ try {
31
+ const banner = await c.read();
32
+ if (!banner.startsWith("220"))
33
+ return false;
34
+ await c.write("QUIT");
35
+ }
36
+ finally {
37
+ c.close();
38
+ }
39
+ canaryOpenUntil = Date.now() + CANARY_TTL_MS;
40
+ return true;
41
+ }
42
+ catch {
43
+ return false;
44
+ }
45
+ }
46
+ const server = makeProbeServer({
47
+ probe,
48
+ token,
49
+ canary: port25Open,
50
+ log: (line) => console.log(new Date().toISOString(), line),
51
+ });
52
+ server.listen(port, () => console.log(`mailifier listening on :${port} as ${helo}`));
53
+ for (const sig of ["SIGINT", "SIGTERM"])
54
+ process.once(sig, () => server.close(() => process.exit(0)));
55
+ //# sourceMappingURL=main.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"main.js","sourceRoot":"","sources":["../src/main.ts"],"names":[],"mappings":";AACA,OAAO,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC;AAC9C;;;;GAIG;AACH,OAAO,EAAE,OAAO,EAAE,SAAS,EAAE,SAAS,EAAE,MAAM,WAAW,CAAC;AAE1D,MAAM,IAAI,GAAG,MAAM,CAAC,OAAO,CAAC,GAAG,CAAC,UAAU,IAAI,IAAI,CAAC,CAAC;AACpD,MAAM,KAAK,GAAG,OAAO,CAAC,GAAG,CAAC,WAAW,IAAI,EAAE,CAAC;AAC5C,MAAM,IAAI,GAAG,OAAO,CAAC,GAAG,CAAC,UAAU,IAAI,EAAE,CAAC;AAC1C,IAAI,CAAC,KAAK,IAAI,CAAC,IAAI,EAAE,CAAC;IACpB,OAAO,CAAC,KAAK,CAAC,yCAAyC,CAAC,CAAC;IACzD,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;AAClB,CAAC;AAED,MAAM,KAAK,GAAG,IAAI,SAAS,CAAC;IAC1B,IAAI;IACJ,YAAY,EAAE,MAAM,CAAC,OAAO,CAAC,GAAG,CAAC,iBAAiB,IAAI,IAAI,CAAC;CAC5D,CAAC,CAAC;AACH,sFAAsF;AACtF,oFAAoF;AACpF,MAAM,UAAU,GAAG,OAAO,CAAC,GAAG,CAAC,iBAAiB,IAAI,4BAA4B,CAAC;AACjF,MAAM,aAAa,GAAG,EAAE,GAAG,MAAM,CAAC;AAClC,IAAI,eAAe,GAAG,CAAC,CAAC;AACxB,KAAK,UAAU,UAAU;IACvB,IAAI,IAAI,CAAC,GAAG,EAAE,GAAG,eAAe;QAAE,OAAO,IAAI,CAAC;IAC9C,IAAI,CAAC;QACH,MAAM,CAAC,GAAG,MAAM,OAAO,CAAC,UAAU,EAAE,SAAS,EAAE,KAAK,CAAC,CAAC;QACtD,IAAI,CAAC;YACH,MAAM,MAAM,GAAG,MAAM,CAAC,CAAC,IAAI,EAAE,CAAC;YAC9B,IAAI,CAAC,MAAM,CAAC,UAAU,CAAC,KAAK,CAAC;gBAAE,OAAO,KAAK,CAAC;YAC5C,MAAM,CAAC,CAAC,KAAK,CAAC,MAAM,CAAC,CAAC;QACxB,CAAC;gBAAS,CAAC;YACT,CAAC,CAAC,KAAK,EAAE,CAAC;QACZ,CAAC;QACD,eAAe,GAAG,IAAI,CAAC,GAAG,EAAE,GAAG,aAAa,CAAC;QAC7C,OAAO,IAAI,CAAC;IACd,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,KAAK,CAAC;IACf,CAAC;AACH,CAAC;AAED,MAAM,MAAM,GAAG,eAAe,CAAC;IAC7B,KAAK;IACL,KAAK;IACL,MAAM,EAAE,UAAU;IAClB,GAAG,EAAE,CAAC,IAAI,EAAE,EAAE,CAAC,OAAO,CAAC,GAAG,CAAC,IAAI,IAAI,EAAE,CAAC,WAAW,EAAE,EAAE,IAAI,CAAC;CAC3D,CAAC,CAAC;AACH,MAAM,CAAC,MAAM,CAAC,IAAI,EAAE,GAAG,EAAE,CAAC,OAAO,CAAC,GAAG,CAAC,2BAA2B,IAAI,OAAO,IAAI,EAAE,CAAC,CAAC,CAAC;AACrF,KAAK,MAAM,GAAG,IAAI,CAAC,QAAQ,EAAE,SAAS,CAAU;IAC9C,OAAO,CAAC,IAAI,CAAC,GAAG,EAAE,GAAG,EAAE,CAAC,MAAM,CAAC,KAAK,CAAC,GAAG,EAAE,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC"}
@@ -0,0 +1,22 @@
1
+ /**
2
+ * The probe as a service: `POST /verify {email}` → a verdict, run on a host that can
3
+ * open port 25. Bearer-authenticated; one process, one probe, so the per-MX gap holds
4
+ * across every caller. `GET /healthz` for the box's own checks. Nothing here sends
5
+ * mail: no DATA, no delivery, ever.
6
+ *
7
+ * The canary: before a verdict the server asks whether port 25 is open from here at
8
+ * all (most clouds close it until asked). Closed means 503, never a `risky` verdict
9
+ * per address: that is this host's problem, not the mailbox's.
10
+ */
11
+ import { type Server } from "node:http";
12
+ import type { MailboxProbe } from "./verdict.js";
13
+ export interface ProbeServerOptions {
14
+ probe: MailboxProbe;
15
+ token: string;
16
+ /** Calls at once across all MX hosts; the probe still serialises per host. */
17
+ maxInFlight?: number;
18
+ /** Can this host open port 25 right now? Absent = assume yes. */
19
+ canary?: () => Promise<boolean>;
20
+ log?: (line: string) => void;
21
+ }
22
+ export declare function makeProbeServer(opts: ProbeServerOptions): Server;
package/dist/server.js ADDED
@@ -0,0 +1,89 @@
1
+ /**
2
+ * The probe as a service: `POST /verify {email}` → a verdict, run on a host that can
3
+ * open port 25. Bearer-authenticated; one process, one probe, so the per-MX gap holds
4
+ * across every caller. `GET /healthz` for the box's own checks. Nothing here sends
5
+ * mail: no DATA, no delivery, ever.
6
+ *
7
+ * The canary: before a verdict the server asks whether port 25 is open from here at
8
+ * all (most clouds close it until asked). Closed means 503, never a `risky` verdict
9
+ * per address: that is this host's problem, not the mailbox's.
10
+ */
11
+ import { createServer } from "node:http";
12
+ import { emailSyntaxError } from "./address.js";
13
+ const MAX_BODY = 4_096;
14
+ export function makeProbeServer(opts) {
15
+ const maxInFlight = opts.maxInFlight ?? 8;
16
+ const log = opts.log ?? (() => { });
17
+ let inFlight = 0;
18
+ return createServer(async (req, res) => {
19
+ try {
20
+ if (req.method === "GET" && req.url === "/healthz") {
21
+ const port25 = opts.canary ? await opts.canary() : null;
22
+ return json(res, 200, { ok: true, in_flight: inFlight, port_25: port25 });
23
+ }
24
+ if (req.method !== "POST" || req.url !== "/verify")
25
+ return json(res, 404, { error: "no" });
26
+ const offered = (req.headers.authorization ?? "").replace(/^Bearer\s+/i, "");
27
+ if (!timingSafeEqual(offered, opts.token))
28
+ return json(res, 401, { error: "no" });
29
+ if (inFlight >= maxInFlight)
30
+ return json(res, 429, { error: "busy" });
31
+ const body = await readJson(req);
32
+ const email = typeof body.email === "string" ? body.email.trim() : "";
33
+ const syntax = email ? emailSyntaxError(email) : "missing email";
34
+ if (syntax)
35
+ return json(res, 400, { error: syntax });
36
+ if (opts.canary && !(await opts.canary())) {
37
+ log("port 25 closed from this host");
38
+ return json(res, 503, { error: "port 25 closed from this host" });
39
+ }
40
+ inFlight += 1;
41
+ try {
42
+ const t0 = Date.now();
43
+ const verdict = await opts.probe.verify(email);
44
+ log(`${verdict.result} ${String(verdict.raw.reason ?? "")} ${Date.now() - t0}ms`);
45
+ return json(res, 200, verdict);
46
+ }
47
+ finally {
48
+ inFlight -= 1;
49
+ }
50
+ }
51
+ catch (err) {
52
+ log(`error ${err instanceof Error ? err.name : "Error"}`);
53
+ return json(res, 500, { error: err instanceof Error ? err.name : "error" });
54
+ }
55
+ });
56
+ }
57
+ function json(res, status, payload) {
58
+ const text = JSON.stringify(payload);
59
+ res.writeHead(status, {
60
+ "content-type": "application/json",
61
+ "content-length": Buffer.byteLength(text),
62
+ });
63
+ res.end(text);
64
+ }
65
+ async function readJson(req) {
66
+ let text = "";
67
+ for await (const chunk of req) {
68
+ text += chunk;
69
+ if (text.length > MAX_BODY)
70
+ throw new Error("body too large");
71
+ }
72
+ try {
73
+ const parsed = JSON.parse(text || "{}");
74
+ return parsed && typeof parsed === "object" ? parsed : {};
75
+ }
76
+ catch {
77
+ return {};
78
+ }
79
+ }
80
+ /** Compare the whole string every time; no early exit on a wrong prefix. */
81
+ function timingSafeEqual(a, b) {
82
+ if (a.length !== b.length || b.length === 0)
83
+ return false;
84
+ let diff = 0;
85
+ for (let i = 0; i < a.length; i++)
86
+ diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
87
+ return diff === 0;
88
+ }
89
+ //# sourceMappingURL=server.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"server.js","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"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"}
package/dist/smtp.d.ts ADDED
@@ -0,0 +1,68 @@
1
+ import { type Resolver } from "./dns.js";
2
+ import type { MailboxProbe, MailboxResult, Verdict } from "./verdict.js";
3
+ export declare const SMTP_PORT = 25;
4
+ /** One line of SMTP conversation, kept for the verdict's `raw` (no addresses beyond ours). */
5
+ export interface Exchange {
6
+ sent: string | null;
7
+ code: number;
8
+ reply: string;
9
+ }
10
+ /** What the wire said, before it is read as a verdict. */
11
+ export interface ProbeOutcome {
12
+ result: MailboxResult;
13
+ /** Why, in one word: accepted, rejected, catch_all, greylisted, blocked, unreachable, no_mx. */
14
+ reason: string;
15
+ mx: string | null;
16
+ /** The RCPT reply for the address itself, when one was given. */
17
+ code: number | null;
18
+ transcript: Exchange[];
19
+ }
20
+ /** A connected line-oriented conversation; the real one is a TCP socket, tests hand in a script. */
21
+ export interface Conversation {
22
+ /** Next reply (a full, possibly multi-line SMTP response). */
23
+ read(): Promise<string>;
24
+ write(line: string): Promise<void>;
25
+ close(): void;
26
+ }
27
+ export type Dialer = (host: string, port: number, timeoutMs: number) => Promise<Conversation>;
28
+ export interface SmtpProbeOptions {
29
+ /** Our HELO name; forward and reverse DNS should agree on it. */
30
+ helo: string;
31
+ /** MAIL FROM address, defaults to postmaster@helo. */
32
+ mailFrom?: string;
33
+ timeoutMs?: number;
34
+ dial?: Dialer;
35
+ resolver?: Resolver;
36
+ random?: () => string;
37
+ }
38
+ /**
39
+ * MX hosts in priority order (lowest number first), falling back to the domain's own A
40
+ * record when there is none, as mail does. Empty = nothing to connect to.
41
+ */
42
+ export declare function mailHosts(domain: string, resolver: Resolver): Promise<string[]>;
43
+ /**
44
+ * Probe one address: the first MX that will talk decides. An MX that refuses us
45
+ * before RCPT (policy 5xx at EHLO/MAIL FROM) or cannot be reached is skipped for the
46
+ * next; when every one does, the verdict is risky, with the reason.
47
+ */
48
+ export declare function probeMailbox(email: string, opts: SmtpProbeOptions): Promise<ProbeOutcome>;
49
+ /** The real thing: a TCP socket read line by line, multi-line replies joined. */
50
+ export declare const dialTcp: Dialer;
51
+ export interface SmtpProbeSettings extends SmtpProbeOptions {
52
+ /** Least time between two probes at the same MX; ours is a guest there. */
53
+ perHostGapMs?: number;
54
+ sleep?: (ms: number) => Promise<void>;
55
+ }
56
+ /**
57
+ * The probe over `probeMailbox`, serialising per MX host with a gap between them: one
58
+ * conversation at a time with any given server, because we are a guest there.
59
+ */
60
+ export declare class SmtpProbe implements MailboxProbe {
61
+ private readonly opts;
62
+ readonly name = "smtp";
63
+ private readonly lastByHost;
64
+ private readonly gapMs;
65
+ private readonly sleep;
66
+ constructor(opts: SmtpProbeSettings);
67
+ verify(email: string): Promise<Verdict>;
68
+ }