@bobfrankston/rmfmail 1.2.297 → 1.2.299

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 CHANGED
@@ -1,32 +1,53 @@
1
- Reading a message outranks syncing it; a filter is not a selection
2
-
3
- Two things Bob hit on 2026-08-29.
4
-
5
- **"If I look at a letter while sync, things get confused. Eventually it reports
6
- an error rather than retrying. Viewing the body should get priority instead of
7
- waiting for sync."** Both halves were real:
8
-
9
- - Lanes are strictly sequential, and a click-time body fetch went on the tail
10
- of the fast lane behind whatever background work was already queued.
11
- `withConnection` now takes `priority`, which puts the task at the FRONT of
12
- its lane; the interactive fetch uses it and a 30 s cap instead of 90 s. It
13
- still cannot preempt the command in flight an IMAP command cannot be
14
- cancelled without dropping the connection but it no longer waits out the
15
- backlog.
16
- - `fetchMessageBody` swallowed every error into `null`. To the reconciler that
17
- means "the server has no body for this message", which is classified
18
- non-transient, skips the 3-attempt retry budget, and paints a banner. A lane
19
- timeout says nothing about whether the message has a body: transient errors
20
- (timeout, dropped socket, discarded client) now propagate, so the existing
21
- retry-with-backoff applies and the reader gets the message instead of an
22
- error. Only a genuine "no body here" still arms the prefetch backoff a
23
- timeout used to arm it too, poisoning the next view for up to 12 h.
24
-
25
- **"If I press * on search it shouldn't automatically select those letters, just
26
- show them."** Nothing was selected the flagged-only filter washed the whole
27
- list in the flag colour (added 2026-06-26 to make the filter unmistakable), and
28
- a coloured background over rows is exactly how this list says "chosen". The
29
- wash is gone; the filter now announces itself above the list, by name:
30
- "Showing flagged (★) messages only click to show everything", and clicking it
31
- clears the filter. Same chip covers the priority-senders filter, which had the
32
- same shape.
1
+ Laundering needs a second party a redirector alone is not one
2
+
3
+ Google's account-security notice, which is exactly the mail a reader must be
4
+ able to believe, carried a caution banner: "A link hides where it goes: it
5
+ passes through accounts.google.com and ends at myaccount.google.com." Both
6
+ halves of that sentence name Google. Bob, on the message:
7
+ `mailxstore/bobma/e1/e193f2bb….eml`, "this is valid."
8
+
9
+ The check compared `target.hostname === url.hostname` and reported anything
10
+ else. That catches the case it was written for `castanet-sa.fr/redirect.php
11
+ ?newurl=https://evil.example`, someone's abandoned redirect script spending a
12
+ real business's reputation but the comparison is not what the finding
13
+ claims. Laundering requires a SECOND PARTY, someone whose name is on the
14
+ visible link without their consent. Two ways a link has none, both now checked,
15
+ neither of them a threshold:
16
+
17
+ 1. The redirector and the destination are the same organisation. Equality
18
+ widened to the sub/parent-domain test this file already uses twice, now a
19
+ shared `sameOrg()` deliberately not "the last two labels match", which
20
+ would make paypal.co.uk and evil.co.uk relatives.
21
+
22
+ 2. The redirector IS the sender, proved. accounts.google.com and
23
+ myaccount.google.com are siblings, so rule 1 does not reach them; what
24
+ clears the message is that the receiving server recorded `dmarc=pass
25
+ header.from=accounts.google.com`. The host in the visible link belongs to
26
+ the party that provably sent the mail. Whether that party is trustworthy
27
+ is a different question than the one this check asks.
28
+
29
+ The proof comes from `provedSenderDomain()`, which asks dmarcProof first an
30
+ Authentication-Results header alone is enough, so this works on Gmail-API mail
31
+ with no SpamAssassin headers and falls back to the DKIM_VALID_AU /
32
+ ALL_TRUSTED proofs SpamAssassin states in its own rule list. It reads the
33
+ topmost Authentication-Results only, which the sender cannot write. No proof
34
+ means the check stays on: an escape a forger could open by typing a domain
35
+ into From would be worse than no check.
36
+
37
+ The mirror rule is NOT safe and is deliberately absent. "The link ENDS at the
38
+ proved sender" would clear `legit-bank.com/r?u=https://evil.com` sent from a
39
+ DMARC-proved evil.com, which is the laundering case in its purest form — the
40
+ reader sees the bank's hostname. Only the visible host may be the sender's.
41
+
42
+ Measured over the 800 most recent messages in the store: redirector-link fires
43
+ 15 times before, 4 after. Cleared: Substack's own link wrapper on Substack
44
+ mail (6), Google's alert, Microsoft Teams, dev.to. Still flagged, correctly by
45
+ this rule's terms: Microsoft's Safe Links wrapper (nam.safelink.emails.azure.net
46
+ -> admin.microsoft.com, 3) and a donation platform bouncing back to the charity
47
+ that mailed. Those are third-party hostnames in front of the reader, and no
48
+ hostname allowlist is going in to quiet them.
49
+
50
+ Four tests: the sibling-subdomain case, the Google alert with its proof and
51
+ again with the proof removed, and a forged accounts.google.com whose server
52
+ recorded the DMARC failure — still flagged, as the escape is the server's
53
+ verdict and never the From line's say-so.
@@ -2226,6 +2226,13 @@ function bare(addr) {
2226
2226
  const m = (addr || "").match(/[^\s<>,;]+@[^\s<>,;]+/);
2227
2227
  return (m ? m[0] : addr || "").toLowerCase().replace(/[.,;]+$/, "");
2228
2228
  }
2229
+ function sameOrg(a, b) {
2230
+ const x = (a || "").toLowerCase();
2231
+ const y = (b || "").toLowerCase();
2232
+ if (!x || !y)
2233
+ return false;
2234
+ return x === y || x.endsWith("." + y) || y.endsWith("." + x);
2235
+ }
2229
2236
  function classifyRule(name, dmarcFailed) {
2230
2237
  if (dmarcFailed && name === "DKIM_INVALID")
2231
2238
  return "forgery";
@@ -2455,7 +2462,17 @@ function hiddenLinkOverlay(bodyHtml) {
2455
2462
  }
2456
2463
  return null;
2457
2464
  }
2458
- function redirectorLink(bodyHtml) {
2465
+ function provedSenderDomain(input) {
2466
+ const fromDomain = (bare(input.fromAddress).split("@")[1] || "").toLowerCase();
2467
+ if (!fromDomain)
2468
+ return "";
2469
+ if (dmarcProof(input).pass)
2470
+ return fromDomain;
2471
+ return analyzeServerSpam(input)?.proved ? fromDomain : "";
2472
+ }
2473
+ function redirectorLink(input) {
2474
+ const bodyHtml = input.bodyHtml || "";
2475
+ const senderDomain = provedSenderDomain(input);
2459
2476
  for (const m of bodyHtml.matchAll(/href\s*=\s*["']([^"']+)["']/gi)) {
2460
2477
  let url;
2461
2478
  try {
@@ -2465,6 +2482,8 @@ function redirectorLink(bodyHtml) {
2465
2482
  }
2466
2483
  if (!/^https?:$/.test(url.protocol))
2467
2484
  continue;
2485
+ if (senderDomain && sameOrg(url.hostname, senderDomain))
2486
+ continue;
2468
2487
  for (const [, value] of url.searchParams) {
2469
2488
  let target;
2470
2489
  try {
@@ -2474,7 +2493,7 @@ function redirectorLink(bodyHtml) {
2474
2493
  }
2475
2494
  if (!/^https?:$/.test(target.protocol))
2476
2495
  continue;
2477
- if (target.hostname === url.hostname)
2496
+ if (sameOrg(target.hostname, url.hostname))
2478
2497
  continue;
2479
2498
  return {
2480
2499
  id: "redirector-link",
@@ -2493,7 +2512,7 @@ function assessMessageTrust(input) {
2493
2512
  relayAuthMismatch(input),
2494
2513
  zeroWidthObfuscation(input.bodyText || ""),
2495
2514
  hiddenLinkOverlay(input.bodyHtml || ""),
2496
- redirectorLink(input.bodyHtml || "")
2515
+ redirectorLink(input)
2497
2516
  ].filter(Boolean);
2498
2517
  const rank = { danger: 0, caution: 1, info: 2 };
2499
2518
  return findings.sort((a, b) => rank[a.severity] - rank[b.severity]);
@@ -2506,9 +2525,7 @@ function relayAuthMismatch(input) {
2506
2525
  for (const name of RELAY_IDENTITY_HEADERS) {
2507
2526
  const stamped = bare(header(input.headerLines, name));
2508
2527
  const stampedDomain = stamped.split("@")[1] || "";
2509
- if (!stampedDomain || stampedDomain === fromDomain)
2510
- continue;
2511
- if (stampedDomain.endsWith("." + fromDomain) || fromDomain.endsWith("." + stampedDomain))
2528
+ if (!stampedDomain || sameOrg(stampedDomain, fromDomain))
2512
2529
  continue;
2513
2530
  return {
2514
2531
  id: "relay-auth-mismatch",