@bobfrankston/mailx 1.0.313 → 1.0.324

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.
@@ -0,0 +1,59 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Wrap client/icon.png into client/icon.ico using the ICO-with-embedded-PNG
4
+ * format (Windows Vista+). No image decoding needed — Windows accepts a PNG
5
+ * bitstream inside an ICONDIR + ICONDIRENTRY prelude, so we can hand-roll the
6
+ * binary without any imaging dependency.
7
+ *
8
+ * Run: node bin/build-icon-ico.js
9
+ *
10
+ * Output: client/icon.ico derived from client/icon.png. Re-run whenever the
11
+ * source PNG changes.
12
+ */
13
+ import fs from "node:fs";
14
+ import path from "node:path";
15
+
16
+ const root = path.resolve(import.meta.dirname, "..");
17
+ const src = path.join(root, "client", "icon.png");
18
+ const dst = path.join(root, "client", "icon.ico");
19
+
20
+ if (!fs.existsSync(src)) {
21
+ console.error(`build-icon-ico: source not found: ${src}`);
22
+ process.exit(1);
23
+ }
24
+
25
+ const png = fs.readFileSync(src);
26
+
27
+ // Parse PNG IHDR for width / height. PNG magic (8 bytes) + IHDR chunk length
28
+ // (4 bytes) + "IHDR" (4 bytes), then width (4) + height (4) at offsets 16/20.
29
+ if (png.length < 24 || png.toString("ascii", 12, 16) !== "IHDR") {
30
+ console.error("build-icon-ico: source is not a PNG (missing IHDR)");
31
+ process.exit(1);
32
+ }
33
+ let width = png.readUInt32BE(16);
34
+ let height = png.readUInt32BE(20);
35
+ if (width > 256) width = 256;
36
+ if (height > 256) height = 256;
37
+
38
+ // ICONDIR (6 bytes): reserved=0, type=1 (icon), count=1
39
+ // ICONDIRENTRY (16 bytes per image):
40
+ // width (1 byte, 0 = 256), height (1 byte, 0 = 256), palette (0), reserved (0),
41
+ // planes (1), bpp (32), size (4), offset (4)
42
+ const dir = Buffer.alloc(6);
43
+ dir.writeUInt16LE(0, 0);
44
+ dir.writeUInt16LE(1, 2);
45
+ dir.writeUInt16LE(1, 4);
46
+
47
+ const entry = Buffer.alloc(16);
48
+ entry.writeUInt8(width === 256 ? 0 : width, 0);
49
+ entry.writeUInt8(height === 256 ? 0 : height, 1);
50
+ entry.writeUInt8(0, 2); // palette
51
+ entry.writeUInt8(0, 3); // reserved
52
+ entry.writeUInt16LE(1, 4); // color planes
53
+ entry.writeUInt16LE(32, 6); // bpp
54
+ entry.writeUInt32LE(png.length, 8); // image data size
55
+ entry.writeUInt32LE(22, 12); // offset = 6 (dir) + 16 (entry)
56
+
57
+ const ico = Buffer.concat([dir, entry, png]);
58
+ fs.writeFileSync(dst, ico);
59
+ console.log(`build-icon-ico: wrote ${dst} (${ico.length} bytes, ${width}×${height} PNG-embedded)`);
package/bin/mailx.js CHANGED
@@ -23,7 +23,15 @@ import net from "node:net";
23
23
  import { ports } from "@bobfrankston/miscinfo";
24
24
  import { showMessageBox, showService, setAppName, setAppIcon } from "@bobfrankston/mailx-host";
25
25
  setAppName("mailx");
26
- setAppIcon(path.resolve(import.meta.dirname, "..", "client", "icon.png"));
26
+ // Prefer the .ico (Windows Explorer / taskbar-pin shortcut uses the embedded
27
+ // icon resource of the pinned exe, or a Windows icon resource referenced
28
+ // via PKEY_AppUserModel_RelaunchIconResource — PNG can't play either role).
29
+ // Fall back to PNG for the in-window / tao-level icon on non-Windows.
30
+ {
31
+ const icoPath = path.resolve(import.meta.dirname, "..", "client", "icon.ico");
32
+ const pngPath = path.resolve(import.meta.dirname, "..", "client", "icon.png");
33
+ setAppIcon(fs.existsSync(icoPath) ? icoPath : pngPath);
34
+ }
27
35
  const PORT = ports.mailx;
28
36
  const args = process.argv.slice(2);
29
37
  // Normalize: accept both -flag and --flag
@@ -227,6 +235,24 @@ if (hasFlag("kill")) {
227
235
  }
228
236
  }
229
237
  catch { /* */ }
238
+ // Kill orphaned msgernative.exe windows. When the node process dies
239
+ // without cascade-killing its WebView child (old crash, forced
240
+ // taskkill, etc.), the msgernative.exe stays on screen and looks
241
+ // like a live mailx. mailx -kill should leave no trace.
242
+ // Scoped to exes launched for mailx by filtering CommandLine — don't
243
+ // touch msger windows started by other apps (msga, bbs, etc.).
244
+ try {
245
+ const ps = execSync(`powershell -NoProfile -Command "Get-CimInstance Win32_Process -Filter \\"name='msgernative.exe'\\" | Where-Object { $_.CommandLine -match 'mailx' -or $_.Path -match 'mailx' } | Select-Object -ExpandProperty ProcessId"`, { encoding: "utf-8" }).trim();
246
+ for (const pid of ps.split("\n").map(s => s.trim()).filter(s => /^\d+$/.test(s))) {
247
+ try {
248
+ execSync(`taskkill /F /PID ${pid}`, { stdio: "pipe" });
249
+ console.log(`Killed PID ${pid} (mailx msgernative/WebView)`);
250
+ killed++;
251
+ }
252
+ catch { /* */ }
253
+ }
254
+ }
255
+ catch { /* */ }
230
256
  }
231
257
  else {
232
258
  try {
@@ -903,12 +929,20 @@ async function main() {
903
929
  }
904
930
  catch { /* no saved geometry — use defaults */ }
905
931
  const rootPkgVersion = JSON.parse(fs.readFileSync(path.join(import.meta.dirname, "..", "package.json"), "utf-8")).version;
932
+ // Prefer .ico over .png for the window icon: on Windows, the .ico
933
+ // path doubles as the PKEY_AppUserModel_RelaunchIconResource value
934
+ // so taskbar pins use mailx's icon instead of msgernative's embedded
935
+ // resource. msger auto-detects the .ico extension and wires the
936
+ // relaunch icon after window creation.
937
+ const __iconIco = path.join(clientDir, "icon.ico");
938
+ const __iconPng = path.join(clientDir, "icon.png");
939
+ const __iconPath = fs.existsSync(__iconIco) ? __iconIco : __iconPng;
906
940
  const handle = showService({
907
941
  title: `mailx v${rootPkgVersion}`,
908
942
  url: "index.html",
909
943
  contentDir: clientDir,
910
944
  initScript: mailxapiScript,
911
- icon: path.join(clientDir, "icon.png"),
945
+ icon: __iconPath,
912
946
  aumid: "com.frankston.mailx",
913
947
  size: savedGeometry
914
948
  ? { width: savedGeometry.width, height: savedGeometry.height }
@@ -921,8 +955,21 @@ async function main() {
921
955
  // Register ourselves as the live instance so subsequent `mailx` invocations
922
956
  // can detect version-mismatch and upgrade us (see top of file). Clear on
923
957
  // any of: SIGINT, SIGTERM, normal exit.
958
+ //
959
+ // Critical: the SIGTERM handler must *close the WebView child process*
960
+ // (handle.close() → kills msgernative.exe) before Node exits. Without
961
+ // this, the auto-upgrade leaves the old WebView orphaned on screen and
962
+ // the user sees an apparently frozen "old mailx" while the new Node is
963
+ // trying to spawn a second one. Cascade-killing the child makes the
964
+ // version-mismatch auto-upgrade actually transparent to the user.
924
965
  writeInstanceFile(process.pid);
925
- const __cleanupInstance = () => { clearInstanceFile(); };
966
+ const __cleanupInstance = () => {
967
+ clearInstanceFile();
968
+ try {
969
+ handle.close();
970
+ }
971
+ catch { /* already gone */ }
972
+ };
926
973
  process.once("exit", __cleanupInstance);
927
974
  process.once("SIGINT", () => { __cleanupInstance(); process.exit(0); });
928
975
  process.once("SIGTERM", () => { __cleanupInstance(); process.exit(0); });
@@ -957,6 +1004,27 @@ async function main() {
957
1004
  handle.send({ _cbid: req._cbid, result: { ok: true } });
958
1005
  return;
959
1006
  }
1007
+ // Restart the daemon in-place without npm install. Spawn a fresh
1008
+ // detached child running `mailx`, then gracefully shut this process
1009
+ // down. The new daemon's version-mismatch / startup flow (see top
1010
+ // of bin/mailx.ts) will either take over instantly (version same)
1011
+ // or auto-upgrade through the instance-file cascade. Used when the
1012
+ // user edits accounts.jsonc and needs the change to take effect
1013
+ // without a terminal round-trip.
1014
+ if (req._action === "restartDaemon") {
1015
+ handle.send({ _cbid: req._cbid, ok: true, status: "restarting" });
1016
+ try {
1017
+ const { spawn: spawnChild } = await import("child_process");
1018
+ const child = spawnChild("mailx", [], { detached: true, stdio: "ignore", shell: true });
1019
+ child.unref();
1020
+ console.log(" [restart] Spawned fresh daemon; shutting down current");
1021
+ }
1022
+ catch (e) {
1023
+ console.error(` [restart] Spawn failed: ${e.message}`);
1024
+ }
1025
+ gracefulShutdown("User requested restart");
1026
+ return;
1027
+ }
960
1028
  // Auto-update action: run npm install then restart
961
1029
  if (req._action === "performUpdate") {
962
1030
  handle.send({ _cbid: req._cbid, ok: true, status: "updating" });
package/client/app.js CHANGED
@@ -15,12 +15,16 @@ function updateBadge(count) {
15
15
  badgeCount = count;
16
16
  // Update title
17
17
  document.title = count > 0 ? `(${count}) ${baseTitle}` : baseTitle;
18
- // Update favicon with badge
18
+ // Generate a single badge bitmap used for both the favicon (visible on
19
+ // browser tabs / mobile homescreen) AND the Windows taskbar overlay
20
+ // icon (visible as a Thunderbird-style corner pill on the taskbar
21
+ // button when running via msger). Rendered once, consumed twice.
19
22
  const canvas = document.createElement("canvas");
20
23
  canvas.width = 32;
21
24
  canvas.height = 32;
22
25
  const ctx = canvas.getContext("2d");
23
- // Draw base icon (envelope)
26
+ // Base envelope icon (always drawn — so the favicon is a recognizable
27
+ // mailx icon even at 0 count).
24
28
  ctx.fillStyle = "#4a7ccc";
25
29
  ctx.fillRect(2, 8, 28, 20);
26
30
  ctx.fillStyle = "#6a9cec";
@@ -30,12 +34,11 @@ function updateBadge(count) {
30
34
  ctx.lineTo(30, 8);
31
35
  ctx.fill();
32
36
  if (count > 0) {
33
- // Red badge circle
37
+ // Red badge circle with count
34
38
  ctx.fillStyle = "#e33";
35
39
  ctx.beginPath();
36
40
  ctx.arc(24, 8, 8, 0, Math.PI * 2);
37
41
  ctx.fill();
38
- // Badge number
39
42
  ctx.fillStyle = "#fff";
40
43
  ctx.font = "bold 11px sans-serif";
41
44
  ctx.textAlign = "center";
@@ -49,7 +52,25 @@ function updateBadge(count) {
49
52
  link.rel = "icon";
50
53
  document.head.appendChild(link);
51
54
  }
52
- link.href = canvas.toDataURL();
55
+ const dataUrl = canvas.toDataURL("image/png");
56
+ link.href = dataUrl;
57
+ // Also push to the Windows taskbar overlay via msger's IPC helper —
58
+ // no-op on Linux/Mac. For count=0, render a dedicated "no-overlay"
59
+ // icon that's all-transparent so the base icon shows cleanly.
60
+ try {
61
+ const msgapi = window.msgapi;
62
+ if (msgapi?.setTaskbarOverlay) {
63
+ if (count > 0) {
64
+ // strip "data:image/png;base64," prefix → base64 only
65
+ const b64 = dataUrl.split(",")[1] || "";
66
+ msgapi.setTaskbarOverlay(b64, `${count} unread`);
67
+ }
68
+ else {
69
+ msgapi.setTaskbarOverlay("", "");
70
+ }
71
+ }
72
+ }
73
+ catch { /* msgapi unavailable in browser fallback */ }
53
74
  }
54
75
  async function updateNewMessageCount() {
55
76
  try {
@@ -148,6 +169,48 @@ function hideAlert() {
148
169
  }
149
170
  }
150
171
  alertDismiss?.addEventListener("click", hideAlert);
172
+ /** Show the alert banner with a "Restart" button wired to the mailxapi
173
+ * restartDaemon action. Used when a watched config file whose changes
174
+ * don't apply live (accounts.jsonc) has been modified. */
175
+ function showRestartForConfigBanner() {
176
+ if (!alertBanner || !alertText)
177
+ return;
178
+ alertText.textContent = "accounts.jsonc changed — restart to apply.";
179
+ alertBanner.hidden = false;
180
+ alertBanner.dataset.key = "config-restart";
181
+ // Avoid duplicate buttons across repeat changes.
182
+ const existing = alertBanner.querySelector("#alert-restart-btn");
183
+ if (existing)
184
+ return;
185
+ const btn = document.createElement("button");
186
+ btn.id = "alert-restart-btn";
187
+ btn.textContent = "Restart now";
188
+ btn.style.cssText = "margin-left: 12px; padding: 3px 12px; cursor: pointer;";
189
+ btn.addEventListener("click", async () => {
190
+ btn.disabled = true;
191
+ btn.textContent = "Restarting…";
192
+ try {
193
+ const ipc = window.mailxapi;
194
+ if (ipc?.restartDaemon) {
195
+ await ipc.restartDaemon();
196
+ // Service is going down; the WebView should reload shortly
197
+ // when the replacement daemon takes over. Force a reload
198
+ // after a short delay in case the event doesn't arrive.
199
+ setTimeout(() => location.reload(), 2000);
200
+ }
201
+ else {
202
+ // Non-IPC (server/browser mode) — location reload won't
203
+ // restart the daemon but at least gives the user feedback.
204
+ location.reload();
205
+ }
206
+ }
207
+ catch (e) {
208
+ btn.textContent = `Failed: ${e?.message || e}`;
209
+ btn.disabled = false;
210
+ }
211
+ });
212
+ alertText.after(btn);
213
+ }
151
214
  // ── Wire up components ──
152
215
  const folderTree = document.getElementById("folder-tree");
153
216
  let currentFolderSpecialUse = "";
@@ -770,32 +833,46 @@ document.getElementById("btn-flag")?.addEventListener("click", async () => {
770
833
  }
771
834
  });
772
835
  async function spamSelectedMessages() {
836
+ console.log("[spam] click — finding selection");
773
837
  const selected = getSelectedMessages();
774
838
  if (selected.length === 0) {
775
839
  const current = getCurrentMessage();
776
- if (!current)
840
+ if (!current) {
841
+ console.warn("[spam] no message selected and none in viewer — nothing to do");
842
+ alert("No message selected. Click a message first, then the spam button.");
777
843
  return;
844
+ }
778
845
  selected.push({ accountId: current.accountId, uid: current.message.uid, folderId: current.message.folderId });
779
846
  }
847
+ console.log(`[spam] marking ${selected.length} message(s):`, selected);
780
848
  const statusSync = document.getElementById("status-sync");
849
+ // Optimistic: remove from list immediately so the user sees action happen.
850
+ // If the IPC fails, put them back. This matches local-first — the server
851
+ // sync is a background detail, the user's action should feel instant.
852
+ const snapshot = [...selected];
853
+ messageState.removeMessages(selected);
781
854
  try {
782
855
  const byAccount = new Map();
783
- for (const msg of selected) {
856
+ for (const msg of snapshot) {
784
857
  const uids = byAccount.get(msg.accountId) || [];
785
858
  uids.push(msg.uid);
786
859
  byAccount.set(msg.accountId, uids);
787
860
  }
788
861
  for (const [accountId, uids] of byAccount) {
789
- await markAsSpamMessages(accountId, uids);
862
+ const result = await markAsSpamMessages(accountId, uids);
863
+ console.log(`[spam] ${accountId}: moved ${result?.moved ?? uids.length} to folderId=${result?.targetFolderId}`);
790
864
  }
791
865
  if (statusSync)
792
- statusSync.textContent = `Spam: ${selected.length} queued — pending server sync`;
793
- messageState.removeMessages(selected);
866
+ statusSync.textContent = `Spam: ${snapshot.length} queued — pending server sync`;
794
867
  }
795
868
  catch (e) {
869
+ console.error(`[spam] failed:`, e);
796
870
  if (statusSync)
797
- statusSync.textContent = `Spam failed: ${e.message}`;
798
- console.error(`Spam failed: ${e.message}`);
871
+ statusSync.textContent = `Spam failed: ${e?.message || e}`;
872
+ alert(`Mark-as-spam failed: ${e?.message || e}\n\n${selected.length} message(s) stayed in the list; check Settings → account spam folder and accounts.jsonc.`);
873
+ // Best-effort restore: re-set the messages we optimistically removed.
874
+ // removeMessages has no inverse in message-state, so we'll rely on the
875
+ // next folder reload to repopulate. Surface the failure clearly.
799
876
  }
800
877
  }
801
878
  document.getElementById("btn-spam")?.addEventListener("click", spamSelectedMessages);
@@ -1197,8 +1274,14 @@ onWsEvent((event) => {
1197
1274
  case "configChanged":
1198
1275
  // A watched config file was modified — could be user edit via the
1199
1276
  // JSONC editor, a GDrive sync, or mailx itself saving (e.g.
1200
- // allowlist update on "allow sender"). Show in the status bar
1201
- // briefly, NOT as an error banner — it's informational.
1277
+ // allowlist update on "allow sender").
1278
+ //
1279
+ // For accounts.jsonc specifically, surface a sticky banner with a
1280
+ // Restart button — the file change has no effect on the running
1281
+ // daemon (IMAP connections, token caches, sync loops use the old
1282
+ // config snapshot), and users shouldn't need `mailx -kill` just
1283
+ // to apply an edit. For other files (allowlist / clients /
1284
+ // config) the service handles live, a status-bar flash suffices.
1202
1285
  if (statusSync) {
1203
1286
  statusSync.textContent = `${event.filename} updated`;
1204
1287
  setTimeout(() => {
@@ -1206,6 +1289,9 @@ onWsEvent((event) => {
1206
1289
  statusSync.textContent = "";
1207
1290
  }, 8000);
1208
1291
  }
1292
+ if (event.filename && /accounts\.jsonc/i.test(String(event.filename))) {
1293
+ showRestartForConfigBanner();
1294
+ }
1209
1295
  break;
1210
1296
  case "cloudError":
1211
1297
  // Cloud read/write failed (Google Drive auth/network/etc.). Show a
@@ -71,17 +71,24 @@ export function initMessageList(handler) {
71
71
  // Infinite scroll
72
72
  const body = document.getElementById("ml-body");
73
73
  if (body) {
74
- // Touch scroll vs tap: track finger movement at the container level and
75
- // flag "we were scrolling" so row click handlers can bail out. WebView
76
- // sometimes fires click on touchend even when the user dragged — which
77
- // was opening a message just from scrolling the list.
74
+ // Touch scroll vs tap: the WebView occasionally synthesizes a click on
75
+ // touchend even when the user clearly scrolled, which opened a message
76
+ // just from swiping the list. Multi-signal detection so a scroll is
77
+ // reliably classified:
78
+ // 1. touchmove movement ≥ TAP_SLOP — the primary signal
79
+ // 2. actual scrollTop change between touchstart and touchend — always
80
+ // set the flag when the container moved, even if touchmove never
81
+ // fired (some Android builds coalesce events under momentum)
82
+ // 3. longer TAP_SLOP (15px) — fingers are wide; 10px was too twitchy
78
83
  let touchStartY = 0;
79
84
  let touchStartX = 0;
80
- const TAP_SLOP = 10;
85
+ let touchStartScrollTop = 0;
86
+ const TAP_SLOP = 15;
81
87
  body.addEventListener("touchstart", (e) => {
82
88
  const t = e.touches[0];
83
89
  touchStartY = t.clientY;
84
90
  touchStartX = t.clientX;
91
+ touchStartScrollTop = body.scrollTop;
85
92
  touchWasScroll = false;
86
93
  }, { passive: true });
87
94
  body.addEventListener("touchmove", (e) => {
@@ -90,6 +97,13 @@ export function initMessageList(handler) {
90
97
  touchWasScroll = true;
91
98
  }
92
99
  }, { passive: true });
100
+ body.addEventListener("touchend", () => {
101
+ // If the container actually scrolled during this touch, the user
102
+ // was scrolling regardless of how small their finger movement was.
103
+ if (body.scrollTop !== touchStartScrollTop) {
104
+ touchWasScroll = true;
105
+ }
106
+ }, { passive: true });
93
107
  body.addEventListener("scroll", () => {
94
108
  if (loading)
95
109
  return;
@@ -4,7 +4,7 @@
4
4
  * Receives init data via window.opener.postMessage or URL params.
5
5
  */
6
6
  import { createEditor } from "./editor.js";
7
- import { getVersion, getSettings, getAccounts, searchContacts, sendMessage, saveDraft as apiSaveDraft, deleteDraft } from "../lib/api-client.js";
7
+ import { getSettings, getAccounts, searchContacts, sendMessage, saveDraft as apiSaveDraft, deleteDraft } from "../lib/api-client.js";
8
8
  /** Close compose window */
9
9
  function closeCompose() {
10
10
  window.close();
@@ -45,15 +45,37 @@ async function loadEditorAssets(type) {
45
45
  }
46
46
  }
47
47
  // ── Determine editor type from settings ──
48
+ //
49
+ // Compose must open fast. The previous flow awaited getVersion() then
50
+ // getSettings() sequentially before the editor was even loaded — any
51
+ // service-side stall (busy sync, slow IMAP, hung OAuth refresh) turned
52
+ // "click Reply" into a multi-second / multi-minute wait with a blank
53
+ // compose window. Local-first: read the editor-type preference from a
54
+ // tiny localStorage cache that we update whenever getSettings succeeds
55
+ // in the background. Default to quill on first run / cache miss.
48
56
  let editorType = "quill";
49
57
  let appSettings = null;
50
58
  try {
51
- await getVersion(); // verify server is up
52
- appSettings = await getSettings();
53
- if (appSettings.ui?.editor === "tiptap")
54
- editorType = "tiptap";
59
+ const cached = localStorage.getItem("mailx-editor-type");
60
+ if (cached === "tiptap" || cached === "quill")
61
+ editorType = cached;
55
62
  }
56
- catch { /* default to quill */ }
63
+ catch { /* private-mode / SecurityError — default quill */ }
64
+ // Refresh the cache asynchronously — doesn't block compose open.
65
+ (async () => {
66
+ try {
67
+ appSettings = await getSettings();
68
+ const next = appSettings?.ui?.editor === "tiptap" ? "tiptap" : "quill";
69
+ try {
70
+ localStorage.setItem("mailx-editor-type", next);
71
+ }
72
+ catch { /* */ }
73
+ // Note: we don't hot-swap the editor if the preference changed while
74
+ // compose was opening — the old type is already instantiated. Next
75
+ // compose open will pick up the new preference.
76
+ }
77
+ catch { /* non-fatal */ }
78
+ })();
57
79
  await loadEditorAssets(editorType);
58
80
  const container = document.getElementById("compose-editor");
59
81
  container.classList.add(editorType === "tiptap" ? "editor-tiptap" : "editor-quill");
@@ -382,26 +404,63 @@ function scheduleDraftSave() {
382
404
  clearTimeout(draftDebounceTimer);
383
405
  draftDebounceTimer = setTimeout(() => { draftDebounceTimer = null; saveDraft(); }, DRAFT_INPUT_DEBOUNCE_MS);
384
406
  }
385
- // ── Initialize: always fetch real accounts from the API before applying init, then
386
- // start the auto-save timer. Callers like message-viewer's Edit Draft pass
387
- // init.accounts=[], so we can't trust what's in the init blob. ──
407
+ // ── Initialize: local-first population.
408
+ //
409
+ // Reply / Reply-All / Forward callers pre-populate `init.accounts` with the
410
+ // full account list (app.ts:openCompose). In that common case we do NOT need
411
+ // to call getAccounts() — everything required to fill the compose form is
412
+ // already in sessionStorage and reads synchronously. That turns "click Reply"
413
+ // into an instant-open instead of "wait for getAccounts IPC to respond,
414
+ // which can take >120s when the service is busy syncing / hung on IMAP".
415
+ //
416
+ // getAccounts is still called (non-blocking) to refresh the dropdown with
417
+ // the freshest data — and it IS awaited only in the fallback path where
418
+ // init doesn't have an account list (message-viewer's Edit Draft passes
419
+ // init.accounts=[]).
388
420
  (async () => {
389
- let accounts = [];
390
- try {
391
- accounts = await getAccounts();
392
- }
393
- catch (e) {
394
- console.error("Failed to load accounts:", e);
395
- }
396
421
  const stored = sessionStorage.getItem("composeInit");
397
422
  if (stored) {
398
423
  sessionStorage.removeItem("composeInit");
399
424
  const init = JSON.parse(stored);
400
- if (!init.accounts || init.accounts.length === 0)
401
- init.accounts = accounts;
402
- applyInit(init);
425
+ if (init.accounts && init.accounts.length > 0) {
426
+ // Happy path — init is complete. Apply immediately. Kick
427
+ // getAccounts in the background to refresh the dropdown if the
428
+ // user keeps compose open long enough for the result.
429
+ applyInit(init);
430
+ getAccounts().then((fresh) => {
431
+ if (Array.isArray(fresh) && fresh.length > 0) {
432
+ init.accounts = fresh;
433
+ // Re-populate the From dropdown only — don't clobber
434
+ // anything the user may have already typed.
435
+ try {
436
+ populateFromOptions(fresh);
437
+ }
438
+ catch { /* */ }
439
+ }
440
+ }).catch(() => { });
441
+ }
442
+ else {
443
+ // Edit Draft / other callers that didn't pre-fill accounts.
444
+ // Have to wait on getAccounts here — the From dropdown needs it.
445
+ let fresh = [];
446
+ try {
447
+ fresh = await getAccounts();
448
+ }
449
+ catch (e) {
450
+ console.error("Failed to load accounts:", e);
451
+ }
452
+ init.accounts = fresh;
453
+ applyInit(init);
454
+ }
403
455
  }
404
456
  else {
457
+ let accounts = [];
458
+ try {
459
+ accounts = await getAccounts();
460
+ }
461
+ catch (e) {
462
+ console.error("Failed to load accounts:", e);
463
+ }
405
464
  populateFromOptions(accounts);
406
465
  toInput.focus();
407
466
  }
Binary file
@@ -118,6 +118,20 @@
118
118
  aiTransform: function(req) {
119
119
  return callNode("aiTransform", req);
120
120
  },
121
+ // Restart the background service so edits to accounts.jsonc /
122
+ // allowlist.jsonc / other live config take effect without the user
123
+ // having to type `mailx -kill`. The service spawns a fresh detached
124
+ // daemon and then exits; the new daemon takes over.
125
+ restartDaemon: function() {
126
+ return new Promise(function(resolve) {
127
+ var id = String(++_callbackId);
128
+ _callbacks[id] = { resolve: resolve, reject: resolve, timer: setTimeout(function() {
129
+ delete _callbacks[id];
130
+ resolve({ ok: true, status: "service-gone" });
131
+ }, 5000) };
132
+ try { window.ipc.postMessage(JSON.stringify({ _action: "restartDaemon", _cbid: id })); } catch(e) { resolve({ ok: false }); }
133
+ });
134
+ },
121
135
  searchContacts: function(query) {
122
136
  return callNode("searchContacts", { query: query });
123
137
  },
@@ -396,7 +396,10 @@ button.tb-menu-item { background: none; border: none; color: inherit; width: 100
396
396
  &:hover { background: var(--color-bg-hover); }
397
397
  &.selected { background: var(--color-brand); color: var(--color-brand-dark); font-weight: 500; }
398
398
  &.unread {
399
- font-weight: 600;
399
+ /* Thunderbird-level emphasis: true bold (700) rather than semibold
400
+ * (600). At smaller row heights the weight contrast is the cue the
401
+ * eye tracks — color shifts alone read as noise on a dense list. */
402
+ font-weight: 700;
400
403
  color: var(--color-unread);
401
404
  }
402
405
  }
@@ -479,7 +482,7 @@ button.tb-menu-item { background: none; border: none; color: inherit; width: 100
479
482
  }
480
483
  .ml-thread-popup-item:hover { background: var(--color-bg-hover); }
481
484
  .ml-thread-popup-item.unread .ml-thread-popup-from,
482
- .ml-thread-popup-item.unread .ml-thread-popup-subject { font-weight: 600; }
485
+ .ml-thread-popup-item.unread .ml-thread-popup-subject { font-weight: 700; }
483
486
  .ml-thread-popup-from {
484
487
  grid-column: 1;
485
488
  grid-row: 1;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx",
3
- "version": "1.0.313",
3
+ "version": "1.0.324",
4
4
  "description": "Local-first email client with IMAP sync and standalone native app",
5
5
  "type": "module",
6
6
  "main": "bin/mailx.js",
@@ -12,7 +12,7 @@
12
12
  "client"
13
13
  ],
14
14
  "scripts": {
15
- "build": "npm run build --workspaces --if-present && tsc -p bin",
15
+ "build": "npm run build --workspaces --if-present && tsc -p bin && node bin/build-icon-ico.js",
16
16
  "watch": "tsc -w",
17
17
  "start": "node --watch packages/mailx-server/index.js",
18
18
  "start:prod": "node packages/mailx-server/index.js",
@@ -24,7 +24,7 @@
24
24
  "@bobfrankston/iflow-node": "^0.1.7",
25
25
  "@bobfrankston/miscinfo": "^1.0.9",
26
26
  "@bobfrankston/oauthsupport": "^1.0.24",
27
- "@bobfrankston/msger": "^0.1.342",
27
+ "@bobfrankston/msger": "^0.1.343",
28
28
  "@bobfrankston/mailx-host": "^0.1.3",
29
29
  "@capacitor/android": "^8.3.0",
30
30
  "@capacitor/cli": "^8.3.0",
@@ -88,7 +88,7 @@
88
88
  "@bobfrankston/iflow-node": "^0.1.7",
89
89
  "@bobfrankston/miscinfo": "^1.0.9",
90
90
  "@bobfrankston/oauthsupport": "^1.0.24",
91
- "@bobfrankston/msger": "^0.1.342",
91
+ "@bobfrankston/msger": "^0.1.343",
92
92
  "@bobfrankston/mailx-host": "^0.1.3",
93
93
  "@capacitor/android": "^8.3.0",
94
94
  "@capacitor/cli": "^8.3.0",
@@ -249,6 +249,7 @@ export declare class ImapManager extends EventEmitter {
249
249
  /** Stop Outbox worker */
250
250
  stopOutboxWorker(): void;
251
251
  private configWatchers;
252
+ private cloudPollTimers;
252
253
  /** Watch the local config files for external changes. On change, emit
253
254
  * configChanged so the UI can show a "restart to apply" banner. Uses
254
255
  * a debounce to coalesce rapid writes from save tools. */
@@ -564,6 +564,11 @@ export class ImapManager extends EventEmitter {
564
564
  const folders = await client.getFolderList();
565
565
  console.log(` [diag] ${accountId}: getFolderList done in ${Date.now() - t0}ms (${folders.length} folders)`);
566
566
  const specialFolders = client.getSpecialFolders(folders);
567
+ // Collect server paths so we can prune anything the server no longer
568
+ // has (user-renamed / -deleted / case-flipped a folder from another
569
+ // client). IMAP paths are case-sensitive, so "Foo" → "foo" is a real
570
+ // delete+create of two distinct mailboxes.
571
+ const serverPaths = new Set();
567
572
  for (const folder of folders) {
568
573
  // Skip non-selectable folders (virtual parents like "Added", "Added2")
569
574
  const flags = folder.flags;
@@ -584,6 +589,34 @@ export class ImapManager extends EventEmitter {
584
589
  else if (specialFolders.archive === folder.path)
585
590
  specialUse = "archive";
586
591
  this.db.upsertFolder(accountId, folder.path, folder.name || folder.path.split(folder.delimiter || "/").pop() || folder.path, specialUse, folder.delimiter || "/");
592
+ serverPaths.add(folder.path);
593
+ }
594
+ // Prune: any local folder whose exact path (case-sensitive) isn't in
595
+ // the server's list has been deleted or renamed server-side. Safety
596
+ // rails: only prune when the server returned a non-empty list (empty
597
+ // result is more likely a transient protocol / auth error than "all
598
+ // your folders were deleted"). Never prune INBOX under any
599
+ // circumstances — even a broken server response shouldn't make us
600
+ // drop the account's primary mailbox. All other special-use folders
601
+ // ARE prunable: if the user actually deleted Sent on the server,
602
+ // we should reflect that locally, and the next sync will re-detect
603
+ // the server's real Sent folder and re-upsert.
604
+ if (folders.length > 0) {
605
+ const localFolders = this.db.getFolders(accountId);
606
+ const stale = localFolders.filter(f => !serverPaths.has(f.path) &&
607
+ f.specialUse !== "inbox");
608
+ for (const f of stale) {
609
+ console.log(` [sync] ${accountId}: pruning stale folder "${f.path}" (id=${f.id}) — no longer on server`);
610
+ try {
611
+ this.db.deleteFolder(f.id);
612
+ }
613
+ catch (e) {
614
+ console.error(` [sync] ${accountId}: prune failed for "${f.path}": ${e.message}`);
615
+ }
616
+ }
617
+ if (stale.length > 0) {
618
+ this.emit("folderCountsChanged", accountId, {});
619
+ }
587
620
  }
588
621
  this.emit("syncProgress", accountId, "folders", 100);
589
622
  // Notify UI that folder structure changed — triggers tree re-render
@@ -2847,6 +2880,7 @@ export class ImapManager extends EventEmitter {
2847
2880
  }
2848
2881
  // ── Config file watcher ──
2849
2882
  configWatchers = [];
2883
+ cloudPollTimers = [];
2850
2884
  /** Watch the local config files for external changes. On change, emit
2851
2885
  * configChanged so the UI can show a "restart to apply" banner. Uses
2852
2886
  * a debounce to coalesce rapid writes from save tools. */
@@ -2875,6 +2909,55 @@ export class ImapManager extends EventEmitter {
2875
2909
  console.error(` [watch] Failed to watch ${filename}: ${e.message}`);
2876
2910
  }
2877
2911
  }
2912
+ // GDrive has no push/watch for arbitrary Drive files, so edits on
2913
+ // another device (or via Drive web) never fire fs.watch locally.
2914
+ // Poll the cloud copies of the replicated-to-cloud config files
2915
+ // (accounts.jsonc, allowlist.jsonc, clients.jsonc) every 3 minutes,
2916
+ // compare to local, and write-through on difference. The local
2917
+ // fs.watch above then picks up the write and emits configChanged.
2918
+ // config.jsonc is per-machine / local-only — never polled.
2919
+ const cloudFiles = ["accounts.jsonc", "allowlist.jsonc", "clients.jsonc"];
2920
+ const CLOUD_POLL_MS = 3 * 60 * 1000;
2921
+ const pollCloud = async () => {
2922
+ let cloudRead;
2923
+ try {
2924
+ ({ cloudRead } = await import("@bobfrankston/mailx-settings"));
2925
+ }
2926
+ catch {
2927
+ return; /* cloud module unavailable */
2928
+ }
2929
+ for (const filename of cloudFiles) {
2930
+ try {
2931
+ const cloudContent = await cloudRead(filename);
2932
+ if (!cloudContent)
2933
+ continue;
2934
+ const localPath = path.join(configDir, filename);
2935
+ let localContent = null;
2936
+ try {
2937
+ localContent = fs.readFileSync(localPath, "utf-8");
2938
+ }
2939
+ catch { /* missing */ }
2940
+ if (localContent === cloudContent)
2941
+ continue;
2942
+ // Cloud copy differs — write through so watchers / downstream
2943
+ // readers see the new value. fs.watch above will fire and
2944
+ // emit configChanged → UI banner.
2945
+ fs.writeFileSync(localPath, cloudContent);
2946
+ console.log(` [cloud-poll] ${filename} updated from cloud copy`);
2947
+ }
2948
+ catch (e) {
2949
+ // Drive unreachable, auth expired, file missing in cloud —
2950
+ // silent retry on next tick; no user-visible fallout.
2951
+ console.log(` [cloud-poll] ${filename} check skipped: ${e?.message || e}`);
2952
+ }
2953
+ }
2954
+ };
2955
+ // First poll ~10s after startup, then every 3 min.
2956
+ setTimeout(() => {
2957
+ pollCloud();
2958
+ const interval = setInterval(pollCloud, CLOUD_POLL_MS);
2959
+ this.cloudPollTimers.push(interval);
2960
+ }, 10_000);
2878
2961
  }
2879
2962
  /** Stop all config file watchers */
2880
2963
  stopWatchingConfig() {
@@ -2885,6 +2968,13 @@ export class ImapManager extends EventEmitter {
2885
2968
  catch { /* ignore */ }
2886
2969
  }
2887
2970
  this.configWatchers = [];
2971
+ for (const t of this.cloudPollTimers) {
2972
+ try {
2973
+ clearInterval(t);
2974
+ }
2975
+ catch { /* ignore */ }
2976
+ }
2977
+ this.cloudPollTimers = [];
2888
2978
  }
2889
2979
  // ── Google Contacts Sync ──
2890
2980
  contactsSyncToken = null;
@@ -587,16 +587,33 @@ export class MailxService {
587
587
  throw new Error("Folder not found");
588
588
  const client = this.imapManager.createPublicClient(accountId);
589
589
  try {
590
- if (client.deleteMailbox) {
591
- await client.deleteMailbox(folder.path);
590
+ try {
591
+ if (client.deleteMailbox) {
592
+ await client.deleteMailbox(folder.path);
593
+ }
594
+ else {
595
+ await client.withConnection(async () => {
596
+ await client.client.mailboxDelete(folder.path);
597
+ });
598
+ }
592
599
  }
593
- else {
594
- await client.withConnection(async () => {
595
- await client.client.mailboxDelete(folder.path);
596
- });
600
+ catch (e) {
601
+ // Server already doesn't have this folder — common case when
602
+ // the user deleted / renamed it from another client and mailx
603
+ // is still showing the stale local row. Silently treat as
604
+ // success and proceed with local cleanup; the user's intent
605
+ // ("make this go away") is met either way.
606
+ const msg = String(e?.message || e || "").toLowerCase();
607
+ const alreadyGone = /nonexistent|does not exist|no such|not found|NO \[.*\] Mailbox|404/i.test(msg);
608
+ if (!alreadyGone)
609
+ throw e;
610
+ console.log(` [folder] ${accountId} delete "${folder.path}": server says already gone — cleaning local DB`);
597
611
  }
598
612
  this.db.deleteFolder(folderId);
599
- await client.logout();
613
+ try {
614
+ await client.logout();
615
+ }
616
+ catch { /* ignore */ }
600
617
  }
601
618
  finally {
602
619
  try {
@@ -647,6 +664,16 @@ export class MailxService {
647
664
  }
648
665
  // ── Drafts ──
649
666
  async saveDraft(accountId, subject, bodyHtml, bodyText, to, cc, previousDraftUid, draftId) {
667
+ // Local-first: commit the draft to the local filesystem synchronously
668
+ // and return immediately. The IMAP APPEND (and the previous-draft
669
+ // delete) run in the background. Previously this method awaited IMAP
670
+ // inline, which produced the 30/120s `mailxapi timeout: saveDraft`
671
+ // the user reported — every IMAP stall (slow server, hung OAuth,
672
+ // maxed connection pool) froze autosave. The local `.eml` written
673
+ // below is the user's crash-safety net; IMAP is a sync target, not
674
+ // a prerequisite. X-Mailx-Draft-ID is carried in the MIME headers
675
+ // so the reconciler can de-duplicate on the server by header search
676
+ // even without the previousDraftUid round-trip.
650
677
  const settings = loadSettings();
651
678
  const account = settings.accounts.find(a => a.id === accountId);
652
679
  if (!account)
@@ -663,7 +690,8 @@ export class MailxService {
663
690
  `MIME-Version: 1.0`, `Content-Type: text/html; charset=UTF-8`, `Content-Transfer-Encoding: quoted-printable`,
664
691
  ].filter(h => h !== null).join("\r\n");
665
692
  const raw = `${headers}\r\n\r\n${bodyEncoded}`;
666
- // Save local editing copy crash recovery, keep last 3
693
+ // Local commit: write editing copy to disk. Crash recovery lives in
694
+ // the last 3 files. Synchronous fs (~ms) so the caller returns fast.
667
695
  try {
668
696
  const editingDir = path.join(getConfigDir(), "sending", accountId, "editing");
669
697
  fs.mkdirSync(editingDir, { recursive: true });
@@ -677,9 +705,16 @@ export class MailxService {
677
705
  fs.unlinkSync(path.join(editingDir, files.shift()));
678
706
  }
679
707
  }
680
- catch { /* ignore */ }
681
- const draftUid = await this.imapManager.saveDraft(accountId, raw, previousDraftUid, id);
682
- return { draftUid, draftId: id };
708
+ catch { /* non-fatal — draft stays in memory at least */ }
709
+ // Background reconcile to server Drafts folder. Fire-and-forget
710
+ // the ACK to the client is already on its way.
711
+ this.imapManager.saveDraft(accountId, raw, previousDraftUid, id).catch((e) => {
712
+ console.error(` [draft] background IMAP save failed for ${id}: ${e?.message || e}`);
713
+ // Surface as an event so the UI can show a status-bar hint without
714
+ // blocking the caller. Draft is preserved on disk regardless.
715
+ this.emit?.("draftSaveDeferred", { accountId, draftId: id, error: String(e?.message || e) });
716
+ });
717
+ return { draftUid: null, draftId: id };
683
718
  }
684
719
  async deleteDraft(accountId, draftUid, draftId) {
685
720
  await this.imapManager.deleteDraft(accountId, draftUid, draftId);