@bobfrankston/rmfmail 1.2.285 → 1.2.286

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 (32) hide show
  1. package/.commitmsg +37 -31
  2. package/TODO.md +2 -0
  3. package/client/android-bootstrap.bundle.js +202 -19
  4. package/client/android-bootstrap.bundle.js.map +2 -2
  5. package/client/app.bundle.js +7 -4
  6. package/client/app.bundle.js.map +2 -2
  7. package/client/components/message-viewer.js +32 -9
  8. package/client/components/message-viewer.js.map +1 -1
  9. package/client/components/message-viewer.ts +36 -10
  10. package/client/styles/components.css +9 -0
  11. package/docs/spam-false-positives.md +208 -0
  12. package/npmchanges.md +40 -0
  13. package/package.json +1 -1
  14. package/packages/mailx-imap/package-lock.json +2 -2
  15. package/packages/mailx-imap/package.json +1 -1
  16. package/packages/mailx-service/index.ts +20 -3
  17. package/packages/mailx-service/package.json +1 -1
  18. package/packages/mailx-settings/docs/spam-false-positives.md +208 -0
  19. package/packages/mailx-settings/package.json +1 -1
  20. package/packages/mailx-store/package.json +1 -1
  21. package/packages/mailx-store/store.d.ts.map +1 -1
  22. package/packages/mailx-store/store.js +7 -3
  23. package/packages/mailx-store/store.js.map +1 -1
  24. package/packages/mailx-store/store.ts +7 -3
  25. package/packages/mailx-store-web/package.json +1 -1
  26. package/packages/mailx-types/package.json +1 -1
  27. package/packages/mailx-types/trust.d.ts +16 -3
  28. package/packages/mailx-types/trust.d.ts.map +1 -1
  29. package/packages/mailx-types/trust.js +262 -23
  30. package/packages/mailx-types/trust.js.map +1 -1
  31. package/packages/mailx-types/trust.ts +343 -25
  32. /package/packages/mailx-imap/{node_modules.npmglobalize-stash-85316 → node_modules.npmglobalize-stash-50896}/.package-lock.json +0 -0
@@ -1665,33 +1665,59 @@ export async function showMessage(accountId: string, uid: number, folderId?: num
1665
1665
  // prompted all of this scored 2.7 against 4.5 and was delivered
1666
1666
  // unflagged, so showing only the flagged ones would miss exactly the
1667
1667
  // case worth showing.
1668
- const spam = (msg as any).spamScore as { score: number; threshold: number; flagged: boolean };
1668
+ //
1669
+ // The COLOUR comes from what the rules that scored are evidence of, not
1670
+ // from the number (Claude Code 2026-08-27): a newsletter DMARC proved,
1671
+ // or one whose whole score is bulk-mail rules, gets the neutral chip
1672
+ // however high it climbed. Blue Orchestra scored 10.9 on a Spamhaus
1673
+ // listing of a linked advocacy site and a bulk-mail hit; painting that
1674
+ // the same red as a forged sender is what teaches a reader to ignore
1675
+ // the chip. The floor stays at half the server's own threshold —
1676
+ // see docs/spam-false-positives.md on why tuning it is not the fix.
1677
+ const spam = (msg as any).spamScore as {
1678
+ score: number; threshold: number; flagged: boolean;
1679
+ kind?: string; proved?: boolean; reasons?: string[];
1680
+ };
1669
1681
  if (spam && Number.isFinite(spam.score) && (spam.flagged || spam.score >= spam.threshold / 2)) {
1670
- const level = spam.flagged ? "high" : "mid";
1682
+ const benign = !!spam.proved || spam.kind === "bulk";
1683
+ const level = benign ? "bulk" : spam.flagged ? "high" : "mid";
1671
1684
  const chip = document.createElement("div");
1672
1685
  chip.className = `mv-spamscore mv-spamscore-${level}`;
1673
- chip.title = level === "high"
1674
- ? "Your mail server scored this at or above its spam threshold and flagged it."
1675
- : "Below your server's spam threshold, but more than halfway to it.";
1686
+ const why = (spam.reasons || []).length ? "\n\n" + spam.reasons.join("\n") : "";
1687
+ chip.title = (level === "bulk"
1688
+ ? (spam.proved
1689
+ ? "Scored by bulk-mail or blocklist rules; the sending domain passed DMARC, so the sender is proved."
1690
+ : "Every rule that scored is a bulk-mail test — none of them test who sent this.")
1691
+ : level === "high"
1692
+ ? "Your mail server scored this at or above its spam threshold and flagged it."
1693
+ : "Below your server's spam threshold, but more than halfway to it.") + why;
1676
1694
  chip.textContent = `spam score ${spam.score} of ${spam.threshold}`;
1677
1695
  bodyEl.appendChild(chip);
1678
1696
  }
1679
1697
 
1680
1698
  const trust = ((msg as any).trust || []) as { id: string; severity: string; text: string; detail: string }[];
1681
1699
  if (trust.length) {
1682
- const worst = trust.some(f => f.severity === "danger") ? "danger" : "caution";
1700
+ // An `info` finding is not an accusation, so it gets neither the
1701
+ // headline nor the "do not open anything" footer — it renders as a
1702
+ // plain note in the register of the remote-content banner. A single
1703
+ // danger or caution finding anywhere in the set still sets the
1704
+ // whole banner, because the worst evidence is the one that matters.
1705
+ // (Claude Code 2026-08-27)
1706
+ const worst = trust.some(f => f.severity === "danger") ? "danger"
1707
+ : trust.some(f => f.severity === "caution") ? "caution" : "info";
1708
+ const head = worst === "danger" ? "This message is not what it says it is"
1709
+ : worst === "caution" ? "Check this message before acting on it" : "";
1683
1710
  const warn = document.createElement("div");
1684
1711
  warn.className = `mv-trust mv-trust-${worst}`;
1685
1712
  warn.innerHTML =
1686
- `<div class="mv-trust-head">${worst === "danger"
1687
- ? "This message is not what it says it is"
1688
- : "Check this message before acting on it"}</div>` +
1713
+ (head ? `<div class="mv-trust-head">${head}</div>` : "") +
1689
1714
  trust.map(f =>
1690
1715
  `<div class="mv-trust-item mv-trust-${escapeText(f.severity)}">` +
1691
1716
  `<div class="mv-trust-text">${escapeText(f.text)}</div>` +
1692
1717
  `<div class="mv-trust-detail">${escapeText(f.detail)}</div>` +
1693
1718
  `</div>`).join("") +
1694
- `<div class="mv-trust-foot">Links and attachments here should not be opened.</div>`;
1719
+ (worst === "info" ? ""
1720
+ : `<div class="mv-trust-foot">Links and attachments here should not be opened.</div>`);
1695
1721
  bodyEl.appendChild(warn);
1696
1722
  }
1697
1723
 
@@ -2945,6 +2945,12 @@ body.calendar-sidebar-on .calendar-sidebar { display: flex; }
2945
2945
  }
2946
2946
  .mv-trust-caution { box-shadow: inset 0 0 0 2px oklch(0.65 0.20 50); }
2947
2947
 
2948
+ /* `info` — the server said something, and it is not an accusation: bulk mail
2949
+ from a sender DMARC proved. Grey, no headline, no "do not open" footer. A
2950
+ red box on a newsletter Bob subscribed to is what teaches him to click past
2951
+ the box that matters. (Claude Code 2026-08-27) */
2952
+ .mv-trust-info { box-shadow: inset 0 0 0 2px oklch(0.70 0.02 250); }
2953
+
2948
2954
  .mv-trust-head {
2949
2955
  background: oklch(0.42 0.20 25);
2950
2956
  color: white;
@@ -2994,3 +3000,6 @@ body.calendar-sidebar-on .calendar-sidebar { display: flex; }
2994
3000
  }
2995
3001
  .mv-spamscore-mid { background: oklch(0.55 0.18 50); color: white; font-weight: 600; }
2996
3002
  .mv-spamscore-high { background: oklch(0.42 0.20 25); color: white; font-weight: 700; }
3003
+ /* Scored by bulk-mail or blocklist rules with the sender proved — the number
3004
+ is real and stays visible, but it is not an identity finding. */
3005
+ .mv-spamscore-bulk { background: oklch(0.55 0.02 250); color: white; font-weight: 600; }
@@ -0,0 +1,208 @@
1
+ # Spam false positives — why the trust banner fires on mail Bob asked for
2
+
3
+ *Session note, 2026-08-27 (Claude Code), at v1.2.285. State: **first cut shipped
4
+ 2026-08-27** — ideas (1) and (2) below are implemented in `packages/mailx-types/trust.ts`
5
+ with tests over all four samples in `tests/trust.test.ts`; sender trust (7) is still open.
6
+ See "What shipped" at the end.*
7
+
8
+ Bob 2026-08-27: *"better ideas for detecting false positives for spam"*, then
9
+ *"Trusting senders may be a route."*
10
+
11
+ ## The four sample messages
12
+
13
+ All still on disk under `C:\Users\Bob\.rmfmail\mailxstore\bobma\`:
14
+
15
+ | file | sender | SA score | why it scored | Bayes | DMARC | Delivered-To |
16
+ |---|---|---|---|---|---|---|
17
+ | `f1/f1e6c04745e2403b89afa56de88af993.eml` | Blue Orchestra via Constant Contact | **10.9 YES** | `SH_DBL_HEADERS` 8.0 (blueorchestra.org in Spamhaus DBL) + `DCC_CHECK` 3.0 | BAYES_00 | pass, aligned, **p=reject** | actblue@bob.ma |
18
+ | `cc/ccdf707f078b451697c68681f1dc0a2f.eml` | Adobe MAX | 3.9 no | 100% bulk rules: DCC_CHECK, DCC_REPUT_90_94, PYZOR_CHECK, MAILING_LIST_MULTI, HTML_FONT_SIZE_HUGE, HTML_IMAGE_RATIO_04, WORD_INVIS | BAYES_00 | pass, aligned, p=reject | adobe@bob.ma |
19
+ | `9c/9c5a0904f8974558ac714d139ec8a235.eml` | Mokin via Shopify/SendGrid | **4.9 YES** | `URIBL_SBL` 6.0 | BAYES_00 | pass, aligned, p=quarantine | mokin@bob.ma |
20
+ | `01/01fe446185024fd8beb851f8c29a87c2.eml` | Josh Shapiro via ActionKit | 2.2 (quiet — correct) | DCC_REPUT_70_89, KAM_TRACKIMAGE, URIBL_CSS_A, HTML_FONT_SIZE_LARGE | BAYES_00 | pass, aligned, p=reject | actblue@bob.ma |
21
+
22
+ Two of the four raise the red banner ("This message is not what it says it is" /
23
+ "Links and attachments here should not be opened"). Adobe raises the amber
24
+ `spam score 3.9 of 4.5` chip. Shapiro is quiet.
25
+
26
+ ## What the sample proves
27
+
28
+ **1. Shapiro is quiet by luck, not by discrimination.** The chip floor in
29
+ `client/components/message-viewer.ts` is `spam.score >= spam.threshold / 2` = 2.25.
30
+ Shapiro scored 2.2. It is the *same kind of mail* as Adobe — ESP-sent political/marketing
31
+ bulk, same rule families. Landing 0.05 on the right side of an arbitrary line is not a
32
+ classifier. **Do not respond by nudging that constant** — raising it to 0.75×threshold
33
+ silences Adobe at 3.9 and still fires on the next legitimate newsletter at 4.0.
34
+
35
+ **2. The big rules that flagged these are guilt by association, at third and fourth order.**
36
+ Mokin's `X-Spam-Report` reads verbatim:
37
+
38
+ ```
39
+ * 6.0 URIBL_SBL Contains an URL's NS IP listed in the Spamhaus SBL blocklist
40
+ * [URI: ns12.xincache.com/112.80.181.111]
41
+ ```
42
+
43
+ link → its domain → that domain's nameserver → the nameserver's IP → SBL. Fourth order,
44
+ worth more than the entire 4.5 threshold on its own. Blue Orchestra is the same shape:
45
+ `SH_DBL_HEADERS` 8.0 because blueorchestra.org (a real advocacy org) sits in the DBL.
46
+ Neither rule says anything about whether *this message* is forged.
47
+
48
+ **3. Every one of the four carries `BAYES_00` (score 0.0000).** That is Bob's own
49
+ SpamAssassin at gal.iecc.com, trained on years of Bob's own mail, saying 0–1% spam
50
+ probability. Everything that pushed these over the line is a *network* rule about a third
51
+ party, not a statement about the content.
52
+
53
+ **4. Every one of the four passes DMARC, aligned, on a published reject/quarantine policy.**
54
+ The domain owner staked their policy on it and the signature verified. The From line is
55
+ *proved*. On such a message the headline "This message is not what it says it is" is
56
+ **factually false**.
57
+
58
+ **5. Every one arrived at a Bob-minted tag address**, and in two cases the tag names the
59
+ sender's own domain: `mokin@bob.ma` → mokintech.com, `adobe@bob.ma` → adobe.com.
60
+
61
+ ## Root cause
62
+
63
+ `serverSpamVerdict()` in `packages/mailx-types/trust.ts` consumes SpamAssassin's **bottom
64
+ line** — a number tuned to answer "should this be delivered" — and reuses it to answer a
65
+ different question, "is this forged", then prints the strongest banner in the app. The
66
+ `tests=` list that would distinguish the two questions is in the same header and is never
67
+ parsed. `spamScoreOf()` reads `score=` and `required=` out of `X-Spam-Status` and drops
68
+ everything else.
69
+
70
+ This is the same mistake trust.ts's own header warns against — its doctrine is
71
+ *"Everything here is EVIDENCE… Each finding names what was found and what it means, so
72
+ the reader can check it rather than trust a score."* The score finding is the one item in
73
+ the file that is a bare number with no named evidence.
74
+
75
+ ## Ideas, ranked by leverage
76
+
77
+ **1. A DMARC-aligned pass vetoes the danger headline.** ~10 lines, no tuning, kills the
78
+ worst FP class outright. Does not make mail *wanted* — makes "not what it says it is"
79
+ unsayable. Parse the existing `Authentication-Results` (already read by `selfSpoof`).
80
+
81
+ **2. Classify `tests=` instead of summing it.** Three disjoint classes:
82
+
83
+ - *bulk* — `DCC_*`, `PYZOR_*`, `RAZOR2_*`, `MAILING_LIST_MULTI`, `KAM_UNSUB1`,
84
+ `KAM_*MARKETINGBL*`, `KAM_TRACKIMAGE`, `KAM_REALLYHUGEIMGSRC`, `HTML_FONT_SIZE_*`,
85
+ `HTML_IMAGE_RATIO_*`, `MIME_HTML_ONLY`, `WORD_INVIS`, `HTML_TAG_BALANCE_*`,
86
+ `URIBL_GREY`, `URIBL_CSS_A`, `SH_BODYURI_REVERSE_CSS`
87
+ - *forgery* — `SPF_FAIL`/`SOFTFAIL`, DKIM invalid **with** DMARC fail, `FORGED_*`, `SPOOF_*`
88
+ - *guilt by association* — `URIBL_SBL`, `SH_DBL_HEADERS`, `URIBL_BLACK`
89
+
90
+ A score composed entirely of the bulk class is a **bulk verdict**, however high. Adobe's
91
+ 3.9 is 100% bulk. Say "your server rates this bulk marketing" in the neutral register of
92
+ the remote-content banner, or say nothing. Red is reserved for the forgery class.
93
+
94
+ **3. `BAYES_00` is a demotion signal.** Flagged + BAYES_00 + DMARC-aligned means "network
95
+ lists dislike a third party this message mentions", not "this resembles spam Bob receives".
96
+
97
+ **4. The Delivered-To tag is a record of consent, and it is free.** Bob minted
98
+ `mokin@bob.ma` and gave it to exactly one correspondent. When the tag's local part matches
99
+ the authenticated sending domain, that is documented subscription. Needs no maintenance,
100
+ unlike the allowlist. `deliveredTo` is already parsed at `packages/mailx-store/store.ts:543`
101
+ and already flows to the viewer — nothing reads it for trust purposes.
102
+
103
+ **5. Bob's own store is a better ham oracle than any RBL.** Prior messages from the same
104
+ registrable domain that he read, replied to, or kept (not Junk, not deleted) — 50k+ rows of
105
+ ground truth about his actual correspondents. A domain with 40 kept messages does not become
106
+ a forger today because its nameserver landed in SBL.
107
+
108
+ **6. Name the evidence for the score, as every other finding does.** `spam score 4.9 of 4.5`
109
+ is unactionable; *"6.0 URIBL_SBL — the nameserver of a linked host is in Spamhaus SBL"* is
110
+ judged in one second. Caveat found in the sample: `X-Spam-Report` appears only on flagged
111
+ messages (present on Blue Orchestra + Mokin, absent on Adobe + Shapiro); `tests=` is always
112
+ present, so degrade to rule names when the report is missing.
113
+
114
+ **7. Extinguishable, not tuned** — see next section, this is the direction Bob picked.
115
+
116
+ ## Trusting senders — the direction Bob chose
117
+
118
+ Bob 2026-08-27: *"Trusting senders may be a route."* Consistent with the standing rule that
119
+ heuristic warnings should be **user-extinguishable rather than threshold-tuned**
120
+ (memory: `allowlist_is_the_approved_sender_store`).
121
+
122
+ What exists already and should be reused, not reinvented:
123
+
124
+ - `allowlist.jsonc` **is** the approved-sender store; `getAllowlist()` at
125
+ `packages/mailx-service/index.ts:1980` returns
126
+ `{senders, domains, recipients, flaggedSenders, flaggedDomains}`, exposed over IPC at
127
+ `jsonrpc.ts:404` and bridged for Android at `mailx-store-web/android-bootstrap.ts:1794`.
128
+ Design doc: `docs/allowlist.md`.
129
+ - The viewer already offers `✓ Trust all senders at <domain>` in the address context menu
130
+ (`client/components/message-viewer.ts:1371`), calling `approve("domain", dom)`.
131
+ - `store.ts:570` already re-checks the recipient allowlist **with Delivered-To in hand** to
132
+ decide `allowRemote`. That is exactly the shape the trust suppression wants, one decision
133
+ earlier in the same function.
134
+
135
+ What is missing:
136
+
137
+ - Nothing consults the allowlist when computing `trust` / `spamScore` — an approved sender
138
+ still gets the red banner. `assessMessageTrust` is called at `store.ts:586` with no
139
+ allowlist input.
140
+ - The trust banner has no inline action. A reader who has to find a context menu on an
141
+ address to say "this is mail I asked for" will not do it.
142
+
143
+ Open design questions for next session:
144
+
145
+ - **Trust key granularity**: envelope domain, From registrable domain, DKIM `d=` domain, or
146
+ the Delivered-To tag? Blue Orchestra argues against the From domain here —
147
+ `shared1.ccsend.com` is shared by every Constant Contact customer, so trusting it would
148
+ trust all of them; `blueorchestra.org` (Reply-To, and the domain in the DKIM-signed
149
+ `List-Unsubscribe`) is the real identity. Mokin argues the **tag** is the cleanest key —
150
+ `mokin@bob.ma` is single-purpose by construction.
151
+ - Does trusting a sender suppress the *whole* banner, or only the bulk/association findings,
152
+ leaving genuine forgery evidence visible? Leaning: suppress the score chip and the
153
+ association findings, **never** the forgery class — a trusted domain can still be spoofed,
154
+ and that is precisely the case the banner exists for.
155
+ - Auto-trust on a positive act (reply / move out of Junk / add to contacts) vs explicit only.
156
+
157
+ ## Recommended minimum first cut
158
+
159
+ (1) + (2): DMARC-alignment veto plus rule classification. Together they silence all three
160
+ false positives here and leave the 2026-08-25 sextortion/phish cases that motivated
161
+ trust.ts untouched — those had spf=none/fail, no DKIM, no DMARC pass, and Bayes did not say
162
+ 0%. Sender-trust (7) then layers on top as the user-driven escape hatch rather than as the
163
+ primary mechanism.
164
+
165
+ ## Files involved
166
+
167
+ - `packages/mailx-types/trust.ts` — `serverSpamVerdict()`, `spamScoreOf()`, `assessMessageTrust()`
168
+ - `packages/mailx-store/store.ts:586` — the single call site; has `deliveredTo`, `recipients`, raw `headerLines`
169
+ - `client/components/message-viewer.ts:1655-1697` — the score chip (`threshold/2` floor) and the trust banner
170
+ - `docs/allowlist.md` + `getAllowlist()` — the approved-sender store
171
+
172
+ ## What shipped, 2026-08-27
173
+
174
+ Ideas (1) and (2), as the "recommended minimum first cut" below says. In
175
+ `serverSpamVerdict()` / the new `analyzeServerSpam()` in
176
+ `packages/mailx-types/trust.ts`:
177
+
178
+ - `tests=` is parsed and every rule classified as **forgery**, **association**,
179
+ **bulk**, **neutral**, or **other** (`FORGERY_RULES` / `ASSOCIATION_RULES` /
180
+ `BULK_RULES` / `NEUTRAL_RULES`). A rule that scored zero or less cannot be the
181
+ reason a message is over the line, so it is not counted.
182
+ - `X-Spam-Report` gives each rule its score and description, so findings name their
183
+ evidence — "8.0 SH_DBL_HEADERS (A domain found in headers … is listed in DBL)" —
184
+ and degrade to bare rule names when the report is absent (idea 6).
185
+ - The **topmost** `Authentication-Results` is read for a DMARC-aligned pass, and its
186
+ `header.from=` is checked against the real From line. Only the topmost, because
187
+ headers are prepended: anything below it travelled with the message, and a forger
188
+ who could add `dmarc=pass` further down would have a switch for turning the banner
189
+ off.
190
+ - Severity comes from the rules, never the number:
191
+ **danger** when a forgery rule fired or the server recorded an SPF/DKIM/DMARC
192
+ failure (phishing and malware URL listings count — a lookalike domain can get its
193
+ own valid DKIM); **info**, a new neutral severity with no headline and no
194
+ "do not open anything" footer, when the sender is proved and every rule that scored
195
+ is one the classifier can name, or the whole score is bulk; **caution** otherwise —
196
+ including a proved sender whose score came from unclassified rules, because unknown
197
+ is never a reason to go quiet. That last distinction is 326 messages in Bob's store.
198
+ - `spamScoreOf()` returns `kind`, `proved` and `reasons` from the same analysis, so the
199
+ chip and the banner cannot disagree. The chip is grey when the sender is proved or
200
+ the score is all bulk, red only when the banner is. **The `threshold/2` floor is
201
+ unchanged.**
202
+
203
+ Measured over 20,291 messages in the store: 1,131 were flagged spam, and every one of
204
+ them used to get the red banner. Now 294 do (forgery), 672 are caution, 165 are quiet
205
+ notes. All four samples above are quiet or neutral; the 2026-08-25 phishes stay red.
206
+
207
+ Still open (C165): sender trust via `allowlist.jsonc`, the inline trust action on the
208
+ banner, and the three design questions in the section above.
package/npmchanges.md CHANGED
@@ -828,3 +828,43 @@ printed "already running (PID 34012, v1.2.283) — raising its window", exited 0
828
828
  wrote the request, and left the running daemon alive — where the old code would
829
829
  have killed it.
830
830
 
831
+ ## v1.2.285 — 2026-08-27
832
+
833
+ Close the window where three daemons could start at once
834
+
835
+ "This does not look like a single instance ... but not one of the windows came
836
+ to the front." It wasn't one. Three daemons were running:
837
+
838
+ PID 1452 23:52:15.9
839
+ PID 34012 23:52:19.9 <- the only one instance.json knew about
840
+ PID 94964 23:52:25.0
841
+
842
+ Launched 4 and 5 seconds apart, each with its own window, and replace-on-launch
843
+ could never clean them up because it only ever knew about the last one to
844
+ register. Win+4 could not raise "the" window because there were three of them
845
+ and nothing owned the taskbar identity.
846
+
847
+ The hole: the launcher spawns a DETACHED daemon and exits immediately, while
848
+ the daemon does not register itself until it has built the store, started the
849
+ popout server and opened its window — several seconds later. Every launch
850
+ arriving inside that gap reads instance.json, sees nothing alive, and starts
851
+ another daemon. Three launches within ten seconds, three daemons.
852
+
853
+ The launcher now claims the slot for its child the moment spawn returns, before
854
+ it exits. The child is alive from that instant and pidIsMailx is a liveness
855
+ check, so the claim is valid straight away; when the daemon later reaches its
856
+ own registration it writes the same PID again. A launch landing mid-boot now
857
+ takes the activate path added in the previous release and drops
858
+ pending-activate.json, which the daemon consumes as soon as it starts watching
859
+ — which is exactly why that path checks for a request that arrived while it was
860
+ booting.
861
+
862
+ Also: startedAt was recomputed on EVERY write, and every popup and popout
863
+ window rewrites this file, so the field reported the last window event rather
864
+ than when the daemon started. It now survives childPids updates. That one cost
865
+ a wrong turn while diagnosing this — the file claimed the daemon had started
866
+ seconds ago when it had been up for minutes.
867
+
868
+ Ships separately from the activate fix in 1.2.284 — verified that release did
869
+ not already contain it.
870
+
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/rmfmail",
3
- "version": "1.2.285",
3
+ "version": "1.2.286",
4
4
  "description": "Local-first email client with IMAP sync and standalone native app",
5
5
  "type": "module",
6
6
  "main": "bin/mailx.js",
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx-imap",
3
- "version": "0.1.160",
3
+ "version": "0.1.161",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bobfrankston/mailx-imap",
9
- "version": "0.1.160",
9
+ "version": "0.1.161",
10
10
  "license": "ISC",
11
11
  "dependencies": {
12
12
  "@bobfrankston/iflow-direct": "^0.1.27",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx-imap",
3
- "version": "0.1.160",
3
+ "version": "0.1.161",
4
4
  "type": "module",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",
@@ -4861,12 +4861,29 @@ Resolve relative dates ("tomorrow", "next Friday") against today's date. If no y
4861
4861
  // <name>": the editor is not the place to be censored.
4862
4862
  // Disabled here rather than filtered client-side so the
4863
4863
  // lint is never computed or shipped over IPC.
4864
+ //
4865
+ // CompoundNouns ("Did you mean the closed compound noun
4866
+ // “keypoint”?") fires on any noun+noun pair whose joined
4867
+ // form is in Harper's dictionary — Bob 2026-08-27 on "One
4868
+ // key point in my recent efforts": it invents a word he did
4869
+ // not write and marks correct English as an error. Same
4870
+ // objection as AvoidCurses: the tool proofreads, it does not
4871
+ // get a vote on word choice. Its siblings are still on
4872
+ // (MergeWords, MakeupCompoundNoun, OvertimeCompoundNoun,
4873
+ // ThereAfterCompound, EasyGoingCompoundAdjective) — add the
4874
+ // name here if one of them starts nagging.
4875
+ //
4876
+ // A partial setLintConfig MERGES: only the named keys change
4877
+ // and every other rule keeps its default. Rule names are
4878
+ // PascalCase and are NOT the user-visible message —
4879
+ // `JSON.parse(await linter.getLintConfigAsJSON())` lists all
4880
+ // ~750, getLintDescriptionsAsJSON() explains each.
4864
4881
  try {
4865
- await linter.setLintConfig({ AvoidCurses: false } as any);
4882
+ await linter.setLintConfig({ AvoidCurses: false, CompoundNouns: false } as any);
4866
4883
  } catch (e: any) {
4867
- console.warn(` [grammar] could not disable AvoidCurses: ${e?.message || e}`);
4884
+ console.warn(` [grammar] could not disable AvoidCurses/CompoundNouns: ${e?.message || e}`);
4868
4885
  }
4869
- console.log(" [grammar] Harper linter initialized (AvoidCurses off)");
4886
+ console.log(" [grammar] Harper linter initialized (AvoidCurses, CompoundNouns off)");
4870
4887
  return linter;
4871
4888
  })();
4872
4889
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx-service",
3
- "version": "0.1.28",
3
+ "version": "0.1.29",
4
4
  "type": "module",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",
@@ -0,0 +1,208 @@
1
+ # Spam false positives — why the trust banner fires on mail Bob asked for
2
+
3
+ *Session note, 2026-08-27 (Claude Code), at v1.2.285. State: **first cut shipped
4
+ 2026-08-27** — ideas (1) and (2) below are implemented in `packages/mailx-types/trust.ts`
5
+ with tests over all four samples in `tests/trust.test.ts`; sender trust (7) is still open.
6
+ See "What shipped" at the end.*
7
+
8
+ Bob 2026-08-27: *"better ideas for detecting false positives for spam"*, then
9
+ *"Trusting senders may be a route."*
10
+
11
+ ## The four sample messages
12
+
13
+ All still on disk under `C:\Users\Bob\.rmfmail\mailxstore\bobma\`:
14
+
15
+ | file | sender | SA score | why it scored | Bayes | DMARC | Delivered-To |
16
+ |---|---|---|---|---|---|---|
17
+ | `f1/f1e6c04745e2403b89afa56de88af993.eml` | Blue Orchestra via Constant Contact | **10.9 YES** | `SH_DBL_HEADERS` 8.0 (blueorchestra.org in Spamhaus DBL) + `DCC_CHECK` 3.0 | BAYES_00 | pass, aligned, **p=reject** | actblue@bob.ma |
18
+ | `cc/ccdf707f078b451697c68681f1dc0a2f.eml` | Adobe MAX | 3.9 no | 100% bulk rules: DCC_CHECK, DCC_REPUT_90_94, PYZOR_CHECK, MAILING_LIST_MULTI, HTML_FONT_SIZE_HUGE, HTML_IMAGE_RATIO_04, WORD_INVIS | BAYES_00 | pass, aligned, p=reject | adobe@bob.ma |
19
+ | `9c/9c5a0904f8974558ac714d139ec8a235.eml` | Mokin via Shopify/SendGrid | **4.9 YES** | `URIBL_SBL` 6.0 | BAYES_00 | pass, aligned, p=quarantine | mokin@bob.ma |
20
+ | `01/01fe446185024fd8beb851f8c29a87c2.eml` | Josh Shapiro via ActionKit | 2.2 (quiet — correct) | DCC_REPUT_70_89, KAM_TRACKIMAGE, URIBL_CSS_A, HTML_FONT_SIZE_LARGE | BAYES_00 | pass, aligned, p=reject | actblue@bob.ma |
21
+
22
+ Two of the four raise the red banner ("This message is not what it says it is" /
23
+ "Links and attachments here should not be opened"). Adobe raises the amber
24
+ `spam score 3.9 of 4.5` chip. Shapiro is quiet.
25
+
26
+ ## What the sample proves
27
+
28
+ **1. Shapiro is quiet by luck, not by discrimination.** The chip floor in
29
+ `client/components/message-viewer.ts` is `spam.score >= spam.threshold / 2` = 2.25.
30
+ Shapiro scored 2.2. It is the *same kind of mail* as Adobe — ESP-sent political/marketing
31
+ bulk, same rule families. Landing 0.05 on the right side of an arbitrary line is not a
32
+ classifier. **Do not respond by nudging that constant** — raising it to 0.75×threshold
33
+ silences Adobe at 3.9 and still fires on the next legitimate newsletter at 4.0.
34
+
35
+ **2. The big rules that flagged these are guilt by association, at third and fourth order.**
36
+ Mokin's `X-Spam-Report` reads verbatim:
37
+
38
+ ```
39
+ * 6.0 URIBL_SBL Contains an URL's NS IP listed in the Spamhaus SBL blocklist
40
+ * [URI: ns12.xincache.com/112.80.181.111]
41
+ ```
42
+
43
+ link → its domain → that domain's nameserver → the nameserver's IP → SBL. Fourth order,
44
+ worth more than the entire 4.5 threshold on its own. Blue Orchestra is the same shape:
45
+ `SH_DBL_HEADERS` 8.0 because blueorchestra.org (a real advocacy org) sits in the DBL.
46
+ Neither rule says anything about whether *this message* is forged.
47
+
48
+ **3. Every one of the four carries `BAYES_00` (score 0.0000).** That is Bob's own
49
+ SpamAssassin at gal.iecc.com, trained on years of Bob's own mail, saying 0–1% spam
50
+ probability. Everything that pushed these over the line is a *network* rule about a third
51
+ party, not a statement about the content.
52
+
53
+ **4. Every one of the four passes DMARC, aligned, on a published reject/quarantine policy.**
54
+ The domain owner staked their policy on it and the signature verified. The From line is
55
+ *proved*. On such a message the headline "This message is not what it says it is" is
56
+ **factually false**.
57
+
58
+ **5. Every one arrived at a Bob-minted tag address**, and in two cases the tag names the
59
+ sender's own domain: `mokin@bob.ma` → mokintech.com, `adobe@bob.ma` → adobe.com.
60
+
61
+ ## Root cause
62
+
63
+ `serverSpamVerdict()` in `packages/mailx-types/trust.ts` consumes SpamAssassin's **bottom
64
+ line** — a number tuned to answer "should this be delivered" — and reuses it to answer a
65
+ different question, "is this forged", then prints the strongest banner in the app. The
66
+ `tests=` list that would distinguish the two questions is in the same header and is never
67
+ parsed. `spamScoreOf()` reads `score=` and `required=` out of `X-Spam-Status` and drops
68
+ everything else.
69
+
70
+ This is the same mistake trust.ts's own header warns against — its doctrine is
71
+ *"Everything here is EVIDENCE… Each finding names what was found and what it means, so
72
+ the reader can check it rather than trust a score."* The score finding is the one item in
73
+ the file that is a bare number with no named evidence.
74
+
75
+ ## Ideas, ranked by leverage
76
+
77
+ **1. A DMARC-aligned pass vetoes the danger headline.** ~10 lines, no tuning, kills the
78
+ worst FP class outright. Does not make mail *wanted* — makes "not what it says it is"
79
+ unsayable. Parse the existing `Authentication-Results` (already read by `selfSpoof`).
80
+
81
+ **2. Classify `tests=` instead of summing it.** Three disjoint classes:
82
+
83
+ - *bulk* — `DCC_*`, `PYZOR_*`, `RAZOR2_*`, `MAILING_LIST_MULTI`, `KAM_UNSUB1`,
84
+ `KAM_*MARKETINGBL*`, `KAM_TRACKIMAGE`, `KAM_REALLYHUGEIMGSRC`, `HTML_FONT_SIZE_*`,
85
+ `HTML_IMAGE_RATIO_*`, `MIME_HTML_ONLY`, `WORD_INVIS`, `HTML_TAG_BALANCE_*`,
86
+ `URIBL_GREY`, `URIBL_CSS_A`, `SH_BODYURI_REVERSE_CSS`
87
+ - *forgery* — `SPF_FAIL`/`SOFTFAIL`, DKIM invalid **with** DMARC fail, `FORGED_*`, `SPOOF_*`
88
+ - *guilt by association* — `URIBL_SBL`, `SH_DBL_HEADERS`, `URIBL_BLACK`
89
+
90
+ A score composed entirely of the bulk class is a **bulk verdict**, however high. Adobe's
91
+ 3.9 is 100% bulk. Say "your server rates this bulk marketing" in the neutral register of
92
+ the remote-content banner, or say nothing. Red is reserved for the forgery class.
93
+
94
+ **3. `BAYES_00` is a demotion signal.** Flagged + BAYES_00 + DMARC-aligned means "network
95
+ lists dislike a third party this message mentions", not "this resembles spam Bob receives".
96
+
97
+ **4. The Delivered-To tag is a record of consent, and it is free.** Bob minted
98
+ `mokin@bob.ma` and gave it to exactly one correspondent. When the tag's local part matches
99
+ the authenticated sending domain, that is documented subscription. Needs no maintenance,
100
+ unlike the allowlist. `deliveredTo` is already parsed at `packages/mailx-store/store.ts:543`
101
+ and already flows to the viewer — nothing reads it for trust purposes.
102
+
103
+ **5. Bob's own store is a better ham oracle than any RBL.** Prior messages from the same
104
+ registrable domain that he read, replied to, or kept (not Junk, not deleted) — 50k+ rows of
105
+ ground truth about his actual correspondents. A domain with 40 kept messages does not become
106
+ a forger today because its nameserver landed in SBL.
107
+
108
+ **6. Name the evidence for the score, as every other finding does.** `spam score 4.9 of 4.5`
109
+ is unactionable; *"6.0 URIBL_SBL — the nameserver of a linked host is in Spamhaus SBL"* is
110
+ judged in one second. Caveat found in the sample: `X-Spam-Report` appears only on flagged
111
+ messages (present on Blue Orchestra + Mokin, absent on Adobe + Shapiro); `tests=` is always
112
+ present, so degrade to rule names when the report is missing.
113
+
114
+ **7. Extinguishable, not tuned** — see next section, this is the direction Bob picked.
115
+
116
+ ## Trusting senders — the direction Bob chose
117
+
118
+ Bob 2026-08-27: *"Trusting senders may be a route."* Consistent with the standing rule that
119
+ heuristic warnings should be **user-extinguishable rather than threshold-tuned**
120
+ (memory: `allowlist_is_the_approved_sender_store`).
121
+
122
+ What exists already and should be reused, not reinvented:
123
+
124
+ - `allowlist.jsonc` **is** the approved-sender store; `getAllowlist()` at
125
+ `packages/mailx-service/index.ts:1980` returns
126
+ `{senders, domains, recipients, flaggedSenders, flaggedDomains}`, exposed over IPC at
127
+ `jsonrpc.ts:404` and bridged for Android at `mailx-store-web/android-bootstrap.ts:1794`.
128
+ Design doc: `docs/allowlist.md`.
129
+ - The viewer already offers `✓ Trust all senders at <domain>` in the address context menu
130
+ (`client/components/message-viewer.ts:1371`), calling `approve("domain", dom)`.
131
+ - `store.ts:570` already re-checks the recipient allowlist **with Delivered-To in hand** to
132
+ decide `allowRemote`. That is exactly the shape the trust suppression wants, one decision
133
+ earlier in the same function.
134
+
135
+ What is missing:
136
+
137
+ - Nothing consults the allowlist when computing `trust` / `spamScore` — an approved sender
138
+ still gets the red banner. `assessMessageTrust` is called at `store.ts:586` with no
139
+ allowlist input.
140
+ - The trust banner has no inline action. A reader who has to find a context menu on an
141
+ address to say "this is mail I asked for" will not do it.
142
+
143
+ Open design questions for next session:
144
+
145
+ - **Trust key granularity**: envelope domain, From registrable domain, DKIM `d=` domain, or
146
+ the Delivered-To tag? Blue Orchestra argues against the From domain here —
147
+ `shared1.ccsend.com` is shared by every Constant Contact customer, so trusting it would
148
+ trust all of them; `blueorchestra.org` (Reply-To, and the domain in the DKIM-signed
149
+ `List-Unsubscribe`) is the real identity. Mokin argues the **tag** is the cleanest key —
150
+ `mokin@bob.ma` is single-purpose by construction.
151
+ - Does trusting a sender suppress the *whole* banner, or only the bulk/association findings,
152
+ leaving genuine forgery evidence visible? Leaning: suppress the score chip and the
153
+ association findings, **never** the forgery class — a trusted domain can still be spoofed,
154
+ and that is precisely the case the banner exists for.
155
+ - Auto-trust on a positive act (reply / move out of Junk / add to contacts) vs explicit only.
156
+
157
+ ## Recommended minimum first cut
158
+
159
+ (1) + (2): DMARC-alignment veto plus rule classification. Together they silence all three
160
+ false positives here and leave the 2026-08-25 sextortion/phish cases that motivated
161
+ trust.ts untouched — those had spf=none/fail, no DKIM, no DMARC pass, and Bayes did not say
162
+ 0%. Sender-trust (7) then layers on top as the user-driven escape hatch rather than as the
163
+ primary mechanism.
164
+
165
+ ## Files involved
166
+
167
+ - `packages/mailx-types/trust.ts` — `serverSpamVerdict()`, `spamScoreOf()`, `assessMessageTrust()`
168
+ - `packages/mailx-store/store.ts:586` — the single call site; has `deliveredTo`, `recipients`, raw `headerLines`
169
+ - `client/components/message-viewer.ts:1655-1697` — the score chip (`threshold/2` floor) and the trust banner
170
+ - `docs/allowlist.md` + `getAllowlist()` — the approved-sender store
171
+
172
+ ## What shipped, 2026-08-27
173
+
174
+ Ideas (1) and (2), as the "recommended minimum first cut" below says. In
175
+ `serverSpamVerdict()` / the new `analyzeServerSpam()` in
176
+ `packages/mailx-types/trust.ts`:
177
+
178
+ - `tests=` is parsed and every rule classified as **forgery**, **association**,
179
+ **bulk**, **neutral**, or **other** (`FORGERY_RULES` / `ASSOCIATION_RULES` /
180
+ `BULK_RULES` / `NEUTRAL_RULES`). A rule that scored zero or less cannot be the
181
+ reason a message is over the line, so it is not counted.
182
+ - `X-Spam-Report` gives each rule its score and description, so findings name their
183
+ evidence — "8.0 SH_DBL_HEADERS (A domain found in headers … is listed in DBL)" —
184
+ and degrade to bare rule names when the report is absent (idea 6).
185
+ - The **topmost** `Authentication-Results` is read for a DMARC-aligned pass, and its
186
+ `header.from=` is checked against the real From line. Only the topmost, because
187
+ headers are prepended: anything below it travelled with the message, and a forger
188
+ who could add `dmarc=pass` further down would have a switch for turning the banner
189
+ off.
190
+ - Severity comes from the rules, never the number:
191
+ **danger** when a forgery rule fired or the server recorded an SPF/DKIM/DMARC
192
+ failure (phishing and malware URL listings count — a lookalike domain can get its
193
+ own valid DKIM); **info**, a new neutral severity with no headline and no
194
+ "do not open anything" footer, when the sender is proved and every rule that scored
195
+ is one the classifier can name, or the whole score is bulk; **caution** otherwise —
196
+ including a proved sender whose score came from unclassified rules, because unknown
197
+ is never a reason to go quiet. That last distinction is 326 messages in Bob's store.
198
+ - `spamScoreOf()` returns `kind`, `proved` and `reasons` from the same analysis, so the
199
+ chip and the banner cannot disagree. The chip is grey when the sender is proved or
200
+ the score is all bulk, red only when the banner is. **The `threshold/2` floor is
201
+ unchanged.**
202
+
203
+ Measured over 20,291 messages in the store: 1,131 were flagged spam, and every one of
204
+ them used to get the red banner. Now 294 do (forgery), 672 are caution, 165 are quiet
205
+ notes. All four samples above are quiet or neutral; the 2026-08-25 phishes stay red.
206
+
207
+ Still open (C165): sender trust via `allowlist.jsonc`, the inline trust action on the
208
+ banner, and the three design questions in the section above.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx-settings",
3
- "version": "0.1.62",
3
+ "version": "0.1.64",
4
4
  "type": "module",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx-store",
3
- "version": "0.1.95",
3
+ "version": "0.1.97",
4
4
  "type": "module",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",