@bobfrankston/rmfmail 1.2.285 → 1.2.287

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 (40) hide show
  1. package/.commitmsg +28 -32
  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/compose/compose.bundle.js +106 -38
  11. package/client/compose/compose.bundle.js.map +2 -2
  12. package/client/compose/harper-lint.js +125 -42
  13. package/client/compose/harper-lint.js.map +1 -1
  14. package/client/compose/harper-lint.ts +120 -40
  15. package/client/styles/components.css +9 -0
  16. package/docs/spam-false-positives.md +208 -0
  17. package/npmchanges.md +86 -0
  18. package/package.json +6 -6
  19. package/packages/mailx-imap/package-lock.json +2 -2
  20. package/packages/mailx-imap/package.json +1 -1
  21. package/packages/mailx-service/index.d.ts.map +1 -1
  22. package/packages/mailx-service/index.js +20 -3
  23. package/packages/mailx-service/index.js.map +1 -1
  24. package/packages/mailx-service/index.ts +20 -3
  25. package/packages/mailx-service/package.json +1 -1
  26. package/packages/mailx-settings/docs/spam-false-positives.md +208 -0
  27. package/packages/mailx-settings/package.json +1 -1
  28. package/packages/mailx-store/package.json +1 -1
  29. package/packages/mailx-store/store.d.ts.map +1 -1
  30. package/packages/mailx-store/store.js +7 -3
  31. package/packages/mailx-store/store.js.map +1 -1
  32. package/packages/mailx-store/store.ts +7 -3
  33. package/packages/mailx-store-web/package.json +1 -1
  34. package/packages/mailx-types/package.json +1 -1
  35. package/packages/mailx-types/trust.d.ts +16 -3
  36. package/packages/mailx-types/trust.d.ts.map +1 -1
  37. package/packages/mailx-types/trust.js +262 -23
  38. package/packages/mailx-types/trust.js.map +1 -1
  39. package/packages/mailx-types/trust.ts +343 -25
  40. /package/packages/mailx-imap/{node_modules.npmglobalize-stash-85316 → node_modules.npmglobalize-stash-38644}/.package-lock.json +0 -0
@@ -5,6 +5,125 @@ const POPUP_ID = "harper-popup";
5
5
  // Spelling squiggles stay with the native WebView2 spellchecker (red, with
6
6
  // its own suggestion menu) — double-underlining every typo helps no one.
7
7
  const SKIP_KINDS = new Set(["Spelling"]);
8
+ /* ── Pure helpers ───────────────────────────────────────────────────────────
9
+ * Module scope, and exported, because they are the part with rules worth
10
+ * testing: where a line break falls in the extracted text, which spans may be
11
+ * painted, and which sentence the writer is still in the middle of. jsdom can
12
+ * exercise all three without an editor — tests/dom/harper-lint.test.ts.
13
+ */
14
+ /** Blocks = top-level element children of the editable root (or the root
15
+ * itself when it has none). Quoted reply chains are the author's
16
+ * correspondent's text — skip them (also keeps lint cost proportional
17
+ * to what's actually being written). */
18
+ export const collectBlocks = (root) => {
19
+ const kids = Array.from(root.children).filter((c) => c instanceof HTMLElement);
20
+ const blocks = (kids.length ? kids : [root]).filter(b => b.tagName !== "BLOCKQUOTE" && !b.closest("blockquote") && !b.querySelector("blockquote"));
21
+ return blocks;
22
+ };
23
+ /** Elements that end a line on screen. A reader sees a break here, so the
24
+ * text handed to the grammar engine has to contain one too. */
25
+ const LINE_BREAKING = new Set([
26
+ "BR", "P", "DIV", "LI", "UL", "OL", "TR", "TD", "TH", "TABLE", "PRE", "HR",
27
+ "BLOCKQUOTE", "H1", "H2", "H3", "H4", "H5", "H6", "SECTION", "ARTICLE", "FIGURE",
28
+ ]);
29
+ /**
30
+ * Flatten a block to plain text + a map back to its text nodes.
31
+ *
32
+ * Line breaks are REAL characters in that text. Concatenating the text
33
+ * nodes alone glues the last word of one paragraph to the first word of the
34
+ * next, and Harper — correctly, for the string it was given — reports the
35
+ * seam: Bob 2026-08-27 forwarding a message got "`networkTo` should
36
+ * probably be written as `network To`" out of "…of the network" followed by
37
+ * "To: …". Worse, the lint's span then covers a junction that on screen is
38
+ * two blank lines, and getClientRects paints a full-width dotted underline
39
+ * across the whitespace — an error message pointing at nothing.
40
+ *
41
+ * The separators occupy offsets no text node owns, which is deliberate: a
42
+ * lint that starts or ends inside one cannot be placed, and spanToRange
43
+ * drops it rather than guessing.
44
+ */
45
+ export const blockText = (block, doc) => {
46
+ const walker = doc.createTreeWalker(block, NodeFilter.SHOW_TEXT | NodeFilter.SHOW_ELEMENT);
47
+ const nodes = [];
48
+ let text = "";
49
+ let pendingBreak = false;
50
+ for (let n = walker.nextNode(); n; n = walker.nextNode()) {
51
+ if (n.nodeType === Node.ELEMENT_NODE) {
52
+ if (LINE_BREAKING.has(n.tagName))
53
+ pendingBreak = true;
54
+ continue;
55
+ }
56
+ const data = n.data;
57
+ if (!data)
58
+ continue;
59
+ if (pendingBreak && text)
60
+ text += "\n";
61
+ pendingBreak = false;
62
+ nodes.push({ node: n, from: text.length });
63
+ text += data;
64
+ }
65
+ return { text, nodes };
66
+ };
67
+ /** The nearest thing that renders as its own line — used to refuse any
68
+ * squiggle whose two ends live in different ones. */
69
+ export const lineBox = (node) => {
70
+ for (let e = node; e; e = e.parentNode)
71
+ if (e.nodeType === Node.ELEMENT_NODE && LINE_BREAKING.has(e.tagName))
72
+ return e;
73
+ return null;
74
+ };
75
+ /**
76
+ * Where the caret is inside this block's flattened text, or -1.
77
+ *
78
+ * A sentence is wrong until it is finished. Underlining the words someone
79
+ * is still typing — Bob 2026-08-27, "annoying ... appearing even as I type
80
+ * a sentence in progress" — is the editor arguing with a thought that isn't
81
+ * out yet.
82
+ */
83
+ export const caretOffset = (doc, nodes) => {
84
+ const sel = doc.getSelection?.();
85
+ if (!sel || !sel.focusNode)
86
+ return -1;
87
+ for (const e of nodes)
88
+ if (e.node === sel.focusNode)
89
+ return e.from + sel.focusOffset;
90
+ return -1;
91
+ };
92
+ /** Start of the sentence the caret sits in — everything after the last
93
+ * terminator that precedes it. */
94
+ export const sentenceStart = (text, caret) => {
95
+ const before = text.slice(0, caret);
96
+ const m = before.match(/[.!?…\n][^.!?…\n]*$/);
97
+ return m ? caret - (m[0].length - 1) : 0;
98
+ };
99
+ export const spanToRange = (doc, nodes, start, end) => {
100
+ let a = null;
101
+ let b = null;
102
+ for (const e of nodes) {
103
+ const len = e.node.data.length;
104
+ if (!a && start >= e.from && start <= e.from + len)
105
+ a = { node: e.node, off: start - e.from };
106
+ if (end >= e.from && end <= e.from + len)
107
+ b = { node: e.node, off: end - e.from };
108
+ }
109
+ if (!a || !b)
110
+ return null;
111
+ // Both ends must sit on the same rendered line-box. A range that spans
112
+ // a paragraph boundary paints a full-width underline across every line
113
+ // between its ends, blank ones included — which is what the reader sees
114
+ // as "a blue line over white space".
115
+ if (lineBox(a.node) !== lineBox(b.node))
116
+ return null;
117
+ try {
118
+ const r = doc.createRange();
119
+ r.setStart(a.node, a.off);
120
+ r.setEnd(b.node, b.off);
121
+ return r;
122
+ }
123
+ catch {
124
+ return null;
125
+ }
126
+ };
8
127
  export function initHarperLint(ed) {
9
128
  let disposed = false;
10
129
  let timer = null;
@@ -32,48 +151,6 @@ export function initHarperLint(ed) {
32
151
  doc.getElementById(OVERLAY_ID)?.remove();
33
152
  doc.getElementById(POPUP_ID)?.remove();
34
153
  };
35
- /** Blocks = top-level element children of the editable root (or the root
36
- * itself when it has none). Quoted reply chains are the author's
37
- * correspondent's text — skip them (also keeps lint cost proportional
38
- * to what's actually being written). */
39
- const collectBlocks = (root) => {
40
- const kids = Array.from(root.children).filter((c) => c instanceof HTMLElement);
41
- const blocks = (kids.length ? kids : [root]).filter(b => b.tagName !== "BLOCKQUOTE" && !b.closest("blockquote") && !b.querySelector("blockquote"));
42
- return blocks;
43
- };
44
- /** Flatten a block to plain text + a map back to its text nodes. */
45
- const blockText = (block, doc) => {
46
- const walker = doc.createTreeWalker(block, NodeFilter.SHOW_TEXT);
47
- const nodes = [];
48
- let text = "";
49
- for (let n = walker.nextNode(); n; n = walker.nextNode()) {
50
- nodes.push({ node: n, from: text.length });
51
- text += n.data;
52
- }
53
- return { text, nodes };
54
- };
55
- const spanToRange = (doc, nodes, start, end) => {
56
- let a = null;
57
- let b = null;
58
- for (const e of nodes) {
59
- const len = e.node.data.length;
60
- if (!a && start >= e.from && start <= e.from + len)
61
- a = { node: e.node, off: start - e.from };
62
- if (end >= e.from && end <= e.from + len)
63
- b = { node: e.node, off: end - e.from };
64
- }
65
- if (!a || !b)
66
- return null;
67
- try {
68
- const r = doc.createRange();
69
- r.setStart(a.node, a.off);
70
- r.setEnd(b.node, b.off);
71
- return r;
72
- }
73
- catch {
74
- return null;
75
- }
76
- };
77
154
  const paint = () => {
78
155
  if (disposed)
79
156
  return;
@@ -148,9 +225,15 @@ export function initHarperLint(ed) {
148
225
  const tx = texts[i];
149
226
  if (!tx)
150
227
  return;
228
+ // The unfinished sentence under the caret is exempt: it is still
229
+ // being written, and half a sentence is not an error yet.
230
+ const caret = caretOffset(t.doc, tx.nodes);
231
+ const writingFrom = caret < 0 ? -1 : sentenceStart(tx.text, caret);
151
232
  for (const l of lints || []) {
152
233
  if (SKIP_KINDS.has(l.kind))
153
234
  continue;
235
+ if (writingFrom >= 0 && l.end > writingFrom && l.start <= caret)
236
+ continue;
154
237
  const range = spanToRange(t.doc, tx.nodes, l.start, l.end);
155
238
  if (range)
156
239
  next.push({ lint: l, range });
@@ -1 +1 @@
1
- {"version":3,"file":"harper-lint.js","sourceRoot":"","sources":["harper-lint.ts"],"names":[],"mappings":"AAgBA,OAAO,EAAE,WAAW,EAAE,MAAM,sBAAsB,CAAC;AAKnD,MAAM,WAAW,GAAG,GAAG,CAAC;AACxB,MAAM,UAAU,GAAG,gBAAgB,CAAC;AACpC,MAAM,QAAQ,GAAG,cAAc,CAAC;AAChC,2EAA2E;AAC3E,yEAAyE;AACzE,MAAM,UAAU,GAAG,IAAI,GAAG,CAAC,CAAC,UAAU,CAAC,CAAC,CAAC;AAEzC,MAAM,UAAU,cAAc,CAAC,EAAe;IAC1C,IAAI,QAAQ,GAAG,KAAK,CAAC;IACrB,IAAI,KAAK,GAAyC,IAAI,CAAC;IACvD,IAAI,MAAM,GAAa,EAAE,CAAC;IAC1B,IAAI,WAAW,GAAG,KAAK,CAAC;IACxB,IAAI,UAAU,GAAG,KAAK,CAAC,CAAG,sDAAsD;IAEhF,MAAM,MAAM,GAAG,GAAgD,EAAE;QAC7D,IAAI,CAAC;YACD,MAAM,GAAG,GAAQ,EAAE,CAAC,YAAY,CAAC;YACjC,IAAI,GAAG,EAAE,OAAO,EAAE,CAAC,CAAG,2CAA2C;gBAC7D,MAAM,CAAC,GAAG,GAAG,CAAC,OAAO,EAAE,CAAC;gBACxB,OAAO,CAAC,CAAC,CAAC,CAAC,EAAE,IAAI,EAAE,CAAC,EAAE,GAAG,EAAE,CAAC,CAAC,aAAa,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC;YACxD,CAAC;YACD,OAAO,EAAE,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,IAAI,EAAE,EAAE,CAAC,IAAI,EAAE,GAAG,EAAE,EAAE,CAAC,IAAI,CAAC,aAAa,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC;QAC1E,CAAC;QAAC,MAAM,CAAC;YAAC,OAAO,IAAI,CAAC;QAAC,CAAC;IAC5B,CAAC,CAAC;IAEF;;2BAEuB;IACvB,MAAM,WAAW,GAAG,CAAC,GAAa,EAAE,IAAiB,EAAe,EAAE,CAClE,CAAC,IAAI,KAAK,GAAG,CAAC,IAAI,CAAC,CAAC,CAAC,GAAG,CAAC,eAAe,CAAC,CAAC,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC;IAEzD,MAAM,YAAY,GAAG,CAAC,GAAa,EAAQ,EAAE;QACzC,GAAG,CAAC,cAAc,CAAC,UAAU,CAAC,EAAE,MAAM,EAAE,CAAC;QACzC,GAAG,CAAC,cAAc,CAAC,QAAQ,CAAC,EAAE,MAAM,EAAE,CAAC;IAC3C,CAAC,CAAC;IAEF;;;6CAGyC;IACzC,MAAM,aAAa,GAAG,CAAC,IAAiB,EAAiB,EAAE;QACvD,MAAM,IAAI,GAAG,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,EAAoB,EAAE,CAAC,CAAC,YAAY,WAAW,CAAC,CAAC;QACjG,MAAM,MAAM,GAAG,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,CACpD,CAAC,CAAC,OAAO,KAAK,YAAY,IAAI,CAAC,CAAC,CAAC,OAAO,CAAC,YAAY,CAAC,IAAI,CAAC,CAAC,CAAC,aAAa,CAAC,YAAY,CAAC,CAAC,CAAC;QAC9F,OAAO,MAAM,CAAC;IAClB,CAAC,CAAC;IAEF,oEAAoE;IACpE,MAAM,SAAS,GAAG,CAAC,KAAkB,EAAE,GAAa,EAA2D,EAAE;QAC7G,MAAM,MAAM,GAAG,GAAG,CAAC,gBAAgB,CAAC,KAAK,EAAE,UAAU,CAAC,SAAS,CAAC,CAAC;QACjE,MAAM,KAAK,GAAmC,EAAE,CAAC;QACjD,IAAI,IAAI,GAAG,EAAE,CAAC;QACd,KAAK,IAAI,CAAC,GAAG,MAAM,CAAC,QAAQ,EAAE,EAAE,CAAC,EAAE,CAAC,GAAG,MAAM,CAAC,QAAQ,EAAE,EAAE,CAAC;YACvD,KAAK,CAAC,IAAI,CAAC,EAAE,IAAI,EAAE,CAAS,EAAE,IAAI,EAAE,IAAI,CAAC,MAAM,EAAE,CAAC,CAAC;YACnD,IAAI,IAAK,CAAU,CAAC,IAAI,CAAC;QAC7B,CAAC;QACD,OAAO,EAAE,IAAI,EAAE,KAAK,EAAE,CAAC;IAC3B,CAAC,CAAC;IAEF,MAAM,WAAW,GAAG,CAAC,GAAa,EAAE,KAAqC,EAAE,KAAa,EAAE,GAAW,EAAgB,EAAE;QACnH,IAAI,CAAC,GAAuC,IAAI,CAAC;QACjD,IAAI,CAAC,GAAuC,IAAI,CAAC;QACjD,KAAK,MAAM,CAAC,IAAI,KAAK,EAAE,CAAC;YACpB,MAAM,GAAG,GAAG,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,MAAM,CAAC;YAC/B,IAAI,CAAC,CAAC,IAAI,KAAK,IAAI,CAAC,CAAC,IAAI,IAAI,KAAK,IAAI,CAAC,CAAC,IAAI,GAAG,GAAG;gBAAE,CAAC,GAAG,EAAE,IAAI,EAAE,CAAC,CAAC,IAAI,EAAE,GAAG,EAAE,KAAK,GAAG,CAAC,CAAC,IAAI,EAAE,CAAC;YAC9F,IAAI,GAAG,IAAI,CAAC,CAAC,IAAI,IAAI,GAAG,IAAI,CAAC,CAAC,IAAI,GAAG,GAAG;gBAAE,CAAC,GAAG,EAAE,IAAI,EAAE,CAAC,CAAC,IAAI,EAAE,GAAG,EAAE,GAAG,GAAG,CAAC,CAAC,IAAI,EAAE,CAAC;QACtF,CAAC;QACD,IAAI,CAAC,CAAC,IAAI,CAAC,CAAC;YAAE,OAAO,IAAI,CAAC;QAC1B,IAAI,CAAC;YACD,MAAM,CAAC,GAAG,GAAG,CAAC,WAAW,EAAE,CAAC;YAC5B,CAAC,CAAC,QAAQ,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC,CAAC,GAAG,CAAC,CAAC;YAC1B,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC,CAAC,GAAG,CAAC,CAAC;YACxB,OAAO,CAAC,CAAC;QACb,CAAC;QAAC,MAAM,CAAC;YAAC,OAAO,IAAI,CAAC;QAAC,CAAC;IAC5B,CAAC,CAAC;IAEF,MAAM,KAAK,GAAG,GAAS,EAAE;QACrB,IAAI,QAAQ;YAAE,OAAO;QACrB,MAAM,CAAC,GAAG,MAAM,EAAE,CAAC;QACnB,IAAI,CAAC,CAAC;YAAE,OAAO;QACf,YAAY,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC;QACpB,IAAI,MAAM,CAAC,MAAM,KAAK,CAAC;YAAE,OAAO;QAChC,MAAM,IAAI,GAAG,WAAW,CAAC,CAAC,CAAC,GAAG,EAAE,CAAC,CAAC,IAAI,CAAC,CAAC;QACxC,MAAM,EAAE,GAAG,CAAC,CAAC,GAAG,CAAC,aAAa,CAAC,KAAK,CAAC,CAAC;QACtC,EAAE,CAAC,EAAE,GAAG,UAAU,CAAC;QACnB,EAAE,CAAC,KAAK,CAAC,OAAO,GAAG,iGAAiG,CAAC;QACrH,wEAAwE;QACxE,wEAAwE;QACxE,qEAAqE;QACrE,sEAAsE;QACtE,qEAAqE;QACrE,uEAAuE;QACvE,wCAAwC;QACxC,IAAI,CAAC,WAAW,CAAC,EAAE,CAAC,CAAC;QACrB,MAAM,MAAM,GAAG,EAAE,CAAC,qBAAqB,EAAE,CAAC;QAC1C,KAAK,MAAM,CAAC,IAAI,MAAM,EAAE,CAAC;YACrB,IAAI,KAAK,GAA4B,EAAE,CAAC;YACxC,IAAI,CAAC;gBAAC,KAAK,GAAG,CAAC,CAAC,KAAK,CAAC,cAAc,EAAE,CAAC;YAAC,CAAC;YAAC,MAAM,CAAC;gBAAC,SAAS;YAAC,CAAC;YAC7D,KAAK,MAAM,CAAC,IAAI,KAAK,CAAC,IAAI,CAAC,KAAK,CAAC,EAAE,CAAC;gBAChC,IAAI,CAAC,CAAC,KAAK,GAAG,CAAC;oBAAE,SAAS;gBAC1B,MAAM,CAAC,GAAG,CAAC,CAAC,GAAG,CAAC,aAAa,CAAC,KAAK,CAAC,CAAC;gBACrC,uDAAuD;gBACvD,0DAA0D;gBAC1D,6CAA6C;gBAC7C,CAAC,CAAC,KAAK,CAAC,OAAO,GAAG,0BAA0B,CAAC,CAAC,CAAC,IAAI,GAAG,MAAM,CAAC,IAAI,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,UAAU,CAAC,CAAC,CAAC,MAAM,GAAG,MAAM,CAAC,GAAG,GAAG,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,YAAY,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,gFAAgF,CAAC;gBAC5O,EAAE,CAAC,WAAW,CAAC,CAAC,CAAC,CAAC;YACtB,CAAC;QACL,CAAC;IACL,CAAC,CAAC;IAEF,MAAM,UAAU,GAAG,GAAS,EAAE;QAC1B,IAAI,WAAW,IAAI,QAAQ;YAAE,OAAO;QACpC,WAAW,GAAG,IAAI,CAAC;QACnB,qBAAqB,CAAC,GAAG,EAAE,GAAG,WAAW,GAAG,KAAK,CAAC,CAAC,KAAK,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC;IACnE,CAAC,CAAC;IAEF,MAAM,IAAI,GAAG,KAAK,IAAmB,EAAE;QACnC,IAAI,QAAQ,IAAI,UAAU;YAAE,OAAO;QACnC,MAAM,CAAC,GAAG,MAAM,EAAE,CAAC;QACnB,IAAI,CAAC,CAAC;YAAE,OAAO;QACf,MAAM,MAAM,GAAG,aAAa,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC;QACrC,MAAM,KAAK,GAAG,MAAM,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,CAAC,SAAS,CAAC,CAAC,EAAE,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC;QACnD,IAAI,GAA4C,CAAC;QACjD,IAAI,CAAC;YACD,GAAG,GAAG,MAAM,WAAW,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC;QACpD,CAAC;QAAC,MAAM,CAAC;YACL,kEAAkE;YAClE,sDAAsD;YACtD,UAAU,GAAG,IAAI,CAAC;YAClB,eAAe,EAAE,CAAC;YAClB,OAAO;QACX,CAAC;QACD,IAAI,QAAQ;YAAE,OAAO;QACrB,MAAM,IAAI,GAAa,EAAE,CAAC;QAC1B,CAAC,GAAG,EAAE,MAAM,IAAI,EAAE,CAAC,CAAC,OAAO,CAAC,CAAC,KAAK,EAAE,CAAC,EAAE,EAAE;YACrC,MAAM,EAAE,GAAG,KAAK,CAAC,CAAC,CAAC,CAAC;YACpB,IAAI,CAAC,EAAE;gBAAE,OAAO;YAChB,KAAK,MAAM,CAAC,IAAI,KAAK,IAAI,EAAE,EAAE,CAAC;gBAC1B,IAAI,UAAU,CAAC,GAAG,CAAC,CAAC,CAAC,IAAI,CAAC;oBAAE,SAAS;gBACrC,MAAM,KAAK,GAAG,WAAW,CAAC,CAAC,CAAC,GAAG,EAAE,EAAE,CAAC,KAAK,EAAE,CAAC,CAAC,KAAK,EAAE,CAAC,CAAC,GAAG,CAAC,CAAC;gBAC3D,IAAI,KAAK;oBAAE,IAAI,CAAC,IAAI,CAAC,EAAE,IAAI,EAAE,CAAC,EAAE,KAAK,EAAE,CAAC,CAAC;YAC7C,CAAC;QACL,CAAC,CAAC,CAAC;QACH,MAAM,GAAG,IAAI,CAAC;QACd,UAAU,EAAE,CAAC;IACjB,CAAC,CAAC;IAEF,MAAM,eAAe,GAAG,GAAS,EAAE;QAC/B,MAAM,CAAC,GAAG,MAAM,EAAE,CAAC;QACnB,IAAI,CAAC;YAAE,YAAY,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC;QAC3B,MAAM,GAAG,EAAE,CAAC;IAChB,CAAC,CAAC;IAEF,MAAM,QAAQ,GAAG,GAAS,EAAE;QACxB,IAAI,KAAK;YAAE,YAAY,CAAC,KAAK,CAAC,CAAC;QAC/B,KAAK,GAAG,UAAU,CAAC,GAAG,EAAE,GAAG,KAAK,IAAI,EAAE,CAAC,CAAC,CAAC,EAAE,WAAW,CAAC,CAAC;IAC5D,CAAC,CAAC;IAEF;;yBAEqB;IACrB,MAAM,QAAQ,GAAG,CAAC,CAAS,EAAE,WAAmB,EAAE,GAAa,EAAQ,EAAE;QACrE,IAAI,CAAC;YACD,MAAM,GAAG,GAAQ,EAAE,CAAC,YAAY,CAAC;YACjC,IAAI,GAAG,EAAE,SAAS,EAAE,MAAM,IAAI,GAAG,EAAE,aAAa,EAAE,CAAC,CAAG,UAAU;gBAC5D,GAAG,CAAC,SAAS,CAAC,MAAM,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC;gBAC9B,MAAM,GAAG,GAAG,WAAW,CAAC,OAAO,CAAC,IAAI,EAAE,OAAO,CAAC,CAAC,OAAO,CAAC,IAAI,EAAE,MAAM,CAAC,CAAC,OAAO,CAAC,IAAI,EAAE,MAAM,CAAC,CAAC;gBAC3F,GAAG,CAAC,aAAa,CAAC,GAAG,CAAC,CAAC;YAC3B,CAAC;iBAAM,CAAC;gBACJ,MAAM,GAAG,GAAG,CAAC,GAAG,CAAC,WAAW,IAAI,MAAM,CAAC,CAAC,YAAY,EAAE,CAAC;gBACvD,IAAI,CAAC,GAAG;oBAAE,OAAO;gBACjB,GAAG,CAAC,eAAe,EAAE,CAAC;gBACtB,GAAG,CAAC,QAAQ,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC;gBACtB,GAAG,CAAC,WAAW,CAAC,YAAY,EAAE,KAAK,EAAE,WAAW,CAAC,CAAC;YACtD,CAAC;QACL,CAAC;QAAC,MAAM,CAAC,CAAC,4CAA4C,CAAC,CAAC;QACxD,GAAG,CAAC,cAAc,CAAC,QAAQ,CAAC,EAAE,MAAM,EAAE,CAAC;QACvC,QAAQ,EAAE,CAAC;IACf,CAAC,CAAC;IAEF,MAAM,SAAS,GAAG,CAAC,CAAS,EAAE,CAAS,EAAE,CAAS,EAAE,GAAa,EAAQ,EAAE;QACvE,GAAG,CAAC,cAAc,CAAC,QAAQ,CAAC,EAAE,MAAM,EAAE,CAAC;QACvC,MAAM,GAAG,GAAG,GAAG,CAAC,aAAa,CAAC,KAAK,CAAC,CAAC;QACrC,GAAG,CAAC,EAAE,GAAG,QAAQ,CAAC;QAClB,GAAG,CAAC,KAAK,CAAC,OAAO,GAAG,0BAA0B,CAAC,UAAU,CAAC,GAAG,CAAC,4MAA4M,CAAC;QAC3Q,MAAM,GAAG,GAAG,GAAG,CAAC,aAAa,CAAC,KAAK,CAAC,CAAC;QACrC,GAAG,CAAC,KAAK,CAAC,OAAO,GAAG,+DAA+D,CAAC;QACpF,GAAG,CAAC,WAAW,GAAG,CAAC,CAAC,IAAI,CAAC,OAAO,CAAC;QACjC,GAAG,CAAC,WAAW,CAAC,GAAG,CAAC,CAAC;QACrB,KAAK,MAAM,CAAC,IAAI,CAAC,CAAC,IAAI,CAAC,WAAW,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC,CAAC,EAAE,CAAC;YAC7C,MAAM,IAAI,GAAG,GAAG,CAAC,aAAa,CAAC,KAAK,CAAC,CAAC;YACtC,IAAI,CAAC,KAAK,CAAC,OAAO,GAAG,kDAAkD,CAAC;YACxE,IAAI,CAAC,WAAW,GAAG,CAAC,CAAC;YACrB,IAAI,CAAC,gBAAgB,CAAC,YAAY,EAAE,GAAG,EAAE,GAAG,IAAI,CAAC,KAAK,CAAC,UAAU,GAAG,SAAS,CAAC,CAAC,CAAC,CAAC,CAAC;YAClF,IAAI,CAAC,gBAAgB,CAAC,YAAY,EAAE,GAAG,EAAE,GAAG,IAAI,CAAC,KAAK,CAAC,UAAU,GAAG,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC;YAC3E,IAAI,CAAC,gBAAgB,CAAC,WAAW,EAAE,CAAC,CAAC,EAAE,EAAE,GAAG,CAAC,CAAC,cAAc,EAAE,CAAC,CAAC,CAAC,CAAC,eAAe,EAAE,CAAC,CAAC,QAAQ,CAAC,CAAC,EAAE,CAAC,EAAE,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;YAC7G,GAAG,CAAC,WAAW,CAAC,IAAI,CAAC,CAAC;QAC1B,CAAC;QACD,IAAI,CAAC,CAAC,IAAI,CAAC,WAAW,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;YAClC,MAAM,IAAI,GAAG,GAAG,CAAC,aAAa,CAAC,KAAK,CAAC,CAAC;YACtC,IAAI,CAAC,KAAK,CAAC,OAAO,GAAG,8BAA8B,CAAC;YACpD,IAAI,CAAC,WAAW,GAAG,mCAAmC,CAAC;YACvD,GAAG,CAAC,WAAW,CAAC,IAAI,CAAC,CAAC;QAC1B,CAAC;QACD,WAAW,CAAC,GAAG,EAAE,MAAM,EAAE,EAAE,IAAI,IAAI,GAAG,CAAC,IAAI,CAAC,CAAC,WAAW,CAAC,GAAG,CAAC,CAAC;QAC9D,MAAM,OAAO,GAAG,GAAS,EAAE,GAAG,GAAG,CAAC,MAAM,EAAE,CAAC,CAAC,GAAG,CAAC,mBAAmB,CAAC,WAAW,EAAE,OAAO,EAAE,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC;QACnG,UAAU,CAAC,GAAG,EAAE,CAAC,GAAG,CAAC,gBAAgB,CAAC,WAAW,EAAE,OAAO,EAAE,IAAI,CAAC,EAAE,CAAC,CAAC,CAAC;IAC1E,CAAC,CAAC;IAEF,MAAM,OAAO,GAAG,CAAC,CAAa,EAAQ,EAAE;QACpC,IAAI,QAAQ,IAAI,MAAM,CAAC,MAAM,KAAK,CAAC;YAAE,OAAO;QAC5C,MAAM,CAAC,GAAG,MAAM,EAAE,CAAC;QACnB,IAAI,CAAC,CAAC;YAAE,OAAO;QACf,KAAK,MAAM,CAAC,IAAI,MAAM,EAAE,CAAC;YACrB,IAAI,KAAK,GAAc,EAAE,CAAC;YAC1B,IAAI,CAAC;gBAAC,KAAK,GAAG,KAAK,CAAC,IAAI,CAAC,CAAC,CAAC,KAAK,CAAC,cAAc,EAAE,CAAC,CAAC;YAAC,CAAC;YAAC,MAAM,CAAC;gBAAC,SAAS;YAAC,CAAC;YACzE,KAAK,MAAM,CAAC,IAAI,KAAK,EAAE,CAAC;gBACpB,2DAA2D;gBAC3D,IAAI,CAAC,CAAC,OAAO,IAAI,CAAC,CAAC,IAAI,GAAG,CAAC,IAAI,CAAC,CAAC,OAAO,IAAI,CAAC,CAAC,KAAK,GAAG,CAAC,IAAI,CAAC,CAAC,OAAO,IAAI,CAAC,CAAC,GAAG,GAAG,CAAC,IAAI,CAAC,CAAC,OAAO,IAAI,CAAC,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;oBAC7G,MAAM,GAAG,GAAG,CAAC,CAAC,GAAG,CAAC,WAAW,IAAI,MAAM,CAAC;oBACxC,SAAS,CAAC,CAAC,EAAE,CAAC,CAAC,IAAI,GAAG,GAAG,CAAC,OAAO,EAAE,CAAC,CAAC,MAAM,GAAG,GAAG,CAAC,OAAO,EAAE,CAAC,CAAC,GAAG,CAAC,CAAC;oBAClE,OAAO;gBACX,CAAC;YACL,CAAC;QACL,CAAC;IACL,CAAC,CAAC;IAEF,eAAe;IACf,MAAM,EAAE,GAAG,MAAM,EAAE,CAAC;IACpB,EAAE,CAAC,eAAe,CAAC,QAAQ,CAAC,CAAC;IAC7B,IAAI,EAAE,EAAE,CAAC;QACL,EAAE,CAAC,GAAG,CAAC,gBAAgB,CAAC,OAAO,EAAE,OAAO,CAAC,CAAC;QAC1C,EAAE,CAAC,GAAG,CAAC,gBAAgB,CAAC,QAAQ,EAAE,UAAU,EAAE,IAAI,CAAC,CAAC;QACpD,CAAC,EAAE,CAAC,GAAG,CAAC,WAAW,IAAI,MAAM,CAAC,CAAC,gBAAgB,CAAC,QAAQ,EAAE,UAAU,CAAC,CAAC;IAC1E,CAAC;IACD,IAAI,CAAC;QAAC,EAAE,CAAC,kBAAkB,EAAE,EAAE,gBAAgB,CAAC,QAAQ,EAAE,UAAU,CAAC,CAAC;IAAC,CAAC;IAAC,MAAM,CAAC,CAAC,KAAK,CAAC,CAAC;IACxF,sEAAsE;IACtE,UAAU,CAAC,GAAG,EAAE,GAAG,KAAK,IAAI,EAAE,CAAC,CAAC,CAAC,EAAE,IAAI,CAAC,CAAC;IAEzC,OAAO,GAAG,EAAE;QACR,QAAQ,GAAG,IAAI,CAAC;QAChB,IAAI,KAAK;YAAE,YAAY,CAAC,KAAK,CAAC,CAAC;QAC/B,eAAe,EAAE,CAAC;QAClB,MAAM,CAAC,GAAG,MAAM,EAAE,CAAC;QACnB,IAAI,CAAC,EAAE,CAAC;YACJ,CAAC,CAAC,GAAG,CAAC,mBAAmB,CAAC,OAAO,EAAE,OAAO,CAAC,CAAC;YAC5C,CAAC,CAAC,GAAG,CAAC,mBAAmB,CAAC,QAAQ,EAAE,UAAU,EAAE,IAAI,CAAC,CAAC;YACtD,CAAC,CAAC,CAAC,GAAG,CAAC,WAAW,IAAI,MAAM,CAAC,CAAC,mBAAmB,CAAC,QAAQ,EAAE,UAAU,CAAC,CAAC;QAC5E,CAAC;IACL,CAAC,CAAC;AACN,CAAC"}
1
+ {"version":3,"file":"harper-lint.js","sourceRoot":"","sources":["harper-lint.ts"],"names":[],"mappings":"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"}
@@ -26,6 +26,121 @@ const POPUP_ID = "harper-popup";
26
26
  // its own suggestion menu) — double-underlining every typo helps no one.
27
27
  const SKIP_KINDS = new Set(["Spelling"]);
28
28
 
29
+ /* ── Pure helpers ───────────────────────────────────────────────────────────
30
+ * Module scope, and exported, because they are the part with rules worth
31
+ * testing: where a line break falls in the extracted text, which spans may be
32
+ * painted, and which sentence the writer is still in the middle of. jsdom can
33
+ * exercise all three without an editor — tests/dom/harper-lint.test.ts.
34
+ */
35
+ /** Blocks = top-level element children of the editable root (or the root
36
+ * itself when it has none). Quoted reply chains are the author's
37
+ * correspondent's text — skip them (also keeps lint cost proportional
38
+ * to what's actually being written). */
39
+ export const collectBlocks = (root: HTMLElement): HTMLElement[] => {
40
+ const kids = Array.from(root.children).filter((c): c is HTMLElement => c instanceof HTMLElement);
41
+ const blocks = (kids.length ? kids : [root]).filter(b =>
42
+ b.tagName !== "BLOCKQUOTE" && !b.closest("blockquote") && !b.querySelector("blockquote"));
43
+ return blocks;
44
+ };
45
+
46
+ /** Elements that end a line on screen. A reader sees a break here, so the
47
+ * text handed to the grammar engine has to contain one too. */
48
+ const LINE_BREAKING = new Set([
49
+ "BR", "P", "DIV", "LI", "UL", "OL", "TR", "TD", "TH", "TABLE", "PRE", "HR",
50
+ "BLOCKQUOTE", "H1", "H2", "H3", "H4", "H5", "H6", "SECTION", "ARTICLE", "FIGURE",
51
+ ]);
52
+
53
+ /**
54
+ * Flatten a block to plain text + a map back to its text nodes.
55
+ *
56
+ * Line breaks are REAL characters in that text. Concatenating the text
57
+ * nodes alone glues the last word of one paragraph to the first word of the
58
+ * next, and Harper — correctly, for the string it was given — reports the
59
+ * seam: Bob 2026-08-27 forwarding a message got "`networkTo` should
60
+ * probably be written as `network To`" out of "…of the network" followed by
61
+ * "To: …". Worse, the lint's span then covers a junction that on screen is
62
+ * two blank lines, and getClientRects paints a full-width dotted underline
63
+ * across the whitespace — an error message pointing at nothing.
64
+ *
65
+ * The separators occupy offsets no text node owns, which is deliberate: a
66
+ * lint that starts or ends inside one cannot be placed, and spanToRange
67
+ * drops it rather than guessing.
68
+ */
69
+ export const blockText = (block: HTMLElement, doc: Document): { text: string; nodes: { node: Text; from: number }[] } => {
70
+ const walker = doc.createTreeWalker(block, NodeFilter.SHOW_TEXT | NodeFilter.SHOW_ELEMENT);
71
+ const nodes: { node: Text; from: number }[] = [];
72
+ let text = "";
73
+ let pendingBreak = false;
74
+ for (let n = walker.nextNode(); n; n = walker.nextNode()) {
75
+ if (n.nodeType === Node.ELEMENT_NODE) {
76
+ if (LINE_BREAKING.has((n as HTMLElement).tagName)) pendingBreak = true;
77
+ continue;
78
+ }
79
+ const data = (n as Text).data;
80
+ if (!data) continue;
81
+ if (pendingBreak && text) text += "\n";
82
+ pendingBreak = false;
83
+ nodes.push({ node: n as Text, from: text.length });
84
+ text += data;
85
+ }
86
+ return { text, nodes };
87
+ };
88
+
89
+ /** The nearest thing that renders as its own line — used to refuse any
90
+ * squiggle whose two ends live in different ones. */
91
+ export const lineBox = (node: Node): Node => {
92
+ for (let e: Node = node; e; e = e.parentNode)
93
+ if (e.nodeType === Node.ELEMENT_NODE && LINE_BREAKING.has((e as HTMLElement).tagName)) return e;
94
+ return null;
95
+ };
96
+
97
+ /**
98
+ * Where the caret is inside this block's flattened text, or -1.
99
+ *
100
+ * A sentence is wrong until it is finished. Underlining the words someone
101
+ * is still typing — Bob 2026-08-27, "annoying ... appearing even as I type
102
+ * a sentence in progress" — is the editor arguing with a thought that isn't
103
+ * out yet.
104
+ */
105
+ export const caretOffset = (doc: Document, nodes: { node: Text; from: number }[]): number => {
106
+ const sel = doc.getSelection?.();
107
+ if (!sel || !sel.focusNode) return -1;
108
+ for (const e of nodes)
109
+ if (e.node === sel.focusNode) return e.from + sel.focusOffset;
110
+ return -1;
111
+ };
112
+
113
+ /** Start of the sentence the caret sits in — everything after the last
114
+ * terminator that precedes it. */
115
+ export const sentenceStart = (text: string, caret: number): number => {
116
+ const before = text.slice(0, caret);
117
+ const m = before.match(/[.!?…\n][^.!?…\n]*$/);
118
+ return m ? caret - (m[0].length - 1) : 0;
119
+ };
120
+
121
+ export const spanToRange = (doc: Document, nodes: { node: Text; from: number }[], start: number, end: number): Range | null => {
122
+ let a: { node: Text; off: number } | null = null;
123
+ let b: { node: Text; off: number } | null = null;
124
+ for (const e of nodes) {
125
+ const len = e.node.data.length;
126
+ if (!a && start >= e.from && start <= e.from + len) a = { node: e.node, off: start - e.from };
127
+ if (end >= e.from && end <= e.from + len) b = { node: e.node, off: end - e.from };
128
+ }
129
+ if (!a || !b) return null;
130
+ // Both ends must sit on the same rendered line-box. A range that spans
131
+ // a paragraph boundary paints a full-width underline across every line
132
+ // between its ends, blank ones included — which is what the reader sees
133
+ // as "a blue line over white space".
134
+ if (lineBox(a.node) !== lineBox(b.node)) return null;
135
+ try {
136
+ const r = doc.createRange();
137
+ r.setStart(a.node, a.off);
138
+ r.setEnd(b.node, b.off);
139
+ return r;
140
+ } catch { return null; }
141
+ };
142
+
143
+
29
144
  export function initHarperLint(ed: MailxEditor): () => void {
30
145
  let disposed = false;
31
146
  let timer: ReturnType<typeof setTimeout> | null = null;
@@ -55,46 +170,6 @@ export function initHarperLint(ed: MailxEditor): () => void {
55
170
  doc.getElementById(POPUP_ID)?.remove();
56
171
  };
57
172
 
58
- /** Blocks = top-level element children of the editable root (or the root
59
- * itself when it has none). Quoted reply chains are the author's
60
- * correspondent's text — skip them (also keeps lint cost proportional
61
- * to what's actually being written). */
62
- const collectBlocks = (root: HTMLElement): HTMLElement[] => {
63
- const kids = Array.from(root.children).filter((c): c is HTMLElement => c instanceof HTMLElement);
64
- const blocks = (kids.length ? kids : [root]).filter(b =>
65
- b.tagName !== "BLOCKQUOTE" && !b.closest("blockquote") && !b.querySelector("blockquote"));
66
- return blocks;
67
- };
68
-
69
- /** Flatten a block to plain text + a map back to its text nodes. */
70
- const blockText = (block: HTMLElement, doc: Document): { text: string; nodes: { node: Text; from: number }[] } => {
71
- const walker = doc.createTreeWalker(block, NodeFilter.SHOW_TEXT);
72
- const nodes: { node: Text; from: number }[] = [];
73
- let text = "";
74
- for (let n = walker.nextNode(); n; n = walker.nextNode()) {
75
- nodes.push({ node: n as Text, from: text.length });
76
- text += (n as Text).data;
77
- }
78
- return { text, nodes };
79
- };
80
-
81
- const spanToRange = (doc: Document, nodes: { node: Text; from: number }[], start: number, end: number): Range | null => {
82
- let a: { node: Text; off: number } | null = null;
83
- let b: { node: Text; off: number } | null = null;
84
- for (const e of nodes) {
85
- const len = e.node.data.length;
86
- if (!a && start >= e.from && start <= e.from + len) a = { node: e.node, off: start - e.from };
87
- if (end >= e.from && end <= e.from + len) b = { node: e.node, off: end - e.from };
88
- }
89
- if (!a || !b) return null;
90
- try {
91
- const r = doc.createRange();
92
- r.setStart(a.node, a.off);
93
- r.setEnd(b.node, b.off);
94
- return r;
95
- } catch { return null; }
96
- };
97
-
98
173
  const paint = (): void => {
99
174
  if (disposed) return;
100
175
  const t = target();
@@ -156,8 +231,13 @@ export function initHarperLint(ed: MailxEditor): () => void {
156
231
  (res?.blocks || []).forEach((lints, i) => {
157
232
  const tx = texts[i];
158
233
  if (!tx) return;
234
+ // The unfinished sentence under the caret is exempt: it is still
235
+ // being written, and half a sentence is not an error yet.
236
+ const caret = caretOffset(t.doc, tx.nodes);
237
+ const writingFrom = caret < 0 ? -1 : sentenceStart(tx.text, caret);
159
238
  for (const l of lints || []) {
160
239
  if (SKIP_KINDS.has(l.kind)) continue;
240
+ if (writingFrom >= 0 && l.end > writingFrom && l.start <= caret) continue;
161
241
  const range = spanToRange(t.doc, tx.nodes, l.start, l.end);
162
242
  if (range) next.push({ lint: l, range });
163
243
  }
@@ -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.