@bobfrankston/rmfmail 1.1.105 → 1.1.107

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.
@@ -322,19 +322,48 @@ export async function createTinyMceEditor(container, opts = {}) {
322
322
  // (reply / forward — user types above the quoted block);
323
323
  // anything else → cursor at END (legacy "put cursor at end"
324
324
  // semantics).
325
- try {
326
- editor.selection.select(editor.getBody(), true);
327
- editor.selection.collapse(pos === 0 /* true = start */);
328
- editor.focus();
329
- // Scroll the viewport to match the caret. For pos 0 that's
330
- // the TOP of the body on a reply the user types above the
331
- // quoted block and must land looking at that empty line, not
332
- // scrolled down inside the quote (Bob 2026-05-18: "the cursor
333
- // should be at the top of the window").
334
- if (pos === 0)
325
+ const place = () => {
326
+ const body = editor.getBody();
327
+ if (pos === 0) {
328
+ // Put the caret INSIDE the first block element. Collapsing
329
+ // to raw body-start lands it outside any block (before /
330
+ // between bare nodes) where contentEditable insertion is
331
+ // unpredictable Bob 2026-05-21: "typing goes in the
332
+ // wrong place until you try again". rmfmail's reply body
333
+ // now leads with a real <p>; drop the caret into it.
334
+ const first = body.firstChild;
335
+ if (first && first.nodeType === 1 /* element */) {
336
+ editor.selection.setCursorLocation(first, 0);
337
+ }
338
+ else {
339
+ editor.selection.select(body, true);
340
+ editor.selection.collapse(true);
341
+ }
342
+ editor.focus();
343
+ // Viewport to the top so the user looks at the empty
344
+ // reply line, not scrolled down into the quote.
335
345
  editor.getWin()?.scrollTo(0, 0);
336
- else
346
+ }
347
+ else {
348
+ editor.selection.select(body, true);
349
+ editor.selection.collapse(false);
350
+ editor.focus();
337
351
  editor.selection.scrollIntoView();
352
+ }
353
+ };
354
+ try {
355
+ place();
356
+ // Re-apply on the next frame. Cross-iframe focus (compose is
357
+ // an iframe; TinyMCE's edit area is a nested iframe) lets the
358
+ // first selection set get clobbered by a late layout / focus
359
+ // event — the "have to click in and try again" symptom. A
360
+ // second pass after the frame settles makes it stick.
361
+ editor.getWin()?.requestAnimationFrame?.(() => {
362
+ try {
363
+ place();
364
+ }
365
+ catch { /* */ }
366
+ });
338
367
  }
339
368
  catch { /* */ }
340
369
  },
@@ -125,6 +125,24 @@ button.tb-menu-item { background: none; border: none; color: inherit; width: 100
125
125
  .tb-menu-item input[type="radio"] { accent-color: var(--color-accent); }
126
126
  .tb-sep { width: 1px; height: 1.2rem; background: var(--color-border); margin: 0 var(--gap-xs); }
127
127
 
128
+ /* Inline load-status badge on the TinyMCE menu item (pending → ready). */
129
+ .tb-menu-status { font-size: 0.8em; color: var(--color-text-muted); }
130
+ .tb-menu-status[data-state="pending"] { color: var(--color-accent); }
131
+ .tb-menu-status[data-state="ready"] { color: oklch(0.6 0.13 150); }
132
+ .tb-menu-status[data-state="error"] { color: oklch(0.55 0.18 25); }
133
+ .tb-menu-status[data-state="pending"]::before {
134
+ content: "";
135
+ display: inline-block;
136
+ width: 0.7em; height: 0.7em;
137
+ margin-right: 0.3em;
138
+ border: 2px solid currentColor;
139
+ border-right-color: transparent;
140
+ border-radius: 50%;
141
+ animation: tb-menu-spin 0.7s linear infinite;
142
+ vertical-align: -0.05em;
143
+ }
144
+ @keyframes tb-menu-spin { to { transform: rotate(360deg); } }
145
+
128
146
  /* Submenu — single-line label that expands a nested popout. Used for the
129
147
  Theme picker so the parent dropdown isn't cluttered with three radios. */
130
148
  .tb-menu-submenu { position: relative; }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/rmfmail",
3
- "version": "1.1.105",
3
+ "version": "1.1.107",
4
4
  "description": "Local-first email client with IMAP sync and standalone native app",
5
5
  "type": "module",
6
6
  "main": "bin/mailx.js",
@@ -1 +1 @@
1
- {"version":3,"file":"charset.d.ts","sourceRoot":"","sources":["charset.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAEH;0DAC0D;AAC1D,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAQtD"}
1
+ {"version":3,"file":"charset.d.ts","sourceRoot":"","sources":["charset.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAEH;0DAC0D;AAC1D,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAiBtD"}
@@ -17,11 +17,31 @@ export function sniffAndFixCharset(raw) {
17
17
  const re = /charset\s*=\s*"?(iso-8859-1|us-ascii|windows-1252|latin1)"?/gi;
18
18
  if (!re.test(head))
19
19
  return raw;
20
+ // The rewrite is only sound when the raw bytes ARE the body bytes — i.e.
21
+ // an 8bit / binary part. For a quoted-printable or base64 part the raw
22
+ // .eml is pure ASCII (the high bytes live inside `=XX` / base64 chars),
23
+ // so isValidUtf8(raw) passes vacuously and we would relabel a genuine
24
+ // Windows-1252 part as utf-8 — every smart-quote / em-dash then decodes
25
+ // as mojibake (Bob's 2026-05-21 report: a QP Windows-1252 Outlook mail).
26
+ // Requiring an actual non-ASCII byte in the raw gates the heuristic to
27
+ // the only case where the UTF-8 sniff is meaningful.
28
+ if (!hasNonAscii(raw))
29
+ return raw;
20
30
  if (!isValidUtf8(raw))
21
31
  return raw;
22
32
  const fixed = head.replace(/charset\s*=\s*"?(iso-8859-1|us-ascii|windows-1252|latin1)"?/gi, "charset=utf-8");
23
33
  return Buffer.concat([Buffer.from(fixed, "latin1"), raw.subarray(head.length)]);
24
34
  }
35
+ /** True if the buffer contains at least one byte >= 0x80. A pure-ASCII
36
+ * buffer is trivially valid UTF-8, so the isValidUtf8 sniff tells us
37
+ * nothing about a quoted-printable / base64 body — gate on this first. */
38
+ function hasNonAscii(buf) {
39
+ for (let i = 0; i < buf.length; i++) {
40
+ if (buf[i] >= 0x80)
41
+ return true;
42
+ }
43
+ return false;
44
+ }
25
45
  /** Strict UTF-8 validity check: rejects overlong forms, invalid start
26
46
  * bytes, and dangling continuations. Used to confirm the body is really
27
47
  * UTF-8 before overriding a Latin-1 declaration. */
@@ -1 +1 @@
1
- {"version":3,"file":"charset.js","sourceRoot":"","sources":["charset.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAEH;0DAC0D;AAC1D,MAAM,UAAU,kBAAkB,CAAC,GAAW;IAC1C,MAAM,UAAU,GAAG,KAAK,CAAC;IACzB,MAAM,IAAI,GAAG,GAAG,CAAC,QAAQ,CAAC,CAAC,EAAE,IAAI,CAAC,GAAG,CAAC,UAAU,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC,CAAC,QAAQ,CAAC,QAAQ,CAAC,CAAC;IAClF,MAAM,EAAE,GAAG,+DAA+D,CAAC;IAC3E,IAAI,CAAC,EAAE,CAAC,IAAI,CAAC,IAAI,CAAC;QAAE,OAAO,GAAG,CAAC;IAC/B,IAAI,CAAC,WAAW,CAAC,GAAG,CAAC;QAAE,OAAO,GAAG,CAAC;IAClC,MAAM,KAAK,GAAG,IAAI,CAAC,OAAO,CAAC,+DAA+D,EAAE,eAAe,CAAC,CAAC;IAC7G,OAAO,MAAM,CAAC,MAAM,CAAC,CAAC,MAAM,CAAC,IAAI,CAAC,KAAK,EAAE,QAAQ,CAAC,EAAE,GAAG,CAAC,QAAQ,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC;AACpF,CAAC;AAED;;qDAEqD;AACrD,SAAS,WAAW,CAAC,GAAW;IAC5B,IAAI,CAAC,GAAG,CAAC,CAAC;IACV,OAAO,CAAC,GAAG,GAAG,CAAC,MAAM,EAAE,CAAC;QACpB,MAAM,CAAC,GAAG,GAAG,CAAC,CAAC,CAAC,CAAC;QACjB,IAAI,CAAC,GAAG,IAAI,EAAE,CAAC;YAAC,CAAC,EAAE,CAAC;YAAC,SAAS;QAAC,CAAC;QAChC,IAAI,IAAY,CAAC;QACjB,IAAI,CAAC,CAAC,GAAG,IAAI,CAAC,KAAK,IAAI,EAAE,CAAC;YAAC,IAAI,CAAC,GAAG,IAAI;gBAAE,OAAO,KAAK,CAAC;YAAC,IAAI,GAAG,CAAC,CAAC;QAAC,CAAC;aAC7D,IAAI,CAAC,CAAC,GAAG,IAAI,CAAC,KAAK,IAAI;YAAE,IAAI,GAAG,CAAC,CAAC;aAClC,IAAI,CAAC,CAAC,GAAG,IAAI,CAAC,KAAK,IAAI,EAAE,CAAC;YAAC,IAAI,CAAC,GAAG,IAAI;gBAAE,OAAO,KAAK,CAAC;YAAC,IAAI,GAAG,CAAC,CAAC;QAAC,CAAC;;YAClE,OAAO,KAAK,CAAC;QAClB,IAAI,CAAC,GAAG,IAAI,IAAI,GAAG,CAAC,MAAM;YAAE,OAAO,KAAK,CAAC;QACzC,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,IAAI,IAAI,EAAE,CAAC,EAAE,EAAE,CAAC;YAC7B,IAAI,CAAC,GAAG,CAAC,CAAC,GAAG,CAAC,CAAC,GAAG,IAAI,CAAC,KAAK,IAAI;gBAAE,OAAO,KAAK,CAAC;QACnD,CAAC;QACD,CAAC,IAAI,IAAI,GAAG,CAAC,CAAC;IAClB,CAAC;IACD,OAAO,IAAI,CAAC;AAChB,CAAC"}
1
+ {"version":3,"file":"charset.js","sourceRoot":"","sources":["charset.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAEH;0DAC0D;AAC1D,MAAM,UAAU,kBAAkB,CAAC,GAAW;IAC1C,MAAM,UAAU,GAAG,KAAK,CAAC;IACzB,MAAM,IAAI,GAAG,GAAG,CAAC,QAAQ,CAAC,CAAC,EAAE,IAAI,CAAC,GAAG,CAAC,UAAU,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC,CAAC,QAAQ,CAAC,QAAQ,CAAC,CAAC;IAClF,MAAM,EAAE,GAAG,+DAA+D,CAAC;IAC3E,IAAI,CAAC,EAAE,CAAC,IAAI,CAAC,IAAI,CAAC;QAAE,OAAO,GAAG,CAAC;IAC/B,yEAAyE;IACzE,uEAAuE;IACvE,wEAAwE;IACxE,sEAAsE;IACtE,wEAAwE;IACxE,yEAAyE;IACzE,uEAAuE;IACvE,qDAAqD;IACrD,IAAI,CAAC,WAAW,CAAC,GAAG,CAAC;QAAE,OAAO,GAAG,CAAC;IAClC,IAAI,CAAC,WAAW,CAAC,GAAG,CAAC;QAAE,OAAO,GAAG,CAAC;IAClC,MAAM,KAAK,GAAG,IAAI,CAAC,OAAO,CAAC,+DAA+D,EAAE,eAAe,CAAC,CAAC;IAC7G,OAAO,MAAM,CAAC,MAAM,CAAC,CAAC,MAAM,CAAC,IAAI,CAAC,KAAK,EAAE,QAAQ,CAAC,EAAE,GAAG,CAAC,QAAQ,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC;AACpF,CAAC;AAED;;2EAE2E;AAC3E,SAAS,WAAW,CAAC,GAAW;IAC5B,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,GAAG,CAAC,MAAM,EAAE,CAAC,EAAE,EAAE,CAAC;QAClC,IAAI,GAAG,CAAC,CAAC,CAAC,IAAI,IAAI;YAAE,OAAO,IAAI,CAAC;IACpC,CAAC;IACD,OAAO,KAAK,CAAC;AACjB,CAAC;AAED;;qDAEqD;AACrD,SAAS,WAAW,CAAC,GAAW;IAC5B,IAAI,CAAC,GAAG,CAAC,CAAC;IACV,OAAO,CAAC,GAAG,GAAG,CAAC,MAAM,EAAE,CAAC;QACpB,MAAM,CAAC,GAAG,GAAG,CAAC,CAAC,CAAC,CAAC;QACjB,IAAI,CAAC,GAAG,IAAI,EAAE,CAAC;YAAC,CAAC,EAAE,CAAC;YAAC,SAAS;QAAC,CAAC;QAChC,IAAI,IAAY,CAAC;QACjB,IAAI,CAAC,CAAC,GAAG,IAAI,CAAC,KAAK,IAAI,EAAE,CAAC;YAAC,IAAI,CAAC,GAAG,IAAI;gBAAE,OAAO,KAAK,CAAC;YAAC,IAAI,GAAG,CAAC,CAAC;QAAC,CAAC;aAC7D,IAAI,CAAC,CAAC,GAAG,IAAI,CAAC,KAAK,IAAI;YAAE,IAAI,GAAG,CAAC,CAAC;aAClC,IAAI,CAAC,CAAC,GAAG,IAAI,CAAC,KAAK,IAAI,EAAE,CAAC;YAAC,IAAI,CAAC,GAAG,IAAI;gBAAE,OAAO,KAAK,CAAC;YAAC,IAAI,GAAG,CAAC,CAAC;QAAC,CAAC;;YAClE,OAAO,KAAK,CAAC;QAClB,IAAI,CAAC,GAAG,IAAI,IAAI,GAAG,CAAC,MAAM;YAAE,OAAO,KAAK,CAAC;QACzC,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,IAAI,IAAI,EAAE,CAAC,EAAE,EAAE,CAAC;YAC7B,IAAI,CAAC,GAAG,CAAC,CAAC,GAAG,CAAC,CAAC,GAAG,IAAI,CAAC,KAAK,IAAI;gBAAE,OAAO,KAAK,CAAC;QACnD,CAAC;QACD,CAAC,IAAI,IAAI,GAAG,CAAC,CAAC;IAClB,CAAC;IACD,OAAO,IAAI,CAAC;AAChB,CAAC"}
@@ -17,11 +17,30 @@ export function sniffAndFixCharset(raw: Buffer): Buffer {
17
17
  const head = raw.subarray(0, Math.min(HEAD_LIMIT, raw.length)).toString("latin1");
18
18
  const re = /charset\s*=\s*"?(iso-8859-1|us-ascii|windows-1252|latin1)"?/gi;
19
19
  if (!re.test(head)) return raw;
20
+ // The rewrite is only sound when the raw bytes ARE the body bytes — i.e.
21
+ // an 8bit / binary part. For a quoted-printable or base64 part the raw
22
+ // .eml is pure ASCII (the high bytes live inside `=XX` / base64 chars),
23
+ // so isValidUtf8(raw) passes vacuously and we would relabel a genuine
24
+ // Windows-1252 part as utf-8 — every smart-quote / em-dash then decodes
25
+ // as mojibake (Bob's 2026-05-21 report: a QP Windows-1252 Outlook mail).
26
+ // Requiring an actual non-ASCII byte in the raw gates the heuristic to
27
+ // the only case where the UTF-8 sniff is meaningful.
28
+ if (!hasNonAscii(raw)) return raw;
20
29
  if (!isValidUtf8(raw)) return raw;
21
30
  const fixed = head.replace(/charset\s*=\s*"?(iso-8859-1|us-ascii|windows-1252|latin1)"?/gi, "charset=utf-8");
22
31
  return Buffer.concat([Buffer.from(fixed, "latin1"), raw.subarray(head.length)]);
23
32
  }
24
33
 
34
+ /** True if the buffer contains at least one byte >= 0x80. A pure-ASCII
35
+ * buffer is trivially valid UTF-8, so the isValidUtf8 sniff tells us
36
+ * nothing about a quoted-printable / base64 body — gate on this first. */
37
+ function hasNonAscii(buf: Buffer): boolean {
38
+ for (let i = 0; i < buf.length; i++) {
39
+ if (buf[i] >= 0x80) return true;
40
+ }
41
+ return false;
42
+ }
43
+
25
44
  /** Strict UTF-8 validity check: rejects overlong forms, invalid start
26
45
  * bytes, and dangling continuations. Used to confirm the body is really
27
46
  * UTF-8 before overriding a Latin-1 declaration. */