@bobfrankston/mailx-types 0.1.106 → 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.
Files changed (3) hide show
  1. package/package.json +1 -1
  2. package/trust.d.ts +19 -0
  3. package/trust.js +72 -13
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx-types",
3
- "version": "0.1.106",
3
+ "version": "0.1.108",
4
4
  "type": "module",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",
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) {
@@ -378,8 +380,10 @@ function unfold(value) {
378
380
  * * blocklist
379
381
  * * [URI: ns12.xincache.com/112.80.181.111]
380
382
  *
381
- * The bracketed evidence lines are dropped: they name the third party, and
382
- * 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). */
383
387
  function parseSpamReport(report) {
384
388
  const out = new Map();
385
389
  let current = null;
@@ -389,12 +393,20 @@ function parseSpamReport(report) {
389
393
  continue;
390
394
  const start = line.match(/^(-?\d+(?:\.\d+)?)\s+([A-Z0-9_]{3,})\s*(.*)$/);
391
395
  if (start) {
392
- current = { score: Number(start[1]), description: start[3].trim() };
396
+ current = { score: Number(start[1]), description: start[3].trim(), uris: [] };
393
397
  out.set(start[2], current);
394
398
  continue;
395
399
  }
396
- if (current && !line.startsWith("["))
397
- current.description += " " + line;
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;
398
410
  }
399
411
  for (const rule of out.values())
400
412
  rule.description = rule.description
@@ -403,6 +415,19 @@ function parseSpamReport(report) {
403
415
  .trim();
404
416
  return out;
405
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
+ }
406
431
  function analyzeServerSpam(input) {
407
432
  const status = unfold(header(input.headerLines, "x-spam-status"));
408
433
  const flag = header(input.headerLines, "x-spam-flag");
@@ -433,15 +458,39 @@ function analyzeServerSpam(input) {
433
458
  // from delivering a rule called WORD_INVISshortcircuit.
434
459
  const names = (status.match(/tests=([\s\S]*?)(?=\s+[a-z_]+=|$)/)?.[1] || "")
435
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;
436
468
  const rules = names.map(name => {
437
469
  const found = report.get(name);
438
- return {
439
- name,
440
- score: found ? found.score : NaN,
441
- description: found ? found.description : "",
442
- cls: classifyRule(name, dmarc.failed, list.proved),
443
- };
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 };
444
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] : [];
445
494
  // A rule that scored zero or negative did not put this message over any
446
495
  // line, so it cannot be the reason the message is here. Unscored rules
447
496
  // (no X-Spam-Report, which is most unflagged mail) still count — absence
@@ -522,6 +571,7 @@ function analyzeServerSpam(input) {
522
571
  signers: dkimSigners(input),
523
572
  list,
524
573
  senderVia: provedSenderVia(input),
574
+ linkHosts,
525
575
  };
526
576
  }
527
577
  /**
@@ -611,15 +661,24 @@ function serverSpamVerdict(input) {
611
661
  : "flagged by SpamAssassin";
612
662
  const why = a.reasons.length ? ` — ${a.reasons.join("; ")}` : "";
613
663
  const bayes = a.bayesHam ? "; your own trained filter puts it at 0-1% spam (BAYES_00)" : "";
614
- 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
+ : "";
615
672
  return {
616
673
  id: "server-spam-verdict",
617
674
  severity: "danger",
618
675
  text: "Your mail server classified this as spam, and the rules that fired test who sent it.",
619
676
  detail: a.authFailures.length
620
677
  ? `${a.authFailures.join(" and ")} authentication failed at your server. ${numbers}${why}`
621
- : `${numbers}${why}`,
678
+ : `${numbers}${why}${linkNote}`,
679
+ ...(hosts.length ? { linkHosts: hosts } : {}),
622
680
  };
681
+ }
623
682
  if (a.proved && a.kind !== "unclassified")
624
683
  return {
625
684
  id: "server-spam-verdict",