@bobfrankston/rmfmail 1.2.257 → 1.2.259

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.
@@ -246,6 +246,49 @@ function calendarRowEquals(prior, fresh) {
246
246
  && (prior.htmlLink || null) === (fresh.htmlLink || null)
247
247
  && (prior.calendarId || "") === (fresh.calendarId || "");
248
248
  }
249
+ /** Spamhaus DBL — listings are 127.0.1.2 … 127.0.1.106; 127.255.255.x is
250
+ * status/error. https://www.spamhaus.org/faq/section/DNSBL%20Usage */
251
+ const DBL_LISTINGS = {
252
+ 2: "spam", 4: "phishing", 5: "malware", 6: "botnet C&C",
253
+ 102: "abused legit spam", 103: "abused spammed redirector",
254
+ 104: "abused legit phishing", 105: "abused legit malware", 106: "abused legit botnet",
255
+ };
256
+ const DBL_STATUS = {
257
+ "127.255.255.252": "query name malformed",
258
+ "127.255.255.253": "anonymous query — a DQS key is required",
259
+ "127.255.255.254": "query came through a public/open DNS resolver — Spamhaus refuses these (set a Spamhaus DQS key in Settings)",
260
+ "127.255.255.255": "query volume limit exceeded",
261
+ };
262
+ export const interpretSpamhausDbl = (ip) => {
263
+ const m = /^127\.0\.1\.(\d+)$/.exec(ip);
264
+ const code = m ? Number(m[1]) : NaN;
265
+ if (DBL_LISTINGS[code])
266
+ return { verdict: DBL_LISTINGS[code] };
267
+ if (m && code >= 2 && code <= 106)
268
+ return { verdict: "listed" };
269
+ return { reason: DBL_STATUS[ip] || `unrecognized answer ${ip || "(empty)"}` };
270
+ };
271
+ /** URIBL / SURBL — both answer 127.0.0.<bitmask>. In BOTH, 127.0.0.1 means
272
+ * "query refused" (public resolver / not permitted) and 127.0.0.255 means
273
+ * "blocked, too many queries" — neither is a listing. */
274
+ const bitmaskInterpret = (bits) => (ip) => {
275
+ const m = /^127\.0\.0\.(\d+)$/.exec(ip);
276
+ if (!m)
277
+ return { reason: `unrecognized answer ${ip || "(empty)"}` };
278
+ const v = Number(m[1]);
279
+ if (v === 1)
280
+ return { reason: "query refused — this list does not answer public DNS resolvers" };
281
+ if (v === 255)
282
+ return { reason: "query volume limit exceeded" };
283
+ const names = Object.keys(bits).map(Number).filter(b => v & b).map(b => bits[b]);
284
+ if (names.length)
285
+ return { verdict: `listed (${names.join(" + ")})` };
286
+ return { reason: `unrecognized answer ${ip}` };
287
+ };
288
+ /** URIBL multi: 2 black, 4 grey, 8 red. */
289
+ export const interpretUribl = bitmaskInterpret({ 2: "black", 4: "grey", 8: "red" });
290
+ /** SURBL multi: 8 phishing, 16 malware, 32 abuse, 64 cracked. */
291
+ export const interpretSurbl = bitmaskInterpret({ 8: "phishing", 16: "malware", 32: "abuse", 64: "cracked" });
249
292
  /** How long an un-consumed compose-popout init stash survives. The popout
250
293
  * window normally consumes it within a second of spawning; the timer only
251
294
  * matters when the window fails to launch. */
@@ -1991,15 +2034,30 @@ export class MailxService {
1991
2034
  * SURBL multi — `<d>.multi.surbl.org` mixed (ph/mw/abuse)
1992
2035
  * URIBL multi — `<d>.multi.uribl.com` black/grey/red lists
1993
2036
  *
2037
+ * A DNSBL answers in 127.0.0.0/8, and only SOME of those addresses mean
2038
+ * "listed" — the rest are STATUS codes. Treating any A record as a
2039
+ * listing is the bug that flagged e.nytimes.com as spam on 2 of 2
2040
+ * services (Bob 2026-08-13). From a consumer connection every DBL query
2041
+ * comes back `127.255.255.254` ("query via a public/open resolver"),
2042
+ * including for google.com and for Spamhaus's own dbltest.com, and
2043
+ * URIBL answers `127.0.0.1` ("query refused") the same way — so the old
2044
+ * code convicted every domain it looked at. Each service now gets an
2045
+ * interpreter that returns a verdict ONLY for a documented listing code;
2046
+ * anything else is "unavailable" and never reaches the banner.
2047
+ *
1994
2048
  * Each lookup is bounded at 500 ms; missing/slow services are treated
1995
2049
  * as "unknown" (don't poison the cache). Returns the aggregate plus
1996
2050
  * the per-service detail so the UI can show "N of 3 services flag
1997
2051
  * this domain" with the contributing source list.
1998
2052
  *
1999
- * Privacy: each query leaks the bare domain to that DNSBL's
2000
- * infrastructure plus the user's local resolver. Opt-in via Settings.
2053
+ * Spamhaus DQS: a free personal key (https://www.spamhaus.com/free-trial/)
2054
+ * makes DBL answer from any resolver queries go to
2055
+ * `<domain>.<key>.dbl.dq.spamhaus.net` instead. Set `spamhausDqsKey` in
2056
+ * Settings; without it, DBL is simply reported as unavailable rather
2057
+ * than guessed at.
2001
2058
  *
2002
- * No API keys, free for personal use across all three services. */
2059
+ * Privacy: each query leaks the bare domain to that DNSBL's
2060
+ * infrastructure plus the user's local resolver. Opt-in via Settings. */
2003
2061
  async checkDomainReputation(domain) {
2004
2062
  domain = (domain || "").toLowerCase().trim();
2005
2063
  if (!domain)
@@ -2007,48 +2065,56 @@ export class MailxService {
2007
2065
  const cached = this.reputationCache.get(domain);
2008
2066
  if (cached && cached.expiresAt > Date.now())
2009
2067
  return cached.result;
2010
- const probe = async (service, host, mapVerdict) => {
2068
+ const probe = async (service, host, interpret) => {
2011
2069
  try {
2012
2070
  const lookup = dns.resolve4(`${domain}.${host}`);
2013
2071
  const timeout = new Promise((_, reject) => setTimeout(() => reject(new Error("dnsbl-timeout")), MailxService.REPUTATION_TIMEOUT_MS));
2014
2072
  const records = await Promise.race([lookup, timeout]);
2015
- const last = records[0]?.split(".").pop() || "";
2016
- return { service, flagged: true, verdict: mapVerdict(last) };
2073
+ const ip = records[0] || "";
2074
+ const seen = interpret(ip);
2075
+ return "verdict" in seen
2076
+ ? { service, status: "listed", verdict: seen.verdict }
2077
+ : { service, status: "unavailable", reason: seen.reason };
2017
2078
  }
2018
2079
  catch (e) {
2019
2080
  const code = e?.code || "";
2020
- if (code === "ENOTFOUND" || code === "ENODATA") {
2021
- return { service, flagged: false, verdict: "clean" };
2022
- }
2023
- return null; // timeout / network unknown
2081
+ // NXDOMAIN / no A record is the DNSBL way of saying "not listed".
2082
+ if (code === "ENOTFOUND" || code === "ENODATA")
2083
+ return { service, status: "clean" };
2084
+ return { service, status: "unavailable", reason: code || String(e?.message || e) };
2024
2085
  }
2025
2086
  };
2026
- const dblVerdict = (last) => last === "2" ? "spam" :
2027
- last === "4" ? "phishing" :
2028
- last === "5" ? "malware" :
2029
- last === "6" ? "botnet" :
2030
- "listed";
2031
- // SURBL/URIBL encode multiple list memberships in a bitfield; the
2032
- // distinction matters less to the end user than "how many sources
2033
- // agree", so we keep a generic "listed" verdict for both.
2034
- const generic = (_last) => "listed";
2087
+ // A DQS key routes DBL queries to Spamhaus's keyed service, which
2088
+ // answers from any resolver. Without one we still ask the public zone —
2089
+ // it works from an ISP resolver that Spamhaus doesn't classify as open.
2090
+ const dqsKey = String(this.getCachedSettings()?.spamhausDqsKey || "").trim();
2091
+ const dblHost = dqsKey ? `${dqsKey}.dbl.dq.spamhaus.net` : "dbl.spamhaus.org";
2035
2092
  const sources = await Promise.all([
2036
- probe("Spamhaus DBL", "dbl.spamhaus.org", dblVerdict),
2037
- probe("SURBL", "multi.surbl.org", generic),
2038
- probe("URIBL", "multi.uribl.com", generic),
2093
+ probe("Spamhaus DBL", dblHost, interpretSpamhausDbl),
2094
+ probe("SURBL", "multi.surbl.org", interpretSurbl),
2095
+ probe("URIBL", "multi.uribl.com", interpretUribl),
2039
2096
  ]);
2040
- const known = sources.filter((s) => s !== null);
2041
- const flagged = known.filter(s => s.flagged);
2097
+ const listed = sources.filter((s) => s.status === "listed");
2098
+ const unavailable = sources.filter((s) => s.status === "unavailable");
2099
+ const answered = sources.filter(s => s.status !== "unavailable");
2042
2100
  const result = {
2043
- flagged: flagged.length > 0,
2044
- listedCount: flagged.length,
2045
- checkedCount: known.length,
2046
- sources: flagged,
2101
+ flagged: listed.length > 0,
2102
+ listedCount: listed.length,
2103
+ checkedCount: answered.length,
2104
+ sources: listed.map(s => ({ service: s.service, flagged: true, verdict: s.verdict })),
2047
2105
  // Pick the most specific verdict if Spamhaus contributed (since
2048
2106
  // DBL distinguishes phishing/malware/etc); otherwise generic.
2049
- verdict: flagged.find(s => s.service === "Spamhaus DBL")?.verdict || flagged[0]?.verdict || "clean",
2050
- service: flagged.map(s => s.service).join(", ") || "Spamhaus DBL / SURBL / URIBL",
2107
+ verdict: listed.find(s => s.service === "Spamhaus DBL")?.verdict || listed[0]?.verdict || "clean",
2108
+ service: listed.map(s => s.service).join(", ") || "Spamhaus DBL / SURBL / URIBL",
2109
+ unavailable: unavailable.map(s => ({ service: s.service, reason: s.reason })),
2051
2110
  };
2111
+ // Say so when the feature is blind — a domain nobody could check must
2112
+ // not read as a domain everybody cleared. Once per cache entry (5 min),
2113
+ // and only when NOTHING answered, so this can't spam the log.
2114
+ if (answered.length === 0) {
2115
+ console.warn(`[reputation] ${domain}: no service could answer — `
2116
+ + unavailable.map(s => `${s.service}: ${s.reason}`).join("; "));
2117
+ }
2052
2118
  this.reputationCache.set(domain, { result, expiresAt: Date.now() + MailxService.REPUTATION_TTL_MS });
2053
2119
  return result;
2054
2120
  }