@emailens/engine 0.10.4 → 0.11.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,4 @@
1
- import { u as InputFormat } from './types-BLR3-Fzo.js';
1
+ import { x as InputFormat } from './types-bvGDId6f.js';
2
2
 
3
3
  /**
4
4
  * Unified error class for all email compilation failures.
@@ -17,17 +17,17 @@ declare class CompileError extends Error {
17
17
  /**
18
18
  * Sandbox strategy for JSX execution.
19
19
  *
20
- * - `"vm"` `node:vm` with hardened globals. Fast, zero-dependency,
20
+ * - `"vm"`: `node:vm` with hardened globals. Fast, zero-dependency,
21
21
  * but NOT a true security boundary (prototype-chain escapes are possible).
22
22
  * Suitable for CLI / local use where users run their own code.
23
23
  *
24
- * - `"isolated-vm"` (default) Separate V8 isolate via the `isolated-vm`
24
+ * - `"isolated-vm"` (default): Separate V8 isolate via the `isolated-vm`
25
25
  * npm package. True heap isolation; escapes require a V8 engine bug.
26
26
  * Requires `isolated-vm` to be installed (native addon).
27
27
  *
28
- * - `"quickjs"` Validates code structure in a QuickJS WASM sandbox, then
28
+ * - `"quickjs"`: Validates code structure in a QuickJS WASM sandbox, then
29
29
  * executes in `node:vm` for React rendering. Security is equivalent to
30
- * `node:vm` the QuickJS phase validates import restrictions only.
30
+ * `node:vm`: the QuickJS phase validates import restrictions only.
31
31
  * No native addons needed, but only supports ES2020 and is slower.
32
32
  * For true isolation on servers, use `isolated-vm`.
33
33
  */
@@ -1,9 +1,9 @@
1
1
  import {
2
2
  compileReactEmail
3
- } from "./chunk-HBKZMR7V.js";
3
+ } from "./chunk-UFSYNSDP.js";
4
4
  import "./chunk-Z22AD2IU.js";
5
5
  import "./chunk-2NMEKWO5.js";
6
6
  export {
7
7
  compileReactEmail
8
8
  };
9
- //# sourceMappingURL=react-email-QKHRTEJW.js.map
9
+ //# sourceMappingURL=react-email-CVZZNVZ2.js.map
@@ -1,4 +1,4 @@
1
- import { u as InputFormat } from './types-BLR3-Fzo.cjs';
1
+ import { x as InputFormat } from './types-bvGDId6f.cjs';
2
2
 
3
3
  /**
4
4
  * Unified error class for all email compilation failures.
@@ -17,17 +17,17 @@ declare class CompileError extends Error {
17
17
  /**
18
18
  * Sandbox strategy for JSX execution.
19
19
  *
20
- * - `"vm"` `node:vm` with hardened globals. Fast, zero-dependency,
20
+ * - `"vm"`: `node:vm` with hardened globals. Fast, zero-dependency,
21
21
  * but NOT a true security boundary (prototype-chain escapes are possible).
22
22
  * Suitable for CLI / local use where users run their own code.
23
23
  *
24
- * - `"isolated-vm"` (default) Separate V8 isolate via the `isolated-vm`
24
+ * - `"isolated-vm"` (default): Separate V8 isolate via the `isolated-vm`
25
25
  * npm package. True heap isolation; escapes require a V8 engine bug.
26
26
  * Requires `isolated-vm` to be installed (native addon).
27
27
  *
28
- * - `"quickjs"` Validates code structure in a QuickJS WASM sandbox, then
28
+ * - `"quickjs"`: Validates code structure in a QuickJS WASM sandbox, then
29
29
  * executes in `node:vm` for React rendering. Security is equivalent to
30
- * `node:vm` the QuickJS phase validates import restrictions only.
30
+ * `node:vm`: the QuickJS phase validates import restrictions only.
31
31
  * No native addons needed, but only supports ES2020 and is slower.
32
32
  * For true isolation on servers, use `isolated-vm`.
33
33
  */
package/dist/server.cjs CHANGED
@@ -78,7 +78,7 @@ async function checkMX(domain, dns) {
78
78
  return {
79
79
  name: "mx",
80
80
  status: "fail",
81
- message: "No MX records found \u2014 domain cannot receive email."
81
+ message: "No MX records found; domain cannot receive email."
82
82
  };
83
83
  }
84
84
  const sorted = records.sort((a, b) => a.priority - b.priority);
@@ -94,7 +94,7 @@ async function checkMX(domain, dns) {
94
94
  return {
95
95
  name: "mx",
96
96
  status: "fail",
97
- message: "No MX records found \u2014 domain cannot receive email."
97
+ message: "No MX records found; domain cannot receive email."
98
98
  };
99
99
  }
100
100
  return {
@@ -111,7 +111,7 @@ async function checkSPF(domain, dns) {
111
111
  return {
112
112
  name: "spf",
113
113
  status: "fail",
114
- message: "No SPF record found \u2014 receiving servers can't verify authorized senders.",
114
+ message: "No SPF record found; receiving servers can't verify authorized senders.",
115
115
  detail: 'Add a TXT record starting with "v=spf1" to your domain.'
116
116
  };
117
117
  }
@@ -119,7 +119,7 @@ async function checkSPF(domain, dns) {
119
119
  return {
120
120
  name: "spf",
121
121
  status: "fail",
122
- message: "SPF record uses +all \u2014 this allows any server to send as your domain.",
122
+ message: "SPF record uses +all; this allows any server to send as your domain.",
123
123
  record: spfRecord,
124
124
  detail: "Replace +all with -all (hard fail) or ~all (soft fail) to restrict authorized senders."
125
125
  };
@@ -137,7 +137,7 @@ async function checkSPF(domain, dns) {
137
137
  return {
138
138
  name: "spf",
139
139
  status: "warn",
140
- message: "SPF record uses ~all (soft fail) \u2014 consider upgrading to -all (hard fail) for stronger protection.",
140
+ message: "SPF record uses ~all (soft fail); consider upgrading to -all (hard fail) for stronger protection.",
141
141
  record: spfRecord
142
142
  };
143
143
  }
@@ -171,7 +171,7 @@ async function checkDKIM(domain, dns) {
171
171
  return {
172
172
  name: "dkim",
173
173
  status: "warn",
174
- message: `No DKIM records found for ${DKIM_SELECTORS.length} common selectors \u2014 DKIM may use a custom selector we didn't probe.`,
174
+ message: `No DKIM records found for ${DKIM_SELECTORS.length} common selectors; DKIM may use a custom selector we didn't probe.`,
175
175
  detail: "This doesn't mean DKIM is missing; the actual selector may differ from the common ones we checked."
176
176
  };
177
177
  }
@@ -183,7 +183,7 @@ async function checkDMARC(domain, dns) {
183
183
  return {
184
184
  name: "dmarc",
185
185
  status: "fail",
186
- message: "No DMARC record found \u2014 inbox providers may reject or quarantine your emails.",
186
+ message: "No DMARC record found; inbox providers may reject or quarantine your emails.",
187
187
  detail: 'Add a TXT record at _dmarc.yourdomain.com starting with "v=DMARC1".'
188
188
  };
189
189
  }
@@ -193,7 +193,7 @@ async function checkDMARC(domain, dns) {
193
193
  return {
194
194
  name: "dmarc",
195
195
  status: "warn",
196
- message: "DMARC record found with p=none (monitoring only) \u2014 consider upgrading to p=quarantine or p=reject.",
196
+ message: "DMARC record found with p=none (monitoring only); consider upgrading to p=quarantine or p=reject.",
197
197
  record: dmarcRecord
198
198
  };
199
199
  }
@@ -211,14 +211,14 @@ async function checkBIMI(domain, dns) {
211
211
  return {
212
212
  name: "bimi",
213
213
  status: "skip",
214
- message: "No BIMI record found \u2014 optional brand indicator (logo in inbox).",
214
+ message: "No BIMI record found; BIMI is an optional brand indicator (logo in inbox).",
215
215
  detail: "BIMI is a nice-to-have. It displays your brand logo next to emails in supported clients."
216
216
  };
217
217
  }
218
218
  return {
219
219
  name: "bimi",
220
220
  status: "pass",
221
- message: "BIMI record found \u2014 your brand logo may appear in supported email clients.",
221
+ message: "BIMI record found; your brand logo may appear in supported email clients.",
222
222
  record: bimiRecord
223
223
  };
224
224
  }
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/server.ts","../src/deliverability-checker.ts","../src/spamassassin.ts"],"sourcesContent":["/**\r\n * Server-only exports that depend on Node.js builtins (dns, child_process).\r\n * Import from \"@emailens/engine/server\" — never from the main entry point\r\n * in client-side / browser code.\r\n */\r\nexport { checkDeliverability } from \"./deliverability-checker\";\r\nexport { checkSpamAssassin } from \"./spamassassin\";\r\nexport type { SpamAssassinResult, SpamAssassinOptions } from \"./spamassassin\";\r\n","/**\r\n * DNS-based email deliverability checker.\r\n *\r\n * Validates SPF, DKIM, DMARC, MX, and BIMI records for a domain\r\n * using node:dns/promises. No external dependencies.\r\n */\r\n\r\nimport { promises as defaultDns } from \"node:dns\";\r\nimport type { DeliverabilityCheck, DeliverabilityReport, DeliverabilityIssue } from \"./types\";\r\n\r\n/** DNS resolver interface — injectable for testing. */\r\nexport interface DnsResolver {\r\n resolveMx: typeof defaultDns.resolveMx;\r\n resolveTxt: typeof defaultDns.resolveTxt;\r\n}\r\n\r\n/** Common DKIM selectors to probe (we can't know the real selector). */\r\nconst DKIM_SELECTORS = [\r\n \"google\", \"selector1\", \"selector2\", \"default\", \"dkim\",\r\n \"k1\", \"k2\", \"k3\", \"mail\", \"s1\", \"s2\",\r\n \"mandrill\", \"pm\", \"protonmail\", \"smtp\",\r\n];\r\n\r\nconst DNS_TIMEOUT_MS = 5_000;\r\n\r\n/**\r\n * Race a DNS call against a timeout. Node's DNS API has no signal support,\r\n * so we use Promise.race — the underlying query may continue in the background\r\n * but we stop waiting.\r\n */\r\nasync function withTimeout<T>(\r\n fn: () => Promise<T>,\r\n timeoutMs = DNS_TIMEOUT_MS,\r\n): Promise<T> {\r\n let timer: ReturnType<typeof setTimeout> | undefined;\r\n const timeout = new Promise<never>((_, reject) => {\r\n timer = setTimeout(() => {\r\n const err = Object.assign(new Error(`DNS timeout after ${timeoutMs}ms`), { code: \"ABORT_ERR\" });\r\n reject(err);\r\n }, timeoutMs);\r\n });\r\n try {\r\n return await Promise.race([fn(), timeout]);\r\n } finally {\r\n clearTimeout(timer);\r\n }\r\n}\r\n\r\n/**\r\n * Safe DNS TXT lookup — returns empty array on ENOTFOUND/ENODATA.\r\n */\r\nasync function resolveTxtSafe(domain: string, dns: DnsResolver): Promise<string[]> {\r\n try {\r\n const records = await withTimeout(() => dns.resolveTxt(domain));\r\n return records.map((chunks) => chunks.join(\"\"));\r\n } catch (err: unknown) {\r\n const code = (err as NodeJS.ErrnoException).code;\r\n if (code === \"ENOTFOUND\" || code === \"ENODATA\" || code === \"ETIMEOUT\" || code === \"ABORT_ERR\"\r\n || code === \"ESERVFAIL\" || code === \"ECONNREFUSED\" || code === \"EAI_AGAIN\" || code === \"ECANCELLED\") {\r\n return [];\r\n }\r\n throw err;\r\n }\r\n}\r\n\r\n// ── Individual checks ────────────────────────────────────────────────────────\r\n\r\nasync function checkMX(domain: string, dns: DnsResolver): Promise<DeliverabilityCheck> {\r\n try {\r\n const records = await withTimeout(() => dns.resolveMx(domain));\r\n if (records.length === 0) {\r\n return {\r\n name: \"mx\",\r\n status: \"fail\",\r\n message: \"No MX records found — domain cannot receive email.\",\r\n };\r\n }\r\n const sorted = records.sort((a, b) => a.priority - b.priority);\r\n return {\r\n name: \"mx\",\r\n status: \"pass\",\r\n message: `${records.length} MX record(s) found.`,\r\n detail: sorted.map((r) => `${r.priority} ${r.exchange}`).join(\", \"),\r\n };\r\n } catch (err: unknown) {\r\n const code = (err as NodeJS.ErrnoException).code;\r\n if (code === \"ENOTFOUND\" || code === \"ENODATA\") {\r\n return {\r\n name: \"mx\",\r\n status: \"fail\",\r\n message: \"No MX records found — domain cannot receive email.\",\r\n };\r\n }\r\n // Gracefully handle all DNS failures (ESERVFAIL, ECONNREFUSED, timeouts, etc.)\r\n return {\r\n name: \"mx\",\r\n status: \"skip\",\r\n message: `MX lookup failed: ${(err as Error).message}`,\r\n };\r\n }\r\n}\r\n\r\nasync function checkSPF(domain: string, dns: DnsResolver): Promise<DeliverabilityCheck> {\r\n const records = await resolveTxtSafe(domain, dns);\r\n const spfRecord = records.find((r) => r.startsWith(\"v=spf1\"));\r\n\r\n if (!spfRecord) {\r\n return {\r\n name: \"spf\",\r\n status: \"fail\",\r\n message: \"No SPF record found — receiving servers can't verify authorized senders.\",\r\n detail: 'Add a TXT record starting with \"v=spf1\" to your domain.',\r\n };\r\n }\r\n\r\n // Detect dangerous +all (allows any server to send as your domain)\r\n if (spfRecord.includes(\"+all\")) {\r\n return {\r\n name: \"spf\",\r\n status: \"fail\",\r\n message: \"SPF record uses +all — this allows any server to send as your domain.\",\r\n record: spfRecord,\r\n detail: \"Replace +all with -all (hard fail) or ~all (soft fail) to restrict authorized senders.\",\r\n };\r\n }\r\n\r\n // Validate syntax\r\n if (!spfRecord.includes(\"~all\") && !spfRecord.includes(\"-all\") && !spfRecord.includes(\"?all\")) {\r\n return {\r\n name: \"spf\",\r\n status: \"warn\",\r\n message: \"SPF record found but missing enforcement mechanism (~all or -all).\",\r\n record: spfRecord,\r\n detail: \"Add ~all (soft fail) or -all (hard fail) to the end of your SPF record.\",\r\n };\r\n }\r\n\r\n if (spfRecord.includes(\"~all\")) {\r\n return {\r\n name: \"spf\",\r\n status: \"warn\",\r\n message: \"SPF record uses ~all (soft fail) — consider upgrading to -all (hard fail) for stronger protection.\",\r\n record: spfRecord,\r\n };\r\n }\r\n\r\n return {\r\n name: \"spf\",\r\n status: \"pass\",\r\n message: \"SPF record found with -all enforcement.\",\r\n record: spfRecord,\r\n };\r\n}\r\n\r\nasync function checkDKIM(domain: string, dns: DnsResolver): Promise<DeliverabilityCheck> {\r\n // Probe common selectors in parallel\r\n const results = await Promise.allSettled(\r\n DKIM_SELECTORS.map(async (selector) => {\r\n const records = await resolveTxtSafe(`${selector}._domainkey.${domain}`, dns);\r\n const dkimRecord = records.find((r) => r.includes(\"v=DKIM1\") || r.includes(\"p=\"));\r\n if (dkimRecord) {\r\n return { selector, record: dkimRecord };\r\n }\r\n return null;\r\n }),\r\n );\r\n\r\n const matches = results\r\n .filter((r): r is PromiseFulfilledResult<{ selector: string; record: string } | null> =>\r\n r.status === \"fulfilled\" && r.value !== null)\r\n .map((r) => r.value!);\r\n\r\n if (matches.length > 0) {\r\n return {\r\n name: \"dkim\",\r\n status: \"pass\",\r\n message: `DKIM record(s) found for selector(s): ${matches.map((m) => m.selector).join(\", \")}.`,\r\n record: matches[0].record,\r\n };\r\n }\r\n\r\n return {\r\n name: \"dkim\",\r\n status: \"warn\",\r\n message: `No DKIM records found for ${DKIM_SELECTORS.length} common selectors — DKIM may use a custom selector we didn't probe.`,\r\n detail: \"This doesn't mean DKIM is missing; the actual selector may differ from the common ones we checked.\",\r\n };\r\n}\r\n\r\nasync function checkDMARC(domain: string, dns: DnsResolver): Promise<DeliverabilityCheck> {\r\n const records = await resolveTxtSafe(`_dmarc.${domain}`, dns);\r\n const dmarcRecord = records.find((r) => r.startsWith(\"v=DMARC1\"));\r\n\r\n if (!dmarcRecord) {\r\n return {\r\n name: \"dmarc\",\r\n status: \"fail\",\r\n message: \"No DMARC record found — inbox providers may reject or quarantine your emails.\",\r\n detail: 'Add a TXT record at _dmarc.yourdomain.com starting with \"v=DMARC1\".',\r\n };\r\n }\r\n\r\n const policyMatch = dmarcRecord.match(/(?:^|;)\\s*p=(\\w+)/);\r\n const policy = policyMatch?.[1]?.toLowerCase();\r\n\r\n if (policy === \"none\") {\r\n return {\r\n name: \"dmarc\",\r\n status: \"warn\",\r\n message: 'DMARC record found with p=none (monitoring only) — consider upgrading to p=quarantine or p=reject.',\r\n record: dmarcRecord,\r\n };\r\n }\r\n\r\n return {\r\n name: \"dmarc\",\r\n status: \"pass\",\r\n message: `DMARC record found with p=${policy || \"unknown\"}.`,\r\n record: dmarcRecord,\r\n };\r\n}\r\n\r\nasync function checkBIMI(domain: string, dns: DnsResolver): Promise<DeliverabilityCheck> {\r\n const records = await resolveTxtSafe(`default._bimi.${domain}`, dns);\r\n const bimiRecord = records.find((r) => r.startsWith(\"v=BIMI1\"));\r\n\r\n if (!bimiRecord) {\r\n return {\r\n name: \"bimi\",\r\n status: \"skip\",\r\n message: \"No BIMI record found — optional brand indicator (logo in inbox).\",\r\n detail: \"BIMI is a nice-to-have. It displays your brand logo next to emails in supported clients.\",\r\n };\r\n }\r\n\r\n return {\r\n name: \"bimi\",\r\n status: \"pass\",\r\n message: \"BIMI record found — your brand logo may appear in supported email clients.\",\r\n record: bimiRecord,\r\n };\r\n}\r\n\r\n// ── Main API ─────────────────────────────────────────────────────────────────\r\n\r\n/**\r\n * Check email deliverability for a domain.\r\n *\r\n * Validates MX, SPF, DKIM, DMARC, and BIMI DNS records.\r\n * All DNS queries have a 5-second timeout.\r\n *\r\n * @example\r\n * ```ts\r\n * const report = await checkDeliverability(\"example.com\");\r\n * console.log(report.score); // 0-100\r\n * console.log(report.checks); // individual check results\r\n * ```\r\n */\r\nexport async function checkDeliverability(\r\n domain: string,\r\n options?: { _resolver?: DnsResolver },\r\n): Promise<DeliverabilityReport> {\r\n const dns: DnsResolver = options?._resolver ?? defaultDns;\r\n\r\n // Normalize domain — strip protocol, path, and port\r\n const cleanDomain = domain.trim().toLowerCase()\r\n .replace(/^https?:\\/\\//, \"\")\r\n .replace(/\\/.*$/, \"\")\r\n .replace(/:\\d+$/, \"\");\r\n\r\n if (!cleanDomain || !cleanDomain.includes(\".\")) {\r\n return {\r\n domain: cleanDomain,\r\n checks: [],\r\n score: 0,\r\n issues: [{\r\n rule: \"invalid-domain\",\r\n severity: \"error\",\r\n message: `Invalid domain: \"${cleanDomain}\"`,\r\n }],\r\n };\r\n }\r\n\r\n // Run all checks in parallel — use allSettled so one failure can't crash the rest\r\n const checkNames: DeliverabilityCheck[\"name\"][] = [\"mx\", \"spf\", \"dkim\", \"dmarc\", \"bimi\"];\r\n const settled = await Promise.allSettled([\r\n checkMX(cleanDomain, dns),\r\n checkSPF(cleanDomain, dns),\r\n checkDKIM(cleanDomain, dns),\r\n checkDMARC(cleanDomain, dns),\r\n checkBIMI(cleanDomain, dns),\r\n ]);\r\n\r\n const checks: DeliverabilityCheck[] = settled.map((result, i) =>\r\n result.status === \"fulfilled\"\r\n ? result.value\r\n : { name: checkNames[i], status: \"skip\" as const, message: `Check failed: ${result.reason?.message ?? \"unknown error\"}` },\r\n );\r\n\r\n // Calculate score — skip checks are excluded from the denominator\r\n const weights: Record<string, number> = {\r\n mx: 25,\r\n spf: 25,\r\n dkim: 20,\r\n dmarc: 20,\r\n bimi: 10,\r\n };\r\n\r\n let totalWeight = 0;\r\n let earnedScore = 0;\r\n for (const check of checks) {\r\n const weight = weights[check.name] || 0;\r\n if (check.status === \"skip\") continue; // exclude from denominator\r\n totalWeight += weight;\r\n if (check.status === \"pass\") earnedScore += weight;\r\n else if (check.status === \"warn\") earnedScore += weight * 0.5;\r\n // \"fail\" = 0 points\r\n }\r\n\r\n let score = totalWeight > 0 ? Math.round((earnedScore / totalWeight) * 100) : 0;\r\n score = Math.max(0, Math.min(100, score));\r\n\r\n // Build issues list\r\n const issues: DeliverabilityIssue[] = [];\r\n for (const check of checks) {\r\n if (check.status === \"fail\") {\r\n issues.push({\r\n rule: check.name,\r\n severity: \"error\",\r\n message: check.message,\r\n detail: check.detail,\r\n });\r\n } else if (check.status === \"warn\") {\r\n issues.push({\r\n rule: check.name,\r\n severity: \"warning\",\r\n message: check.message,\r\n detail: check.detail,\r\n });\r\n }\r\n }\r\n\r\n return { domain: cleanDomain, checks, score, issues };\r\n}\r\n","/**\r\n * Optional SpamAssassin integration.\r\n *\r\n * Shells out to `spamc` (daemon mode) or `spamassassin` (standalone mode)\r\n * using execFile (safe — no shell interpolation).\r\n * Returns null if neither is installed.\r\n *\r\n * Requires a full RFC 2822 message (headers + body), not just HTML.\r\n */\r\n\r\nimport { execFile as nodeExecFile } from \"node:child_process\";\r\n\r\nexport interface SpamAssassinResult {\r\n score: number;\r\n threshold: number;\r\n isSpam: boolean;\r\n rules: Array<{ name: string; score: number; description: string }>;\r\n rawOutput: string;\r\n}\r\n\r\nexport interface SpamAssassinOptions {\r\n /** Timeout in ms (default: 30000) */\r\n timeoutMs?: number;\r\n}\r\n\r\n/**\r\n * Run a raw RFC 2822 email message through SpamAssassin.\r\n *\r\n * Tries `spamc -R` (daemon mode, faster) first, then falls back to\r\n * `spamassassin -t` (standalone mode). Returns `null` if neither\r\n * binary is available.\r\n *\r\n * Uses execFile (not exec) — no shell interpolation, safe from injection.\r\n *\r\n * @example\r\n * ```ts\r\n * const result = await checkSpamAssassin(rawMessage);\r\n * if (result) {\r\n * console.log(`Score: ${result.score}/${result.threshold}`);\r\n * console.log(`Spam: ${result.isSpam}`);\r\n * for (const rule of result.rules) {\r\n * console.log(` ${rule.score} ${rule.name}: ${rule.description}`);\r\n * }\r\n * }\r\n * ```\r\n */\r\nexport async function checkSpamAssassin(\r\n rawMessage: string,\r\n options?: SpamAssassinOptions,\r\n): Promise<SpamAssassinResult | null> {\r\n const timeout = options?.timeoutMs ?? 30_000;\r\n\r\n // Try spamc first (daemon mode — faster)\r\n const spamcResult = await tryExecFile(\"spamc\", [\"-R\"], rawMessage, timeout);\r\n if (spamcResult !== null) return parseOutput(spamcResult);\r\n\r\n // Fall back to standalone spamassassin\r\n const saResult = await tryExecFile(\"spamassassin\", [\"-t\"], rawMessage, timeout);\r\n if (saResult !== null) return parseOutput(saResult);\r\n\r\n // Neither available\r\n return null;\r\n}\r\n\r\n/**\r\n * Safe wrapper around execFile — no shell, no interpolation.\r\n * Returns stdout on success, null if binary not found.\r\n */\r\nfunction tryExecFile(\r\n cmd: string,\r\n args: string[],\r\n input: string,\r\n timeout: number,\r\n): Promise<string | null> {\r\n return new Promise((resolve) => {\r\n try {\r\n const child = nodeExecFile(cmd, args, {\r\n timeout,\r\n maxBuffer: 1024 * 1024,\r\n encoding: \"utf-8\",\r\n }, (error, stdout, stderr) => {\r\n if (error) {\r\n if ((error as NodeJS.ErrnoException).code === \"ENOENT\") {\r\n resolve(null);\r\n } else {\r\n // SpamAssassin returns non-zero for spam — stdout is still valid\r\n resolve(stdout || stderr || null);\r\n }\r\n } else {\r\n resolve(stdout);\r\n }\r\n });\r\n\r\n if (child.stdin) {\r\n child.stdin.on(\"error\", () => {}); // swallow EPIPE if process dies before reading\r\n child.stdin.write(input);\r\n child.stdin.end();\r\n }\r\n } catch {\r\n resolve(null);\r\n }\r\n });\r\n}\r\n\r\nfunction parseOutput(output: string): SpamAssassinResult {\r\n let score = 0;\r\n let threshold = 5;\r\n let isSpam = false;\r\n const rules: SpamAssassinResult[\"rules\"] = [];\r\n\r\n // Parse X-Spam-Status line\r\n const statusMatch = output.match(/X-Spam-Status:\\s*(Yes|No),\\s*score=([\\d.-]+)\\s+required=([\\d.-]+)/i);\r\n if (statusMatch) {\r\n isSpam = statusMatch[1].toLowerCase() === \"yes\";\r\n score = parseFloat(statusMatch[2]);\r\n threshold = parseFloat(statusMatch[3]);\r\n } else {\r\n // spamc -R format: first line is \"score/threshold\"\r\n const scoreMatch = output.match(/^([\\d.-]+)\\/([\\d.-]+)/m);\r\n if (scoreMatch) {\r\n score = parseFloat(scoreMatch[1]);\r\n threshold = parseFloat(scoreMatch[2]);\r\n isSpam = score >= threshold;\r\n }\r\n }\r\n\r\n // Parse rules table\r\n const rulePattern = /^\\s*([\\d.-]+)\\s+([A-Z_][A-Z0-9_]+)\\s+(.+)$/gm;\r\n let match;\r\n while ((match = rulePattern.exec(output)) !== null) {\r\n rules.push({\r\n score: parseFloat(match[1]),\r\n name: match[2],\r\n description: match[3].trim(),\r\n });\r\n }\r\n\r\n return { score, threshold, isSpam, rules, rawOutput: output };\r\n}\r\n"],"mappings":"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","names":["defaultDns","_a","nodeExecFile"]}
1
+ {"version":3,"sources":["../src/server.ts","../src/deliverability-checker.ts","../src/spamassassin.ts"],"sourcesContent":["/**\r\n * Server-only exports that depend on Node.js builtins (dns, child_process).\r\n * Import from \"@emailens/engine/server\", never from the main entry point\r\n * in client-side / browser code.\r\n */\r\nexport { checkDeliverability } from \"./deliverability-checker\";\r\nexport { checkSpamAssassin } from \"./spamassassin\";\r\nexport type { SpamAssassinResult, SpamAssassinOptions } from \"./spamassassin\";\r\n","/**\r\n * DNS-based email deliverability checker.\r\n *\r\n * Validates SPF, DKIM, DMARC, MX, and BIMI records for a domain\r\n * using node:dns/promises. No external dependencies.\r\n */\r\n\r\nimport { promises as defaultDns } from \"node:dns\";\r\nimport type { DeliverabilityCheck, DeliverabilityReport, DeliverabilityIssue } from \"./types\";\r\n\r\n/** DNS resolver interface: injectable for testing. */\r\nexport interface DnsResolver {\r\n resolveMx: typeof defaultDns.resolveMx;\r\n resolveTxt: typeof defaultDns.resolveTxt;\r\n}\r\n\r\n/** Common DKIM selectors to probe (we can't know the real selector). */\r\nconst DKIM_SELECTORS = [\r\n \"google\", \"selector1\", \"selector2\", \"default\", \"dkim\",\r\n \"k1\", \"k2\", \"k3\", \"mail\", \"s1\", \"s2\",\r\n \"mandrill\", \"pm\", \"protonmail\", \"smtp\",\r\n];\r\n\r\nconst DNS_TIMEOUT_MS = 5_000;\r\n\r\n/**\r\n * Race a DNS call against a timeout. Node's DNS API has no signal support,\r\n * so we use Promise.race; the underlying query may continue in the background\r\n * but we stop waiting.\r\n */\r\nasync function withTimeout<T>(\r\n fn: () => Promise<T>,\r\n timeoutMs = DNS_TIMEOUT_MS,\r\n): Promise<T> {\r\n let timer: ReturnType<typeof setTimeout> | undefined;\r\n const timeout = new Promise<never>((_, reject) => {\r\n timer = setTimeout(() => {\r\n const err = Object.assign(new Error(`DNS timeout after ${timeoutMs}ms`), { code: \"ABORT_ERR\" });\r\n reject(err);\r\n }, timeoutMs);\r\n });\r\n try {\r\n return await Promise.race([fn(), timeout]);\r\n } finally {\r\n clearTimeout(timer);\r\n }\r\n}\r\n\r\n/**\r\n * Safe DNS TXT lookup: returns empty array on ENOTFOUND/ENODATA.\r\n */\r\nasync function resolveTxtSafe(domain: string, dns: DnsResolver): Promise<string[]> {\r\n try {\r\n const records = await withTimeout(() => dns.resolveTxt(domain));\r\n return records.map((chunks) => chunks.join(\"\"));\r\n } catch (err: unknown) {\r\n const code = (err as NodeJS.ErrnoException).code;\r\n if (code === \"ENOTFOUND\" || code === \"ENODATA\" || code === \"ETIMEOUT\" || code === \"ABORT_ERR\"\r\n || code === \"ESERVFAIL\" || code === \"ECONNREFUSED\" || code === \"EAI_AGAIN\" || code === \"ECANCELLED\") {\r\n return [];\r\n }\r\n throw err;\r\n }\r\n}\r\n\r\n// ── Individual checks ────────────────────────────────────────────────────────\r\n\r\nasync function checkMX(domain: string, dns: DnsResolver): Promise<DeliverabilityCheck> {\r\n try {\r\n const records = await withTimeout(() => dns.resolveMx(domain));\r\n if (records.length === 0) {\r\n return {\r\n name: \"mx\",\r\n status: \"fail\",\r\n message: \"No MX records found; domain cannot receive email.\",\r\n };\r\n }\r\n const sorted = records.sort((a, b) => a.priority - b.priority);\r\n return {\r\n name: \"mx\",\r\n status: \"pass\",\r\n message: `${records.length} MX record(s) found.`,\r\n detail: sorted.map((r) => `${r.priority} ${r.exchange}`).join(\", \"),\r\n };\r\n } catch (err: unknown) {\r\n const code = (err as NodeJS.ErrnoException).code;\r\n if (code === \"ENOTFOUND\" || code === \"ENODATA\") {\r\n return {\r\n name: \"mx\",\r\n status: \"fail\",\r\n message: \"No MX records found; domain cannot receive email.\",\r\n };\r\n }\r\n // Gracefully handle all DNS failures (ESERVFAIL, ECONNREFUSED, timeouts, etc.)\r\n return {\r\n name: \"mx\",\r\n status: \"skip\",\r\n message: `MX lookup failed: ${(err as Error).message}`,\r\n };\r\n }\r\n}\r\n\r\nasync function checkSPF(domain: string, dns: DnsResolver): Promise<DeliverabilityCheck> {\r\n const records = await resolveTxtSafe(domain, dns);\r\n const spfRecord = records.find((r) => r.startsWith(\"v=spf1\"));\r\n\r\n if (!spfRecord) {\r\n return {\r\n name: \"spf\",\r\n status: \"fail\",\r\n message: \"No SPF record found; receiving servers can't verify authorized senders.\",\r\n detail: 'Add a TXT record starting with \"v=spf1\" to your domain.',\r\n };\r\n }\r\n\r\n // Detect dangerous +all (allows any server to send as your domain)\r\n if (spfRecord.includes(\"+all\")) {\r\n return {\r\n name: \"spf\",\r\n status: \"fail\",\r\n message: \"SPF record uses +all; this allows any server to send as your domain.\",\r\n record: spfRecord,\r\n detail: \"Replace +all with -all (hard fail) or ~all (soft fail) to restrict authorized senders.\",\r\n };\r\n }\r\n\r\n // Validate syntax\r\n if (!spfRecord.includes(\"~all\") && !spfRecord.includes(\"-all\") && !spfRecord.includes(\"?all\")) {\r\n return {\r\n name: \"spf\",\r\n status: \"warn\",\r\n message: \"SPF record found but missing enforcement mechanism (~all or -all).\",\r\n record: spfRecord,\r\n detail: \"Add ~all (soft fail) or -all (hard fail) to the end of your SPF record.\",\r\n };\r\n }\r\n\r\n if (spfRecord.includes(\"~all\")) {\r\n return {\r\n name: \"spf\",\r\n status: \"warn\",\r\n message: \"SPF record uses ~all (soft fail); consider upgrading to -all (hard fail) for stronger protection.\",\r\n record: spfRecord,\r\n };\r\n }\r\n\r\n return {\r\n name: \"spf\",\r\n status: \"pass\",\r\n message: \"SPF record found with -all enforcement.\",\r\n record: spfRecord,\r\n };\r\n}\r\n\r\nasync function checkDKIM(domain: string, dns: DnsResolver): Promise<DeliverabilityCheck> {\r\n // Probe common selectors in parallel\r\n const results = await Promise.allSettled(\r\n DKIM_SELECTORS.map(async (selector) => {\r\n const records = await resolveTxtSafe(`${selector}._domainkey.${domain}`, dns);\r\n const dkimRecord = records.find((r) => r.includes(\"v=DKIM1\") || r.includes(\"p=\"));\r\n if (dkimRecord) {\r\n return { selector, record: dkimRecord };\r\n }\r\n return null;\r\n }),\r\n );\r\n\r\n const matches = results\r\n .filter((r): r is PromiseFulfilledResult<{ selector: string; record: string } | null> =>\r\n r.status === \"fulfilled\" && r.value !== null)\r\n .map((r) => r.value!);\r\n\r\n if (matches.length > 0) {\r\n return {\r\n name: \"dkim\",\r\n status: \"pass\",\r\n message: `DKIM record(s) found for selector(s): ${matches.map((m) => m.selector).join(\", \")}.`,\r\n record: matches[0].record,\r\n };\r\n }\r\n\r\n return {\r\n name: \"dkim\",\r\n status: \"warn\",\r\n message: `No DKIM records found for ${DKIM_SELECTORS.length} common selectors; DKIM may use a custom selector we didn't probe.`,\r\n detail: \"This doesn't mean DKIM is missing; the actual selector may differ from the common ones we checked.\",\r\n };\r\n}\r\n\r\nasync function checkDMARC(domain: string, dns: DnsResolver): Promise<DeliverabilityCheck> {\r\n const records = await resolveTxtSafe(`_dmarc.${domain}`, dns);\r\n const dmarcRecord = records.find((r) => r.startsWith(\"v=DMARC1\"));\r\n\r\n if (!dmarcRecord) {\r\n return {\r\n name: \"dmarc\",\r\n status: \"fail\",\r\n message: \"No DMARC record found; inbox providers may reject or quarantine your emails.\",\r\n detail: 'Add a TXT record at _dmarc.yourdomain.com starting with \"v=DMARC1\".',\r\n };\r\n }\r\n\r\n const policyMatch = dmarcRecord.match(/(?:^|;)\\s*p=(\\w+)/);\r\n const policy = policyMatch?.[1]?.toLowerCase();\r\n\r\n if (policy === \"none\") {\r\n return {\r\n name: \"dmarc\",\r\n status: \"warn\",\r\n message: 'DMARC record found with p=none (monitoring only); consider upgrading to p=quarantine or p=reject.',\r\n record: dmarcRecord,\r\n };\r\n }\r\n\r\n return {\r\n name: \"dmarc\",\r\n status: \"pass\",\r\n message: `DMARC record found with p=${policy || \"unknown\"}.`,\r\n record: dmarcRecord,\r\n };\r\n}\r\n\r\nasync function checkBIMI(domain: string, dns: DnsResolver): Promise<DeliverabilityCheck> {\r\n const records = await resolveTxtSafe(`default._bimi.${domain}`, dns);\r\n const bimiRecord = records.find((r) => r.startsWith(\"v=BIMI1\"));\r\n\r\n if (!bimiRecord) {\r\n return {\r\n name: \"bimi\",\r\n status: \"skip\",\r\n message: \"No BIMI record found; BIMI is an optional brand indicator (logo in inbox).\",\r\n detail: \"BIMI is a nice-to-have. It displays your brand logo next to emails in supported clients.\",\r\n };\r\n }\r\n\r\n return {\r\n name: \"bimi\",\r\n status: \"pass\",\r\n message: \"BIMI record found; your brand logo may appear in supported email clients.\",\r\n record: bimiRecord,\r\n };\r\n}\r\n\r\n// ── Main API ─────────────────────────────────────────────────────────────────\r\n\r\n/**\r\n * Check email deliverability for a domain.\r\n *\r\n * Validates MX, SPF, DKIM, DMARC, and BIMI DNS records.\r\n * All DNS queries have a 5-second timeout.\r\n *\r\n * @example\r\n * ```ts\r\n * const report = await checkDeliverability(\"example.com\");\r\n * console.log(report.score); // 0-100\r\n * console.log(report.checks); // individual check results\r\n * ```\r\n */\r\nexport async function checkDeliverability(\r\n domain: string,\r\n options?: { _resolver?: DnsResolver },\r\n): Promise<DeliverabilityReport> {\r\n const dns: DnsResolver = options?._resolver ?? defaultDns;\r\n\r\n // Normalize domain: strip protocol, path, and port\r\n const cleanDomain = domain.trim().toLowerCase()\r\n .replace(/^https?:\\/\\//, \"\")\r\n .replace(/\\/.*$/, \"\")\r\n .replace(/:\\d+$/, \"\");\r\n\r\n if (!cleanDomain || !cleanDomain.includes(\".\")) {\r\n return {\r\n domain: cleanDomain,\r\n checks: [],\r\n score: 0,\r\n issues: [{\r\n rule: \"invalid-domain\",\r\n severity: \"error\",\r\n message: `Invalid domain: \"${cleanDomain}\"`,\r\n }],\r\n };\r\n }\r\n\r\n // Run all checks in parallel: use allSettled so one failure can't crash the rest\r\n const checkNames: DeliverabilityCheck[\"name\"][] = [\"mx\", \"spf\", \"dkim\", \"dmarc\", \"bimi\"];\r\n const settled = await Promise.allSettled([\r\n checkMX(cleanDomain, dns),\r\n checkSPF(cleanDomain, dns),\r\n checkDKIM(cleanDomain, dns),\r\n checkDMARC(cleanDomain, dns),\r\n checkBIMI(cleanDomain, dns),\r\n ]);\r\n\r\n const checks: DeliverabilityCheck[] = settled.map((result, i) =>\r\n result.status === \"fulfilled\"\r\n ? result.value\r\n : { name: checkNames[i], status: \"skip\" as const, message: `Check failed: ${result.reason?.message ?? \"unknown error\"}` },\r\n );\r\n\r\n // Calculate score; skip checks are excluded from the denominator\r\n const weights: Record<string, number> = {\r\n mx: 25,\r\n spf: 25,\r\n dkim: 20,\r\n dmarc: 20,\r\n bimi: 10,\r\n };\r\n\r\n let totalWeight = 0;\r\n let earnedScore = 0;\r\n for (const check of checks) {\r\n const weight = weights[check.name] || 0;\r\n if (check.status === \"skip\") continue; // exclude from denominator\r\n totalWeight += weight;\r\n if (check.status === \"pass\") earnedScore += weight;\r\n else if (check.status === \"warn\") earnedScore += weight * 0.5;\r\n // \"fail\" = 0 points\r\n }\r\n\r\n let score = totalWeight > 0 ? Math.round((earnedScore / totalWeight) * 100) : 0;\r\n score = Math.max(0, Math.min(100, score));\r\n\r\n // Build issues list\r\n const issues: DeliverabilityIssue[] = [];\r\n for (const check of checks) {\r\n if (check.status === \"fail\") {\r\n issues.push({\r\n rule: check.name,\r\n severity: \"error\",\r\n message: check.message,\r\n detail: check.detail,\r\n });\r\n } else if (check.status === \"warn\") {\r\n issues.push({\r\n rule: check.name,\r\n severity: \"warning\",\r\n message: check.message,\r\n detail: check.detail,\r\n });\r\n }\r\n }\r\n\r\n return { domain: cleanDomain, checks, score, issues };\r\n}\r\n","/**\r\n * Optional SpamAssassin integration.\r\n *\r\n * Shells out to `spamc` (daemon mode) or `spamassassin` (standalone mode)\r\n * using execFile (safe, no shell interpolation).\r\n * Returns null if neither is installed.\r\n *\r\n * Requires a full RFC 2822 message (headers + body), not just HTML.\r\n */\r\n\r\nimport { execFile as nodeExecFile } from \"node:child_process\";\r\n\r\nexport interface SpamAssassinResult {\r\n score: number;\r\n threshold: number;\r\n isSpam: boolean;\r\n rules: Array<{ name: string; score: number; description: string }>;\r\n rawOutput: string;\r\n}\r\n\r\nexport interface SpamAssassinOptions {\r\n /** Timeout in ms (default: 30000) */\r\n timeoutMs?: number;\r\n}\r\n\r\n/**\r\n * Run a raw RFC 2822 email message through SpamAssassin.\r\n *\r\n * Tries `spamc -R` (daemon mode, faster) first, then falls back to\r\n * `spamassassin -t` (standalone mode). Returns `null` if neither\r\n * binary is available.\r\n *\r\n * Uses execFile (not exec): no shell interpolation, safe from injection.\r\n *\r\n * @example\r\n * ```ts\r\n * const result = await checkSpamAssassin(rawMessage);\r\n * if (result) {\r\n * console.log(`Score: ${result.score}/${result.threshold}`);\r\n * console.log(`Spam: ${result.isSpam}`);\r\n * for (const rule of result.rules) {\r\n * console.log(` ${rule.score} ${rule.name}: ${rule.description}`);\r\n * }\r\n * }\r\n * ```\r\n */\r\nexport async function checkSpamAssassin(\r\n rawMessage: string,\r\n options?: SpamAssassinOptions,\r\n): Promise<SpamAssassinResult | null> {\r\n const timeout = options?.timeoutMs ?? 30_000;\r\n\r\n // Try spamc first (daemon mode, faster)\r\n const spamcResult = await tryExecFile(\"spamc\", [\"-R\"], rawMessage, timeout);\r\n if (spamcResult !== null) return parseOutput(spamcResult);\r\n\r\n // Fall back to standalone spamassassin\r\n const saResult = await tryExecFile(\"spamassassin\", [\"-t\"], rawMessage, timeout);\r\n if (saResult !== null) return parseOutput(saResult);\r\n\r\n // Neither available\r\n return null;\r\n}\r\n\r\n/**\r\n * Safe wrapper around execFile: no shell, no interpolation.\r\n * Returns stdout on success, null if binary not found.\r\n */\r\nfunction tryExecFile(\r\n cmd: string,\r\n args: string[],\r\n input: string,\r\n timeout: number,\r\n): Promise<string | null> {\r\n return new Promise((resolve) => {\r\n try {\r\n const child = nodeExecFile(cmd, args, {\r\n timeout,\r\n maxBuffer: 1024 * 1024,\r\n encoding: \"utf-8\",\r\n }, (error, stdout, stderr) => {\r\n if (error) {\r\n if ((error as NodeJS.ErrnoException).code === \"ENOENT\") {\r\n resolve(null);\r\n } else {\r\n // SpamAssassin returns non-zero for spam, stdout is still valid\r\n resolve(stdout || stderr || null);\r\n }\r\n } else {\r\n resolve(stdout);\r\n }\r\n });\r\n\r\n if (child.stdin) {\r\n child.stdin.on(\"error\", () => {}); // swallow EPIPE if process dies before reading\r\n child.stdin.write(input);\r\n child.stdin.end();\r\n }\r\n } catch {\r\n resolve(null);\r\n }\r\n });\r\n}\r\n\r\nfunction parseOutput(output: string): SpamAssassinResult {\r\n let score = 0;\r\n let threshold = 5;\r\n let isSpam = false;\r\n const rules: SpamAssassinResult[\"rules\"] = [];\r\n\r\n // Parse X-Spam-Status line\r\n const statusMatch = output.match(/X-Spam-Status:\\s*(Yes|No),\\s*score=([\\d.-]+)\\s+required=([\\d.-]+)/i);\r\n if (statusMatch) {\r\n isSpam = statusMatch[1].toLowerCase() === \"yes\";\r\n score = parseFloat(statusMatch[2]);\r\n threshold = parseFloat(statusMatch[3]);\r\n } else {\r\n // spamc -R format: first line is \"score/threshold\"\r\n const scoreMatch = output.match(/^([\\d.-]+)\\/([\\d.-]+)/m);\r\n if (scoreMatch) {\r\n score = parseFloat(scoreMatch[1]);\r\n threshold = parseFloat(scoreMatch[2]);\r\n isSpam = score >= threshold;\r\n }\r\n }\r\n\r\n // Parse rules table\r\n const rulePattern = /^\\s*([\\d.-]+)\\s+([A-Z_][A-Z0-9_]+)\\s+(.+)$/gm;\r\n let match;\r\n while ((match = rulePattern.exec(output)) !== null) {\r\n rules.push({\r\n score: parseFloat(match[1]),\r\n name: match[2],\r\n description: match[3].trim(),\r\n });\r\n }\r\n\r\n return { score, threshold, isSpam, rules, rawOutput: output };\r\n}\r\n"],"mappings":"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","names":["defaultDns","_a","nodeExecFile"]}
package/dist/server.d.cts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { promises } from 'node:dns';
2
- import { j as DeliverabilityReport } from './types-BLR3-Fzo.cjs';
2
+ import { m as DeliverabilityReport } from './types-bvGDId6f.cjs';
3
3
 
4
4
  /**
5
5
  * DNS-based email deliverability checker.
@@ -8,7 +8,7 @@ import { j as DeliverabilityReport } from './types-BLR3-Fzo.cjs';
8
8
  * using node:dns/promises. No external dependencies.
9
9
  */
10
10
 
11
- /** DNS resolver interface injectable for testing. */
11
+ /** DNS resolver interface: injectable for testing. */
12
12
  interface DnsResolver {
13
13
  resolveMx: typeof promises.resolveMx;
14
14
  resolveTxt: typeof promises.resolveTxt;
@@ -34,7 +34,7 @@ declare function checkDeliverability(domain: string, options?: {
34
34
  * Optional SpamAssassin integration.
35
35
  *
36
36
  * Shells out to `spamc` (daemon mode) or `spamassassin` (standalone mode)
37
- * using execFile (safe no shell interpolation).
37
+ * using execFile (safe, no shell interpolation).
38
38
  * Returns null if neither is installed.
39
39
  *
40
40
  * Requires a full RFC 2822 message (headers + body), not just HTML.
@@ -61,7 +61,7 @@ interface SpamAssassinOptions {
61
61
  * `spamassassin -t` (standalone mode). Returns `null` if neither
62
62
  * binary is available.
63
63
  *
64
- * Uses execFile (not exec) no shell interpolation, safe from injection.
64
+ * Uses execFile (not exec): no shell interpolation, safe from injection.
65
65
  *
66
66
  * @example
67
67
  * ```ts
package/dist/server.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { promises } from 'node:dns';
2
- import { j as DeliverabilityReport } from './types-BLR3-Fzo.js';
2
+ import { m as DeliverabilityReport } from './types-bvGDId6f.js';
3
3
 
4
4
  /**
5
5
  * DNS-based email deliverability checker.
@@ -8,7 +8,7 @@ import { j as DeliverabilityReport } from './types-BLR3-Fzo.js';
8
8
  * using node:dns/promises. No external dependencies.
9
9
  */
10
10
 
11
- /** DNS resolver interface injectable for testing. */
11
+ /** DNS resolver interface: injectable for testing. */
12
12
  interface DnsResolver {
13
13
  resolveMx: typeof promises.resolveMx;
14
14
  resolveTxt: typeof promises.resolveTxt;
@@ -34,7 +34,7 @@ declare function checkDeliverability(domain: string, options?: {
34
34
  * Optional SpamAssassin integration.
35
35
  *
36
36
  * Shells out to `spamc` (daemon mode) or `spamassassin` (standalone mode)
37
- * using execFile (safe no shell interpolation).
37
+ * using execFile (safe, no shell interpolation).
38
38
  * Returns null if neither is installed.
39
39
  *
40
40
  * Requires a full RFC 2822 message (headers + body), not just HTML.
@@ -61,7 +61,7 @@ interface SpamAssassinOptions {
61
61
  * `spamassassin -t` (standalone mode). Returns `null` if neither
62
62
  * binary is available.
63
63
  *
64
- * Uses execFile (not exec) no shell interpolation, safe from injection.
64
+ * Uses execFile (not exec): no shell interpolation, safe from injection.
65
65
  *
66
66
  * @example
67
67
  * ```ts
package/dist/server.js CHANGED
@@ -53,7 +53,7 @@ async function checkMX(domain, dns) {
53
53
  return {
54
54
  name: "mx",
55
55
  status: "fail",
56
- message: "No MX records found \u2014 domain cannot receive email."
56
+ message: "No MX records found; domain cannot receive email."
57
57
  };
58
58
  }
59
59
  const sorted = records.sort((a, b) => a.priority - b.priority);
@@ -69,7 +69,7 @@ async function checkMX(domain, dns) {
69
69
  return {
70
70
  name: "mx",
71
71
  status: "fail",
72
- message: "No MX records found \u2014 domain cannot receive email."
72
+ message: "No MX records found; domain cannot receive email."
73
73
  };
74
74
  }
75
75
  return {
@@ -86,7 +86,7 @@ async function checkSPF(domain, dns) {
86
86
  return {
87
87
  name: "spf",
88
88
  status: "fail",
89
- message: "No SPF record found \u2014 receiving servers can't verify authorized senders.",
89
+ message: "No SPF record found; receiving servers can't verify authorized senders.",
90
90
  detail: 'Add a TXT record starting with "v=spf1" to your domain.'
91
91
  };
92
92
  }
@@ -94,7 +94,7 @@ async function checkSPF(domain, dns) {
94
94
  return {
95
95
  name: "spf",
96
96
  status: "fail",
97
- message: "SPF record uses +all \u2014 this allows any server to send as your domain.",
97
+ message: "SPF record uses +all; this allows any server to send as your domain.",
98
98
  record: spfRecord,
99
99
  detail: "Replace +all with -all (hard fail) or ~all (soft fail) to restrict authorized senders."
100
100
  };
@@ -112,7 +112,7 @@ async function checkSPF(domain, dns) {
112
112
  return {
113
113
  name: "spf",
114
114
  status: "warn",
115
- message: "SPF record uses ~all (soft fail) \u2014 consider upgrading to -all (hard fail) for stronger protection.",
115
+ message: "SPF record uses ~all (soft fail); consider upgrading to -all (hard fail) for stronger protection.",
116
116
  record: spfRecord
117
117
  };
118
118
  }
@@ -146,7 +146,7 @@ async function checkDKIM(domain, dns) {
146
146
  return {
147
147
  name: "dkim",
148
148
  status: "warn",
149
- message: `No DKIM records found for ${DKIM_SELECTORS.length} common selectors \u2014 DKIM may use a custom selector we didn't probe.`,
149
+ message: `No DKIM records found for ${DKIM_SELECTORS.length} common selectors; DKIM may use a custom selector we didn't probe.`,
150
150
  detail: "This doesn't mean DKIM is missing; the actual selector may differ from the common ones we checked."
151
151
  };
152
152
  }
@@ -158,7 +158,7 @@ async function checkDMARC(domain, dns) {
158
158
  return {
159
159
  name: "dmarc",
160
160
  status: "fail",
161
- message: "No DMARC record found \u2014 inbox providers may reject or quarantine your emails.",
161
+ message: "No DMARC record found; inbox providers may reject or quarantine your emails.",
162
162
  detail: 'Add a TXT record at _dmarc.yourdomain.com starting with "v=DMARC1".'
163
163
  };
164
164
  }
@@ -168,7 +168,7 @@ async function checkDMARC(domain, dns) {
168
168
  return {
169
169
  name: "dmarc",
170
170
  status: "warn",
171
- message: "DMARC record found with p=none (monitoring only) \u2014 consider upgrading to p=quarantine or p=reject.",
171
+ message: "DMARC record found with p=none (monitoring only); consider upgrading to p=quarantine or p=reject.",
172
172
  record: dmarcRecord
173
173
  };
174
174
  }
@@ -186,14 +186,14 @@ async function checkBIMI(domain, dns) {
186
186
  return {
187
187
  name: "bimi",
188
188
  status: "skip",
189
- message: "No BIMI record found \u2014 optional brand indicator (logo in inbox).",
189
+ message: "No BIMI record found; BIMI is an optional brand indicator (logo in inbox).",
190
190
  detail: "BIMI is a nice-to-have. It displays your brand logo next to emails in supported clients."
191
191
  };
192
192
  }
193
193
  return {
194
194
  name: "bimi",
195
195
  status: "pass",
196
- message: "BIMI record found \u2014 your brand logo may appear in supported email clients.",
196
+ message: "BIMI record found; your brand logo may appear in supported email clients.",
197
197
  record: bimiRecord
198
198
  };
199
199
  }
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/deliverability-checker.ts","../src/spamassassin.ts"],"sourcesContent":["/**\r\n * DNS-based email deliverability checker.\r\n *\r\n * Validates SPF, DKIM, DMARC, MX, and BIMI records for a domain\r\n * using node:dns/promises. No external dependencies.\r\n */\r\n\r\nimport { promises as defaultDns } from \"node:dns\";\r\nimport type { DeliverabilityCheck, DeliverabilityReport, DeliverabilityIssue } from \"./types\";\r\n\r\n/** DNS resolver interface — injectable for testing. */\r\nexport interface DnsResolver {\r\n resolveMx: typeof defaultDns.resolveMx;\r\n resolveTxt: typeof defaultDns.resolveTxt;\r\n}\r\n\r\n/** Common DKIM selectors to probe (we can't know the real selector). */\r\nconst DKIM_SELECTORS = [\r\n \"google\", \"selector1\", \"selector2\", \"default\", \"dkim\",\r\n \"k1\", \"k2\", \"k3\", \"mail\", \"s1\", \"s2\",\r\n \"mandrill\", \"pm\", \"protonmail\", \"smtp\",\r\n];\r\n\r\nconst DNS_TIMEOUT_MS = 5_000;\r\n\r\n/**\r\n * Race a DNS call against a timeout. Node's DNS API has no signal support,\r\n * so we use Promise.race — the underlying query may continue in the background\r\n * but we stop waiting.\r\n */\r\nasync function withTimeout<T>(\r\n fn: () => Promise<T>,\r\n timeoutMs = DNS_TIMEOUT_MS,\r\n): Promise<T> {\r\n let timer: ReturnType<typeof setTimeout> | undefined;\r\n const timeout = new Promise<never>((_, reject) => {\r\n timer = setTimeout(() => {\r\n const err = Object.assign(new Error(`DNS timeout after ${timeoutMs}ms`), { code: \"ABORT_ERR\" });\r\n reject(err);\r\n }, timeoutMs);\r\n });\r\n try {\r\n return await Promise.race([fn(), timeout]);\r\n } finally {\r\n clearTimeout(timer);\r\n }\r\n}\r\n\r\n/**\r\n * Safe DNS TXT lookup — returns empty array on ENOTFOUND/ENODATA.\r\n */\r\nasync function resolveTxtSafe(domain: string, dns: DnsResolver): Promise<string[]> {\r\n try {\r\n const records = await withTimeout(() => dns.resolveTxt(domain));\r\n return records.map((chunks) => chunks.join(\"\"));\r\n } catch (err: unknown) {\r\n const code = (err as NodeJS.ErrnoException).code;\r\n if (code === \"ENOTFOUND\" || code === \"ENODATA\" || code === \"ETIMEOUT\" || code === \"ABORT_ERR\"\r\n || code === \"ESERVFAIL\" || code === \"ECONNREFUSED\" || code === \"EAI_AGAIN\" || code === \"ECANCELLED\") {\r\n return [];\r\n }\r\n throw err;\r\n }\r\n}\r\n\r\n// ── Individual checks ────────────────────────────────────────────────────────\r\n\r\nasync function checkMX(domain: string, dns: DnsResolver): Promise<DeliverabilityCheck> {\r\n try {\r\n const records = await withTimeout(() => dns.resolveMx(domain));\r\n if (records.length === 0) {\r\n return {\r\n name: \"mx\",\r\n status: \"fail\",\r\n message: \"No MX records found — domain cannot receive email.\",\r\n };\r\n }\r\n const sorted = records.sort((a, b) => a.priority - b.priority);\r\n return {\r\n name: \"mx\",\r\n status: \"pass\",\r\n message: `${records.length} MX record(s) found.`,\r\n detail: sorted.map((r) => `${r.priority} ${r.exchange}`).join(\", \"),\r\n };\r\n } catch (err: unknown) {\r\n const code = (err as NodeJS.ErrnoException).code;\r\n if (code === \"ENOTFOUND\" || code === \"ENODATA\") {\r\n return {\r\n name: \"mx\",\r\n status: \"fail\",\r\n message: \"No MX records found — domain cannot receive email.\",\r\n };\r\n }\r\n // Gracefully handle all DNS failures (ESERVFAIL, ECONNREFUSED, timeouts, etc.)\r\n return {\r\n name: \"mx\",\r\n status: \"skip\",\r\n message: `MX lookup failed: ${(err as Error).message}`,\r\n };\r\n }\r\n}\r\n\r\nasync function checkSPF(domain: string, dns: DnsResolver): Promise<DeliverabilityCheck> {\r\n const records = await resolveTxtSafe(domain, dns);\r\n const spfRecord = records.find((r) => r.startsWith(\"v=spf1\"));\r\n\r\n if (!spfRecord) {\r\n return {\r\n name: \"spf\",\r\n status: \"fail\",\r\n message: \"No SPF record found — receiving servers can't verify authorized senders.\",\r\n detail: 'Add a TXT record starting with \"v=spf1\" to your domain.',\r\n };\r\n }\r\n\r\n // Detect dangerous +all (allows any server to send as your domain)\r\n if (spfRecord.includes(\"+all\")) {\r\n return {\r\n name: \"spf\",\r\n status: \"fail\",\r\n message: \"SPF record uses +all — this allows any server to send as your domain.\",\r\n record: spfRecord,\r\n detail: \"Replace +all with -all (hard fail) or ~all (soft fail) to restrict authorized senders.\",\r\n };\r\n }\r\n\r\n // Validate syntax\r\n if (!spfRecord.includes(\"~all\") && !spfRecord.includes(\"-all\") && !spfRecord.includes(\"?all\")) {\r\n return {\r\n name: \"spf\",\r\n status: \"warn\",\r\n message: \"SPF record found but missing enforcement mechanism (~all or -all).\",\r\n record: spfRecord,\r\n detail: \"Add ~all (soft fail) or -all (hard fail) to the end of your SPF record.\",\r\n };\r\n }\r\n\r\n if (spfRecord.includes(\"~all\")) {\r\n return {\r\n name: \"spf\",\r\n status: \"warn\",\r\n message: \"SPF record uses ~all (soft fail) — consider upgrading to -all (hard fail) for stronger protection.\",\r\n record: spfRecord,\r\n };\r\n }\r\n\r\n return {\r\n name: \"spf\",\r\n status: \"pass\",\r\n message: \"SPF record found with -all enforcement.\",\r\n record: spfRecord,\r\n };\r\n}\r\n\r\nasync function checkDKIM(domain: string, dns: DnsResolver): Promise<DeliverabilityCheck> {\r\n // Probe common selectors in parallel\r\n const results = await Promise.allSettled(\r\n DKIM_SELECTORS.map(async (selector) => {\r\n const records = await resolveTxtSafe(`${selector}._domainkey.${domain}`, dns);\r\n const dkimRecord = records.find((r) => r.includes(\"v=DKIM1\") || r.includes(\"p=\"));\r\n if (dkimRecord) {\r\n return { selector, record: dkimRecord };\r\n }\r\n return null;\r\n }),\r\n );\r\n\r\n const matches = results\r\n .filter((r): r is PromiseFulfilledResult<{ selector: string; record: string } | null> =>\r\n r.status === \"fulfilled\" && r.value !== null)\r\n .map((r) => r.value!);\r\n\r\n if (matches.length > 0) {\r\n return {\r\n name: \"dkim\",\r\n status: \"pass\",\r\n message: `DKIM record(s) found for selector(s): ${matches.map((m) => m.selector).join(\", \")}.`,\r\n record: matches[0].record,\r\n };\r\n }\r\n\r\n return {\r\n name: \"dkim\",\r\n status: \"warn\",\r\n message: `No DKIM records found for ${DKIM_SELECTORS.length} common selectors — DKIM may use a custom selector we didn't probe.`,\r\n detail: \"This doesn't mean DKIM is missing; the actual selector may differ from the common ones we checked.\",\r\n };\r\n}\r\n\r\nasync function checkDMARC(domain: string, dns: DnsResolver): Promise<DeliverabilityCheck> {\r\n const records = await resolveTxtSafe(`_dmarc.${domain}`, dns);\r\n const dmarcRecord = records.find((r) => r.startsWith(\"v=DMARC1\"));\r\n\r\n if (!dmarcRecord) {\r\n return {\r\n name: \"dmarc\",\r\n status: \"fail\",\r\n message: \"No DMARC record found — inbox providers may reject or quarantine your emails.\",\r\n detail: 'Add a TXT record at _dmarc.yourdomain.com starting with \"v=DMARC1\".',\r\n };\r\n }\r\n\r\n const policyMatch = dmarcRecord.match(/(?:^|;)\\s*p=(\\w+)/);\r\n const policy = policyMatch?.[1]?.toLowerCase();\r\n\r\n if (policy === \"none\") {\r\n return {\r\n name: \"dmarc\",\r\n status: \"warn\",\r\n message: 'DMARC record found with p=none (monitoring only) — consider upgrading to p=quarantine or p=reject.',\r\n record: dmarcRecord,\r\n };\r\n }\r\n\r\n return {\r\n name: \"dmarc\",\r\n status: \"pass\",\r\n message: `DMARC record found with p=${policy || \"unknown\"}.`,\r\n record: dmarcRecord,\r\n };\r\n}\r\n\r\nasync function checkBIMI(domain: string, dns: DnsResolver): Promise<DeliverabilityCheck> {\r\n const records = await resolveTxtSafe(`default._bimi.${domain}`, dns);\r\n const bimiRecord = records.find((r) => r.startsWith(\"v=BIMI1\"));\r\n\r\n if (!bimiRecord) {\r\n return {\r\n name: \"bimi\",\r\n status: \"skip\",\r\n message: \"No BIMI record found — optional brand indicator (logo in inbox).\",\r\n detail: \"BIMI is a nice-to-have. It displays your brand logo next to emails in supported clients.\",\r\n };\r\n }\r\n\r\n return {\r\n name: \"bimi\",\r\n status: \"pass\",\r\n message: \"BIMI record found — your brand logo may appear in supported email clients.\",\r\n record: bimiRecord,\r\n };\r\n}\r\n\r\n// ── Main API ─────────────────────────────────────────────────────────────────\r\n\r\n/**\r\n * Check email deliverability for a domain.\r\n *\r\n * Validates MX, SPF, DKIM, DMARC, and BIMI DNS records.\r\n * All DNS queries have a 5-second timeout.\r\n *\r\n * @example\r\n * ```ts\r\n * const report = await checkDeliverability(\"example.com\");\r\n * console.log(report.score); // 0-100\r\n * console.log(report.checks); // individual check results\r\n * ```\r\n */\r\nexport async function checkDeliverability(\r\n domain: string,\r\n options?: { _resolver?: DnsResolver },\r\n): Promise<DeliverabilityReport> {\r\n const dns: DnsResolver = options?._resolver ?? defaultDns;\r\n\r\n // Normalize domain — strip protocol, path, and port\r\n const cleanDomain = domain.trim().toLowerCase()\r\n .replace(/^https?:\\/\\//, \"\")\r\n .replace(/\\/.*$/, \"\")\r\n .replace(/:\\d+$/, \"\");\r\n\r\n if (!cleanDomain || !cleanDomain.includes(\".\")) {\r\n return {\r\n domain: cleanDomain,\r\n checks: [],\r\n score: 0,\r\n issues: [{\r\n rule: \"invalid-domain\",\r\n severity: \"error\",\r\n message: `Invalid domain: \"${cleanDomain}\"`,\r\n }],\r\n };\r\n }\r\n\r\n // Run all checks in parallel — use allSettled so one failure can't crash the rest\r\n const checkNames: DeliverabilityCheck[\"name\"][] = [\"mx\", \"spf\", \"dkim\", \"dmarc\", \"bimi\"];\r\n const settled = await Promise.allSettled([\r\n checkMX(cleanDomain, dns),\r\n checkSPF(cleanDomain, dns),\r\n checkDKIM(cleanDomain, dns),\r\n checkDMARC(cleanDomain, dns),\r\n checkBIMI(cleanDomain, dns),\r\n ]);\r\n\r\n const checks: DeliverabilityCheck[] = settled.map((result, i) =>\r\n result.status === \"fulfilled\"\r\n ? result.value\r\n : { name: checkNames[i], status: \"skip\" as const, message: `Check failed: ${result.reason?.message ?? \"unknown error\"}` },\r\n );\r\n\r\n // Calculate score — skip checks are excluded from the denominator\r\n const weights: Record<string, number> = {\r\n mx: 25,\r\n spf: 25,\r\n dkim: 20,\r\n dmarc: 20,\r\n bimi: 10,\r\n };\r\n\r\n let totalWeight = 0;\r\n let earnedScore = 0;\r\n for (const check of checks) {\r\n const weight = weights[check.name] || 0;\r\n if (check.status === \"skip\") continue; // exclude from denominator\r\n totalWeight += weight;\r\n if (check.status === \"pass\") earnedScore += weight;\r\n else if (check.status === \"warn\") earnedScore += weight * 0.5;\r\n // \"fail\" = 0 points\r\n }\r\n\r\n let score = totalWeight > 0 ? Math.round((earnedScore / totalWeight) * 100) : 0;\r\n score = Math.max(0, Math.min(100, score));\r\n\r\n // Build issues list\r\n const issues: DeliverabilityIssue[] = [];\r\n for (const check of checks) {\r\n if (check.status === \"fail\") {\r\n issues.push({\r\n rule: check.name,\r\n severity: \"error\",\r\n message: check.message,\r\n detail: check.detail,\r\n });\r\n } else if (check.status === \"warn\") {\r\n issues.push({\r\n rule: check.name,\r\n severity: \"warning\",\r\n message: check.message,\r\n detail: check.detail,\r\n });\r\n }\r\n }\r\n\r\n return { domain: cleanDomain, checks, score, issues };\r\n}\r\n","/**\r\n * Optional SpamAssassin integration.\r\n *\r\n * Shells out to `spamc` (daemon mode) or `spamassassin` (standalone mode)\r\n * using execFile (safe — no shell interpolation).\r\n * Returns null if neither is installed.\r\n *\r\n * Requires a full RFC 2822 message (headers + body), not just HTML.\r\n */\r\n\r\nimport { execFile as nodeExecFile } from \"node:child_process\";\r\n\r\nexport interface SpamAssassinResult {\r\n score: number;\r\n threshold: number;\r\n isSpam: boolean;\r\n rules: Array<{ name: string; score: number; description: string }>;\r\n rawOutput: string;\r\n}\r\n\r\nexport interface SpamAssassinOptions {\r\n /** Timeout in ms (default: 30000) */\r\n timeoutMs?: number;\r\n}\r\n\r\n/**\r\n * Run a raw RFC 2822 email message through SpamAssassin.\r\n *\r\n * Tries `spamc -R` (daemon mode, faster) first, then falls back to\r\n * `spamassassin -t` (standalone mode). Returns `null` if neither\r\n * binary is available.\r\n *\r\n * Uses execFile (not exec) — no shell interpolation, safe from injection.\r\n *\r\n * @example\r\n * ```ts\r\n * const result = await checkSpamAssassin(rawMessage);\r\n * if (result) {\r\n * console.log(`Score: ${result.score}/${result.threshold}`);\r\n * console.log(`Spam: ${result.isSpam}`);\r\n * for (const rule of result.rules) {\r\n * console.log(` ${rule.score} ${rule.name}: ${rule.description}`);\r\n * }\r\n * }\r\n * ```\r\n */\r\nexport async function checkSpamAssassin(\r\n rawMessage: string,\r\n options?: SpamAssassinOptions,\r\n): Promise<SpamAssassinResult | null> {\r\n const timeout = options?.timeoutMs ?? 30_000;\r\n\r\n // Try spamc first (daemon mode — faster)\r\n const spamcResult = await tryExecFile(\"spamc\", [\"-R\"], rawMessage, timeout);\r\n if (spamcResult !== null) return parseOutput(spamcResult);\r\n\r\n // Fall back to standalone spamassassin\r\n const saResult = await tryExecFile(\"spamassassin\", [\"-t\"], rawMessage, timeout);\r\n if (saResult !== null) return parseOutput(saResult);\r\n\r\n // Neither available\r\n return null;\r\n}\r\n\r\n/**\r\n * Safe wrapper around execFile — no shell, no interpolation.\r\n * Returns stdout on success, null if binary not found.\r\n */\r\nfunction tryExecFile(\r\n cmd: string,\r\n args: string[],\r\n input: string,\r\n timeout: number,\r\n): Promise<string | null> {\r\n return new Promise((resolve) => {\r\n try {\r\n const child = nodeExecFile(cmd, args, {\r\n timeout,\r\n maxBuffer: 1024 * 1024,\r\n encoding: \"utf-8\",\r\n }, (error, stdout, stderr) => {\r\n if (error) {\r\n if ((error as NodeJS.ErrnoException).code === \"ENOENT\") {\r\n resolve(null);\r\n } else {\r\n // SpamAssassin returns non-zero for spam — stdout is still valid\r\n resolve(stdout || stderr || null);\r\n }\r\n } else {\r\n resolve(stdout);\r\n }\r\n });\r\n\r\n if (child.stdin) {\r\n child.stdin.on(\"error\", () => {}); // swallow EPIPE if process dies before reading\r\n child.stdin.write(input);\r\n child.stdin.end();\r\n }\r\n } catch {\r\n resolve(null);\r\n }\r\n });\r\n}\r\n\r\nfunction parseOutput(output: string): SpamAssassinResult {\r\n let score = 0;\r\n let threshold = 5;\r\n let isSpam = false;\r\n const rules: SpamAssassinResult[\"rules\"] = [];\r\n\r\n // Parse X-Spam-Status line\r\n const statusMatch = output.match(/X-Spam-Status:\\s*(Yes|No),\\s*score=([\\d.-]+)\\s+required=([\\d.-]+)/i);\r\n if (statusMatch) {\r\n isSpam = statusMatch[1].toLowerCase() === \"yes\";\r\n score = parseFloat(statusMatch[2]);\r\n threshold = parseFloat(statusMatch[3]);\r\n } else {\r\n // spamc -R format: first line is \"score/threshold\"\r\n const scoreMatch = output.match(/^([\\d.-]+)\\/([\\d.-]+)/m);\r\n if (scoreMatch) {\r\n score = parseFloat(scoreMatch[1]);\r\n threshold = parseFloat(scoreMatch[2]);\r\n isSpam = score >= threshold;\r\n }\r\n }\r\n\r\n // Parse rules table\r\n const rulePattern = /^\\s*([\\d.-]+)\\s+([A-Z_][A-Z0-9_]+)\\s+(.+)$/gm;\r\n let match;\r\n while ((match = rulePattern.exec(output)) !== null) {\r\n rules.push({\r\n score: parseFloat(match[1]),\r\n name: match[2],\r\n description: match[3].trim(),\r\n });\r\n }\r\n\r\n return { score, threshold, isSpam, rules, rawOutput: output };\r\n}\r\n"],"mappings":"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","names":["_a"]}
1
+ {"version":3,"sources":["../src/deliverability-checker.ts","../src/spamassassin.ts"],"sourcesContent":["/**\r\n * DNS-based email deliverability checker.\r\n *\r\n * Validates SPF, DKIM, DMARC, MX, and BIMI records for a domain\r\n * using node:dns/promises. No external dependencies.\r\n */\r\n\r\nimport { promises as defaultDns } from \"node:dns\";\r\nimport type { DeliverabilityCheck, DeliverabilityReport, DeliverabilityIssue } from \"./types\";\r\n\r\n/** DNS resolver interface: injectable for testing. */\r\nexport interface DnsResolver {\r\n resolveMx: typeof defaultDns.resolveMx;\r\n resolveTxt: typeof defaultDns.resolveTxt;\r\n}\r\n\r\n/** Common DKIM selectors to probe (we can't know the real selector). */\r\nconst DKIM_SELECTORS = [\r\n \"google\", \"selector1\", \"selector2\", \"default\", \"dkim\",\r\n \"k1\", \"k2\", \"k3\", \"mail\", \"s1\", \"s2\",\r\n \"mandrill\", \"pm\", \"protonmail\", \"smtp\",\r\n];\r\n\r\nconst DNS_TIMEOUT_MS = 5_000;\r\n\r\n/**\r\n * Race a DNS call against a timeout. Node's DNS API has no signal support,\r\n * so we use Promise.race; the underlying query may continue in the background\r\n * but we stop waiting.\r\n */\r\nasync function withTimeout<T>(\r\n fn: () => Promise<T>,\r\n timeoutMs = DNS_TIMEOUT_MS,\r\n): Promise<T> {\r\n let timer: ReturnType<typeof setTimeout> | undefined;\r\n const timeout = new Promise<never>((_, reject) => {\r\n timer = setTimeout(() => {\r\n const err = Object.assign(new Error(`DNS timeout after ${timeoutMs}ms`), { code: \"ABORT_ERR\" });\r\n reject(err);\r\n }, timeoutMs);\r\n });\r\n try {\r\n return await Promise.race([fn(), timeout]);\r\n } finally {\r\n clearTimeout(timer);\r\n }\r\n}\r\n\r\n/**\r\n * Safe DNS TXT lookup: returns empty array on ENOTFOUND/ENODATA.\r\n */\r\nasync function resolveTxtSafe(domain: string, dns: DnsResolver): Promise<string[]> {\r\n try {\r\n const records = await withTimeout(() => dns.resolveTxt(domain));\r\n return records.map((chunks) => chunks.join(\"\"));\r\n } catch (err: unknown) {\r\n const code = (err as NodeJS.ErrnoException).code;\r\n if (code === \"ENOTFOUND\" || code === \"ENODATA\" || code === \"ETIMEOUT\" || code === \"ABORT_ERR\"\r\n || code === \"ESERVFAIL\" || code === \"ECONNREFUSED\" || code === \"EAI_AGAIN\" || code === \"ECANCELLED\") {\r\n return [];\r\n }\r\n throw err;\r\n }\r\n}\r\n\r\n// ── Individual checks ────────────────────────────────────────────────────────\r\n\r\nasync function checkMX(domain: string, dns: DnsResolver): Promise<DeliverabilityCheck> {\r\n try {\r\n const records = await withTimeout(() => dns.resolveMx(domain));\r\n if (records.length === 0) {\r\n return {\r\n name: \"mx\",\r\n status: \"fail\",\r\n message: \"No MX records found; domain cannot receive email.\",\r\n };\r\n }\r\n const sorted = records.sort((a, b) => a.priority - b.priority);\r\n return {\r\n name: \"mx\",\r\n status: \"pass\",\r\n message: `${records.length} MX record(s) found.`,\r\n detail: sorted.map((r) => `${r.priority} ${r.exchange}`).join(\", \"),\r\n };\r\n } catch (err: unknown) {\r\n const code = (err as NodeJS.ErrnoException).code;\r\n if (code === \"ENOTFOUND\" || code === \"ENODATA\") {\r\n return {\r\n name: \"mx\",\r\n status: \"fail\",\r\n message: \"No MX records found; domain cannot receive email.\",\r\n };\r\n }\r\n // Gracefully handle all DNS failures (ESERVFAIL, ECONNREFUSED, timeouts, etc.)\r\n return {\r\n name: \"mx\",\r\n status: \"skip\",\r\n message: `MX lookup failed: ${(err as Error).message}`,\r\n };\r\n }\r\n}\r\n\r\nasync function checkSPF(domain: string, dns: DnsResolver): Promise<DeliverabilityCheck> {\r\n const records = await resolveTxtSafe(domain, dns);\r\n const spfRecord = records.find((r) => r.startsWith(\"v=spf1\"));\r\n\r\n if (!spfRecord) {\r\n return {\r\n name: \"spf\",\r\n status: \"fail\",\r\n message: \"No SPF record found; receiving servers can't verify authorized senders.\",\r\n detail: 'Add a TXT record starting with \"v=spf1\" to your domain.',\r\n };\r\n }\r\n\r\n // Detect dangerous +all (allows any server to send as your domain)\r\n if (spfRecord.includes(\"+all\")) {\r\n return {\r\n name: \"spf\",\r\n status: \"fail\",\r\n message: \"SPF record uses +all; this allows any server to send as your domain.\",\r\n record: spfRecord,\r\n detail: \"Replace +all with -all (hard fail) or ~all (soft fail) to restrict authorized senders.\",\r\n };\r\n }\r\n\r\n // Validate syntax\r\n if (!spfRecord.includes(\"~all\") && !spfRecord.includes(\"-all\") && !spfRecord.includes(\"?all\")) {\r\n return {\r\n name: \"spf\",\r\n status: \"warn\",\r\n message: \"SPF record found but missing enforcement mechanism (~all or -all).\",\r\n record: spfRecord,\r\n detail: \"Add ~all (soft fail) or -all (hard fail) to the end of your SPF record.\",\r\n };\r\n }\r\n\r\n if (spfRecord.includes(\"~all\")) {\r\n return {\r\n name: \"spf\",\r\n status: \"warn\",\r\n message: \"SPF record uses ~all (soft fail); consider upgrading to -all (hard fail) for stronger protection.\",\r\n record: spfRecord,\r\n };\r\n }\r\n\r\n return {\r\n name: \"spf\",\r\n status: \"pass\",\r\n message: \"SPF record found with -all enforcement.\",\r\n record: spfRecord,\r\n };\r\n}\r\n\r\nasync function checkDKIM(domain: string, dns: DnsResolver): Promise<DeliverabilityCheck> {\r\n // Probe common selectors in parallel\r\n const results = await Promise.allSettled(\r\n DKIM_SELECTORS.map(async (selector) => {\r\n const records = await resolveTxtSafe(`${selector}._domainkey.${domain}`, dns);\r\n const dkimRecord = records.find((r) => r.includes(\"v=DKIM1\") || r.includes(\"p=\"));\r\n if (dkimRecord) {\r\n return { selector, record: dkimRecord };\r\n }\r\n return null;\r\n }),\r\n );\r\n\r\n const matches = results\r\n .filter((r): r is PromiseFulfilledResult<{ selector: string; record: string } | null> =>\r\n r.status === \"fulfilled\" && r.value !== null)\r\n .map((r) => r.value!);\r\n\r\n if (matches.length > 0) {\r\n return {\r\n name: \"dkim\",\r\n status: \"pass\",\r\n message: `DKIM record(s) found for selector(s): ${matches.map((m) => m.selector).join(\", \")}.`,\r\n record: matches[0].record,\r\n };\r\n }\r\n\r\n return {\r\n name: \"dkim\",\r\n status: \"warn\",\r\n message: `No DKIM records found for ${DKIM_SELECTORS.length} common selectors; DKIM may use a custom selector we didn't probe.`,\r\n detail: \"This doesn't mean DKIM is missing; the actual selector may differ from the common ones we checked.\",\r\n };\r\n}\r\n\r\nasync function checkDMARC(domain: string, dns: DnsResolver): Promise<DeliverabilityCheck> {\r\n const records = await resolveTxtSafe(`_dmarc.${domain}`, dns);\r\n const dmarcRecord = records.find((r) => r.startsWith(\"v=DMARC1\"));\r\n\r\n if (!dmarcRecord) {\r\n return {\r\n name: \"dmarc\",\r\n status: \"fail\",\r\n message: \"No DMARC record found; inbox providers may reject or quarantine your emails.\",\r\n detail: 'Add a TXT record at _dmarc.yourdomain.com starting with \"v=DMARC1\".',\r\n };\r\n }\r\n\r\n const policyMatch = dmarcRecord.match(/(?:^|;)\\s*p=(\\w+)/);\r\n const policy = policyMatch?.[1]?.toLowerCase();\r\n\r\n if (policy === \"none\") {\r\n return {\r\n name: \"dmarc\",\r\n status: \"warn\",\r\n message: 'DMARC record found with p=none (monitoring only); consider upgrading to p=quarantine or p=reject.',\r\n record: dmarcRecord,\r\n };\r\n }\r\n\r\n return {\r\n name: \"dmarc\",\r\n status: \"pass\",\r\n message: `DMARC record found with p=${policy || \"unknown\"}.`,\r\n record: dmarcRecord,\r\n };\r\n}\r\n\r\nasync function checkBIMI(domain: string, dns: DnsResolver): Promise<DeliverabilityCheck> {\r\n const records = await resolveTxtSafe(`default._bimi.${domain}`, dns);\r\n const bimiRecord = records.find((r) => r.startsWith(\"v=BIMI1\"));\r\n\r\n if (!bimiRecord) {\r\n return {\r\n name: \"bimi\",\r\n status: \"skip\",\r\n message: \"No BIMI record found; BIMI is an optional brand indicator (logo in inbox).\",\r\n detail: \"BIMI is a nice-to-have. It displays your brand logo next to emails in supported clients.\",\r\n };\r\n }\r\n\r\n return {\r\n name: \"bimi\",\r\n status: \"pass\",\r\n message: \"BIMI record found; your brand logo may appear in supported email clients.\",\r\n record: bimiRecord,\r\n };\r\n}\r\n\r\n// ── Main API ─────────────────────────────────────────────────────────────────\r\n\r\n/**\r\n * Check email deliverability for a domain.\r\n *\r\n * Validates MX, SPF, DKIM, DMARC, and BIMI DNS records.\r\n * All DNS queries have a 5-second timeout.\r\n *\r\n * @example\r\n * ```ts\r\n * const report = await checkDeliverability(\"example.com\");\r\n * console.log(report.score); // 0-100\r\n * console.log(report.checks); // individual check results\r\n * ```\r\n */\r\nexport async function checkDeliverability(\r\n domain: string,\r\n options?: { _resolver?: DnsResolver },\r\n): Promise<DeliverabilityReport> {\r\n const dns: DnsResolver = options?._resolver ?? defaultDns;\r\n\r\n // Normalize domain: strip protocol, path, and port\r\n const cleanDomain = domain.trim().toLowerCase()\r\n .replace(/^https?:\\/\\//, \"\")\r\n .replace(/\\/.*$/, \"\")\r\n .replace(/:\\d+$/, \"\");\r\n\r\n if (!cleanDomain || !cleanDomain.includes(\".\")) {\r\n return {\r\n domain: cleanDomain,\r\n checks: [],\r\n score: 0,\r\n issues: [{\r\n rule: \"invalid-domain\",\r\n severity: \"error\",\r\n message: `Invalid domain: \"${cleanDomain}\"`,\r\n }],\r\n };\r\n }\r\n\r\n // Run all checks in parallel: use allSettled so one failure can't crash the rest\r\n const checkNames: DeliverabilityCheck[\"name\"][] = [\"mx\", \"spf\", \"dkim\", \"dmarc\", \"bimi\"];\r\n const settled = await Promise.allSettled([\r\n checkMX(cleanDomain, dns),\r\n checkSPF(cleanDomain, dns),\r\n checkDKIM(cleanDomain, dns),\r\n checkDMARC(cleanDomain, dns),\r\n checkBIMI(cleanDomain, dns),\r\n ]);\r\n\r\n const checks: DeliverabilityCheck[] = settled.map((result, i) =>\r\n result.status === \"fulfilled\"\r\n ? result.value\r\n : { name: checkNames[i], status: \"skip\" as const, message: `Check failed: ${result.reason?.message ?? \"unknown error\"}` },\r\n );\r\n\r\n // Calculate score; skip checks are excluded from the denominator\r\n const weights: Record<string, number> = {\r\n mx: 25,\r\n spf: 25,\r\n dkim: 20,\r\n dmarc: 20,\r\n bimi: 10,\r\n };\r\n\r\n let totalWeight = 0;\r\n let earnedScore = 0;\r\n for (const check of checks) {\r\n const weight = weights[check.name] || 0;\r\n if (check.status === \"skip\") continue; // exclude from denominator\r\n totalWeight += weight;\r\n if (check.status === \"pass\") earnedScore += weight;\r\n else if (check.status === \"warn\") earnedScore += weight * 0.5;\r\n // \"fail\" = 0 points\r\n }\r\n\r\n let score = totalWeight > 0 ? Math.round((earnedScore / totalWeight) * 100) : 0;\r\n score = Math.max(0, Math.min(100, score));\r\n\r\n // Build issues list\r\n const issues: DeliverabilityIssue[] = [];\r\n for (const check of checks) {\r\n if (check.status === \"fail\") {\r\n issues.push({\r\n rule: check.name,\r\n severity: \"error\",\r\n message: check.message,\r\n detail: check.detail,\r\n });\r\n } else if (check.status === \"warn\") {\r\n issues.push({\r\n rule: check.name,\r\n severity: \"warning\",\r\n message: check.message,\r\n detail: check.detail,\r\n });\r\n }\r\n }\r\n\r\n return { domain: cleanDomain, checks, score, issues };\r\n}\r\n","/**\r\n * Optional SpamAssassin integration.\r\n *\r\n * Shells out to `spamc` (daemon mode) or `spamassassin` (standalone mode)\r\n * using execFile (safe, no shell interpolation).\r\n * Returns null if neither is installed.\r\n *\r\n * Requires a full RFC 2822 message (headers + body), not just HTML.\r\n */\r\n\r\nimport { execFile as nodeExecFile } from \"node:child_process\";\r\n\r\nexport interface SpamAssassinResult {\r\n score: number;\r\n threshold: number;\r\n isSpam: boolean;\r\n rules: Array<{ name: string; score: number; description: string }>;\r\n rawOutput: string;\r\n}\r\n\r\nexport interface SpamAssassinOptions {\r\n /** Timeout in ms (default: 30000) */\r\n timeoutMs?: number;\r\n}\r\n\r\n/**\r\n * Run a raw RFC 2822 email message through SpamAssassin.\r\n *\r\n * Tries `spamc -R` (daemon mode, faster) first, then falls back to\r\n * `spamassassin -t` (standalone mode). Returns `null` if neither\r\n * binary is available.\r\n *\r\n * Uses execFile (not exec): no shell interpolation, safe from injection.\r\n *\r\n * @example\r\n * ```ts\r\n * const result = await checkSpamAssassin(rawMessage);\r\n * if (result) {\r\n * console.log(`Score: ${result.score}/${result.threshold}`);\r\n * console.log(`Spam: ${result.isSpam}`);\r\n * for (const rule of result.rules) {\r\n * console.log(` ${rule.score} ${rule.name}: ${rule.description}`);\r\n * }\r\n * }\r\n * ```\r\n */\r\nexport async function checkSpamAssassin(\r\n rawMessage: string,\r\n options?: SpamAssassinOptions,\r\n): Promise<SpamAssassinResult | null> {\r\n const timeout = options?.timeoutMs ?? 30_000;\r\n\r\n // Try spamc first (daemon mode, faster)\r\n const spamcResult = await tryExecFile(\"spamc\", [\"-R\"], rawMessage, timeout);\r\n if (spamcResult !== null) return parseOutput(spamcResult);\r\n\r\n // Fall back to standalone spamassassin\r\n const saResult = await tryExecFile(\"spamassassin\", [\"-t\"], rawMessage, timeout);\r\n if (saResult !== null) return parseOutput(saResult);\r\n\r\n // Neither available\r\n return null;\r\n}\r\n\r\n/**\r\n * Safe wrapper around execFile: no shell, no interpolation.\r\n * Returns stdout on success, null if binary not found.\r\n */\r\nfunction tryExecFile(\r\n cmd: string,\r\n args: string[],\r\n input: string,\r\n timeout: number,\r\n): Promise<string | null> {\r\n return new Promise((resolve) => {\r\n try {\r\n const child = nodeExecFile(cmd, args, {\r\n timeout,\r\n maxBuffer: 1024 * 1024,\r\n encoding: \"utf-8\",\r\n }, (error, stdout, stderr) => {\r\n if (error) {\r\n if ((error as NodeJS.ErrnoException).code === \"ENOENT\") {\r\n resolve(null);\r\n } else {\r\n // SpamAssassin returns non-zero for spam, stdout is still valid\r\n resolve(stdout || stderr || null);\r\n }\r\n } else {\r\n resolve(stdout);\r\n }\r\n });\r\n\r\n if (child.stdin) {\r\n child.stdin.on(\"error\", () => {}); // swallow EPIPE if process dies before reading\r\n child.stdin.write(input);\r\n child.stdin.end();\r\n }\r\n } catch {\r\n resolve(null);\r\n }\r\n });\r\n}\r\n\r\nfunction parseOutput(output: string): SpamAssassinResult {\r\n let score = 0;\r\n let threshold = 5;\r\n let isSpam = false;\r\n const rules: SpamAssassinResult[\"rules\"] = [];\r\n\r\n // Parse X-Spam-Status line\r\n const statusMatch = output.match(/X-Spam-Status:\\s*(Yes|No),\\s*score=([\\d.-]+)\\s+required=([\\d.-]+)/i);\r\n if (statusMatch) {\r\n isSpam = statusMatch[1].toLowerCase() === \"yes\";\r\n score = parseFloat(statusMatch[2]);\r\n threshold = parseFloat(statusMatch[3]);\r\n } else {\r\n // spamc -R format: first line is \"score/threshold\"\r\n const scoreMatch = output.match(/^([\\d.-]+)\\/([\\d.-]+)/m);\r\n if (scoreMatch) {\r\n score = parseFloat(scoreMatch[1]);\r\n threshold = parseFloat(scoreMatch[2]);\r\n isSpam = score >= threshold;\r\n }\r\n }\r\n\r\n // Parse rules table\r\n const rulePattern = /^\\s*([\\d.-]+)\\s+([A-Z_][A-Z0-9_]+)\\s+(.+)$/gm;\r\n let match;\r\n while ((match = rulePattern.exec(output)) !== null) {\r\n rules.push({\r\n score: parseFloat(match[1]),\r\n name: match[2],\r\n description: match[3].trim(),\r\n });\r\n }\r\n\r\n return { score, threshold, isSpam, rules, rawOutput: output };\r\n}\r\n"],"mappings":"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","names":["_a"]}