@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.
- package/client/app.bundle.js +12 -1
- package/client/app.bundle.js.map +2 -2
- package/client/app.js +14 -0
- package/client/app.js.map +1 -1
- package/client/app.ts +13 -0
- package/client/components/message-viewer.js +6 -1
- package/client/components/message-viewer.js.map +1 -1
- package/client/components/message-viewer.ts +7 -1
- package/client/compose/compose.bundle.js +8 -10
- package/client/compose/compose.bundle.js.map +2 -2
- package/client/index.html +2 -1
- package/client/lib/rmf-tiny.js +14 -11
- package/client/package.json +1 -1
- package/package.json +3 -3
- package/packages/mailx-service/index.d.ts +51 -3
- package/packages/mailx-service/index.d.ts.map +1 -1
- package/packages/mailx-service/index.js +96 -30
- package/packages/mailx-service/index.js.map +1 -1
- package/packages/mailx-service/index.ts +116 -32
- package/packages/mailx-service/package.json +1 -1
- /package/packages/mailx-imap/{node_modules.npmglobalize-stash-47236 → node_modules.npmglobalize-stash-37720}/.package-lock.json +0 -0
|
@@ -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
|
-
*
|
|
1961
|
-
*
|
|
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
|
-
*
|
|
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,
|
|
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
|
|
1978
|
-
|
|
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
|
-
|
|
1982
|
-
|
|
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
|
-
|
|
1989
|
-
|
|
1990
|
-
|
|
1991
|
-
|
|
1992
|
-
|
|
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",
|
|
2001
|
-
probe("SURBL", "multi.surbl.org",
|
|
2002
|
-
probe("URIBL", "multi.uribl.com",
|
|
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
|
|
2006
|
-
const
|
|
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:
|
|
2009
|
-
listedCount:
|
|
2010
|
-
checkedCount:
|
|
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:
|
|
2015
|
-
service:
|
|
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
|
}
|