@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.
- package/.commitmsg +36 -28
- package/client/android-bootstrap.bundle.js +27 -7
- package/client/android-bootstrap.bundle.js.map +2 -2
- package/client/app.bundle.js +1 -1
- package/client/app.bundle.js.map +2 -2
- package/client/components/message-viewer.js +14 -2
- package/client/components/message-viewer.js.map +1 -1
- package/client/components/message-viewer.ts +16 -3
- package/npmchanges.md +92 -0
- package/package.json +5 -5
- package/packages/mailx-imap/package-lock.json +2 -2
- package/packages/mailx-imap/package.json +1 -1
- package/packages/mailx-settings/package.json +1 -1
- package/packages/mailx-store/package.json +1 -1
- package/packages/mailx-store-web/package.json +1 -1
- package/packages/mailx-types/package.json +1 -1
- package/packages/mailx-types/trust.d.ts +4 -0
- package/packages/mailx-types/trust.d.ts.map +1 -1
- package/packages/mailx-types/trust.js +81 -9
- package/packages/mailx-types/trust.js.map +1 -1
- package/packages/mailx-types/trust.ts +88 -7
- /package/packages/mailx-imap/{node_modules.npmglobalize-stash-39180 → node_modules.npmglobalize-stash-63828}/.package-lock.json +0 -0
|
@@ -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:
|
|
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(
|
|
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
|
|
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
|
|
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
|
|
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
|
};
|