email-validator-mcp 1.0.2 → 1.0.3

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 CHANGED
@@ -7,8 +7,15 @@ This file is self contained. It reads public data only and never writes to the m
7
7
  ## Tools
8
8
 
9
9
 
10
- * `validate_email` Validate a single email address.
11
- * `validate_batch` Validate multiple addresses.
10
+ * `validate_email` Check one email address: format, live MX records, disposable-domain status, role account (info@, support@) and whether it is a free provider.
11
+ * `validate_batch` Validate up to 25 addresses at once and get a count of well-formed, malformed and risky ones.
12
+ * `check_domain` Domain-level mail check: MX records, A records, and whether the domain is a disposable or free provider.
13
+ * `normalize_email` Canonical form of an address: lowercase the domain, drop the +tag, and remove Gmail dots, so duplicates collapse to one record.
14
+ * `extract_emails` Pull every unique email address out of a block of text, flagging any disposable domains.
15
+ * `disposable_domains` List the known disposable email domains this server checks against.
16
+ * `email_auth_records` The full anti-spoofing picture for a domain: MX hosts, SPF with its all-rule, DMARC policy and DKIM selectors.
17
+ * `mx_health` Every MX host for a domain, whether it resolves to an A record, and its reverse DNS.
18
+ * `correct_typo` Suggests the mail provider a mistyped domain probably meant, e.g. gmial.com -> gmail.com, without contacting the domain.
12
19
 
13
20
  ## Usage
14
21
 
@@ -19,3 +26,22 @@ node dist/index.js
19
26
  ```
20
27
 
21
28
  MX records are resolved through the DNS of the sending machine. A missing MX record does not guarantee an inbox does not exist, the result is honest about that.
29
+
30
+ <!-- paywall -->
31
+ ## Premium tools
32
+
33
+ These tools need a license key:
34
+
35
+ * `smtp_mailbox_check`
36
+ * `spf_evaluate`
37
+
38
+ Buy a key at https://mcp-marketplace.io/server/io-github-mrfentmen-email-validator-mcp and set it in your MCP client config:
39
+
40
+ ```json
41
+ "env": { "MCP_LICENSE_KEY": "mcp_live_..." }
42
+ ```
43
+
44
+ The key is checked against MCP Marketplace, cached for 24 hours, and keeps
45
+ working offline once one check has succeeded. Every other tool on this server
46
+ stays free.
47
+ <!-- /paywall -->
package/dist/api.js CHANGED
@@ -1,38 +1,80 @@
1
1
  import { promises as dns } from "node:dns";
2
2
  export class EmailError extends Error {
3
3
  }
4
- const DISPOSABLE = new Set([
4
+ /**
5
+ * Throwaway-mail domains seen in signup fraud. Kept small on purpose so a
6
+ * false positive is rare; the list is easy to extend.
7
+ */
8
+ export const DISPOSABLE = new Set([
5
9
  "mailinator.com", "10minutemail.com", "guerrillamail.com", "sharklasers.com",
6
10
  "yopmail.com", "tempmail.com", "throwawaymail.com", "maildrop.cc", "temp-mail.org",
7
11
  "getnada.com", "dispostable.com", "mohmal.com", "emailondeck.com", "burnermail.io",
8
12
  "trashmail.com", "spam4.me", "fakeinbox.com", "mailnesia.com", "mintemail.com",
13
+ "mailcatch.com", "mailnull.com", "spamgourmet.com", "tempr.email", "discard.email",
14
+ "33mail.com", "mytemp.email", "tempinbox.com", "email-temp.com",
9
15
  ]);
16
+ /** Shared/role mailboxes — deliverable, but usually not a real person. */
17
+ export const ROLE_LOCALPARTS = new Set([
18
+ "info", "support", "sales", "admin", "administrator", "help", "contact", "billing",
19
+ "accounts", "accounting", "hr", "jobs", "careers", "marketing", "press", "media",
20
+ "noreply", "no-reply", "donotreply", "postmaster", "webmaster", "abuse", "security",
21
+ "hello", "team", "office", "enquiries", "inquiries", "service", "customerservice",
22
+ ]);
23
+ const FREE_PROVIDERS = new Set([
24
+ "gmail.com", "googlemail.com", "yahoo.com", "yahoo.co.uk", "outlook.com", "hotmail.com",
25
+ "live.com", "msn.com", "aol.com", "icloud.com", "me.com", "mac.com", "proton.me",
26
+ "protonmail.com", "gmx.com", "gmx.de", "yandex.com", "zoho.com", "mail.com", "qq.com",
27
+ "163.com", "126.com", "web.de", "fastmail.com", "tutanota.com", "hey.com",
28
+ ]);
29
+ const LOCAL_RE = /^[A-Za-z0-9!#$%&'*+/=?^_`{|}~.-]+$/;
30
+ const DOMAIN_RE = /^(?=.{1,253}$)([A-Za-z0-9](-*[A-Za-z0-9])*\.)+[A-Za-z]{2,}$/;
10
31
  async function checkOne(email) {
11
- const parts = email.split("@");
12
- const result = {
13
- email,
14
- format: parts.length !== 2 || !parts[0] || !/^[^\s@]+$/.test(parts[0]) ? "invalid" : "valid",
32
+ const trimmed = email.trim();
33
+ const at = trimmed.lastIndexOf("@");
34
+ const localpart = at > 0 ? trimmed.slice(0, at) : "";
35
+ const domain = (at > 0 ? trimmed.slice(at + 1) : "").toLowerCase();
36
+ const base = {
37
+ email: trimmed,
38
+ format: at > 0 && localpart && LOCAL_RE.test(localpart) && !localpart.startsWith(".") &&
39
+ !localpart.endsWith(".") && !localpart.includes("..") && DOMAIN_RE.test(domain)
40
+ ? "valid"
41
+ : "invalid",
42
+ domain,
43
+ localpart,
15
44
  mx_hosts: [],
16
- disposable: DISPOSABLE.has((parts[1] ?? "").toLowerCase()),
45
+ disposable: DISPOSABLE.has(domain),
46
+ free_provider: FREE_PROVIDERS.has(domain),
47
+ role_account: ROLE_LOCALPARTS.has(localpart.toLowerCase()),
48
+ plus_tag: localpart.includes("+") ? localpart.slice(localpart.indexOf("+") + 1) : null,
17
49
  deliverable: "unknown",
18
50
  };
19
- if (result.format === "invalid") {
20
- result.deliverable = "not checked";
21
- return result;
51
+ if (base.format === "invalid") {
52
+ base.deliverable = "not checked";
53
+ return base;
22
54
  }
23
55
  try {
24
- const hosts = await dns.resolveMx(parts[1]);
25
- result.mx_hosts = hosts.sort((a, b) => a.priority - b.priority).map((h) => h.exchange);
26
- result.deliverable = result.mx_hosts.length > 0 ? "likely" : "no MX record";
56
+ const hosts = await dns.resolveMx(domain);
57
+ base.mx_hosts = hosts.sort((a, b) => a.priority - b.priority).map((h) => h.exchange);
58
+ base.deliverable = base.mx_hosts.length > 0 ? "likely" : "no MX record";
27
59
  }
28
60
  catch {
29
- result.deliverable = "no MX record or domain not found";
61
+ base.deliverable = "no MX record or domain not found";
30
62
  }
31
- return result;
63
+ return base;
32
64
  }
33
65
  function fmt(r) {
34
- const flags = [r.format === "invalid" ? "INVALID FORMAT" : null, r.disposable ? "DISPOSABLE" : null].filter(Boolean);
35
- return `${r.email}\n Format: ${r.format}\n MX: ${r.mx_hosts.slice(0, 5).join(", ") || "none"}\n Deliverable: ${r.deliverable}${flags.length ? ` | ${flags.join(" | ")}` : ""}`;
66
+ const flags = [
67
+ r.format === "invalid" ? "INVALID FORMAT" : null,
68
+ r.disposable ? "DISPOSABLE" : null,
69
+ r.role_account ? "ROLE ACCOUNT" : null,
70
+ r.free_provider ? "free provider" : null,
71
+ ].filter(Boolean);
72
+ return (`${r.email}\n` +
73
+ ` Format: ${r.format}\n` +
74
+ ` Domain: ${r.domain}\n` +
75
+ ` MX: ${r.mx_hosts.slice(0, 5).join(", ") || "none"}\n` +
76
+ ` Deliverable: ${r.deliverable}${flags.length ? ` | ${flags.join(" | ")}` : ""}` +
77
+ (r.plus_tag ? `\n Plus tag: +${r.plus_tag}` : ""));
36
78
  }
37
79
  export async function validateEmail(args) {
38
80
  const email = (args.email ?? "").trim();
@@ -41,9 +83,756 @@ export async function validateEmail(args) {
41
83
  return fmt(await checkOne(email));
42
84
  }
43
85
  export async function validateBatch(args) {
44
- const list = (args.emails ?? "").split(",").map((e) => e.trim()).filter(Boolean);
86
+ const list = (args.emails ?? "").split(/[,\s]+/).map((e) => e.trim()).filter(Boolean);
45
87
  if (list.length === 0)
46
88
  throw new EmailError("Provide at least one email address");
47
89
  const results = await Promise.all(list.slice(0, 25).map(checkOne));
48
- return results.map(fmt).join("\n\n");
90
+ const valid = results.filter((r) => r.format === "valid").length;
91
+ const risky = results.filter((r) => r.disposable || r.role_account).length;
92
+ return (`${results.length} address(es): ${valid} well-formed, ${results.length - valid} malformed, ${risky} flagged (disposable or role).\n\n` +
93
+ results.map(fmt).join("\n\n"));
94
+ }
95
+ /** Domain-level check: MX plus A record and disposable status, no address needed. */
96
+ export async function checkDomain(args) {
97
+ const domain = (args.domain ?? "").trim().toLowerCase().replace(/^@/, "");
98
+ if (!domain)
99
+ throw new EmailError("Provide a domain, e.g. 'example.com'");
100
+ if (!DOMAIN_RE.test(domain))
101
+ throw new EmailError(`"${domain}" is not a valid domain name`);
102
+ const [mx, a] = await Promise.all([
103
+ dns.resolveMx(domain).catch(() => []),
104
+ dns.resolve4(domain).catch(() => []),
105
+ ]);
106
+ const sorted = mx.sort((x, y) => x.priority - y.priority);
107
+ const lines = [
108
+ `Domain: ${domain}`,
109
+ `Accepts mail: ${sorted.length > 0 ? "yes" : "NO — no MX record, addresses here will bounce"}`,
110
+ `MX records (${sorted.length}):`,
111
+ ...(sorted.length ? sorted.map((m) => ` ${m.priority} ${m.exchange}`) : [" none"]),
112
+ `A records (${a.length}): ${a.slice(0, 4).join(", ") || "none"}`,
113
+ `Disposable provider: ${DISPOSABLE.has(domain) ? "YES" : "no"}`,
114
+ `Free mail provider: ${FREE_PROVIDERS.has(domain) ? "yes" : "no"}`,
115
+ ];
116
+ return lines.join("\n");
117
+ }
118
+ /** Canonical form of an address, plus the variants providers will also accept. */
119
+ export function normalizeEmail(args) {
120
+ const raw = (args.email ?? "").trim();
121
+ if (!raw)
122
+ throw new EmailError("Provide an email address");
123
+ const at = raw.lastIndexOf("@");
124
+ if (at <= 0)
125
+ throw new EmailError(`"${raw}" has no @, so it cannot be normalised`);
126
+ const local = raw.slice(0, at);
127
+ const domain = raw.slice(at + 1).toLowerCase();
128
+ const withoutTag = local.split("+")[0];
129
+ // Gmail and Googlemail ignore dots in the local part.
130
+ const isGoogle = domain === "gmail.com" || domain === "googlemail.com";
131
+ const canonicalLocal = isGoogle ? withoutTag.replace(/\./g, "") : withoutTag;
132
+ const canonical = `${canonicalLocal}@${domain === "googlemail.com" ? "gmail.com" : domain}`;
133
+ const variants = new Set([raw.toLowerCase(), `${local.toLowerCase()}@${domain}`, canonical]);
134
+ return [
135
+ `Input: ${raw}`,
136
+ `Normalised: ${canonical}`,
137
+ `Local part: ${local}${args.email?.includes("+") ? " (has a plus tag)" : ""}`,
138
+ `Domain: ${domain}`,
139
+ "",
140
+ "Equivalent forms:",
141
+ ...[...variants].map((v) => ` ${v}`),
142
+ ].join("\n");
143
+ }
144
+ /** Pull every email address out of a blob of text. */
145
+ export function extractEmails(args) {
146
+ const body = args.text ?? "";
147
+ if (!body.trim())
148
+ throw new EmailError("Provide some text to scan");
149
+ const found = body.match(/[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g) ?? [];
150
+ const unique = [...new Set(found.map((e) => e.replace(/[.,;:]+$/, "")))];
151
+ if (!unique.length)
152
+ return "No email addresses found in that text.";
153
+ const junk = unique.filter((e) => DISPOSABLE.has(e.split("@")[1]?.toLowerCase() ?? ""));
154
+ return (`Found ${unique.length} unique address(es):\n` +
155
+ unique.map((e) => ` ${e}`).join("\n") +
156
+ (junk.length ? `\n\nFlagged as disposable (${junk.length}): ${junk.join(", ")}` : ""));
157
+ }
158
+ /** The disposable-domain list this server checks against. */
159
+ export function disposableDomains(args) {
160
+ const limit = Math.min(Math.max(Number(args.limit) || 40, 1), DISPOSABLE.size);
161
+ const all = [...DISPOSABLE].sort();
162
+ return (`${DISPOSABLE.size} known disposable domains, showing ${Math.min(limit, all.length)}:\n` +
163
+ all.slice(0, limit).map((d, i) => `${i + 1}. ${d}`).join("\n"));
164
+ }
165
+ // ---- batch-4 tools: auth records, MX health, typo help (2026-09-27) ----
166
+ const DKIM_SELECTORS = ["default", "google", "selector1", "selector2", "k1", "mail", "dkim"];
167
+ const txtOf = (name) => dns.resolveTxt(name).then((rows) => rows.map((parts) => parts.join(""))).catch(() => []);
168
+ /** The full anti-spoofing picture: MX hosts, SPF, DMARC policy and DKIM selectors. */
169
+ export async function emailAuthRecords(args) {
170
+ const domain = (args.domain ?? "").trim().toLowerCase().replace(/^@/, "");
171
+ if (!domain)
172
+ throw new EmailError("Provide a domain, e.g. 'example.com'");
173
+ if (!DOMAIN_RE.test(domain))
174
+ throw new EmailError(`"${domain}" is not a valid domain name`);
175
+ const [root, dmarc, mx] = await Promise.all([
176
+ txtOf(domain),
177
+ txtOf(`_dmarc.${domain}`),
178
+ dns.resolveMx(domain).catch(() => []),
179
+ ]);
180
+ const spf = root.find((t) => t.toLowerCase().startsWith("v=spf1"));
181
+ const dmarcRecord = dmarc.find((t) => t.toLowerCase().startsWith("v=dmarc1"));
182
+ const dkim = [];
183
+ for (const selector of DKIM_SELECTORS) {
184
+ const rows = await txtOf(`${selector}._domainkey.${domain}`);
185
+ if (rows.some((r) => /v=dkim1|k=rsa|p=/i.test(r)))
186
+ dkim.push(selector);
187
+ }
188
+ const policy = dmarcRecord?.match(/p=(none|quarantine|reject)/i)?.[1] ?? null;
189
+ const allRule = spf?.match(/([-~?])all/)?.[1] ?? null;
190
+ const allNote = allRule === "-" ? " - hard fail, unauthorised senders are rejected" :
191
+ allRule === "~" ? " - soft fail, unauthorised senders are marked" :
192
+ allRule === "?" ? " - neutral, unauthorised senders are allowed" : "";
193
+ return [
194
+ `Mail authentication for ${domain}`,
195
+ `MX hosts (${mx.length}): ${mx.length ? mx.sort((a, b) => a.priority - b.priority).map((m) => m.exchange).join(", ") : "none - the domain cannot receive mail"}`,
196
+ `SPF: ${spf ?? "missing"}${allRule ? ` (all rule "${allRule}all"${allNote})` : ""}`,
197
+ `DMARC: ${dmarcRecord ?? "missing"}${policy ? ` (policy ${policy.toUpperCase()})` : ""}`,
198
+ `DKIM selectors found: ${dkim.length ? dkim.join(", ") : `none at ${DKIM_SELECTORS.join(", ")}`}`,
199
+ "",
200
+ spf && dmarcRecord && policy && policy !== "none" && dkim.length
201
+ ? "SPF, DMARC and DKIM are all in place."
202
+ : "Gaps above are what let spoofed mail through; fix them at the DNS host.",
203
+ ].join("\n");
204
+ }
205
+ /** Every MX host, whether it resolves, and its reverse DNS. */
206
+ export async function mxHealth(args) {
207
+ const domain = (args.domain ?? "").trim().toLowerCase().replace(/^@/, "");
208
+ if (!domain)
209
+ throw new EmailError("Provide a domain, e.g. 'example.com'");
210
+ if (!DOMAIN_RE.test(domain))
211
+ throw new EmailError(`"${domain}" is not a valid domain name`);
212
+ const mx = await dns.resolveMx(domain).catch(() => []);
213
+ if (!mx.length)
214
+ return `${domain} has no MX record, so it cannot receive mail. Add one at the DNS host.`;
215
+ const rows = [];
216
+ let unreachable = 0;
217
+ for (const record of mx.sort((a, b) => a.priority - b.priority).slice(0, 10)) {
218
+ const host = record.exchange.replace(/\.$/, "");
219
+ const [addresses, ptr] = await Promise.all([
220
+ dns.resolve4(host).catch(() => []),
221
+ dns.reverse(host).catch(() => []),
222
+ ]);
223
+ if (!addresses.length)
224
+ unreachable += 1;
225
+ rows.push(`${String(record.priority).padStart(3)} ${host} | ${addresses.length ? `A ${addresses.slice(0, 2).join(", ")}` : "NO A record - mail routed here will fail"}${ptr.length ? ` | PTR ${ptr[0]}` : ""}`);
226
+ }
227
+ return [
228
+ `MX health for ${domain}: ${mx.length} host(s), ${unreachable ? `${unreachable} without an A record` : "all resolve"}`,
229
+ "",
230
+ ...rows,
231
+ "",
232
+ unreachable ? "Fix the hosts that do not resolve before mail starts bouncing." : "Every MX host resolves, so mail has a route in.",
233
+ ].join("\n");
234
+ }
235
+ const KNOWN_DOMAINS = [...new Set([...FREE_PROVIDERS, "zoho.com", "yandex.com", "gmx.de", "web.de", "fastmail.com", "hey.com", "qq.com", "163.com"])];
236
+ const editDistance = (a, b) => {
237
+ const dp = Array.from({ length: a.length + 1 }, (_, i) => [i, ...Array(b.length).fill(0)]);
238
+ for (let j = 0; j <= b.length; j += 1)
239
+ dp[0][j] = j;
240
+ for (let i = 1; i <= a.length; i += 1) {
241
+ for (let j = 1; j <= b.length; j += 1) {
242
+ dp[i][j] = Math.min(dp[i - 1][j] + 1, dp[i][j - 1] + 1, dp[i - 1][j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
243
+ }
244
+ }
245
+ return dp[a.length][b.length];
246
+ };
247
+ /** Suggests the provider a mistyped domain was probably meant to be. Nothing is sent anywhere. */
248
+ export function correctTypo(args) {
249
+ const raw = (args.email ?? "").trim();
250
+ if (!raw)
251
+ throw new EmailError("Provide an email address to check");
252
+ const at = raw.lastIndexOf("@");
253
+ if (at <= 0)
254
+ throw new EmailError(`"${raw}" has no @, so there is nothing to correct`);
255
+ const local = raw.slice(0, at);
256
+ const domain = raw.slice(at + 1).toLowerCase();
257
+ if (KNOWN_DOMAINS.includes(domain)) {
258
+ return `${raw} uses ${domain}, a known mail provider - no correction suggested.`;
259
+ }
260
+ const best = KNOWN_DOMAINS
261
+ .map((candidate) => ({ candidate, distance: editDistance(domain, candidate) }))
262
+ .sort((a, b) => a.distance - b.distance)[0];
263
+ if (!best || best.distance > 2) {
264
+ return `No close match for the domain "${domain}" - it may be a real domain. Run check_domain to test whether it accepts mail.`;
265
+ }
266
+ return [
267
+ `Possible typo in "${domain}"`,
268
+ `Closest known provider: ${best.candidate} (edit distance ${best.distance})`,
269
+ `Suggested address: ${local}@${best.candidate}`,
270
+ "",
271
+ "Suggestion only - the domain was not contacted.",
272
+ ].join("\n");
273
+ }
274
+ // ---- muxD tools:
275
+ import { promises as dnsPromises } from "node:dns";
276
+ import { createConnection } from "node:net";
277
+ const EDGE_LOCALPARTS = new Set(["postmaster", "abuse", "hostmaster", "webmaster", "noc", "security", "hostmaster", "postmaster"]);
278
+ const GIT_HOSTING = new Set(["github.com", "gitlab.com", "bitbucket.org", "codeberg.org", "gitea.com", "sourcehut.org"]);
279
+ function smtpSession(host, port, helo, rcptTo, budgetMs) {
280
+ return new Promise((resolve) => {
281
+ let done = false;
282
+ const finish = (r) => {
283
+ if (done)
284
+ return;
285
+ done = true;
286
+ try {
287
+ socket.end();
288
+ }
289
+ catch { /* already gone */ }
290
+ try {
291
+ socket.destroy();
292
+ }
293
+ catch { /* already gone */ }
294
+ resolve(r);
295
+ };
296
+ const deadline = setTimeout(() => finish({ kind: "unreachable", code: 0, text: `no reply within ${Math.round(budgetMs / 1000)}s` }), budgetMs);
297
+ const socket = createConnection({ host, port });
298
+ let stage = "greeting";
299
+ let buf = "";
300
+ let heloTried = false;
301
+ socket.setTimeout(budgetMs);
302
+ socket.on("error", (e) => {
303
+ clearTimeout(deadline);
304
+ const reason = e.code === "ECONNREFUSED" ? "connection refused, which usually means outbound port 25 is blocked on this network or the host is not listening on 25"
305
+ : e.code === "ETIMEDOUT" || e.code === "EHOSTUNREACH" ? "timed out reaching the host"
306
+ : e.code === "ECONNRESET" ? "the server reset the connection, which is a common anti-probe measure"
307
+ : e.message;
308
+ finish({ kind: "unreachable", code: 0, text: reason });
309
+ });
310
+ socket.on("timeout", () => {
311
+ clearTimeout(deadline);
312
+ finish({ kind: "unreachable", code: 0, text: "idle timeout" });
313
+ });
314
+ socket.on("data", (chunk) => {
315
+ buf += chunk.toString("utf8");
316
+ // SMTP replies are multiline; only the final "NNN " line ends one.
317
+ let idx;
318
+ while ((idx = buf.search(/^\d{3} /m)) !== -1) {
319
+ // A line with a space after the code is the last line of a reply, so the
320
+ // match is always final. Read the text from that line itself, otherwise
321
+ // single-line replies (the common case for RCPT errors) lose the reason.
322
+ const finalLine = buf.slice(idx).split(/\r?\n/)[0];
323
+ const code = Number(finalLine.slice(0, 3));
324
+ const text = finalLine.slice(4).trim();
325
+ buf = buf.slice(idx + finalLine.length);
326
+ if (stage === "greeting") {
327
+ if (code !== 220)
328
+ return finish({ kind: "neutral", code, text });
329
+ socket.write(`EHLO ${helo}\r\n`);
330
+ stage = "ehlo";
331
+ }
332
+ else if (stage === "ehlo") {
333
+ // Some old servers speak no ESMTP at all; HELO is the fallback.
334
+ if (code !== 250 && !heloTried) {
335
+ heloTried = true;
336
+ socket.write(`HELO ${helo}\r\n`);
337
+ }
338
+ else if (code === 250) {
339
+ // A null sender keeps the probe from being treated as a real message
340
+ // source, which many servers reject outright.
341
+ socket.write("MAIL FROM:<>\r\n");
342
+ stage = "mail";
343
+ }
344
+ else {
345
+ finish({ kind: "neutral", code, text });
346
+ }
347
+ }
348
+ else if (stage === "mail") {
349
+ if (code >= 200 && code < 300) {
350
+ socket.write(`RCPT TO:<${rcptTo}>\r\n`);
351
+ stage = "rcpt";
352
+ }
353
+ else {
354
+ finish({ kind: "neutral", code, text });
355
+ }
356
+ }
357
+ else {
358
+ clearTimeout(deadline);
359
+ if (code >= 200 && code < 300)
360
+ finish({ kind: "exists", code, text });
361
+ else if (code >= 400 && code < 500)
362
+ finish({ kind: "defer", code, text });
363
+ else if (code >= 500 && code < 600)
364
+ finish({ kind: "absent", code, text });
365
+ else
366
+ finish({ kind: "neutral", code, text });
367
+ }
368
+ }
369
+ });
370
+ socket.on("close", () => {
371
+ clearTimeout(deadline);
372
+ if (!done)
373
+ finish({ kind: "unreachable", code: 0, text: "the server closed the connection before answering" });
374
+ });
375
+ });
376
+ }
377
+ const randTag = () => Math.random().toString(36).slice(2, 12);
378
+ /** Asks the receiving server whether one address is real, and whether the domain answers yes to everything. */
379
+ export async function smtpMailboxCheck(args) {
380
+ const email = String(args.email ?? "").trim().toLowerCase();
381
+ if (!email)
382
+ throw new EmailError("Provide an email address, e.g. 'someone@example.com'");
383
+ const at = email.lastIndexOf("@");
384
+ if (at <= 0 || at === email.length - 1)
385
+ throw new EmailError(`"${email}" is not an address, so there is nothing to probe`);
386
+ const domain = email.slice(at + 1);
387
+ if (!DOMAIN_RE.test(domain))
388
+ throw new EmailError(`"${domain}" is not a valid domain name`);
389
+ const budget = Math.min(Math.max(Math.floor(args.timeout_ms ?? 8000) || 8000, 2000), 20000);
390
+ const mx = await dnsPromises.resolveMx(domain).catch(() => []);
391
+ if (!mx.length) {
392
+ return [
393
+ `SMTP check for ${email}`,
394
+ "",
395
+ `${domain} publishes no MX record, so nothing accepts mail for it and the address cannot be probed.`,
396
+ "",
397
+ "This is a domain-level failure, not a statement about the mailbox. If mail to this domain bounces with 'no such user' that is misleading: the domain has nowhere to deliver at all.",
398
+ ].join("\n");
399
+ }
400
+ const hosts = mx.sort((a, b) => a.priority - b.priority).map((m) => m.exchange.replace(/\.$/, ""));
401
+ const helo = domain;
402
+ const probeOne = async (addr) => {
403
+ for (const host of hosts) {
404
+ const r = await smtpSession(host, 25, helo, addr, budget);
405
+ if (r.kind !== "unreachable")
406
+ return { host, r };
407
+ }
408
+ // Every host was unreachable; report the last reason so the cause is visible.
409
+ const last = await smtpSession(hosts[0], 25, helo, addr, budget);
410
+ return { host: hosts[0], r: last };
411
+ };
412
+ const target = await probeOne(email);
413
+ const lines = [`SMTP mailbox check for ${email}`, `MX hosts tried (in priority order): ${hosts.join(", ")}`, ""];
414
+ const describe = (r) => {
415
+ switch (r.kind) {
416
+ case "exists": return `mailbox accepted (${r.code})`;
417
+ case "absent": return `mailbox refused, no such user (${r.code})`;
418
+ case "defer": return `temporarily deferred (${r.code})`;
419
+ case "neutral": return `the server would not answer (${r.code})`;
420
+ case "unreachable": return `could not be probed: ${r.text}`;
421
+ }
422
+ };
423
+ if (target.r.kind === "unreachable" || target.r.kind === "neutral") {
424
+ lines.push(`Result: ${describe(target.r)}`, "", "This is NOT evidence that the address does not exist. The check could not be completed, and reporting it as an invalid address would be a false negative.", "", target.r.kind === "unreachable"
425
+ ? "Outbound port 25 is blocked by most consumer networks and by most cloud providers, so this fails on locked-down hosts. Running it from a machine with port 25 open, or behind a provider that offers an SMTP-verification API, is what makes this work."
426
+ : "The server answered but would not run the transaction, which usually means it requires a client that authenticates, or it blocks verification probes deliberately.");
427
+ return lines.join("\n");
428
+ }
429
+ if (target.r.kind === "defer") {
430
+ lines.push(`Result: ${describe(target.r)} via ${target.host}`, "", "A 4xx is a soft failure: the server is asking to be retried, not claiming the address is bad. Greylisting and rate limits produce this on the first probe and 250 on the second, so treat it as unknown rather than as either a pass or a fail.");
431
+ return lines.join("\n");
432
+ }
433
+ let catchAll = null;
434
+ let catchAddr = "";
435
+ if (args.check_catch_all !== false) {
436
+ catchAddr = `${randTag()}.${randTag()}@${domain}`;
437
+ catchAll = (await probeOne(catchAddr)).r;
438
+ }
439
+ if (target.r.kind === "absent") {
440
+ lines.push(`Result: ${describe(target.r)} via ${target.host}`, "", "The receiving server itself rejected the recipient, so the mailbox does not exist at this domain. This is a real negative, which makes it one of the few reliable do-not-send signals available.");
441
+ if (catchAll?.kind === "absent") {
442
+ lines.push("", `The domain also rejected ${catchAddr}, an address that cannot exist, so it is not accepting everything and this negative is trustworthy.`);
443
+ }
444
+ else if (catchAll?.kind === "exists") {
445
+ lines.push("", `The domain also accepted ${catchAddr}, an address that cannot exist, so it is catch-all and even this rejection carries less weight than it would otherwise.`);
446
+ }
447
+ else if (catchAll?.kind === "defer") {
448
+ lines.push("", `The catch-all probe was deferred (${catchAll.code}), so the domain's behaviour towards unknown addresses is not established.`);
449
+ }
450
+ else if (catchAll) {
451
+ lines.push("", "However this domain accepted a random address that cannot exist, so it is catch-all. Servers that accept everything are often honeypots that exist to catch spam traps, and a 250 here is not evidence of a real mailbox.");
452
+ }
453
+ return lines.join("\n");
454
+ }
455
+ // exists
456
+ if (catchAll?.kind === "exists") {
457
+ lines.push(`Result: mailbox accepted (250) via ${target.host}, but the domain is catch-all`, "", "A random address that cannot possibly exist was also accepted, so this server returns success for everything. The 250 for the real address therefore carries no information: it will look identical for an address you invented.", "", "Treat this as unusable for verification. Greylisted catch-all setups behave this way.");
458
+ return lines.join("\n");
459
+ }
460
+ lines.push(`Result: ${describe(target.r)} via ${target.host}`, catchAll ? `Catch-all probe: the random address was refused, so this domain does not accept everything` : "", "", "The server accepted the recipient and refused a random one, which is the strongest positive an SMTP check can give.", "", "This still does not prove the mailbox reads its mail. Role addresses and some providers' aliases are accepted without a person behind them, and a mailbox that was deactivated can be accepted for a while. It is a delivery-path check, not proof of a human inbox.");
461
+ return lines.filter((l) => l !== "").join("\n");
462
+ }
463
+ const isIpv4 = (s) => /^\d{1,3}(\.\d{1,3}){3}$/.test(s);
464
+ const toIpv4 = (s) => s.split(".").map((n) => Number(n));
465
+ /** CIDR containment for IPv4; ranges are the only form SPF uses for ip4. */
466
+ function ip4InCidr(ip, cidr) {
467
+ const [rangeRaw, bitsRaw] = cidr.split("/");
468
+ if (!isIpv4(ip) || !isIpv4(rangeRaw))
469
+ return false;
470
+ const bits = bitsRaw === undefined ? 32 : Number(bitsRaw);
471
+ if (!Number.isFinite(bits) || bits < 0 || bits > 32)
472
+ return false;
473
+ const a = toIpv4(ip);
474
+ const b = toIpv4(rangeRaw);
475
+ for (let i = 0; i < 4; i += 1) {
476
+ const remaining = bits - i * 8;
477
+ if (remaining <= 0)
478
+ return true;
479
+ const mask = remaining >= 8 ? 255 : (0xff << (8 - remaining)) & 0xff;
480
+ if ((a[i] & mask) !== (b[i] & mask))
481
+ return false;
482
+ }
483
+ return true;
484
+ }
485
+ /** Expands ipv6 loosely: enough to compare when both sides are written in the same form. */
486
+ function ip6Expand(addr) {
487
+ let s = addr.toLowerCase().split("%")[0];
488
+ if (!s.includes(":"))
489
+ return null;
490
+ if (s.includes(":::"))
491
+ return null;
492
+ const doubleIdx = s.indexOf("::");
493
+ if (doubleIdx !== -1 && s.indexOf("::", doubleIdx + 1) !== -1)
494
+ return null;
495
+ const [head, tail] = doubleIdx === -1 ? [s, ""] : [s.slice(0, doubleIdx), s.slice(doubleIdx + 2)];
496
+ const headParts = head ? head.split(":") : [];
497
+ const tailParts = tail ? tail.split(":") : [];
498
+ const gap = 8 - headParts.length - tailParts.length;
499
+ if (doubleIdx === -1 && headParts.length !== 8)
500
+ return null;
501
+ if (doubleIdx !== -1 && gap < 1)
502
+ return null;
503
+ const parts = [...headParts, ...Array(gap).fill("0"), ...tailParts];
504
+ if (parts.length !== 8)
505
+ return null;
506
+ const nums = parts.map((p) => {
507
+ if (/^[0-9a-f]{1,4}$/.test(p))
508
+ return parseInt(p, 16);
509
+ return NaN;
510
+ });
511
+ if (nums.some((n) => !Number.isFinite(n)))
512
+ return null;
513
+ return nums.map((n) => n.toString(16).padStart(4, "0")).join(":");
514
+ }
515
+ function ip6InCidr(ip, cidr) {
516
+ const [rangeRaw, bitsRaw] = cidr.split("/");
517
+ const a = ip6Expand(ip);
518
+ const b = ip6Expand(rangeRaw);
519
+ if (!a || !b)
520
+ return false;
521
+ const bits = bitsRaw === undefined ? 128 : Number(bitsRaw);
522
+ if (!Number.isFinite(bits) || bits < 0 || bits > 128)
523
+ return false;
524
+ const ah = a.split(":"), bh = b.split(":");
525
+ for (let i = 0; i < 8; i += 1) {
526
+ const remaining = bits - i * 16;
527
+ if (remaining <= 0)
528
+ return true;
529
+ if (remaining >= 16) {
530
+ if (ah[i] !== bh[i])
531
+ return false;
532
+ continue;
533
+ }
534
+ const mask = ((0xffff << (16 - remaining)) & 0xffff).toString(16).padStart(4, "0");
535
+ if ((parseInt(ah[i], 16) & parseInt(mask, 16)) !== (parseInt(bh[i], 16) & parseInt(mask, 16)))
536
+ return false;
537
+ }
538
+ return true;
539
+ }
540
+ const spfTxt = async (domain) => {
541
+ const rows = await dnsPromises.resolveTxt(domain).catch(() => []);
542
+ const flat = rows.map((r) => r.join("")).filter((t) => /^v=spf1(\s|$)/i.test(t.trim()));
543
+ if (!flat.length)
544
+ return null;
545
+ // RFC 7208 4.5: more than one v=spf1 record is a permerror, not a pick.
546
+ if (flat.length > 1)
547
+ throw new EmailError(`${domain} publishes ${flat.length} v=spf1 records, which is a permerror: a domain must have exactly one.`);
548
+ return flat[0].trim();
549
+ };
550
+ async function spfEval(domain, ip, depth = 0, lookups = 0, chain = []) {
551
+ const explain = [...chain];
552
+ if (depth > 10)
553
+ return { result: "permerror", domain, explain: [...explain, "include nested more than 10 deep, which is a permerror"], lookups };
554
+ if (lookups > 10)
555
+ return { result: "permerror", domain, explain: [...explain, "more than 10 DNS lookups, which is a permerror under RFC 7208 4.6.4"], lookups };
556
+ let record;
557
+ try {
558
+ record = await spfTxt(domain);
559
+ }
560
+ catch (e) {
561
+ return { result: "permerror", domain, explain: [...explain, e instanceof Error ? e.message : String(e)], lookups };
562
+ }
563
+ if (!record) {
564
+ return { result: depth === 0 ? "none" : "none", domain, explain: [...explain, `${domain} has no SPF record, so this include contributes no authorization`], lookups };
565
+ }
566
+ if (record.length > 255 * 10) {
567
+ return { result: "permerror", domain, explain: [...explain, "the record exceeds the 255 character limit, so it is a permerror"], lookups };
568
+ }
569
+ const terms = record.split(/\s+/).slice(1);
570
+ let used = lookups;
571
+ for (const rawTerm of terms) {
572
+ if (!rawTerm)
573
+ continue;
574
+ if (rawTerm.includes("="))
575
+ continue; // unknown modifier
576
+ // The qualifier is the leading + - ~ ? and belongs to the term; it must be
577
+ // captured, not skipped, or -all is never reached and every record that
578
+ // relies on it returns none instead of fail.
579
+ const qual = "+-~?".includes(rawTerm[0]) ? rawTerm[0] : "";
580
+ const term = qual ? rawTerm.slice(1) : rawTerm;
581
+ const [mechRaw, ...argParts] = term.split(":");
582
+ const mech = mechRaw.toLowerCase();
583
+ const arg = argParts.join(":");
584
+ const cidrSplit = arg.split("/");
585
+ const value = cidrSplit[0];
586
+ const range = cidrSplit[1];
587
+ const verdict = (hit, note) => {
588
+ if (hit === null)
589
+ return null;
590
+ if (hit) {
591
+ if (qual === "-")
592
+ return { result: "fail", domain, explain: [...explain, note], lookups: used };
593
+ if (qual === "~")
594
+ return { result: "softfail", domain, explain: [...explain, note], lookups: used };
595
+ if (qual === "?")
596
+ return { result: "neutral", domain, explain: [...explain, note], lookups: used };
597
+ return { result: "pass", domain, explain: [...explain, note], lookups: used };
598
+ }
599
+ return null;
600
+ };
601
+ if (mech === "v")
602
+ continue;
603
+ if (mech === "ip4" || mech === "ip6") {
604
+ if (!value)
605
+ return { result: "permerror", domain, explain: [...explain, `${rawTerm} is missing an address`], lookups: used };
606
+ const cidr = range !== undefined ? `${value}/${range}` : value;
607
+ if (mech === "ip4") {
608
+ const hit = isIpv4(ip) ? ip4InCidr(ip, cidr) : null;
609
+ const v = verdict(hit, `ip4:${cidr} ${hit ? "matches" : "does not match"} ${ip}`);
610
+ if (v)
611
+ return v;
612
+ }
613
+ else {
614
+ const hit = ip.includes(":") ? ip6InCidr(ip, cidr) : null;
615
+ const v = verdict(hit, `ip6:${cidr} ${hit ? "matches" : "does not match"} ${ip}`);
616
+ if (v)
617
+ return v;
618
+ }
619
+ continue;
620
+ }
621
+ if (mech === "a" || mech === "mx") {
622
+ used += 1;
623
+ const d = value || domain;
624
+ let addrs = [];
625
+ if (mech === "a") {
626
+ addrs = [...(await dnsPromises.resolve4(d).catch(() => [])), ...(await dnsPromises.resolve6(d).catch(() => []))];
627
+ }
628
+ else {
629
+ const mxs = await dnsPromises.resolveMx(d).catch(() => []);
630
+ for (const mx of mxs.slice(0, 10)) {
631
+ const h = mx.exchange.replace(/\.$/, "");
632
+ addrs.push(...(await dnsPromises.resolve4(h).catch(() => [])), ...(await dnsPromises.resolve6(h).catch(() => [])));
633
+ }
634
+ }
635
+ if (!addrs.length) {
636
+ if (qual === "-")
637
+ return { result: "fail", domain, explain: [...explain, `${mech}:${d} resolves to nothing, and a -qualifier makes that a hard fail`], lookups: used };
638
+ continue;
639
+ }
640
+ for (const addr of addrs) {
641
+ if (mech === "a") {
642
+ const hit = isIpv4(addr) ? ip4InCidr(ip, `${addr}/${range ?? "32"}`) : ip6InCidr(ip, `${addr}/${range ?? "128"}`);
643
+ const v = verdict(hit, `${mech}:${d} address ${addr} ${hit ? "matches" : "does not match"} ${ip}`);
644
+ if (v)
645
+ return v;
646
+ }
647
+ else if (mech === "mx") {
648
+ const hit = isIpv4(addr) ? ip4InCidr(ip, `${addr}/${range ?? "32"}`) : ip6InCidr(ip, `${addr}/${range ?? "128"}`);
649
+ const v = verdict(hit, `${mech}:${d} host address ${addr} ${hit ? "matches" : "does not match"} ${ip}`);
650
+ if (v)
651
+ return v;
652
+ }
653
+ }
654
+ continue;
655
+ }
656
+ if (mech === "include") {
657
+ used += 1;
658
+ const target = value.toLowerCase().replace(/\.$/, "");
659
+ if (!target)
660
+ return { result: "permerror", domain, explain: [...explain, "include: is missing a domain"], lookups: used };
661
+ const sub = await spfEval(target, ip, depth + 1, used, [...explain, `include:${target} evaluated`]);
662
+ used = sub.lookups;
663
+ if (sub.result === "pass") {
664
+ if (qual === "-")
665
+ return { result: "fail", domain, explain: [...explain, `include:${target} returned pass, but the -qualifier makes it a hard fail`], lookups: used };
666
+ if (qual === "~")
667
+ return { result: "softfail", domain, explain: [...explain, `include:${target} returned pass, qualified softfail`], lookups: used };
668
+ if (qual === "?")
669
+ return { result: "neutral", domain, explain: [...explain, `include:${target} returned pass, qualified neutral`], lookups: used };
670
+ return { result: "pass", domain, explain: [...explain, `include:${target} evaluated to pass`], lookups: used };
671
+ }
672
+ if (sub.result === "temperror") {
673
+ return { result: "temperror", domain, explain: [...explain, `include:${target} hit a temporary DNS error`], lookups: used };
674
+ }
675
+ // permerror inside an include does not void the outer record, it just
676
+ // fails to authorize. Treat the include as not matching.
677
+ explain.push(`include:${target} evaluated to ${sub.result}, so it does not authorize`);
678
+ continue;
679
+ }
680
+ if (mech === "ptr") {
681
+ used += 1;
682
+ explain.push("ptr: is deprecated in RFC 7208 and many implementations ignore it, so it was not evaluated");
683
+ continue;
684
+ }
685
+ if (mech === "all") {
686
+ if (qual === "-")
687
+ return { result: "fail", domain, explain: [...explain, "reached -all without a match, so the sender is not authorized"], lookups: used };
688
+ if (qual === "~")
689
+ return { result: "softfail", domain, explain: [...explain, "reached ~all without a match, so the sender is only soft-failed"], lookups: used };
690
+ if (qual === "?")
691
+ return { result: "neutral", domain, explain: [...explain, "reached ?all without a match, which authorizes nothing and blocks nothing"], lookups: used };
692
+ return { result: "pass", domain, explain: [...explain, "reached +all, so anything that reached the end is authorized"], lookups: used };
693
+ }
694
+ return { result: "permerror", domain, explain: [...explain, `unknown mechanism "${mech}" in ${rawTerm}`], lookups: used };
695
+ }
696
+ return { result: "none", domain, explain: [...explain, "no mechanism matched and there is no all mechanism, so SPF returns none"], lookups: used };
697
+ }
698
+ /** Actually runs SPF for one sender IP against a domain's record, rather than only printing the record. */
699
+ export async function spfEvaluate(args) {
700
+ const domain = (args.domain ?? "").trim().toLowerCase().replace(/^@/, "");
701
+ if (!domain)
702
+ throw new EmailError("Provide a domain, e.g. 'example.com'");
703
+ if (!DOMAIN_RE.test(domain))
704
+ throw new EmailError(`"${domain}" is not a valid domain name`);
705
+ const ip = String(args.ip ?? "").trim().replace(/^\[|\]$/g, "");
706
+ if (!ip)
707
+ throw new EmailError("Provide the sender IP address that SPF should be evaluated against, e.g. 203.0.113.10");
708
+ if (!isIpv4(ip) && !ip.includes(":")) {
709
+ throw new EmailError(`"${ip}" is not an IP address. SPF evaluates an IP, not a hostname; look up the address first with an A record lookup.`);
710
+ }
711
+ if (isIpv4(ip) && toIpv4(ip).some((n) => n > 255)) {
712
+ throw new EmailError(`"${ip}" is not a valid IPv4 address`);
713
+ }
714
+ const record = await spfTxt(domain).catch((e) => {
715
+ throw new EmailError(e instanceof Error ? e.message : String(e));
716
+ });
717
+ if (!record) {
718
+ return [
719
+ `SPF evaluation for ${domain} against ${ip}`,
720
+ "",
721
+ `${domain} publishes no v=spf1 record, so the result is none.`,
722
+ "",
723
+ "none means the domain makes no statement at all, which receivers treat differently from a deliberate fail: a domain with no SPF is checked for a From address in its own domain and can be a weaker signal to rely on than it looks. Add an SPF record.",
724
+ ].join("\n");
725
+ }
726
+ const v = await spfEval(domain, ip);
727
+ const verdict = {
728
+ pass: "PASS - this sender is authorized",
729
+ fail: "FAIL - this sender is not authorized, and receivers should reject it",
730
+ softfail: "SOFTFAIL - no match, treated as a weak failure; most receivers still deliver but score it down",
731
+ neutral: "NEUTRAL - the domain explicitly declines to say, which is the same as making no statement",
732
+ none: "NONE - no mechanism matched and there is no all mechanism",
733
+ permerror: "PERMERROR - the record is broken, so no result can be trusted",
734
+ temperror: "TEMPERROR - a temporary DNS failure interrupted the check",
735
+ };
736
+ const lines = [
737
+ `SPF evaluation for ${domain} against ${ip}`,
738
+ `record: ${record}`,
739
+ `result: ${v.result.toUpperCase()}`,
740
+ verdict[v.result],
741
+ `DNS lookups used: ${v.lookups} of the 10 allowed`,
742
+ "",
743
+ "Chain:",
744
+ ...v.explain.map((e) => ` - ${e}`),
745
+ "",
746
+ ];
747
+ if (v.result === "permerror") {
748
+ lines.push("A permerror is not a pass or a fail. It means the record is misconfigured, so receiving servers are meant to ignore SPF entirely for this domain, which removes the protection rather than enforcing it.");
749
+ }
750
+ else if (v.result === "pass") {
751
+ lines.push("This sender passes SPF. Alignment with the visible From domain is a separate DMARC check, so a pass here is not on its own proof that the message is legitimate.");
752
+ }
753
+ else if (v.result === "fail") {
754
+ lines.push("A hard fail here is what makes DMARC reject work: this is the result a spoofer produces, and it is the outcome a correct -all should give for everyone else.");
755
+ }
756
+ if (v.lookups > 10) {
757
+ lines.push("", "The lookup count is over the RFC 7208 limit of 10. Receivers must treat that as a permerror, so a record that authorizes correctly in this evaluation may still be ignored in practice.");
758
+ }
759
+ if (args.include_record === false)
760
+ lines.splice(1, 1);
761
+ lines.push("", "This is SPF only. Check the DMARC policy for the domain to know whether a failure here actually rejects the message.");
762
+ return lines.join("\n");
763
+ }
764
+ /** Purely local: derives the plus-tag and dot-stripped forms of an address so alias-based duplicates can be spotted. */
765
+ export async function aliasVariants(args) {
766
+ const email = String(args.email ?? "").trim().toLowerCase();
767
+ if (!email)
768
+ throw new EmailError("Provide an email address, e.g. 'someone+news@example.com'");
769
+ const at = email.lastIndexOf("@");
770
+ if (at <= 0 || at === email.length - 1)
771
+ throw new EmailError(`"${email}" is not an address`);
772
+ const localRaw = email.slice(0, at);
773
+ const domain = email.slice(at + 1);
774
+ if (!DOMAIN_RE.test(domain))
775
+ throw new EmailError(`"${domain}" is not a valid domain name`);
776
+ if (!LOCAL_RE.test(localRaw))
777
+ throw new EmailError(`"${localRaw}" contains characters that are not valid in a local part`);
778
+ const max = Math.min(Math.max(Math.floor(args.max ?? 40) || 40, 1), 200);
779
+ const set = new Set();
780
+ const add = (s) => { if (s && s !== email && s.length <= 64)
781
+ set.add(s); };
782
+ const plusIdx = localRaw.indexOf("+");
783
+ const base = plusIdx === -1 ? localRaw : localRaw.slice(0, plusIdx);
784
+ const tag = plusIdx === -1 ? null : localRaw.slice(plusIdx + 1);
785
+ if (tag) {
786
+ add(`${base}@${domain}`);
787
+ // The tag is a routing label, so every plausible reading of it is a
788
+ // different mailbox that reaches the same person.
789
+ for (const part of tag.split(/[._-]+/).filter(Boolean).slice(0, 6)) {
790
+ add(`${base}+${part}@${domain}`);
791
+ add(`${base}+${part.toUpperCase()}@${domain}`);
792
+ }
793
+ }
794
+ // Gmail and a handful of others ignore dots in the local part, so these all
795
+ // resolve to the same mailbox. Almost no other provider does this.
796
+ const dotInsensitive = new Set(["gmail.com", "googlemail.com"]);
797
+ if (dotInsensitive.has(domain) && base.includes(".")) {
798
+ add(`${base.replace(/\./g, "")}@${domain}`);
799
+ if (base.replace(/\./g, "").length > 0)
800
+ add(`${base.split(".")[0]}@${domain}`);
801
+ }
802
+ if (domain === "googlemail.com")
803
+ add(`${base}@gmail.com`);
804
+ if (domain === "gmail.com")
805
+ add(`${base}@googlemail.com`);
806
+ // Fastmail-style plus addressing is a documented feature, not a provider quirk.
807
+ if (["fastmail.com", "fastmail.fm", "migumail.com", "runbox.com", "hushmail.com", "tutanota.com", "zoho.com", "yandex.com", "hey.com", "icloud.com", "outlook.com", "proton.me", "protonmail.com", "fastmail.ch"].includes(domain)) {
808
+ add(`${base}+${tag ?? "unused"}@${domain}`);
809
+ }
810
+ const out = [...set].slice(0, max);
811
+ const isRole = EDGE_LOCALPARTS.has(base) || ROLE_LOCALPARTS.has(base);
812
+ const isFree = FREE_PROVIDERS.has(domain);
813
+ const isHosting = GIT_HOSTING.has(domain);
814
+ const lines = [
815
+ `Alias analysis for ${email}`,
816
+ `canonical-looking local part: ${base}${tag !== null ? ` (plus tag "${tag}" stripped)` : ""}`,
817
+ `domain: ${domain} ${isFree ? "- a free consumer provider, where addresses are cheap to create in bulk" : ""}${isHosting ? "- a code-hosting domain, often a forge login rather than a person" : ""}`,
818
+ "",
819
+ ];
820
+ if (isRole) {
821
+ lines.push(`"${base}" is a role address, not a person. Role mailboxes are accepted by mail servers and always accept an SMTP probe, so an SMTP existence check will pass for one and it should not be read as a deliverable individual.`, "");
822
+ }
823
+ if (isFree) {
824
+ lines.push("Free providers hand out addresses freely, so a valid address from one is weak evidence that anyone chose it deliberately.", "");
825
+ }
826
+ if (isHosting) {
827
+ lines.push("On a code-hosting domain the address is usually a forge account, so a signup there is not a personal mailbox and often is not intended to receive mail.", "");
828
+ }
829
+ lines.push(`${out.length} variant(s) that reach the same person or the same inbox:`);
830
+ for (const v of out.slice(0, 20))
831
+ lines.push(` ${v}`);
832
+ if (out.length > 20)
833
+ lines.push(` ...and ${out.length - 20} more`);
834
+ if (!out.length)
835
+ lines.push(" none: there is no plus tag, no dot-insensitive provider and no alias feature to expand");
836
+ lines.push("", "Whether these are the same person cannot be decided from the address. Only a provider that documents alias collapsing, such as Gmail ignoring dots or Fastmail accepting plus tags, will treat them as identical; everywhere else plus is a real, separate mailbox.");
837
+ return lines.join("\n");
49
838
  }
@@ -0,0 +1,79 @@
1
+ // Premium tools on this server need a license key bought from MCP Marketplace.
2
+ // Buyers put the key in their MCP client config as MCP_LICENSE_KEY. Every tool
3
+ // that is not listed in PREMIUM keeps working without a key.
4
+ const SLUG = "email-validator-mcp";
5
+ // Tools that need a key. Everything else is free.
6
+ const PREMIUM = new Set([
7
+ "smtp_mailbox_check",
8
+ "spf_evaluate",
9
+ ]);
10
+ const BUY_URL = `https://mcp-marketplace.io/server/io-github-mrfentmen-${SLUG}`;
11
+ // The license check calls the marketplace's verify endpoint directly instead of
12
+ // using @mcp_marketplace/license. That SDK sends no `apikey` header, so every
13
+ // check it makes is rejected by Supabase before it reaches the function. The
14
+ // publishable key below is public by design - it ships in mcp-marketplace.io's
15
+ // own JavaScript and only ever reaches their licence check.
16
+ const DEFAULT_VERIFY_URL = "https://virupvwhtkpkjsiskckg.supabase.co/functions/v1/verify-key";
17
+ const PUBLISHABLE_KEY = "sb_publishable_BuqlW96Ke8C_zzJG-LQv1Q_YHi_r_4h";
18
+ const OK_CACHE_MS = 60 * 60 * 1000;
19
+ const BAD_CACHE_MS = 60 * 1000;
20
+ const seen = new Map();
21
+ const REASONS = {
22
+ missing_key: "no key is set",
23
+ invalid_format: "that key is not a valid MCP Marketplace key",
24
+ not_found: "that key was not found",
25
+ revoked: "that key has been revoked",
26
+ rotated: "that key was rotated, use your new one",
27
+ expired: "that key has expired, renew it",
28
+ rate_limited: "there were too many checks just now, try again shortly",
29
+ network_error: "the license server could not be reached",
30
+ };
31
+ function blocked(tool, reason) {
32
+ const why = REASONS[reason] ?? `the license server answered "${reason}"`;
33
+ return `"${tool}" needs a license key, but ${why}. ` +
34
+ `Set MCP_LICENSE_KEY in your MCP client config. Get a key: ${BUY_URL}`;
35
+ }
36
+ async function verify(key) {
37
+ const res = await fetch(process.env.MCP_LICENSE_VERIFY_URL || DEFAULT_VERIFY_URL, {
38
+ method: "POST",
39
+ headers: {
40
+ apikey: PUBLISHABLE_KEY,
41
+ Authorization: `Bearer ${PUBLISHABLE_KEY}`,
42
+ "Content-Type": "application/json",
43
+ },
44
+ body: JSON.stringify({ key, slug: SLUG }),
45
+ // an unusable key answers 400 with a JSON body, so read the body either way
46
+ signal: AbortSignal.timeout(15000),
47
+ });
48
+ const data = (await res.json());
49
+ if (typeof data?.valid !== "boolean")
50
+ return { valid: false, reason: "unexpected_response" };
51
+ return data;
52
+ }
53
+ /**
54
+ * Returns null when the caller may run the tool, or a short message telling
55
+ * them how to get a key.
56
+ *
57
+ * A good key is remembered for an hour, so a busy session does not hit the
58
+ * license server on every call and keeps working through a brief outage.
59
+ */
60
+ export async function premiumRequired(tool) {
61
+ if (!PREMIUM.has(tool))
62
+ return null;
63
+ const key = process.env.MCP_LICENSE_KEY;
64
+ if (!key)
65
+ return blocked(tool, "missing_key");
66
+ const hit = seen.get(key);
67
+ if (hit && Date.now() - hit.at < (hit.valid ? OK_CACHE_MS : BAD_CACHE_MS)) {
68
+ return hit.valid ? null : blocked(tool, hit.reason ?? "invalid");
69
+ }
70
+ let result;
71
+ try {
72
+ result = await verify(key);
73
+ }
74
+ catch {
75
+ result = { valid: false, reason: "network_error" };
76
+ }
77
+ seen.set(key, { ...result, at: Date.now() });
78
+ return result.valid ? null : blocked(tool, result.reason ?? "invalid");
79
+ }
package/dist/server.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
2
  import { z } from "zod";
3
- import { validateBatch } from "./api.js";
4
- import { validateEmail } from "./api.js";
3
+ import { checkDomain, disposableDomains, extractEmails, normalizeEmail, validateBatch, validateEmail, emailAuthRecords, mxHealth, correctTypo, smtpMailboxCheck, spfEvaluate, aliasVariants } from "./api.js";
4
+ import { premiumRequired } from "./license.js";
5
5
  const text = (value) => ({ content: [{ type: "text", text: value }] });
6
6
  const textError = (t) => ({ content: [{ type: "text", text: t }], isError: true });
7
7
  const READ_ONLY = { readOnlyHint: true, openWorldHint: true };
@@ -10,8 +10,11 @@ export function createServer() {
10
10
  const server = new McpServer({ name: "email-validator-mcp", version: "1.0.0" });
11
11
  server.registerTool("validate_email", {
12
12
  title: "Validate email",
13
- description: "Check an email address format, MX record, and disposable status.",
14
- inputSchema: z.object({ email: z.string().describe("Email address to validate.") }),
13
+ description: "Check one email address: format, live MX records, disposable-domain status, role account (info@, support@) " +
14
+ "and whether it is a free provider.",
15
+ inputSchema: z.object({
16
+ email: z.string().describe("Email address to validate."),
17
+ }),
15
18
  annotations: READ_ONLY,
16
19
  }, async (args) => {
17
20
  try {
@@ -23,8 +26,10 @@ export function createServer() {
23
26
  });
24
27
  server.registerTool("validate_batch", {
25
28
  title: "Validate batch",
26
- description: "Check a list of comma separated email addresses.",
27
- inputSchema: z.object({ emails: z.string().describe("Comma separated email addresses.") }),
29
+ description: "Validate up to 25 addresses at once and get a count of well-formed, malformed and risky ones.",
30
+ inputSchema: z.object({
31
+ emails: z.string().describe("Comma or space separated email addresses (max 25)."),
32
+ }),
28
33
  annotations: READ_ONLY,
29
34
  }, async (args) => {
30
35
  try {
@@ -34,5 +39,171 @@ export function createServer() {
34
39
  return textError(error(e));
35
40
  }
36
41
  });
42
+ server.registerTool("check_domain", {
43
+ title: "Check domain",
44
+ description: "Domain-level mail check: MX records, A records, and whether the domain is a disposable or free provider.",
45
+ inputSchema: z.object({
46
+ domain: z.string().describe("Domain name, e.g. 'example.com' (a leading @ is fine)."),
47
+ }),
48
+ annotations: READ_ONLY,
49
+ }, async (args) => {
50
+ try {
51
+ return text(await checkDomain(args));
52
+ }
53
+ catch (e) {
54
+ return textError(error(e));
55
+ }
56
+ });
57
+ server.registerTool("normalize_email", {
58
+ title: "Normalize email",
59
+ description: "Canonical form of an address: lowercase the domain, drop the +tag, and remove Gmail dots, " +
60
+ "so duplicates collapse to one record.",
61
+ inputSchema: z.object({
62
+ email: z.string().describe("Email address to normalise."),
63
+ }),
64
+ annotations: READ_ONLY,
65
+ }, async (args) => {
66
+ try {
67
+ return text(normalizeEmail(args));
68
+ }
69
+ catch (e) {
70
+ return textError(error(e));
71
+ }
72
+ });
73
+ server.registerTool("extract_emails", {
74
+ title: "Extract emails",
75
+ description: "Pull every unique email address out of a block of text, flagging any disposable domains.",
76
+ inputSchema: z.object({
77
+ text: z.string().describe("Text to scan, e.g. a pasted email thread or CSV cell."),
78
+ }),
79
+ annotations: READ_ONLY,
80
+ }, async (args) => {
81
+ try {
82
+ return text(extractEmails(args));
83
+ }
84
+ catch (e) {
85
+ return textError(error(e));
86
+ }
87
+ });
88
+ server.registerTool("disposable_domains", {
89
+ title: "Disposable domains",
90
+ description: "List the known disposable email domains this server checks against.",
91
+ inputSchema: z.object({
92
+ limit: z.number().int().min(1).max(100).default(40).describe("How many domains to list"),
93
+ }),
94
+ annotations: READ_ONLY,
95
+ }, async (args) => {
96
+ try {
97
+ return text(disposableDomains(args));
98
+ }
99
+ catch (e) {
100
+ return textError(error(e));
101
+ }
102
+ });
103
+ server.registerTool("email_auth_records", {
104
+ title: "Email auth records",
105
+ description: "The full anti-spoofing picture for a domain: MX hosts, SPF with its all-rule, DMARC policy and DKIM selectors.",
106
+ inputSchema: z.object({
107
+ domain: z.string().describe("Domain name, e.g. 'example.com' (a leading @ is fine)."),
108
+ }),
109
+ annotations: READ_ONLY,
110
+ }, async (args) => {
111
+ try {
112
+ return text(await emailAuthRecords(args));
113
+ }
114
+ catch (e) {
115
+ return textError(error(e));
116
+ }
117
+ });
118
+ server.registerTool("mx_health", {
119
+ title: "MX health",
120
+ description: "Every MX host for a domain, whether it resolves to an A record, and its reverse DNS.",
121
+ inputSchema: z.object({
122
+ domain: z.string().describe("Domain name, e.g. 'example.com' (a leading @ is fine)."),
123
+ }),
124
+ annotations: READ_ONLY,
125
+ }, async (args) => {
126
+ try {
127
+ return text(await mxHealth(args));
128
+ }
129
+ catch (e) {
130
+ return textError(error(e));
131
+ }
132
+ });
133
+ server.registerTool("correct_typo", {
134
+ title: "Correct typo",
135
+ description: "Suggests the mail provider a mistyped domain probably meant, e.g. gmial.com -> gmail.com, without contacting the domain.",
136
+ inputSchema: z.object({
137
+ email: z.string().describe("Email address that may contain a typo."),
138
+ }),
139
+ annotations: READ_ONLY,
140
+ }, async (args) => {
141
+ try {
142
+ return text(correctTypo(args));
143
+ }
144
+ catch (e) {
145
+ return textError(error(e));
146
+ }
147
+ });
148
+ server.registerTool("smtp_mailbox_check", {
149
+ title: "SMTP mailbox check",
150
+ description: "Asks the receiving mail server whether a specific address actually exists, using an SMTP RCPT TO probe after negotiating with each MX host in priority order. An MX record only proves the domain accepts mail somewhere, not that one mailbox is real, so this is the only real existence signal available. Also probes a random address to detect catch-all domains, where a success reply means nothing. Distinguishes a genuine no-such-user from a network failure or a greylisted deferral, and never reports an unreachable server as an invalid address.",
151
+ inputSchema: z.object({
152
+ email: z.string().describe("Address to probe, e.g. someone@example.com"),
153
+ check_catch_all: z.boolean().default(true).describe("Also probe a random address to detect a catch-all domain. Default true"),
154
+ timeout_ms: z.number().int().min(2000).max(20000).default(8000).describe("Per-host timeout in ms, default 8000"),
155
+ }),
156
+ annotations: READ_ONLY,
157
+ }, async (args) => {
158
+ // ---- paywall: smtp_mailbox_check ----
159
+ const paywallMessage = await premiumRequired("smtp_mailbox_check");
160
+ if (paywallMessage)
161
+ return { content: [{ type: "text", text: paywallMessage }], isError: true };
162
+ // ---- paywall: end ----
163
+ try {
164
+ return text(await smtpMailboxCheck(args));
165
+ }
166
+ catch (e) {
167
+ return textError(error(e));
168
+ }
169
+ });
170
+ server.registerTool("spf_evaluate", {
171
+ title: "Evaluate SPF for a sender IP",
172
+ description: "Runs RFC 7208 SPF evaluation of a sender IP against a domain's actual record: walks every mechanism in order, resolves a, mx and include recursively, applies the + - ~ ? qualifiers, and returns pass, fail, softfail, neutral, none, permerror or temperror with the full chain of reasoning and the DNS lookup count. Existing tools only print the SPF record; this one tells you what it actually decides, and distinguishes a broken record from a deliberate failure.",
173
+ inputSchema: z.object({
174
+ domain: z.string().describe("Domain whose SPF record should be evaluated, e.g. example.com"),
175
+ ip: z.string().describe("Sender IP address to evaluate, e.g. 203.0.113.10"),
176
+ include_record: z.boolean().default(true).describe("Show the raw SPF record. Default true"),
177
+ }),
178
+ annotations: READ_ONLY,
179
+ }, async (args) => {
180
+ // ---- paywall: spf_evaluate ----
181
+ const paywallMessage = await premiumRequired("spf_evaluate");
182
+ if (paywallMessage)
183
+ return { content: [{ type: "text", text: paywallMessage }], isError: true };
184
+ // ---- paywall: end ----
185
+ try {
186
+ return text(await spfEvaluate(args));
187
+ }
188
+ catch (e) {
189
+ return textError(error(e));
190
+ }
191
+ });
192
+ server.registerTool("alias_variants", {
193
+ title: "Alias variants of an address",
194
+ description: "Derives the addresses that reach the same inbox as a given one: the plus-tag and sub-address forms, the Gmail and Googlemail dot-stripped equivalents, and the providers that document plus addressing as a real feature. Flags role addresses, free providers and code-hosting domains where a valid SMTP result does not mean a deliverable person. Instant and entirely local, with no DNS or network access.",
195
+ inputSchema: z.object({
196
+ email: z.string().describe("Address to expand, e.g. someone+news@example.com"),
197
+ max: z.number().int().min(1).max(200).default(40).describe("Variants to return, default 40"),
198
+ }),
199
+ annotations: READ_ONLY,
200
+ }, async (args) => {
201
+ try {
202
+ return text(await aliasVariants(args));
203
+ }
204
+ catch (e) {
205
+ return textError(error(e));
206
+ }
207
+ });
37
208
  return server;
38
209
  }
package/package.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "1.0.2",
2
+ "version": "1.0.3",
3
3
  "type": "module",
4
4
  "repository": {
5
5
  "type": "git",
@@ -22,11 +22,7 @@
22
22
  "license": "MIT",
23
23
  "dependencies": {
24
24
  "@modelcontextprotocol/sdk": "^1.0.4",
25
- "zod": "^3.23.8",
26
- "pdf-lib": "^1.17.1",
27
- "exceljs": "^4.4.0",
28
- "qrcode": "^1.5.4",
29
- "sharp": "^0.33.4"
25
+ "zod": "^3.23.8"
30
26
  },
31
27
  "devDependencies": {
32
28
  "@types/node": "^22.0.0",