@bobfrankston/mailx-types 0.1.104 → 0.1.108
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/package.json +1 -1
- package/trust.d.ts +19 -0
- package/trust.js +88 -14
package/package.json
CHANGED
package/trust.d.ts
CHANGED
|
@@ -40,6 +40,7 @@ export declare const TRUST_LIST_KEYS: {
|
|
|
40
40
|
readonly intermediary: "trustedIntermediaries";
|
|
41
41
|
readonly list: "trustedLists";
|
|
42
42
|
readonly via: "trustedSendersVia";
|
|
43
|
+
readonly linkhost: "trustedLinkHosts";
|
|
43
44
|
};
|
|
44
45
|
export type TrustListType = keyof typeof TRUST_LIST_KEYS;
|
|
45
46
|
/** One piece of evidence that a message is not what it claims to be. */
|
|
@@ -73,6 +74,11 @@ export interface TrustFinding {
|
|
|
73
74
|
* on a redirector warning when the sender is proved but not yet on the
|
|
74
75
|
* reader's list, so the viewer can offer "Stop warning about". (2026-09-11) */
|
|
75
76
|
sender?: string;
|
|
77
|
+
/** Link hosts a phishing/malware URL listing named, set only when they
|
|
78
|
+
* are the WHOLE of the forgery evidence on a proved sender, so the viewer
|
|
79
|
+
* can offer "Trust links to ‹host›" (allowlist.jsonc `trustedLinkHosts`).
|
|
80
|
+
* (2026-09-30) */
|
|
81
|
+
linkHosts?: string[];
|
|
76
82
|
}
|
|
77
83
|
export interface TrustInput {
|
|
78
84
|
/** Every header line, in order, as mailparser's `headerLines`. Repeated
|
|
@@ -135,6 +141,19 @@ export interface TrustInput {
|
|
|
135
141
|
* forger with their own SES account is not groundnews.email's, and one
|
|
136
142
|
* without a relay signature matches nothing. See provedSenderVia. */
|
|
137
143
|
trustedSendersVia?: string[];
|
|
144
|
+
/** Link hosts the reader vouches for, from allowlist.jsonc
|
|
145
|
+
* `trustedLinkHosts` ("us.list-manage.com"). 2026-09-30, Claude Code
|
|
146
|
+
* (Opus 5.5), at Bob's direction: Fig City News, a newsletter proved by
|
|
147
|
+
* DMARC, went red because its links run through Mailchimp's click tracker
|
|
148
|
+
* us.list-manage.com, which Spamhaus lists as abused for phishing
|
|
149
|
+
* (URIBL_DBL_ABUSE_PHISH, 7.0) — and a phishing listing is forgery-class,
|
|
150
|
+
* so no sender trust could clear it. A phishing listing stays forgery by
|
|
151
|
+
* default: a proved sender can still send a bad link. An entry here
|
|
152
|
+
* demotes such a listing to association ONLY when (a) the From line is
|
|
153
|
+
* proved and nothing failed authentication, and (b) EVERY host the
|
|
154
|
+
* server's report names for that rule is listed. A listing that names no
|
|
155
|
+
* host, or any unlisted host, is untouched. */
|
|
156
|
+
trustedLinkHosts?: string[];
|
|
138
157
|
}
|
|
139
158
|
/**
|
|
140
159
|
* Everything findable about this message, worst first.
|
package/trust.js
CHANGED
|
@@ -40,6 +40,8 @@ export const TRUST_LIST_KEYS = {
|
|
|
40
40
|
intermediary: "trustedIntermediaries",
|
|
41
41
|
list: "trustedLists",
|
|
42
42
|
via: "trustedSendersVia",
|
|
43
|
+
// 2026-09-30 — see TrustInput.trustedLinkHosts.
|
|
44
|
+
linkhost: "trustedLinkHosts",
|
|
43
45
|
};
|
|
44
46
|
/** First value of a header, case-insensitive. */
|
|
45
47
|
function header(lines, name) {
|
|
@@ -346,7 +348,22 @@ function dmarcProof(input) {
|
|
|
346
348
|
&& !fromDomain.endsWith("." + stated)) {
|
|
347
349
|
return { pass: false, failed, authFailures, domain: "", policy };
|
|
348
350
|
}
|
|
349
|
-
|
|
351
|
+
// 2026-09-23 — Claude Code (Fable 5.1), at Bob's direction.
|
|
352
|
+
// An aligned DMARC pass IS the identity verdict: RFC 7489 pass means SPF or
|
|
353
|
+
// DKIM verified on the domain the reader sees in From. A failed mechanism
|
|
354
|
+
// beside it is about some OTHER identity — a relay's broken body hash, a
|
|
355
|
+
// forwarder's envelope — not about the From line. This function already
|
|
356
|
+
// said so for the aligned-pass case in selfSpoof ("one failed signature on
|
|
357
|
+
// a message with an aligned pass is not evidence about the From line; the
|
|
358
|
+
// pass is"), but authFailures still carried the DKIM name up to analyze-
|
|
359
|
+
// ServerSpam, where any authFailure forces kind = "forgery" and the red
|
|
360
|
+
// "not what it says it is" banner. Eversource's payment notice (SendGrid,
|
|
361
|
+
// dkim=fail on eversource.com's own signature, dmarc=pass header.from=
|
|
362
|
+
// notifications.eversource.com policy=reject) was painted as a forgery,
|
|
363
|
+
// and the trust button — gated on no danger — never appeared. Same shape
|
|
364
|
+
// as the list-relay carve-out at the call site, generalised to the case
|
|
365
|
+
// where the server itself pronounced the aligned verdict.
|
|
366
|
+
return { pass: true, failed: false, authFailures: [], domain: stated || fromDomain, policy };
|
|
350
367
|
}
|
|
351
368
|
/** Unfold a header value the way RFC 5322 folds it: delete the line break,
|
|
352
369
|
* KEEP the whitespace that continues the line. SpamAssassin breaks its tests=
|
|
@@ -363,8 +380,10 @@ function unfold(value) {
|
|
|
363
380
|
* * blocklist
|
|
364
381
|
* * [URI: ns12.xincache.com/112.80.181.111]
|
|
365
382
|
*
|
|
366
|
-
* The bracketed evidence lines
|
|
367
|
-
* the finding is about this message
|
|
383
|
+
* The bracketed evidence lines stay out of the description — they name the
|
|
384
|
+
* third party, and the finding is about this message — but `[URI: host]`
|
|
385
|
+
* lines are kept per rule in `uris`: they are the only record of WHICH link
|
|
386
|
+
* host a URL listing is about (2026-09-30, for trustedLinkHosts). */
|
|
368
387
|
function parseSpamReport(report) {
|
|
369
388
|
const out = new Map();
|
|
370
389
|
let current = null;
|
|
@@ -374,12 +393,20 @@ function parseSpamReport(report) {
|
|
|
374
393
|
continue;
|
|
375
394
|
const start = line.match(/^(-?\d+(?:\.\d+)?)\s+([A-Z0-9_]{3,})\s*(.*)$/);
|
|
376
395
|
if (start) {
|
|
377
|
-
current = { score: Number(start[1]), description: start[3].trim() };
|
|
396
|
+
current = { score: Number(start[1]), description: start[3].trim(), uris: [] };
|
|
378
397
|
out.set(start[2], current);
|
|
379
398
|
continue;
|
|
380
399
|
}
|
|
381
|
-
if (current
|
|
382
|
-
|
|
400
|
+
if (!current)
|
|
401
|
+
continue;
|
|
402
|
+
if (line.startsWith("[")) {
|
|
403
|
+
// "[URI: ns12.xincache.com/112.80.181.111]" — keep the host only.
|
|
404
|
+
const uri = line.match(/^\[URI:\s*([^\]\s/]+)/i)?.[1];
|
|
405
|
+
if (uri)
|
|
406
|
+
current.uris.push(uri.toLowerCase());
|
|
407
|
+
continue;
|
|
408
|
+
}
|
|
409
|
+
current.description += " " + line;
|
|
383
410
|
}
|
|
384
411
|
for (const rule of out.values())
|
|
385
412
|
rule.description = rule.description
|
|
@@ -388,6 +415,19 @@ function parseSpamReport(report) {
|
|
|
388
415
|
.trim();
|
|
389
416
|
return out;
|
|
390
417
|
}
|
|
418
|
+
/** Rules that are a third party's listing of a host the message LINKS to.
|
|
419
|
+
* Wider than ASSOCIATION_RULES: it includes the phishing/malware variants
|
|
420
|
+
* (URIBL_PHISH, URIBL_DBL_ABUSE_PHISH), which classifyRule files as forgery.
|
|
421
|
+
* Used only to decide whether trustedLinkHosts may speak to a rule. */
|
|
422
|
+
const URL_LISTING_RULES = [
|
|
423
|
+
/^URIBL_/, /^SH_DBL/, /^SEM_URIBL/, /^KAM_(BODY_|FROM_)?\w*URIBL/,
|
|
424
|
+
];
|
|
425
|
+
const isUrlListing = (name) => URL_LISTING_RULES.some(re => re.test(name));
|
|
426
|
+
/** Is `host` one of the reader's trusted link hosts, or a subdomain of one? */
|
|
427
|
+
function linkHostTrusted(host, trusted) {
|
|
428
|
+
const h = (host || "").toLowerCase();
|
|
429
|
+
return trusted.some(t => h === t || h.endsWith("." + t));
|
|
430
|
+
}
|
|
391
431
|
function analyzeServerSpam(input) {
|
|
392
432
|
const status = unfold(header(input.headerLines, "x-spam-status"));
|
|
393
433
|
const flag = header(input.headerLines, "x-spam-flag");
|
|
@@ -418,15 +458,39 @@ function analyzeServerSpam(input) {
|
|
|
418
458
|
// from delivering a rule called WORD_INVISshortcircuit.
|
|
419
459
|
const names = (status.match(/tests=([\s\S]*?)(?=\s+[a-z_]+=|$)/)?.[1] || "")
|
|
420
460
|
.replace(/\s+/g, "").split(",").map(n => n.toUpperCase()).filter(Boolean);
|
|
461
|
+
// Proof of the From line, from the same three sources provedBy uses below
|
|
462
|
+
// — needed BEFORE classification so a trusted link host can only ever
|
|
463
|
+
// speak for a proved sender. (2026-09-30)
|
|
464
|
+
const fromProved = dmarc.pass || names.includes("DKIM_VALID_AU") || names.includes("ALL_TRUSTED");
|
|
465
|
+
const trustedLinkHosts = (input.trustedLinkHosts || []).map(h => (h || "").trim().toLowerCase()).filter(Boolean);
|
|
466
|
+
const linkHostCandidates = new Set();
|
|
467
|
+
let forgeryOnlyUrlListings = true;
|
|
421
468
|
const rules = names.map(name => {
|
|
422
469
|
const found = report.get(name);
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
470
|
+
let cls = classifyRule(name, dmarc.failed, list.proved);
|
|
471
|
+
const score = found ? found.score : NaN;
|
|
472
|
+
if (cls === "forgery" && !(score <= 0)) {
|
|
473
|
+
const uris = found?.uris || [];
|
|
474
|
+
const clearable = fromProved && !authFailures.length && isUrlListing(name) && uris.length > 0;
|
|
475
|
+
if (clearable && uris.every(u => linkHostTrusted(u, trustedLinkHosts))) {
|
|
476
|
+
// Every host this listing names is one the reader vouched for,
|
|
477
|
+
// on mail the sender proved: evidence about the link, not the From.
|
|
478
|
+
cls = "association";
|
|
479
|
+
}
|
|
480
|
+
else if (clearable) {
|
|
481
|
+
for (const u of uris)
|
|
482
|
+
if (!linkHostTrusted(u, trustedLinkHosts))
|
|
483
|
+
linkHostCandidates.add(u);
|
|
484
|
+
}
|
|
485
|
+
else {
|
|
486
|
+
forgeryOnlyUrlListings = false;
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
return { name, score, description: found ? found.description : "", cls };
|
|
429
490
|
});
|
|
491
|
+
// Offered only when trusting these hosts would remove every forgery rule —
|
|
492
|
+
// a button that leaves the banner red would be a lie about what it does.
|
|
493
|
+
const linkHosts = forgeryOnlyUrlListings && !authFailures.length ? [...linkHostCandidates] : [];
|
|
430
494
|
// A rule that scored zero or negative did not put this message over any
|
|
431
495
|
// line, so it cannot be the reason the message is here. Unscored rules
|
|
432
496
|
// (no X-Spam-Report, which is most unflagged mail) still count — absence
|
|
@@ -507,6 +571,7 @@ function analyzeServerSpam(input) {
|
|
|
507
571
|
signers: dkimSigners(input),
|
|
508
572
|
list,
|
|
509
573
|
senderVia: provedSenderVia(input),
|
|
574
|
+
linkHosts,
|
|
510
575
|
};
|
|
511
576
|
}
|
|
512
577
|
/**
|
|
@@ -596,15 +661,24 @@ function serverSpamVerdict(input) {
|
|
|
596
661
|
: "flagged by SpamAssassin";
|
|
597
662
|
const why = a.reasons.length ? ` — ${a.reasons.join("; ")}` : "";
|
|
598
663
|
const bayes = a.bayesHam ? "; your own trained filter puts it at 0-1% spam (BAYES_00)" : "";
|
|
599
|
-
if (a.kind === "forgery")
|
|
664
|
+
if (a.kind === "forgery") {
|
|
665
|
+
// The one danger finding that carries an action (2026-09-30): the
|
|
666
|
+
// forgery evidence is entirely phishing listings of link hosts, on a
|
|
667
|
+
// proved sender. Say which host and what trusting it does.
|
|
668
|
+
const hosts = a.linkHosts;
|
|
669
|
+
const linkNote = hosts.length
|
|
670
|
+
? `. The only rule here that tests identity is a listing of the link host ${hosts.join(", ")}; ${a.provedDomain} proved it sent this message. If that host is the sender's mail service (a newsletter's click tracker), "Trust links to ${hosts[0]}" stops treating the listing as forgery on proved mail.`
|
|
671
|
+
: "";
|
|
600
672
|
return {
|
|
601
673
|
id: "server-spam-verdict",
|
|
602
674
|
severity: "danger",
|
|
603
675
|
text: "Your mail server classified this as spam, and the rules that fired test who sent it.",
|
|
604
676
|
detail: a.authFailures.length
|
|
605
677
|
? `${a.authFailures.join(" and ")} authentication failed at your server. ${numbers}${why}`
|
|
606
|
-
: `${numbers}${why}`,
|
|
678
|
+
: `${numbers}${why}${linkNote}`,
|
|
679
|
+
...(hosts.length ? { linkHosts: hosts } : {}),
|
|
607
680
|
};
|
|
681
|
+
}
|
|
608
682
|
if (a.proved && a.kind !== "unclassified")
|
|
609
683
|
return {
|
|
610
684
|
id: "server-spam-verdict",
|