@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.
- package/docs/spam-false-positives.md +208 -0
- package/package.json +3 -3
|
@@ -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.
|
|
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.
|
|
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.
|
|
36
|
+
"@bobfrankston/mailx-types": "^0.1.64",
|
|
37
37
|
"jsonc-parser": "^3.3.1"
|
|
38
38
|
}
|
|
39
39
|
}
|