@bobfrankston/rmfmail 1.2.298 → 1.2.300

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.
@@ -98,6 +98,20 @@ function bare(addr: string): string {
98
98
  return (m ? m[0] : addr || "").toLowerCase().replace(/[.,;]+$/, "");
99
99
  }
100
100
 
101
+ /** Do two hostnames belong to the same organisation?
102
+ *
103
+ * Equality or a sub/parent-domain relation, never "the last two labels
104
+ * match": that would make paypal.co.uk and evil.co.uk relatives, which is
105
+ * the case this whole file exists to catch. A subdomain relationship cannot
106
+ * be forged across organisations, so it is safe in the other direction.
107
+ * Same test displayNameClaimsOtherAddress uses on addresses. */
108
+ function sameOrg(a: string, b: string): boolean {
109
+ const x = (a || "").toLowerCase();
110
+ const y = (b || "").toLowerCase();
111
+ if (!x || !y) return false;
112
+ return x === y || x.endsWith("." + y) || y.endsWith("." + x);
113
+ }
114
+
101
115
  /**
102
116
  * What SpamAssassin's rules are evidence OF — which is not what its score is.
103
117
  *
@@ -311,6 +325,11 @@ interface ServerSpamAnalysis {
311
325
  provedBy: "dmarc" | "dkim" | "trusted" | "";
312
326
  provedDomain: string;
313
327
  provedPolicy: string;
328
+ /** Every rule that SCORED is a bulk-mail or blocklist test — nothing that
329
+ * pushed this message up is about its content or its sender. Computed,
330
+ * not inferred from `kind`: `kind` is the WORST class present, so it says
331
+ * nothing about the rules underneath it. */
332
+ bulkOnly: boolean;
314
333
  /** The reader's own trained filter put this at 0-1% spam. */
315
334
  bayesHam: boolean;
316
335
  /** Authentication the receiving server saw fail, by name: "SPF", "DKIM",
@@ -373,6 +392,17 @@ function analyzeServerSpam(input: TrustInput): ServerSpamAnalysis {
373
392
  const unknownCouldFlagAlone = !unknown.length ? false
374
393
  : !Number.isFinite(unknownWeight) || !Number.isFinite(threshold) ? true
375
394
  : unknownWeight >= threshold;
395
+ // What the chip may claim about the rule set. "Every rule that scored is a
396
+ // bulk-mail test" is a statement about ALL of them, so it has to be
397
+ // checked against all of them — deriving it from `proved` (which says
398
+ // nothing about the rules) or from `kind` (the worst class, which says
399
+ // nothing about the rest) asserts something the code never looked at.
400
+ // The Altis Hotels welcome mail, 2026-09-03 (Bob: "marked as spam but is
401
+ // valid"): DMARC-proved, so the chip said "scored by bulk-mail or
402
+ // blocklist rules" — with 3.2 of its 8.3 coming from KAM_ACCOUNTPHISH,
403
+ // which is neither, listed directly underneath.
404
+ const bulkOnly = rules.every(r => !scored(r) || r.cls === "bulk" || r.cls === "neutral");
405
+
376
406
  const kind = (dmarc.authFailures.length || present("forgery")) ? "forgery"
377
407
  : present("association") ? "association"
378
408
  : unknownCouldFlagAlone ? "unclassified"
@@ -413,7 +443,7 @@ function analyzeServerSpam(input: TrustInput): ServerSpamAnalysis {
413
443
 
414
444
  return {
415
445
  score, threshold, flagged, rules, kind,
416
- proved: !!provedBy, provedBy,
446
+ proved: !!provedBy, provedBy, bulkOnly,
417
447
  provedDomain: dmarc.domain || fromDomain, provedPolicy: dmarc.policy,
418
448
  bayesHam: rules.some(r => r.name === "BAYES_00" || r.name === "BAYES_01"),
419
449
  authFailures: dmarc.authFailures,
@@ -500,11 +530,19 @@ function serverSpamVerdict(input: TrustInput): TrustFinding {
500
530
  detail: `${provedNote(a)} ${numbers}${why}${bayes}`,
501
531
  };
502
532
 
533
+ // Same discipline as the chip: "nothing that scored tests who sent it" is
534
+ // a claim about every rule, so it is made only when every rule was
535
+ // actually looked at and classified. `kind === "bulk"` does not establish
536
+ // that — an unclassified rule that could not have flagged the message on
537
+ // its own leaves the kind at bulk while remaining, by definition, a rule
538
+ // this file cannot say anything about.
503
539
  if (a.kind === "bulk")
504
540
  return {
505
541
  id: "server-spam-verdict",
506
542
  severity: "info",
507
- text: "Your mail server rates this bulk mail — nothing that scored tests who sent it.",
543
+ text: a.bulkOnly
544
+ ? "Your mail server rates this bulk mail — nothing that scored tests who sent it."
545
+ : "Your mail server rates this bulk mail.",
508
546
  detail: `${numbers}${why}${bayes}`,
509
547
  };
510
548
 
@@ -624,6 +662,22 @@ function hiddenLinkOverlay(bodyHtml: string): TrustFinding {
624
662
  return null;
625
663
  }
626
664
 
665
+ /** The domain the receiving server proved the From line for — "" when nothing
666
+ * did. One place answers "is this sender who they say they are", so the
667
+ * banner, the chip and the link checks can never disagree about it.
668
+ *
669
+ * dmarcProof is asked first because it needs no SpamAssassin: an
670
+ * Authentication-Results header alone is enough, which is what mail arriving
671
+ * through the Gmail API carries. analyzeServerSpam adds the two proofs
672
+ * SpamAssassin states in its own rule list instead (DKIM_VALID_AU,
673
+ * ALL_TRUSTED) and returns null when the server ran no filter at all. */
674
+ function provedSenderDomain(input: TrustInput): string {
675
+ const fromDomain = (bare(input.fromAddress).split("@")[1] || "").toLowerCase();
676
+ if (!fromDomain) return "";
677
+ if (dmarcProof(input).pass) return fromDomain;
678
+ return analyzeServerSpam(input)?.proved ? fromDomain : "";
679
+ }
680
+
627
681
  /**
628
682
  * Does a link route through a redirector carrying its real destination in the
629
683
  * query string?
@@ -633,17 +687,40 @@ function hiddenLinkOverlay(bodyHtml: string): TrustFinding {
633
687
  * whose redirect script was left open, and the destination appears only
634
688
  * percent-encoded inside a parameter. Reporting the ENDPOINT is the point — a
635
689
  * reader cannot be expected to URL-decode a query string.
690
+ *
691
+ * Laundering needs a SECOND PARTY — someone whose reputation is being spent
692
+ * without their consent. Two ways a link has none, both checked below, and
693
+ * neither of them a threshold:
694
+ *
695
+ * 1. The redirector and the destination are the same organisation. A site
696
+ * bouncing through its own hostnames is routing, not hiding.
697
+ * 2. The redirector IS the proved sender. Google's account-security notice
698
+ * (2026-09-02, Bob: "this is valid") links through
699
+ * `accounts.google.com/AccountChooser?continue=https://myaccount.google.com/…`
700
+ * — two hostnames that are neither equal nor sub/parent of each other,
701
+ * so rule 1 alone does not save it. But the receiving server recorded
702
+ * `dmarc=pass header.from=accounts.google.com`: the host in the visible
703
+ * link belongs to the party that provably sent the mail, spending its
704
+ * own reputation. That the sender might itself be malicious is a
705
+ * different question than the one this check asks.
706
+ *
707
+ * The proof has to come from the topmost Authentication-Results, which the
708
+ * sender cannot write — otherwise the escape is a switch a forger flips by
709
+ * typing a domain into From. No proof means the check stays on.
636
710
  */
637
- function redirectorLink(bodyHtml: string): TrustFinding {
711
+ function redirectorLink(input: TrustInput): TrustFinding {
712
+ const bodyHtml = input.bodyHtml || "";
713
+ const senderDomain = provedSenderDomain(input);
638
714
  for (const m of bodyHtml.matchAll(/href\s*=\s*["']([^"']+)["']/gi)) {
639
715
  let url: URL;
640
716
  try { url = new URL(m[1]); } catch { continue; }
641
717
  if (!/^https?:$/.test(url.protocol)) continue;
718
+ if (senderDomain && sameOrg(url.hostname, senderDomain)) continue; // the sender's own host
642
719
  for (const [, value] of url.searchParams) {
643
720
  let target: URL;
644
721
  try { target = new URL(value); } catch { continue; }
645
722
  if (!/^https?:$/.test(target.protocol)) continue;
646
- if (target.hostname === url.hostname) continue; // same-site, not laundering
723
+ if (sameOrg(target.hostname, url.hostname)) continue; // same site, not laundering
647
724
  return {
648
725
  id: "redirector-link",
649
726
  severity: "caution",
@@ -669,7 +746,7 @@ export function assessMessageTrust(input: TrustInput): TrustFinding[] {
669
746
  relayAuthMismatch(input),
670
747
  zeroWidthObfuscation(input.bodyText || ""),
671
748
  hiddenLinkOverlay(input.bodyHtml || ""),
672
- redirectorLink(input.bodyHtml || ""),
749
+ redirectorLink(input),
673
750
  ].filter(Boolean);
674
751
  const rank = { danger: 0, caution: 1, info: 2 };
675
752
  return findings.sort((a, b) => rank[a.severity] - rank[b.severity]);
@@ -714,10 +791,9 @@ function relayAuthMismatch(input: TrustInput): TrustFinding {
714
791
  for (const name of RELAY_IDENTITY_HEADERS) {
715
792
  const stamped = bare(header(input.headerLines, name));
716
793
  const stampedDomain = stamped.split("@")[1] || "";
717
- if (!stampedDomain || stampedDomain === fromDomain) continue;
718
794
  // Sub/parent-domain is the same organisation — mail.example.com
719
795
  // sending for example.com is ordinary. Cross-organisation is not.
720
- if (stampedDomain.endsWith("." + fromDomain) || fromDomain.endsWith("." + stampedDomain)) continue;
796
+ if (!stampedDomain || sameOrg(stampedDomain, fromDomain)) continue;
721
797
  return {
722
798
  id: "relay-auth-mismatch",
723
799
  severity: "danger",
@@ -795,6 +871,10 @@ export interface SpamScore {
795
871
  * may not exist (Bob's own mail carries no Authentication-Results at
796
872
  * all). */
797
873
  provedBy: "dmarc" | "dkim" | "trusted" | "";
874
+ /** Every rule that scored is a bulk-mail or blocklist test. The chip's
875
+ * tooltip may say so ONLY when this is true — it is a claim about all of
876
+ * the rules, and the reader can read them underneath it. */
877
+ bulkOnly: boolean;
798
878
  /** Top scoring rules, named — "6.0 URIBL_SBL (Contains an URL's NS IP
799
879
  * listed in the Spamhaus SBL blocklist)". A number alone is unactionable;
800
880
  * this can be judged in a second. */
@@ -814,6 +894,7 @@ export function spamScoreOf(input: TrustInput): SpamScore {
814
894
  kind: a.kind,
815
895
  proved: a.proved,
816
896
  provedBy: a.provedBy,
897
+ bulkOnly: a.bulkOnly,
817
898
  trusted: !!input.senderTrusted && a.proved && a.kind !== "forgery" && !a.authFailures.length,
818
899
  reasons: a.reasons,
819
900
  };