@bobfrankston/mailx-settings 0.1.62 → 0.1.63

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.
@@ -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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx-settings",
3
- "version": "0.1.62",
3
+ "version": "0.1.63",
4
4
  "type": "module",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",
@@ -17,7 +17,7 @@
17
17
  },
18
18
  "license": "ISC",
19
19
  "dependencies": {
20
- "@bobfrankston/mailx-types": "^0.1.62",
20
+ "@bobfrankston/mailx-types": "^0.1.64",
21
21
  "jsonc-parser": "^3.3.1"
22
22
  },
23
23
  "repository": {
@@ -33,7 +33,7 @@
33
33
  },
34
34
  ".transformedSnapshot": {
35
35
  "dependencies": {
36
- "@bobfrankston/mailx-types": "^0.1.62",
36
+ "@bobfrankston/mailx-types": "^0.1.64",
37
37
  "jsonc-parser": "^3.3.1"
38
38
  }
39
39
  }