@bobfrankston/rmfmail 1.2.256 → 1.2.258

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.
@@ -264,8 +264,72 @@ interface ReputationResult {
264
264
  sources: Array<{ service: string; flagged: boolean; verdict: string }>;
265
265
  verdict: string;
266
266
  service: string;
267
+ /** Services that answered something that is NOT a listing verdict —
268
+ * "query came through a public resolver", "volume limit exceeded",
269
+ * SERVFAIL. Kept out of listedCount/checkedCount so a refusal can never
270
+ * read as a conviction, and carried to the UI/log so the feature can say
271
+ * it is blind rather than silently reporting "clean". */
272
+ unavailable: Array<{ service: string; reason: string }>;
267
273
  }
268
274
 
275
+ // ── DNSBL answer codes ──
276
+ // A DNSBL answers in 127.0.0.0/8 and only SOME of those addresses mean
277
+ // "listed" — the rest are STATUS codes saying why the list did not answer
278
+ // the question. Treating any A record as a listing is what flagged
279
+ // e.nytimes.com as spam on 2 of 2 services (Bob 2026-08-13): from a
280
+ // consumer connection Spamhaus answers 127.255.255.254 ("query via a
281
+ // public/open resolver") for EVERY domain — google.com and Spamhaus's own
282
+ // dbltest.com included — and URIBL answers 127.0.0.1 ("query refused") the
283
+ // same way. Exported so the code tables are unit-testable without DNS.
284
+
285
+ export type DnsblOutcome =
286
+ | { service: string; status: "listed"; verdict: string }
287
+ | { service: string; status: "clean" }
288
+ | { service: string; status: "unavailable"; reason: string };
289
+
290
+ /** Interpret one A record from a DNSBL. Returns a verdict for a documented
291
+ * listing code, or the reason the answer cannot be read as one. */
292
+ export type DnsblInterpret = (ip: string) => { verdict: string } | { reason: string };
293
+
294
+ /** Spamhaus DBL — listings are 127.0.1.2 … 127.0.1.106; 127.255.255.x is
295
+ * status/error. https://www.spamhaus.org/faq/section/DNSBL%20Usage */
296
+ const DBL_LISTINGS: Record<number, string> = {
297
+ 2: "spam", 4: "phishing", 5: "malware", 6: "botnet C&C",
298
+ 102: "abused legit spam", 103: "abused spammed redirector",
299
+ 104: "abused legit phishing", 105: "abused legit malware", 106: "abused legit botnet",
300
+ };
301
+ const DBL_STATUS: Record<string, string> = {
302
+ "127.255.255.252": "query name malformed",
303
+ "127.255.255.253": "anonymous query — a DQS key is required",
304
+ "127.255.255.254": "query came through a public/open DNS resolver — Spamhaus refuses these (set a Spamhaus DQS key in Settings)",
305
+ "127.255.255.255": "query volume limit exceeded",
306
+ };
307
+ export const interpretSpamhausDbl: DnsblInterpret = (ip) => {
308
+ const m = /^127\.0\.1\.(\d+)$/.exec(ip);
309
+ const code = m ? Number(m[1]) : NaN;
310
+ if (DBL_LISTINGS[code]) return { verdict: DBL_LISTINGS[code] };
311
+ if (m && code >= 2 && code <= 106) return { verdict: "listed" };
312
+ return { reason: DBL_STATUS[ip] || `unrecognized answer ${ip || "(empty)"}` };
313
+ };
314
+
315
+ /** URIBL / SURBL — both answer 127.0.0.<bitmask>. In BOTH, 127.0.0.1 means
316
+ * "query refused" (public resolver / not permitted) and 127.0.0.255 means
317
+ * "blocked, too many queries" — neither is a listing. */
318
+ const bitmaskInterpret = (bits: Record<number, string>): DnsblInterpret => (ip) => {
319
+ const m = /^127\.0\.0\.(\d+)$/.exec(ip);
320
+ if (!m) return { reason: `unrecognized answer ${ip || "(empty)"}` };
321
+ const v = Number(m[1]);
322
+ if (v === 1) return { reason: "query refused — this list does not answer public DNS resolvers" };
323
+ if (v === 255) return { reason: "query volume limit exceeded" };
324
+ const names = Object.keys(bits).map(Number).filter(b => v & b).map(b => bits[b]);
325
+ if (names.length) return { verdict: `listed (${names.join(" + ")})` };
326
+ return { reason: `unrecognized answer ${ip}` };
327
+ };
328
+ /** URIBL multi: 2 black, 4 grey, 8 red. */
329
+ export const interpretUribl = bitmaskInterpret({ 2: "black", 4: "grey", 8: "red" });
330
+ /** SURBL multi: 8 phishing, 16 malware, 32 abuse, 64 cracked. */
331
+ export const interpretSurbl = bitmaskInterpret({ 8: "phishing", 16: "malware", 32: "abuse", 64: "cracked" });
332
+
269
333
  // ── Service ──
270
334
 
271
335
  /** Injected popup function — wraps mailx-host's showMessageBox so the
@@ -1952,68 +2016,88 @@ export class MailxService implements MailxApi {
1952
2016
  * SURBL multi — `<d>.multi.surbl.org` mixed (ph/mw/abuse)
1953
2017
  * URIBL multi — `<d>.multi.uribl.com` black/grey/red lists
1954
2018
  *
2019
+ * A DNSBL answers in 127.0.0.0/8, and only SOME of those addresses mean
2020
+ * "listed" — the rest are STATUS codes. Treating any A record as a
2021
+ * listing is the bug that flagged e.nytimes.com as spam on 2 of 2
2022
+ * services (Bob 2026-08-13). From a consumer connection every DBL query
2023
+ * comes back `127.255.255.254` ("query via a public/open resolver"),
2024
+ * including for google.com and for Spamhaus's own dbltest.com, and
2025
+ * URIBL answers `127.0.0.1` ("query refused") the same way — so the old
2026
+ * code convicted every domain it looked at. Each service now gets an
2027
+ * interpreter that returns a verdict ONLY for a documented listing code;
2028
+ * anything else is "unavailable" and never reaches the banner.
2029
+ *
1955
2030
  * Each lookup is bounded at 500 ms; missing/slow services are treated
1956
2031
  * as "unknown" (don't poison the cache). Returns the aggregate plus
1957
2032
  * the per-service detail so the UI can show "N of 3 services flag
1958
2033
  * this domain" with the contributing source list.
1959
2034
  *
1960
- * Privacy: each query leaks the bare domain to that DNSBL's
1961
- * infrastructure plus the user's local resolver. Opt-in via Settings.
2035
+ * Spamhaus DQS: a free personal key (https://www.spamhaus.com/free-trial/)
2036
+ * makes DBL answer from any resolver queries go to
2037
+ * `<domain>.<key>.dbl.dq.spamhaus.net` instead. Set `spamhausDqsKey` in
2038
+ * Settings; without it, DBL is simply reported as unavailable rather
2039
+ * than guessed at.
1962
2040
  *
1963
- * No API keys, free for personal use across all three services. */
2041
+ * Privacy: each query leaks the bare domain to that DNSBL's
2042
+ * infrastructure plus the user's local resolver. Opt-in via Settings. */
1964
2043
  async checkDomainReputation(domain: string): Promise<ReputationResult | null> {
1965
2044
  domain = (domain || "").toLowerCase().trim();
1966
2045
  if (!domain) return null;
1967
2046
  const cached = this.reputationCache.get(domain);
1968
2047
  if (cached && cached.expiresAt > Date.now()) return cached.result;
1969
2048
 
1970
- const probe = async (service: string, host: string, mapVerdict: (lastOctet: string) => string)
1971
- : Promise<{ service: string; flagged: boolean; verdict: string } | null> => {
2049
+ const probe = async (service: string, host: string, interpret: DnsblInterpret): Promise<DnsblOutcome> => {
1972
2050
  try {
1973
2051
  const lookup = dns.resolve4(`${domain}.${host}`);
1974
2052
  const timeout = new Promise<never>((_, reject) =>
1975
2053
  setTimeout(() => reject(new Error("dnsbl-timeout")), MailxService.REPUTATION_TIMEOUT_MS));
1976
2054
  const records = await Promise.race([lookup, timeout]) as string[];
1977
- const last = records[0]?.split(".").pop() || "";
1978
- return { service, flagged: true, verdict: mapVerdict(last) };
2055
+ const ip = records[0] || "";
2056
+ const seen = interpret(ip);
2057
+ return "verdict" in seen
2058
+ ? { service, status: "listed", verdict: seen.verdict }
2059
+ : { service, status: "unavailable", reason: seen.reason };
1979
2060
  } catch (e: any) {
1980
2061
  const code = e?.code || "";
1981
- if (code === "ENOTFOUND" || code === "ENODATA") {
1982
- return { service, flagged: false, verdict: "clean" };
1983
- }
1984
- return null; // timeout / network — unknown
2062
+ // NXDOMAIN / no A record is the DNSBL way of saying "not listed".
2063
+ if (code === "ENOTFOUND" || code === "ENODATA") return { service, status: "clean" };
2064
+ return { service, status: "unavailable", reason: code || String(e?.message || e) };
1985
2065
  }
1986
2066
  };
1987
2067
 
1988
- const dblVerdict = (last: string) =>
1989
- last === "2" ? "spam" :
1990
- last === "4" ? "phishing" :
1991
- last === "5" ? "malware" :
1992
- last === "6" ? "botnet" :
1993
- "listed";
1994
- // SURBL/URIBL encode multiple list memberships in a bitfield; the
1995
- // distinction matters less to the end user than "how many sources
1996
- // agree", so we keep a generic "listed" verdict for both.
1997
- const generic = (_last: string) => "listed";
2068
+ // A DQS key routes DBL queries to Spamhaus's keyed service, which
2069
+ // answers from any resolver. Without one we still ask the public zone —
2070
+ // it works from an ISP resolver that Spamhaus doesn't classify as open.
2071
+ const dqsKey = String((this.getCachedSettings() as any)?.spamhausDqsKey || "").trim();
2072
+ const dblHost = dqsKey ? `${dqsKey}.dbl.dq.spamhaus.net` : "dbl.spamhaus.org";
1998
2073
 
1999
2074
  const sources = await Promise.all([
2000
- probe("Spamhaus DBL", "dbl.spamhaus.org", dblVerdict),
2001
- probe("SURBL", "multi.surbl.org", generic),
2002
- probe("URIBL", "multi.uribl.com", generic),
2075
+ probe("Spamhaus DBL", dblHost, interpretSpamhausDbl),
2076
+ probe("SURBL", "multi.surbl.org", interpretSurbl),
2077
+ probe("URIBL", "multi.uribl.com", interpretUribl),
2003
2078
  ]);
2004
2079
 
2005
- const known = sources.filter((s): s is { service: string; flagged: boolean; verdict: string } => s !== null);
2006
- const flagged = known.filter(s => s.flagged);
2080
+ const listed = sources.filter((s): s is Extract<DnsblOutcome, { status: "listed" }> => s.status === "listed");
2081
+ const unavailable = sources.filter((s): s is Extract<DnsblOutcome, { status: "unavailable" }> => s.status === "unavailable");
2082
+ const answered = sources.filter(s => s.status !== "unavailable");
2007
2083
  const result: ReputationResult = {
2008
- flagged: flagged.length > 0,
2009
- listedCount: flagged.length,
2010
- checkedCount: known.length,
2011
- sources: flagged,
2084
+ flagged: listed.length > 0,
2085
+ listedCount: listed.length,
2086
+ checkedCount: answered.length,
2087
+ sources: listed.map(s => ({ service: s.service, flagged: true, verdict: s.verdict })),
2012
2088
  // Pick the most specific verdict if Spamhaus contributed (since
2013
2089
  // DBL distinguishes phishing/malware/etc); otherwise generic.
2014
- verdict: flagged.find(s => s.service === "Spamhaus DBL")?.verdict || flagged[0]?.verdict || "clean",
2015
- service: flagged.map(s => s.service).join(", ") || "Spamhaus DBL / SURBL / URIBL",
2090
+ verdict: listed.find(s => s.service === "Spamhaus DBL")?.verdict || listed[0]?.verdict || "clean",
2091
+ service: listed.map(s => s.service).join(", ") || "Spamhaus DBL / SURBL / URIBL",
2092
+ unavailable: unavailable.map(s => ({ service: s.service, reason: s.reason })),
2016
2093
  };
2094
+ // Say so when the feature is blind — a domain nobody could check must
2095
+ // not read as a domain everybody cleared. Once per cache entry (5 min),
2096
+ // and only when NOTHING answered, so this can't spam the log.
2097
+ if (answered.length === 0) {
2098
+ console.warn(`[reputation] ${domain}: no service could answer — `
2099
+ + unavailable.map(s => `${s.service}: ${s.reason}`).join("; "));
2100
+ }
2017
2101
  this.reputationCache.set(domain, { result, expiresAt: Date.now() + MailxService.REPUTATION_TTL_MS });
2018
2102
  return result;
2019
2103
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx-service",
3
- "version": "0.1.26",
3
+ "version": "0.1.27",
4
4
  "type": "module",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",