@bobfrankston/mailx 1.0.317 → 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;
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.317",
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",
@@ -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. */
@@ -2880,6 +2880,7 @@ export class ImapManager extends EventEmitter {
2880
2880
  }
2881
2881
  // ── Config file watcher ──
2882
2882
  configWatchers = [];
2883
+ cloudPollTimers = [];
2883
2884
  /** Watch the local config files for external changes. On change, emit
2884
2885
  * configChanged so the UI can show a "restart to apply" banner. Uses
2885
2886
  * a debounce to coalesce rapid writes from save tools. */
@@ -2908,6 +2909,55 @@ export class ImapManager extends EventEmitter {
2908
2909
  console.error(` [watch] Failed to watch ${filename}: ${e.message}`);
2909
2910
  }
2910
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);
2911
2961
  }
2912
2962
  /** Stop all config file watchers */
2913
2963
  stopWatchingConfig() {
@@ -2918,6 +2968,13 @@ export class ImapManager extends EventEmitter {
2918
2968
  catch { /* ignore */ }
2919
2969
  }
2920
2970
  this.configWatchers = [];
2971
+ for (const t of this.cloudPollTimers) {
2972
+ try {
2973
+ clearInterval(t);
2974
+ }
2975
+ catch { /* ignore */ }
2976
+ }
2977
+ this.cloudPollTimers = [];
2921
2978
  }
2922
2979
  // ── Google Contacts Sync ──
2923
2980
  contactsSyncToken = null;