@bobfrankston/rmfmail 1.2.284 → 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.
- package/.commitmsg +43 -37
- package/TODO.md +2 -0
- package/client/android-bootstrap.bundle.js +202 -19
- package/client/android-bootstrap.bundle.js.map +2 -2
- package/client/app.bundle.js +7 -4
- package/client/app.bundle.js.map +2 -2
- package/client/components/message-viewer.js +32 -9
- package/client/components/message-viewer.js.map +1 -1
- package/client/components/message-viewer.ts +36 -10
- package/client/styles/components.css +9 -0
- package/docs/spam-false-positives.md +208 -0
- package/npmchanges.md +80 -0
- package/package.json +1 -1
- package/packages/mailx-imap/package-lock.json +2 -2
- package/packages/mailx-imap/package.json +1 -1
- package/packages/mailx-service/index.ts +20 -3
- package/packages/mailx-service/package.json +1 -1
- package/packages/mailx-settings/docs/spam-false-positives.md +208 -0
- package/packages/mailx-settings/package.json +1 -1
- package/packages/mailx-store/package.json +1 -1
- package/packages/mailx-store/store.d.ts.map +1 -1
- package/packages/mailx-store/store.js +7 -3
- package/packages/mailx-store/store.js.map +1 -1
- package/packages/mailx-store/store.ts +7 -3
- package/packages/mailx-store-web/package.json +1 -1
- package/packages/mailx-types/package.json +1 -1
- package/packages/mailx-types/trust.d.ts +16 -3
- package/packages/mailx-types/trust.d.ts.map +1 -1
- package/packages/mailx-types/trust.js +262 -23
- package/packages/mailx-types/trust.js.map +1 -1
- package/packages/mailx-types/trust.ts +343 -25
- /package/packages/mailx-imap/{node_modules.npmglobalize-stash-83688 → 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
|
-
|
|
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
|
|
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
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
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
|
-
|
|
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">${
|
|
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
|
-
|
|
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
|
@@ -788,3 +788,83 @@ anything quieter no longer renders.
|
|
|
788
788
|
|
|
789
789
|
Findings are unaffected: they never depended on the score.
|
|
790
790
|
|
|
791
|
+
## v1.2.284 — 2026-08-27
|
|
792
|
+
|
|
793
|
+
Pressing the icon while it is running raises the window instead of restarting
|
|
794
|
+
|
|
795
|
+
"Why does it show and disappear? Are you assuring only a single instance? ...
|
|
796
|
+
you should still put me in the running version when I press the icon on the
|
|
797
|
+
task bar."
|
|
798
|
+
|
|
799
|
+
Single instance was assured — by REPLACEMENT. Every launch that wasn't a
|
|
800
|
+
share/mailto handoff ran replace-on-launch: SIGTERM the running daemon, wait,
|
|
801
|
+
SIGKILL, then boot fresh. Press the taskbar icon and the window you already had
|
|
802
|
+
is killed, disappears, and a new one fades up seconds later. From outside it
|
|
803
|
+
reads as the app flickering out of existence for no reason.
|
|
804
|
+
|
|
805
|
+
Replace-on-launch was written for the upgrade case — an older or orphaned daemon
|
|
806
|
+
surviving a partial upgrade and leaving two windows on two versions — but it
|
|
807
|
+
fired on every launch, ordinary ones included.
|
|
808
|
+
|
|
809
|
+
instance.json already records the running version, so the two cases separate
|
|
810
|
+
without guessing:
|
|
811
|
+
|
|
812
|
+
same version the user wants the app, and it is already there — write
|
|
813
|
+
pending-activate.json, let the running daemon raise its
|
|
814
|
+
window, exit 0. Nothing is killed.
|
|
815
|
+
different an upgrade landed; replace as before.
|
|
816
|
+
|
|
817
|
+
The daemon answers with the same fs.watch + { _msgerWindow: "focus" } shape the
|
|
818
|
+
mailto and share handoffs already use — that native control message exists
|
|
819
|
+
precisely so a background daemon can surface its window, which is exactly this
|
|
820
|
+
situation. It also consumes a request that arrived while it was still booting,
|
|
821
|
+
since fs.watch only sees files that land after it is installed.
|
|
822
|
+
|
|
823
|
+
`-another` still opts out entirely, and every handoff path still returns before
|
|
824
|
+
reaching any of this.
|
|
825
|
+
|
|
826
|
+
Verified against the live daemon before publishing: launching a second copy
|
|
827
|
+
printed "already running (PID 34012, v1.2.283) — raising its window", exited 0,
|
|
828
|
+
wrote the request, and left the running daemon alive — where the old code would
|
|
829
|
+
have killed it.
|
|
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,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bobfrankston/mailx-imap",
|
|
3
|
-
"version": "0.1.
|
|
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.
|
|
9
|
+
"version": "0.1.161",
|
|
10
10
|
"license": "ISC",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@bobfrankston/iflow-direct": "^0.1.27",
|
|
@@ -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
|
}
|