@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 CHANGED
@@ -1,33 +1,41 @@
1
- The signature in accounts.jsonc never reached compose
1
+ The chip claimed every rule was a bulk-mail test without looking at them
2
2
 
3
- `sig` has been a documented field on AccountConfig for months and three of the
4
- four accounts carried one, yet no signature has ever appeared in a compose
5
- window. The reason is a projection: `MailxService.getAccounts()` builds the
6
- client's account list field by field id/name/email from the DB row, then
7
- label, defaultSend, primary*, identityDomains from the config and `sig` and
8
- `signature` were simply not on the list. `normalizeAccount()` resolved them
9
- correctly, `openCompose` forwarded `a.sig` faithfully, and compose.ts appended
10
- `acct.sig` exactly as designed; the value was undefined by the time any of that
11
- ran.
3
+ Altis Hotels' welcome mail (Bob 2026-09-03: "marked as spam but is valid",
4
+ `mailxstore/bobma/90/90fd2404….eml`). The banner on it is already right
5
+ `info`, no headline, "Your mail server scored this as spam, but altishotels.com
6
+ proved it sent this", and the "Stop warning about marketing@altishotels.com"
7
+ button underneath. What was wrong sat one element above: the score chip's
8
+ tooltip read "Scored by bulk-mail or blocklist rules; the sending domain passed
9
+ DMARC, so the sender is proved" — and then listed, on the next line of the same
10
+ tooltip, `3.2 KAM_ACCOUNTPHISH (Spam that tries to get account information)`,
11
+ which is 3.2 of the message's 8.3 and is neither a bulk-mail nor a blocklist
12
+ rule. A reader who reads both lines has been told two different things about
13
+ the same message by the same tooltip, and the app is the one that is wrong.
12
14
 
13
- Both fields are now carried, with a comment saying why the enumeration stays an
14
- enumeration rather than becoming `...cfg`: cfg holds the IMAP and SMTP
15
- passwords, which have no business crossing into the WebView. Anything new that
16
- the client needs has to be added in that spot deliberately.
15
+ The claim was never checked. `benign = proved || kind === "bulk"` picked the
16
+ neutral chip correctly, that part is about the sender and the tooltip then
17
+ described the RULES from that same flag. `proved` is a fact about the headers
18
+ and says nothing about any rule; `kind` is the WORST class present and says
19
+ nothing about the rest. Neither one can support a sentence beginning "every
20
+ rule that scored".
17
21
 
18
- The signature itself moved to the top level of accounts.jsonc — three
19
- byte-identical per-account copies replaced by the one the file format already
20
- supports, which every account without its own `sig` inherits. It is now
21
- `Bob Frankston<br>https://Frankston.com` with `html: true`; the previous text
22
- relied on bare newlines for its line break (HTML collapses those to a space)
23
- and pointed at a relative `href='Frankston.com'`, which resolves against
24
- whatever page the mail client happens to be rendering.
22
+ `analyzeServerSpam` now computes `bulkOnly` every rule that scored is `bulk`
23
+ or `neutral` and `spamScoreOf` carries it to the chip. The bulk-only sentence
24
+ is said only when it is true. Otherwise the tooltip says what IS established
25
+ and nothing more: the proof, by name, plus "not every rule that scored is a
26
+ bulk-mail test they are listed below", or for an unproved sender "no forgery
27
+ rule scored", which `kind === "bulk"` does establish. The `info` banner for a
28
+ bulk verdict loses its "— nothing that scored tests who sent it" clause on the
29
+ same condition, since it was making the same unchecked claim.
25
30
 
26
- Two stale comments corrected while here. `AccountSignature.html` was documented
27
- in both mailx-types and compose.ts as "reserved for future use / currently
28
- ignored". It has never been ignored compose has always branched on it — and
29
- now that a signature actually renders, the difference between escaped text and
30
- raw HTML is the thing an author needs to know.
31
+ Measured over the 800 newest messages in the store: 138 raise the neutral chip,
32
+ and 132 of those were being told the score was bulk-mail rules only. Most are
33
+ unflagged mail scoring above half the threshold, which carries no
34
+ X-Spam-Report at all there the individual scores are unknown, which is
35
+ precisely when a claim about all of them cannot be made.
31
36
 
32
- Compose only. Nothing in the send path or the daemon appends a signature; the
33
- body that leaves the editor is the body that goes out.
37
+ Not changed, on purpose: KAM_ACCOUNTPHISH stays unclassified. Its description
38
+ says it tests what the message asks for, not who sent it, but "other" is the
39
+ honest answer for a rule this file has no basis to classify, and the cautious
40
+ default is what "other" is for. The fix is to stop asserting past it, not to
41
+ guess at it.
@@ -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";
@@ -2311,6 +2318,7 @@ function analyzeServerSpam(input) {
2311
2318
  const unknown = rules.filter((r) => r.cls === "other" && scored(r));
2312
2319
  const unknownWeight = unknown.reduce((sum, r) => sum + r.score, 0);
2313
2320
  const unknownCouldFlagAlone = !unknown.length ? false : !Number.isFinite(unknownWeight) || !Number.isFinite(threshold) ? true : unknownWeight >= threshold;
2321
+ const bulkOnly = rules.every((r) => !scored(r) || r.cls === "bulk" || r.cls === "neutral");
2314
2322
  const kind = dmarc.authFailures.length || present("forgery") ? "forgery" : present("association") ? "association" : unknownCouldFlagAlone ? "unclassified" : rules.length ? "bulk" : "unclassified";
2315
2323
  const has = (name) => rules.some((r) => r.name === name);
2316
2324
  const provedBy = dmarc.pass ? "dmarc" : has("DKIM_VALID_AU") ? "dkim" : has("ALL_TRUSTED") ? "trusted" : "";
@@ -2327,6 +2335,7 @@ function analyzeServerSpam(input) {
2327
2335
  kind,
2328
2336
  proved: !!provedBy,
2329
2337
  provedBy,
2338
+ bulkOnly,
2330
2339
  provedDomain: dmarc.domain || fromDomain,
2331
2340
  provedPolicy: dmarc.policy,
2332
2341
  bayesHam: rules.some((r) => r.name === "BAYES_00" || r.name === "BAYES_01"),
@@ -2374,7 +2383,7 @@ function serverSpamVerdict(input) {
2374
2383
  return {
2375
2384
  id: "server-spam-verdict",
2376
2385
  severity: "info",
2377
- text: "Your mail server rates this bulk mail \u2014 nothing that scored tests who sent it.",
2386
+ text: a.bulkOnly ? "Your mail server rates this bulk mail \u2014 nothing that scored tests who sent it." : "Your mail server rates this bulk mail.",
2378
2387
  detail: `${numbers}${why}${bayes}`
2379
2388
  };
2380
2389
  const proof = a.proved ? `${provedNote(a)} ` : "";
@@ -2455,7 +2464,17 @@ function hiddenLinkOverlay(bodyHtml) {
2455
2464
  }
2456
2465
  return null;
2457
2466
  }
2458
- function redirectorLink(bodyHtml) {
2467
+ function provedSenderDomain(input) {
2468
+ const fromDomain = (bare(input.fromAddress).split("@")[1] || "").toLowerCase();
2469
+ if (!fromDomain)
2470
+ return "";
2471
+ if (dmarcProof(input).pass)
2472
+ return fromDomain;
2473
+ return analyzeServerSpam(input)?.proved ? fromDomain : "";
2474
+ }
2475
+ function redirectorLink(input) {
2476
+ const bodyHtml = input.bodyHtml || "";
2477
+ const senderDomain = provedSenderDomain(input);
2459
2478
  for (const m of bodyHtml.matchAll(/href\s*=\s*["']([^"']+)["']/gi)) {
2460
2479
  let url;
2461
2480
  try {
@@ -2465,6 +2484,8 @@ function redirectorLink(bodyHtml) {
2465
2484
  }
2466
2485
  if (!/^https?:$/.test(url.protocol))
2467
2486
  continue;
2487
+ if (senderDomain && sameOrg(url.hostname, senderDomain))
2488
+ continue;
2468
2489
  for (const [, value] of url.searchParams) {
2469
2490
  let target;
2470
2491
  try {
@@ -2474,7 +2495,7 @@ function redirectorLink(bodyHtml) {
2474
2495
  }
2475
2496
  if (!/^https?:$/.test(target.protocol))
2476
2497
  continue;
2477
- if (target.hostname === url.hostname)
2498
+ if (sameOrg(target.hostname, url.hostname))
2478
2499
  continue;
2479
2500
  return {
2480
2501
  id: "redirector-link",
@@ -2493,7 +2514,7 @@ function assessMessageTrust(input) {
2493
2514
  relayAuthMismatch(input),
2494
2515
  zeroWidthObfuscation(input.bodyText || ""),
2495
2516
  hiddenLinkOverlay(input.bodyHtml || ""),
2496
- redirectorLink(input.bodyHtml || "")
2517
+ redirectorLink(input)
2497
2518
  ].filter(Boolean);
2498
2519
  const rank = { danger: 0, caution: 1, info: 2 };
2499
2520
  return findings.sort((a, b) => rank[a.severity] - rank[b.severity]);
@@ -2506,9 +2527,7 @@ function relayAuthMismatch(input) {
2506
2527
  for (const name of RELAY_IDENTITY_HEADERS) {
2507
2528
  const stamped = bare(header(input.headerLines, name));
2508
2529
  const stampedDomain = stamped.split("@")[1] || "";
2509
- if (!stampedDomain || stampedDomain === fromDomain)
2510
- continue;
2511
- if (stampedDomain.endsWith("." + fromDomain) || fromDomain.endsWith("." + stampedDomain))
2530
+ if (!stampedDomain || sameOrg(stampedDomain, fromDomain))
2512
2531
  continue;
2513
2532
  return {
2514
2533
  id: "relay-auth-mismatch",
@@ -2544,6 +2563,7 @@ function spamScoreOf(input) {
2544
2563
  kind: a.kind,
2545
2564
  proved: a.proved,
2546
2565
  provedBy: a.provedBy,
2566
+ bulkOnly: a.bulkOnly,
2547
2567
  trusted: !!input.senderTrusted && a.proved && a.kind !== "forgery" && !a.authFailures.length,
2548
2568
  reasons: a.reasons
2549
2569
  };