@bobfrankston/rmfmail 1.2.286 → 1.2.288

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.
@@ -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":"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"}
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
  }
@@ -157,6 +157,14 @@ export async function createTinyMceEditor(container, opts = {}) {
157
157
  // ways that produce a blank body, so it's not in the plugin list.
158
158
  menu: {
159
159
  view: { title: "View", items: "rmfsource | visualaid visualchars visualblocks | preview | zoomIn zoomOut zoomReset" },
160
+ // Insert menu — TinyMCE's default list minus the items whose
161
+ // plugins we don't load (media, pagebreak, anchor, accordion),
162
+ // plus the two quoting items. Listed in full because naming a
163
+ // menu REPLACES its item list; the defaults do not carry over.
164
+ insert: {
165
+ title: "Insert",
166
+ items: "image link inserttable | rmfblockquote rmfquotepaste | rmfcode codesample charmap emoticons | hr nonbreaking insertdatetime",
167
+ },
160
168
  },
161
169
  // Disable the "Get all features" upsell badge that TinyMCE 6+
162
170
  // injects automatically when no premium plugins are listed.
@@ -673,6 +681,21 @@ export async function createTinyMceEditor(container, opts = {}) {
673
681
  text: "Code block…",
674
682
  onAction: openCodeDialog,
675
683
  });
684
+ // Quoting, on the Insert menu (Bob 2026-08-27: "it would be
685
+ // nice if the insert had blockquote … I currently go add
686
+ // <blockquote> manually"). Two items, because quoting is two
687
+ // different acts: wrap what is already there, or bring in
688
+ // someone else's words as a quote in one step.
689
+ ed.ui.registry.addMenuItem("rmfblockquote", {
690
+ icon: "quote",
691
+ text: "Blockquote",
692
+ onAction: () => ed.execCommand("mceBlockQuote"),
693
+ });
694
+ ed.ui.registry.addMenuItem("rmfquotepaste", {
695
+ icon: "quote",
696
+ text: "Quoted text (paste)…",
697
+ onAction: () => { void pasteAsQuote(ed); },
698
+ });
676
699
  // View HTML source, caret-positioned (Bob 2026-07-18).
677
700
  // Registered as a menu item (View menu) AND reachable from the
678
701
  // compose NATIVE right-click menu via editor.showSource() —
@@ -1004,6 +1027,68 @@ export async function createTinyMceEditor(container, opts = {}) {
1004
1027
  nativeEditor: editor,
1005
1028
  };
1006
1029
  }
1030
+ /**
1031
+ * Paste the clipboard into a blockquote, in one step.
1032
+ *
1033
+ * The manual version is: paste, select what you pasted, press the quote
1034
+ * button — or, when the paste brought its own block structure, open the HTML
1035
+ * source and type `<blockquote>` around it by hand (Bob 2026-08-27).
1036
+ *
1037
+ * Reads text/html when the clipboard has it, so formatting and links survive;
1038
+ * falls back to text/plain, escaped, with blank lines becoming paragraphs and
1039
+ * single breaks `<br>`. Browsers and Word wrap their HTML flavour in a whole
1040
+ * document plus `<!--StartFragment-->` markers; only the fragment is wanted.
1041
+ *
1042
+ * Clipboard READ is permission-gated and can legitimately be refused. That is
1043
+ * reported in the editor's own notification bar rather than swallowed — a menu
1044
+ * item that silently does nothing is worse than no menu item.
1045
+ */
1046
+ async function pasteAsQuote(ed) {
1047
+ const notify = (text, type) => {
1048
+ try {
1049
+ ed.notificationManager.open({ text, type, timeout: 4000 });
1050
+ }
1051
+ catch { /* editor closing */ }
1052
+ };
1053
+ const escape = (s) => s
1054
+ .replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
1055
+ /** Just the copied fragment: no <html>/<head>/<body>, no Office markers. */
1056
+ const fragment = (html) => {
1057
+ const marked = html.match(/<!--\s*StartFragment\s*-->([\s\S]*?)<!--\s*EndFragment\s*-->/i);
1058
+ const inner = marked ? marked[1] : html.replace(/[\s\S]*<body[^>]*>|<\/body>[\s\S]*/gi, "");
1059
+ return inner.trim();
1060
+ };
1061
+ let html = "";
1062
+ try {
1063
+ const clip = navigator.clipboard;
1064
+ if (clip?.read) {
1065
+ const items = await clip.read();
1066
+ for (const item of items) {
1067
+ if (!item.types.includes("text/html"))
1068
+ continue;
1069
+ html = fragment(await (await item.getType("text/html")).text());
1070
+ break;
1071
+ }
1072
+ }
1073
+ if (!html) {
1074
+ const text = await navigator.clipboard?.readText?.() || "";
1075
+ if (text.trim()) {
1076
+ html = text.trim().split(/\n\s*\n/)
1077
+ .map(para => `<p>${escape(para).replace(/\n/g, "<br>")}</p>`).join("");
1078
+ }
1079
+ }
1080
+ }
1081
+ catch (e) {
1082
+ notify(`Could not read the clipboard: ${e?.message || e}`, "error");
1083
+ return;
1084
+ }
1085
+ if (!html) {
1086
+ notify("There is nothing on the clipboard to quote.", "warning");
1087
+ return;
1088
+ }
1089
+ ed.insertContent(`<blockquote>${html}</blockquote>`);
1090
+ ed.undoManager.add(); // snapshot + fire AddUndo → draft save
1091
+ }
1007
1092
  /** View/edit the full HTML source with the textarea caret placed at the
1008
1093
  * same spot as the current DOM caret (Bob 2026-07-18: "show source that
1009
1094
  * positions me at the current position in the source"). Drops a private-use
package/npmchanges.md CHANGED
@@ -868,3 +868,85 @@ seconds ago when it had been up for minutes.
868
868
  Ships separately from the activate fix in 1.2.284 — verified that release did
869
869
  not already contain it.
870
870
 
871
+ ## v1.2.286 — 2026-08-27
872
+
873
+ Spam banner: classify the rules instead of reusing the delivery score
874
+
875
+ The trust banner asked "is this forged" and answered it with SpamAssassin's
876
+ bottom line, a number tuned to answer "should this be delivered". Four
877
+ newsletters Bob subscribed to (Constant Contact, Adobe MAX, Mokin, ActBlue)
878
+ scored 2.2 to 10.9 and two of them raised "This message is not what it says
879
+ it is" — on mail that passes DMARC aligned on a published reject policy,
880
+ where that headline is factually false. What scored them was bulk-mail rules
881
+ and third-party blocklist entries about hosts they merely link to: Mokin's
882
+ 6.0 URIBL_SBL is the Spamhaus listing of the IP of the nameserver of a linked
883
+ domain, fourth-order guilt by association, more than the whole 4.5 threshold
884
+ on its own.
885
+
886
+ trust.ts now reads the `tests=` list that was sitting in the same header and
887
+ sorts the rules into what they are evidence OF — forgery, third-party
888
+ association, bulk mail, or unclassified — and reads the topmost
889
+ Authentication-Results for a DMARC-aligned pass. Severity follows the rules,
890
+ never the number:
891
+
892
+ - a forgery rule fired, or the server itself recorded an SPF/DKIM/DMARC
893
+ failure -> danger, unchanged. Phishing and malware URL listings count as
894
+ identity evidence, so a lookalike domain with its own valid DKIM cannot
895
+ buy silence.
896
+ - sender DMARC-proved and every rule that scored is one this file can name,
897
+ or the whole score is bulk rules -> info: a new neutral severity with no
898
+ headline and no "do not open anything" footer.
899
+ - anything else -> caution, including a proved sender whose score came from
900
+ rules with no class. Unknown is never a reason to go quiet.
901
+
902
+ Findings and the score chip now come from one analysis, so they cannot
903
+ disagree; the chip goes grey rather than red when the sender is proved or the
904
+ score is all bulk, and both name their evidence ("8.0 SH_DBL_HEADERS (A domain
905
+ found in headers is listed in DBL)") instead of showing a bare number. The
906
+ `threshold/2` chip floor is untouched — see docs/spam-false-positives.md on
907
+ why tuning it is not the fix.
908
+
909
+ Over 20,291 messages in the store, 1,131 were flagged spam and every one of
910
+ them used to get the red banner: now 294 do, 672 are caution, 165 are quiet
911
+ notes. Tests cover all four sample messages verbatim, plus the two ways the
912
+ proof could be gamed (a forged Authentication-Results below the server's own,
913
+ and one naming a different From).
914
+
915
+ TODO C165 first cut. Sender trust via allowlist.jsonc still open.
916
+
917
+ ## v1.2.287 — 2026-08-27
918
+
919
+ Grammar squiggles: stop inventing words, stop underlining white space
920
+
921
+ Three complaints from one compose session (Bob 2026-08-27, forwarding a
922
+ message), all of them the checker arguing with prose that was fine:
923
+
924
+ 1. "keypoint". Harper's CompoundNouns rule fires on any noun+noun pair whose
925
+ joined form is in its dictionary, so "One key point in my recent efforts"
926
+ came back as an error suggesting a word he did not write. Disabled in the
927
+ daemon next to AvoidCurses — same objection as that one: the tool
928
+ proofreads, it does not get a vote on word choice. Its siblings (MergeWords,
929
+ MakeupCompoundNoun, OvertimeCompoundNoun, ThereAfterCompound,
930
+ EasyGoingCompoundAdjective) are named in a comment there, one line each to
931
+ turn off if they start nagging.
932
+
933
+ 2. A blue dotted line across two blank lines. blockText concatenated a block's
934
+ text nodes with no separator, so the last word of a paragraph was glued to
935
+ the first word of the next: "…of the network" + "To: x@y.com" reached Harper
936
+ as "networkTo", and it reported — correctly, for the string it was given —
937
+ that the word should be split. The span then covered a junction that on
938
+ screen is two empty lines, and getClientRects painted a full-width underline
939
+ over the whitespace. Verified both halves against the real engine: gluing
940
+ produces `networkTo` and `FrankstonSubject` lints that vanish once the
941
+ breaks are there. blockText now emits "\n" at every line-breaking element,
942
+ and spanToRange refuses any span whose ends sit in different line boxes, so
943
+ no squiggle can ever be drawn across a paragraph boundary again.
944
+
945
+ 3. Lints on the sentence being typed. The unfinished sentence under the caret
946
+ is now exempt — a thought that is not out yet is not an error.
947
+
948
+ The pure helpers (collectBlocks, blockText, lineBox, caretOffset,
949
+ sentenceStart, spanToRange) moved to module scope and are exported, because
950
+ they hold the decisions worth testing; tests/dom/harper-lint.test.ts covers all
951
+ three rules in jsdom, using the forward markup client/app.ts actually builds.
952
+