email-validator-mcp 1.0.2 → 1.0.4

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,5 @@ 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
+
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,268 @@ 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
+ const EDGE_LOCALPARTS = new Set(["postmaster", "abuse", "hostmaster", "webmaster", "noc", "security", "hostmaster", "postmaster"]);
275
+ const GIT_HOSTING = new Set(["github.com", "gitlab.com", "bitbucket.org", "codeberg.org", "gitea.com", "sourcehut.org"]);
276
+ /** Purely local: derives the plus-tag and dot-stripped forms of an address so alias-based duplicates can be spotted. */
277
+ export async function aliasVariants(args) {
278
+ const email = String(args.email ?? "").trim().toLowerCase();
279
+ if (!email)
280
+ throw new EmailError("Provide an email address, e.g. 'someone+news@example.com'");
281
+ const at = email.lastIndexOf("@");
282
+ if (at <= 0 || at === email.length - 1)
283
+ throw new EmailError(`"${email}" is not an address`);
284
+ const localRaw = email.slice(0, at);
285
+ const domain = email.slice(at + 1);
286
+ if (!DOMAIN_RE.test(domain))
287
+ throw new EmailError(`"${domain}" is not a valid domain name`);
288
+ if (!LOCAL_RE.test(localRaw))
289
+ throw new EmailError(`"${localRaw}" contains characters that are not valid in a local part`);
290
+ const max = Math.min(Math.max(Math.floor(args.max ?? 40) || 40, 1), 200);
291
+ const set = new Set();
292
+ const add = (s) => { if (s && s !== email && s.length <= 64)
293
+ set.add(s); };
294
+ const plusIdx = localRaw.indexOf("+");
295
+ const base = plusIdx === -1 ? localRaw : localRaw.slice(0, plusIdx);
296
+ const tag = plusIdx === -1 ? null : localRaw.slice(plusIdx + 1);
297
+ if (tag) {
298
+ add(`${base}@${domain}`);
299
+ // The tag is a routing label, so every plausible reading of it is a
300
+ // different mailbox that reaches the same person.
301
+ for (const part of tag.split(/[._-]+/).filter(Boolean).slice(0, 6)) {
302
+ add(`${base}+${part}@${domain}`);
303
+ add(`${base}+${part.toUpperCase()}@${domain}`);
304
+ }
305
+ }
306
+ // Gmail and a handful of others ignore dots in the local part, so these all
307
+ // resolve to the same mailbox. Almost no other provider does this.
308
+ const dotInsensitive = new Set(["gmail.com", "googlemail.com"]);
309
+ if (dotInsensitive.has(domain) && base.includes(".")) {
310
+ add(`${base.replace(/\./g, "")}@${domain}`);
311
+ if (base.replace(/\./g, "").length > 0)
312
+ add(`${base.split(".")[0]}@${domain}`);
313
+ }
314
+ if (domain === "googlemail.com")
315
+ add(`${base}@gmail.com`);
316
+ if (domain === "gmail.com")
317
+ add(`${base}@googlemail.com`);
318
+ // Fastmail-style plus addressing is a documented feature, not a provider quirk.
319
+ 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)) {
320
+ add(`${base}+${tag ?? "unused"}@${domain}`);
321
+ }
322
+ const out = [...set].slice(0, max);
323
+ const isRole = EDGE_LOCALPARTS.has(base) || ROLE_LOCALPARTS.has(base);
324
+ const isFree = FREE_PROVIDERS.has(domain);
325
+ const isHosting = GIT_HOSTING.has(domain);
326
+ const lines = [
327
+ `Alias analysis for ${email}`,
328
+ `canonical-looking local part: ${base}${tag !== null ? ` (plus tag "${tag}" stripped)` : ""}`,
329
+ `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" : ""}`,
330
+ "",
331
+ ];
332
+ if (isRole) {
333
+ 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.`, "");
334
+ }
335
+ if (isFree) {
336
+ lines.push("Free providers hand out addresses freely, so a valid address from one is weak evidence that anyone chose it deliberately.", "");
337
+ }
338
+ if (isHosting) {
339
+ 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.", "");
340
+ }
341
+ lines.push(`${out.length} variant(s) that reach the same person or the same inbox:`);
342
+ for (const v of out.slice(0, 20))
343
+ lines.push(` ${v}`);
344
+ if (out.length > 20)
345
+ lines.push(` ...and ${out.length - 20} more`);
346
+ if (!out.length)
347
+ lines.push(" none: there is no plus tag, no dot-insensitive provider and no alias feature to expand");
348
+ 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.");
349
+ return lines.join("\n");
49
350
  }
package/dist/server.js CHANGED
@@ -1,7 +1,6 @@
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, aliasVariants } from "./api.js";
5
4
  const text = (value) => ({ content: [{ type: "text", text: value }] });
6
5
  const textError = (t) => ({ content: [{ type: "text", text: t }], isError: true });
7
6
  const READ_ONLY = { readOnlyHint: true, openWorldHint: true };
@@ -10,28 +9,191 @@ export function createServer() {
10
9
  const server = new McpServer({ name: "email-validator-mcp", version: "1.0.0" });
11
10
  server.registerTool("validate_email", {
12
11
  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.") }),
12
+ description: "Check one email address: format, live MX records, disposable-domain status, role account (info@, support@) " +
13
+ "and whether it is a free provider.",
14
+ inputSchema: z.object({
15
+ email: z.string().describe("Email address to validate."),
16
+ }),
15
17
  annotations: READ_ONLY,
16
18
  }, async (args) => {
17
19
  try {
18
20
  return text(await validateEmail(args));
19
21
  }
20
22
  catch (e) {
21
- return textError(error(e));
23
+ return textError(String(e));
22
24
  }
23
25
  });
24
26
  server.registerTool("validate_batch", {
25
27
  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.") }),
28
+ description: "Validate up to 25 addresses at once and get a count of well-formed, malformed and risky ones.",
29
+ inputSchema: z.object({
30
+ emails: z.string().describe("Comma or space separated email addresses (max 25)."),
31
+ }),
28
32
  annotations: READ_ONLY,
29
33
  }, async (args) => {
30
34
  try {
31
35
  return text(await validateBatch(args));
32
36
  }
33
37
  catch (e) {
34
- return textError(error(e));
38
+ return textError(String(e));
39
+ }
40
+ });
41
+ server.registerTool("check_domain", {
42
+ title: "Check domain",
43
+ description: "Domain-level mail check: MX records, A records, and whether the domain is a disposable or free provider.",
44
+ inputSchema: z.object({
45
+ domain: z.string().describe("Domain name, e.g. 'example.com' (a leading @ is fine)."),
46
+ }),
47
+ annotations: READ_ONLY,
48
+ }, async (args) => {
49
+ try {
50
+ return text(await checkDomain(args));
51
+ }
52
+ catch (e) {
53
+ return textError(String(e));
54
+ }
55
+ });
56
+ server.registerTool("normalize_email", {
57
+ title: "Normalize email",
58
+ description: "Canonical form of an address: lowercase the domain, drop the +tag, and remove Gmail dots, " +
59
+ "so duplicates collapse to one record.",
60
+ inputSchema: z.object({
61
+ email: z.string().describe("Email address to normalise."),
62
+ }),
63
+ annotations: READ_ONLY,
64
+ }, async (args) => {
65
+ try {
66
+ return text(normalizeEmail(args));
67
+ }
68
+ catch (e) {
69
+ return textError(String(e));
70
+ }
71
+ });
72
+ server.registerTool("extract_emails", {
73
+ title: "Extract emails",
74
+ description: "Pull every unique email address out of a block of text, flagging any disposable domains.",
75
+ inputSchema: z.object({
76
+ text: z.string().describe("Text to scan, e.g. a pasted email thread or CSV cell."),
77
+ }),
78
+ annotations: READ_ONLY,
79
+ }, async (args) => {
80
+ try {
81
+ return text(extractEmails(args));
82
+ }
83
+ catch (e) {
84
+ return textError(String(e));
85
+ }
86
+ });
87
+ server.registerTool("disposable_domains", {
88
+ title: "Disposable domains",
89
+ description: "List the known disposable email domains this server checks against.",
90
+ inputSchema: z.object({
91
+ limit: z.number().int().min(1).max(100).default(40).describe("How many domains to list"),
92
+ }),
93
+ annotations: READ_ONLY,
94
+ }, async (args) => {
95
+ try {
96
+ return text(disposableDomains(args));
97
+ }
98
+ catch (e) {
99
+ return textError(String(e));
100
+ }
101
+ });
102
+ server.registerTool("email_auth_records", {
103
+ title: "Email auth records",
104
+ description: "The full anti-spoofing picture for a domain: MX hosts, SPF with its all-rule, DMARC policy and DKIM selectors.",
105
+ inputSchema: z.object({
106
+ domain: z.string().describe("Domain name, e.g. 'example.com' (a leading @ is fine)."),
107
+ }),
108
+ annotations: READ_ONLY,
109
+ }, async (args) => {
110
+ try {
111
+ return text(await emailAuthRecords(args));
112
+ }
113
+ catch (e) {
114
+ return textError(String(e));
115
+ }
116
+ });
117
+ server.registerTool("mx_health", {
118
+ title: "MX health",
119
+ description: "Every MX host for a domain, whether it resolves to an A record, and its reverse DNS.",
120
+ inputSchema: z.object({
121
+ domain: z.string().describe("Domain name, e.g. 'example.com' (a leading @ is fine)."),
122
+ }),
123
+ annotations: READ_ONLY,
124
+ }, async (args) => {
125
+ try {
126
+ return text(await mxHealth(args));
127
+ }
128
+ catch (e) {
129
+ return textError(String(e));
130
+ }
131
+ });
132
+ server.registerTool("correct_typo", {
133
+ title: "Correct typo",
134
+ description: "Suggests the mail provider a mistyped domain probably meant, e.g. gmial.com -> gmail.com, without contacting the domain.",
135
+ inputSchema: z.object({
136
+ email: z.string().describe("Email address that may contain a typo."),
137
+ }),
138
+ annotations: READ_ONLY,
139
+ }, async (args) => {
140
+ try {
141
+ return text(correctTypo(args));
142
+ }
143
+ catch (e) {
144
+ return textError(String(e));
145
+ }
146
+ });
147
+ server.registerTool("smtp_mailbox_check", {
148
+ title: "SMTP mailbox check",
149
+ 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.",
150
+ inputSchema: z.object({
151
+ email: z.string().describe("Address to probe, e.g. someone@example.com"),
152
+ check_catch_all: z.boolean().default(true).describe("Also probe a random address to detect a catch-all domain. Default true"),
153
+ timeout_ms: z.number().int().min(2000).max(20000).default(8000).describe("Per-host timeout in ms, default 8000"),
154
+ }),
155
+ annotations: READ_ONLY,
156
+ }, async (args) => {
157
+ // TODO: implement check locally (paywall removed)
158
+ try {
159
+ return textError("Not yet implemented: check");
160
+ }
161
+ catch (e) {
162
+ return textError(String(e));
163
+ }
164
+ });
165
+ server.registerTool("spf_evaluate", {
166
+ title: "Evaluate SPF for a sender IP",
167
+ 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.",
168
+ inputSchema: z.object({
169
+ domain: z.string().describe("Domain whose SPF record should be evaluated, e.g. example.com"),
170
+ ip: z.string().describe("Sender IP address to evaluate, e.g. 203.0.113.10"),
171
+ include_record: z.boolean().default(true).describe("Show the raw SPF record. Default true"),
172
+ }),
173
+ annotations: READ_ONLY,
174
+ }, async (args) => {
175
+ // TODO: implement evaluate locally (paywall removed)
176
+ try {
177
+ return textError("Not yet implemented: evaluate");
178
+ }
179
+ catch (e) {
180
+ return textError(String(e));
181
+ }
182
+ });
183
+ server.registerTool("alias_variants", {
184
+ title: "Alias variants of an address",
185
+ 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.",
186
+ inputSchema: z.object({
187
+ email: z.string().describe("Address to expand, e.g. someone+news@example.com"),
188
+ max: z.number().int().min(1).max(200).default(40).describe("Variants to return, default 40"),
189
+ }),
190
+ annotations: READ_ONLY,
191
+ }, async (args) => {
192
+ try {
193
+ return text(await aliasVariants(args));
194
+ }
195
+ catch (e) {
196
+ return textError(String(e));
35
197
  }
36
198
  });
37
199
  return server;
package/package.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "1.0.2",
2
+ "version": "1.0.4",
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",
@@ -45,4 +41,4 @@
45
41
  "engines": {
46
42
  "node": ">=20"
47
43
  }
48
- }
44
+ }