@bobfrankston/rmfmail 1.1.104 → 1.1.106

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/client/app.ts CHANGED
@@ -4194,28 +4194,41 @@ optEditorTiptap?.addEventListener("change", () => {
4194
4194
  if (optEditorTiptap.checked) saveEditorSetting("tiptap");
4195
4195
  });
4196
4196
  optEditorTinymce?.addEventListener("change", () => {
4197
- if (optEditorTinymce.checked) {
4198
- saveEditorSetting("tinymce");
4199
- // Q133 pre-warmfire the TinyMCE bundle fetch right now so the
4200
- // next compose-open finds tinymce.min.js already in WebView2's
4201
- // HTTP cache. Without this, the first compose after picking
4202
- // TinyMCE in Settings blocks ~1 s while the bundle downloads.
4203
- try {
4204
- // Default points at the locally bundled TinyMCE (build-tinymce.js
4205
- // copies it from node_modules). Path is relative to index.html
4206
- // which lives at the client/ root; compose.html (in client/compose/)
4207
- // uses `../lib/tinymce/...` instead.
4208
- const cdnUrl = localStorage.getItem("mailx-tinymce-cdn") || "lib/tinymce/tinymce.min.js";
4209
- // Use <link rel="prefetch"> — fires the request as a hint,
4210
- // doesn't execute the script (we don't want a stray global
4211
- // tinymce in the main window; it lives in the compose iframe).
4212
- const link = document.createElement("link");
4213
- link.rel = "prefetch";
4214
- link.as = "script";
4215
- link.href = cdnUrl;
4216
- document.head.appendChild(link);
4217
- } catch { /* prefetch is best-effort */ }
4197
+ if (!optEditorTinymce.checked) return;
4198
+ saveEditorSetting("tinymce");
4199
+ // Q133 + 2026-05-21warm the TinyMCE bundle into the HTTP cache so the
4200
+ // first compose-open doesn't stall downloading it. The old code used a
4201
+ // silent <link rel=prefetch> no completion signal, so picking TinyMCE
4202
+ // looked like a frozen UI ("hang while fetching"). Now we do an explicit
4203
+ // fetch() (I/O, already off the UI thread — a Worker buys nothing for a
4204
+ // download) and surface a pending ready indicator on the menu item.
4205
+ const cdnUrl = localStorage.getItem("mailx-tinymce-cdn") || "lib/tinymce/tinymce.min.js";
4206
+ const label = optEditorTinymce.closest("label");
4207
+ let status = document.getElementById("opt-editor-tinymce-status");
4208
+ if (label && !status) {
4209
+ status = document.createElement("span");
4210
+ status.id = "opt-editor-tinymce-status";
4211
+ status.className = "tb-menu-status";
4212
+ label.appendChild(status);
4218
4213
  }
4214
+ const setStatus = (text: string, state: string): void => {
4215
+ if (!status) return;
4216
+ status.textContent = text;
4217
+ status.dataset.state = state;
4218
+ };
4219
+ setStatus(" loading…", "pending"); // CSS ::before draws the spinner
4220
+ fetch(cdnUrl, { cache: "force-cache" })
4221
+ .then(r => (r.ok ? r.arrayBuffer() : Promise.reject(new Error(`HTTP ${r.status}`))))
4222
+ .then(() => {
4223
+ setStatus(" ✓ ready", "ready");
4224
+ // Clear the badge after a few seconds — but only if still "ready"
4225
+ // (don't wipe a later pending/error state from a re-pick).
4226
+ setTimeout(() => { if (status?.dataset.state === "ready") setStatus("", "idle"); }, 4000);
4227
+ })
4228
+ .catch(e => {
4229
+ setStatus(" ⚠ load failed", "error");
4230
+ console.error("[tinymce] pre-warm fetch failed:", e?.message || e);
4231
+ });
4219
4232
  });
4220
4233
 
4221
4234
  // External editor preference (Edit-in-Word handoff target). Stored under
@@ -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.104",
3
+ "version": "1.1.106",
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. */