@bobfrankston/mailx 1.0.340 → 1.0.349

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/bin/mailx.js CHANGED
@@ -130,6 +130,8 @@ if (!isDaemon && !__isCommandInvocation) {
130
130
  if (!verbose && !isDaemon && !process.argv.slice(2).some(a => /^-/.test(a))) {
131
131
  const { spawn } = await import("node:child_process");
132
132
  const child = spawn(process.execPath, [...process.argv.slice(1), "--daemon"], {
133
+ // windowsHide on the spawn options below — prevents the brief
134
+ // console-window flash when the daemon launches.
133
135
  detached: true,
134
136
  stdio: "ignore",
135
137
  windowsHide: true,
@@ -1070,7 +1072,7 @@ RFC 5322 with CRLF line endings. Bodies are quoted-printable encoded (readable i
1070
1072
  try {
1071
1073
  clearInstanceFile();
1072
1074
  const { spawn: spawnChild } = await import("child_process");
1073
- const child = spawnChild("mailx", [], { detached: true, stdio: "ignore", shell: true });
1075
+ const child = spawnChild("mailx", [], { detached: true, stdio: "ignore", shell: true, windowsHide: true });
1074
1076
  child.unref();
1075
1077
  console.log(" [restart] Spawned fresh daemon; shutting down current");
1076
1078
  // Give the spawn a moment to take hold before we start
@@ -1090,10 +1092,10 @@ RFC 5322 with CRLF line endings. Bodies are quoted-printable encoded (readable i
1090
1092
  try {
1091
1093
  const { execSync, spawn: spawnChild } = await import("child_process");
1092
1094
  console.log(" [update] Installing latest version...");
1093
- execSync("npm install -g @bobfrankston/mailx", { encoding: "utf-8", timeout: 120_000, stdio: "inherit" });
1095
+ execSync("npm install -g @bobfrankston/mailx", { encoding: "utf-8", timeout: 120_000, stdio: "inherit", windowsHide: true });
1094
1096
  console.log(" [update] Install complete — relaunching");
1095
1097
  // Spawn the new version detached so it outlives this process
1096
- const child = spawnChild("mailx", [], { detached: true, stdio: "ignore", shell: true });
1098
+ const child = spawnChild("mailx", [], { detached: true, stdio: "ignore", shell: true, windowsHide: true });
1097
1099
  child.unref();
1098
1100
  }
1099
1101
  catch (e) {
@@ -1102,13 +1104,18 @@ RFC 5322 with CRLF line endings. Bodies are quoted-printable encoded (readable i
1102
1104
  gracefulShutdown("Update applied");
1103
1105
  return;
1104
1106
  }
1107
+ // Per-action wall-clock timing so a "took N seconds" report tells us
1108
+ // where between Rust→stdin→dispatch→service the time actually went.
1109
+ const ipcT0 = Date.now();
1105
1110
  try {
1106
1111
  const response = await dispatch(svc, req);
1107
- console.log(`[ipc] ${req._action} (${req._cbid}) ok`);
1112
+ const elapsed = Date.now() - ipcT0;
1113
+ console.log(`[ipc] → ${req._action} (${req._cbid}) ok in ${elapsed}ms`);
1108
1114
  handle.send(response);
1109
1115
  }
1110
1116
  catch (e) {
1111
- console.error(`[ipc] ${req._action} (${req._cbid}) error: ${e.message}`);
1117
+ const elapsed = Date.now() - ipcT0;
1118
+ console.error(`[ipc] → ${req._action} (${req._cbid}) error in ${elapsed}ms: ${e.message}`);
1112
1119
  handle.send({ _cbid: req._cbid, error: e.message });
1113
1120
  }
1114
1121
  });
@@ -1287,8 +1294,31 @@ RFC 5322 with CRLF line endings. Bodies are quoted-printable encoded (readable i
1287
1294
  const UPDATE_CHECK_MS = 30 * 60_000; // 30 minutes
1288
1295
  async function checkForUpdate() {
1289
1296
  try {
1290
- const { execSync } = await import("child_process");
1291
- const latest = execSync("npm view @bobfrankston/mailx version", { encoding: "utf-8", timeout: 15_000 }).trim();
1297
+ // spawn with windowsHide:true — execSync briefly flashes a cmd
1298
+ // window on Windows every time the periodic check fires.
1299
+ const { spawn } = await import("child_process");
1300
+ const latest = await new Promise((resolve, reject) => {
1301
+ const child = spawn("npm", ["view", "@bobfrankston/mailx", "version"], {
1302
+ windowsHide: true,
1303
+ shell: true,
1304
+ });
1305
+ let out = "";
1306
+ let err = "";
1307
+ child.stdout.on("data", (d) => { out += d.toString(); });
1308
+ child.stderr.on("data", (d) => { err += d.toString(); });
1309
+ const killer = setTimeout(() => { try {
1310
+ child.kill();
1311
+ }
1312
+ catch { /* */ } reject(new Error("npm view timed out")); }, 15_000);
1313
+ child.on("error", (e) => { clearTimeout(killer); reject(e); });
1314
+ child.on("exit", (code) => {
1315
+ clearTimeout(killer);
1316
+ if (code === 0)
1317
+ resolve(out.trim());
1318
+ else
1319
+ reject(new Error(err.trim() || `npm view exit ${code}`));
1320
+ });
1321
+ });
1292
1322
  const current = rootPkgVersion;
1293
1323
  if (latest && latest !== current) {
1294
1324
  console.log(` [update] New version available: ${current} → ${latest}`);
package/client/app.js CHANGED
@@ -151,13 +151,32 @@ const alertBanner = document.getElementById("alert-banner");
151
151
  const alertText = document.getElementById("alert-text");
152
152
  const alertDismiss = document.getElementById("alert-dismiss");
153
153
  const dismissedAlerts = new Set();
154
- function showAlert(message, key) {
154
+ let alertAutoDismissTimer = null;
155
+ function showAlert(message, key, opts) {
155
156
  if (key && dismissedAlerts.has(key))
156
157
  return;
157
158
  if (alertBanner && alertText) {
158
159
  alertText.textContent = message;
159
160
  alertBanner.hidden = false;
160
161
  alertBanner.dataset.key = key || "";
162
+ // Q65: auto-dismiss non-critical banners after 30s; sticky ones
163
+ // (acct-*, ws-error, config-restart) keep showing until user acts.
164
+ if (alertAutoDismissTimer) {
165
+ clearTimeout(alertAutoDismissTimer);
166
+ alertAutoDismissTimer = null;
167
+ }
168
+ const isCritical = !!opts?.sticky
169
+ || (key?.startsWith("acct-"))
170
+ || key === "ws-error"
171
+ || key === "config-restart";
172
+ if (!isCritical) {
173
+ alertAutoDismissTimer = setTimeout(() => {
174
+ if (alertBanner && alertBanner.dataset.key === (key || "")) {
175
+ alertBanner.hidden = true;
176
+ }
177
+ alertAutoDismissTimer = null;
178
+ }, 30_000);
179
+ }
161
180
  }
162
181
  }
163
182
  function hideAlert() {
@@ -304,6 +323,30 @@ messageState.subscribe((change) => {
304
323
  }
305
324
  }
306
325
  });
326
+ // Q53: per-account last-sync timestamps surfaced via the status-sync hover.
327
+ const lastSyncByAccount = {};
328
+ function recordAccountSync(accountId) {
329
+ lastSyncByAccount[accountId] = Date.now();
330
+ refreshSyncTooltip();
331
+ }
332
+ function refreshSyncTooltip() {
333
+ const el = document.getElementById("status-sync");
334
+ if (!el)
335
+ return;
336
+ const accts = Object.keys(lastSyncByAccount).sort();
337
+ if (accts.length === 0) {
338
+ el.title = "";
339
+ return;
340
+ }
341
+ el.title = "Last sync:\n" + accts.map(a => {
342
+ const ts = lastSyncByAccount[a];
343
+ const d = new Date(ts);
344
+ return ` ${a}: ${d.toLocaleTimeString()} (${formatAge(Date.now() - ts)})`;
345
+ }).join("\n");
346
+ }
347
+ // Refresh the tooltip every 30s so the "(12m ago)" stays current even with
348
+ // no new sync events.
349
+ setInterval(refreshSyncTooltip, 30_000);
307
350
  // ── Auto two-line when message list is narrow ──
308
351
  const messageList = document.getElementById("message-list");
309
352
  if (messageList) {
@@ -506,7 +549,7 @@ async function openCompose(mode) {
506
549
  bodyHtml: "",
507
550
  inReplyTo: "",
508
551
  references: [],
509
- accounts: accounts.map((a) => ({ id: a.id, name: a.name, email: a.email })),
552
+ accounts: accounts.map((a) => ({ id: a.id, name: a.name, email: a.email, signature: a.signature })),
510
553
  };
511
554
  // Auto-detect reply From: if the message was delivered to an identity address
512
555
  // (an alias on the account's domain, or the explicit `identityDomains` list
@@ -938,6 +981,21 @@ document.getElementById("rail-unified")?.addEventListener("click", () => {
938
981
  || document.getElementById("ft-all-inboxes");
939
982
  unified?.click();
940
983
  });
984
+ document.getElementById("rail-contacts")?.addEventListener("click", async () => {
985
+ const { openAddressBook } = await import("./components/address-book.js");
986
+ openAddressBook();
987
+ setRailActive("rail-contacts");
988
+ });
989
+ document.getElementById("rail-calendar")?.addEventListener("click", async () => {
990
+ const { openCalendar } = await import("./components/calendar.js");
991
+ openCalendar();
992
+ setRailActive("rail-calendar");
993
+ });
994
+ document.getElementById("rail-tasks")?.addEventListener("click", async () => {
995
+ const { openTasks } = await import("./components/tasks.js");
996
+ openTasks();
997
+ setRailActive("rail-tasks");
998
+ });
941
999
  document.getElementById("rail-settings")?.addEventListener("click", () => {
942
1000
  document.getElementById("btn-settings")?.click();
943
1001
  });
@@ -985,9 +1043,14 @@ function doSearch(immediate = false) {
985
1043
  }
986
1044
  if (query.length < 2 && !immediate)
987
1045
  return;
988
- const scope = searchScope?.value || "all";
1046
+ // P20: orthogonal "Server" checkbox. When checked, scope switches to
1047
+ // "server" which spans all folders on all accounts. Local scope dropdown
1048
+ // is unchanged (all/current) for the local-only case.
1049
+ const serverCheck = document.getElementById("search-server-too");
1050
+ const localScope = searchScope?.value || "all";
1051
+ const effectiveScope = serverCheck?.checked ? "server" : localScope;
989
1052
  // "This folder" scope: instant client-side filter on debounce, server search on Enter
990
- if (scope === "current" && !immediate) {
1053
+ if (effectiveScope === "current" && !immediate) {
991
1054
  // Client-side filter of visible rows
992
1055
  const body = document.getElementById("ml-body");
993
1056
  if (body) {
@@ -999,7 +1062,7 @@ function doSearch(immediate = false) {
999
1062
  }
1000
1063
  return;
1001
1064
  }
1002
- loadSearchResults(query, scope, currentAccountId, currentFolderId);
1065
+ loadSearchResults(query, effectiveScope, currentAccountId, currentFolderId);
1003
1066
  setTitle(`mailx - Search: ${query}`);
1004
1067
  }
1005
1068
  // Track current folder for scoped search
@@ -1098,41 +1161,163 @@ window.addEventListener("message", (e) => {
1098
1161
  window.open(url, "_blank", "noopener,noreferrer");
1099
1162
  }
1100
1163
  }
1164
+ if (e.data?.type === "linkContextMenu") {
1165
+ // C29: right-click in body iframe → Open / Save / Copy URL menu.
1166
+ // Iframe's clientX/Y is relative to the iframe; translate to viewport.
1167
+ let iframeRect = null;
1168
+ for (const f of Array.from(document.querySelectorAll("iframe"))) {
1169
+ if (f.contentWindow === e.source) {
1170
+ iframeRect = f.getBoundingClientRect();
1171
+ break;
1172
+ }
1173
+ }
1174
+ const x = (iframeRect?.left || 0) + (e.data.x || 0);
1175
+ const y = (iframeRect?.top || 0) + (e.data.y || 0);
1176
+ const url = e.data.url || "";
1177
+ // Find a sensible filename for the Save action.
1178
+ const guessName = (() => {
1179
+ try {
1180
+ const u = new URL(url);
1181
+ const last = u.pathname.split("/").pop() || "";
1182
+ return last && last.includes(".") ? last : "";
1183
+ }
1184
+ catch {
1185
+ return "";
1186
+ }
1187
+ })();
1188
+ const items = [
1189
+ { label: "Open in browser", action: () => {
1190
+ window.open(url, "_blank", "noopener,noreferrer");
1191
+ } },
1192
+ { label: guessName ? `Save "${guessName}"…` : "Save link as…", action: () => {
1193
+ // Trigger a download via anchor with download attr.
1194
+ const a = document.createElement("a");
1195
+ a.href = url;
1196
+ if (guessName)
1197
+ a.download = guessName;
1198
+ else
1199
+ a.download = "";
1200
+ a.style.display = "none";
1201
+ document.body.appendChild(a);
1202
+ a.click();
1203
+ setTimeout(() => a.remove(), 1000);
1204
+ } },
1205
+ { label: "Copy URL", action: async () => {
1206
+ try {
1207
+ await navigator.clipboard.writeText(url);
1208
+ }
1209
+ catch {
1210
+ prompt("URL:", url);
1211
+ }
1212
+ } },
1213
+ { label: "Copy link text", action: async () => {
1214
+ try {
1215
+ await navigator.clipboard.writeText(e.data.text || url);
1216
+ }
1217
+ catch {
1218
+ prompt("Text:", e.data.text || url);
1219
+ }
1220
+ } },
1221
+ ];
1222
+ // Build a tiny inline menu (showContextMenu would do but it's in components/).
1223
+ const menu = document.createElement("div");
1224
+ menu.style.cssText = `position:fixed;z-index:2400;background:var(--color-bg);border:1px solid var(--color-border);border-radius:6px;box-shadow:0 4px 16px rgba(0,0,0,0.2);padding:4px 0;font-size:13px;min-width:180px;`;
1225
+ menu.style.left = `${Math.min(x, window.innerWidth - 200)}px`;
1226
+ menu.style.top = `${Math.min(y, window.innerHeight - 200)}px`;
1227
+ for (const it of items) {
1228
+ const row = document.createElement("div");
1229
+ row.textContent = it.label;
1230
+ row.style.cssText = `padding:6px 12px;cursor:pointer;`;
1231
+ row.addEventListener("mouseenter", () => row.style.background = "var(--color-bg-hover)");
1232
+ row.addEventListener("mouseleave", () => row.style.background = "");
1233
+ row.addEventListener("click", () => { menu.remove(); it.action(); });
1234
+ menu.appendChild(row);
1235
+ }
1236
+ document.body.appendChild(menu);
1237
+ const dismiss = () => { menu.remove(); document.removeEventListener("mousedown", dismiss); document.removeEventListener("keydown", dismiss); };
1238
+ setTimeout(() => {
1239
+ document.addEventListener("mousedown", dismiss);
1240
+ document.addEventListener("keydown", dismiss);
1241
+ }, 0);
1242
+ return;
1243
+ }
1244
+ if (e.data?.type === "mailx-send-error") {
1245
+ // Send failed AFTER compose closed (fire-and-forget model). Surface in
1246
+ // the status bar so the user sees something instead of the silence.
1247
+ const statusSync = document.getElementById("status-sync");
1248
+ if (statusSync) {
1249
+ statusSync.textContent = `Send failed: ${e.data.message}`;
1250
+ statusSync.style.color = "oklch(0.65 0.2 25)";
1251
+ }
1252
+ return;
1253
+ }
1101
1254
  if (e.data?.type === "linkHover") {
1255
+ // Cancel any pending show — every hoverover/hoverout from the iframe
1256
+ // triggers this branch. Without the timer, the popover appears
1257
+ // instantly and lingers when the user moves to do anything else,
1258
+ // including punching through the compose overlay (which sits at
1259
+ // z-index 1000 — popover was at 10000, hence the bug in the
1260
+ // screenshot). Now: 500ms hover delay; suppressed entirely when
1261
+ // any overlay (compose, modal) is open; auto-dismissed on click,
1262
+ // scroll, blur, or any keypress.
1263
+ const w = window;
1264
+ if (w._linkHoverShowTimer) {
1265
+ clearTimeout(w._linkHoverShowTimer);
1266
+ w._linkHoverShowTimer = null;
1267
+ }
1102
1268
  let pop = document.getElementById("link-hover-popover");
1269
+ const hidePop = () => { if (pop)
1270
+ pop.style.display = "none"; };
1103
1271
  if (!e.data.url) {
1104
- if (pop)
1105
- pop.style.display = "none";
1272
+ hidePop();
1273
+ return;
1106
1274
  }
1107
- else {
1275
+ // Suppress when compose / modal overlay is up — user shouldn't see
1276
+ // a tooltip for a link they can't reach without dismissing first.
1277
+ if (document.querySelector(".compose-overlay, .mailx-modal-backdrop")) {
1278
+ hidePop();
1279
+ return;
1280
+ }
1281
+ const data = e.data;
1282
+ const source = e.source;
1283
+ w._linkHoverShowTimer = setTimeout(() => {
1284
+ // Re-check overlay state at fire time — overlay may have appeared
1285
+ // during the 500ms wait.
1286
+ if (document.querySelector(".compose-overlay, .mailx-modal-backdrop"))
1287
+ return;
1108
1288
  if (!pop) {
1109
1289
  pop = document.createElement("div");
1110
1290
  pop.id = "link-hover-popover";
1111
- pop.style.cssText = "position:fixed;z-index:10000;max-width:520px;padding:6px 10px;background:var(--color-surface,#fff);color:var(--color-text,#000);border:1px solid var(--color-border,#888);border-radius:6px;box-shadow:0 4px 12px rgba(0,0,0,0.18);font-size:12px;line-height:1.4;word-break:break-all;pointer-events:none;";
1291
+ // z-index 500 above the message body iframe (no z-index)
1292
+ // but BELOW the compose overlay (z-index 1000) and modals (2000).
1293
+ pop.style.cssText = "position:fixed;z-index:500;max-width:520px;padding:6px 10px;background:var(--color-surface,#fff);color:var(--color-text,#000);border:1px solid var(--color-border,#888);border-radius:6px;box-shadow:0 4px 12px rgba(0,0,0,0.18);font-size:12px;line-height:1.4;word-break:break-all;pointer-events:none;";
1112
1294
  document.body.appendChild(pop);
1295
+ // One-time dismissers on the popover lifetime.
1296
+ const dismiss = () => hidePop();
1297
+ document.addEventListener("mousedown", dismiss, true);
1298
+ document.addEventListener("scroll", dismiss, true);
1299
+ document.addEventListener("keydown", dismiss, true);
1300
+ window.addEventListener("blur", dismiss);
1113
1301
  }
1114
- pop.textContent = e.data.url;
1302
+ pop.textContent = data.url;
1115
1303
  pop.style.display = "block";
1116
- // Locate the iframe whose contentWindow matches e.source so we can
1117
- // translate iframe-local rect coords into viewport coords.
1118
1304
  let iframeRect = null;
1119
1305
  for (const f of Array.from(document.querySelectorAll("iframe"))) {
1120
- if (f.contentWindow === e.source) {
1306
+ if (f.contentWindow === source) {
1121
1307
  iframeRect = f.getBoundingClientRect();
1122
1308
  break;
1123
1309
  }
1124
1310
  }
1125
- const r = e.data.rect;
1311
+ const r = data.rect;
1126
1312
  if (iframeRect && r) {
1127
1313
  const x = Math.max(4, Math.min(window.innerWidth - 528, iframeRect.left + r.left));
1128
1314
  let y = iframeRect.top + r.bottom + 4;
1129
- // If it would clip bottom, flip above the link
1130
1315
  if (y + 60 > window.innerHeight)
1131
1316
  y = Math.max(4, iframeRect.top + r.top - 60);
1132
1317
  pop.style.left = x + "px";
1133
1318
  pop.style.top = y + "px";
1134
1319
  }
1135
- }
1320
+ }, 500);
1136
1321
  }
1137
1322
  if (e.data?.type === "previewKey" && typeof e.data.key === "string") {
1138
1323
  // Re-dispatch as a real keydown on document so the hotkey handler
@@ -1222,6 +1407,8 @@ onWsEvent((event) => {
1222
1407
  // After sync completes, refresh the folder tree (critical for first-run on Android
1223
1408
  // where folders don't exist until sync fetches them from Gmail API)
1224
1409
  refreshFolderTree();
1410
+ // Q53: track per-account last-sync timestamp for the status-bar hover.
1411
+ recordAccountSync(event.accountId);
1225
1412
  break;
1226
1413
  case "folderSynced":
1227
1414
  // Per-folder timestamps — drives the tooltip + freshness dot.
@@ -1449,8 +1636,15 @@ document.addEventListener("keydown", (e) => {
1449
1636
  mlBody.querySelectorAll(".ml-row").forEach(r => r.classList.add("selected"));
1450
1637
  }
1451
1638
  }
1452
- // Ctrl+D or Delete = Delete selected messages
1639
+ // Ctrl+D or Delete = Delete selected messages.
1640
+ // P15: don't hijack Delete inside text inputs / textareas / contenteditable
1641
+ // — JSONC editor's Delete key was being eaten because we always preventDefault'd.
1453
1642
  if ((e.ctrlKey && e.key === "d") || e.key === "Delete") {
1643
+ const t = e.target;
1644
+ const tag = t?.tagName;
1645
+ const editable = t?.isContentEditable;
1646
+ if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT" || editable)
1647
+ return;
1454
1648
  e.preventDefault();
1455
1649
  deleteSelectedMessages();
1456
1650
  }
@@ -1491,26 +1685,36 @@ document.addEventListener("keydown", (e) => {
1491
1685
  row.classList.toggle("unread", !newFlags.includes("\\Seen"));
1492
1686
  }).catch(() => { });
1493
1687
  }
1494
- // Arrow keys — navigate message list
1495
- if (e.key === "ArrowDown" || e.key === "ArrowUp") {
1688
+ // Arrow keys + Home/End/PgUp/PgDn — navigate message list (Q58).
1689
+ if (["ArrowDown", "ArrowUp", "Home", "End", "PageDown", "PageUp"].includes(e.key)) {
1496
1690
  const active = document.activeElement;
1497
1691
  if (active && (active.tagName === "INPUT" || active.tagName === "TEXTAREA" || active.tagName === "SELECT"))
1498
1692
  return;
1499
1693
  const body = document.getElementById("ml-body");
1500
1694
  if (!body)
1501
1695
  return;
1502
- const selected = body.querySelector(".ml-row.selected");
1503
- if (!selected) {
1504
- const first = body.querySelector(".ml-row");
1505
- if (first)
1506
- first.click();
1696
+ const rows = Array.from(body.querySelectorAll(".ml-row"));
1697
+ if (rows.length === 0)
1507
1698
  return;
1508
- }
1509
- const next = (e.key === "ArrowDown" ? selected.nextElementSibling : selected.previousElementSibling);
1510
- if (next?.classList.contains("ml-row")) {
1699
+ const selected = body.querySelector(".ml-row.selected");
1700
+ const idx = selected ? rows.indexOf(selected) : -1;
1701
+ let target;
1702
+ if (e.key === "ArrowDown")
1703
+ target = rows[idx + 1] || rows[idx];
1704
+ else if (e.key === "ArrowUp")
1705
+ target = rows[Math.max(0, idx - 1)];
1706
+ else if (e.key === "Home")
1707
+ target = rows[0];
1708
+ else if (e.key === "End")
1709
+ target = rows[rows.length - 1];
1710
+ else if (e.key === "PageDown")
1711
+ target = rows[Math.min(rows.length - 1, idx + 10)];
1712
+ else if (e.key === "PageUp")
1713
+ target = rows[Math.max(0, idx - 10)];
1714
+ if (target && (!selected || target !== selected)) {
1511
1715
  e.preventDefault();
1512
- next.click();
1513
- next.scrollIntoView({ block: "nearest" });
1716
+ target.click();
1717
+ target.scrollIntoView({ block: "nearest" });
1514
1718
  }
1515
1719
  }
1516
1720
  });
@@ -1606,6 +1810,32 @@ document.getElementById("btn-edit-jsonc")?.addEventListener("click", async () =>
1606
1810
  settingsDropdown.hidden = true;
1607
1811
  await openJsoncEditor("accounts.jsonc");
1608
1812
  });
1813
+ // Q61: open ~/.mailx in OS file explorer.
1814
+ document.getElementById("btn-open-mailx-dir")?.addEventListener("click", async () => {
1815
+ const settingsDropdown = document.getElementById("settings-dropdown");
1816
+ if (settingsDropdown)
1817
+ settingsDropdown.hidden = true;
1818
+ try {
1819
+ const { openLocalPath } = await import("./lib/api-client.js");
1820
+ await openLocalPath("config");
1821
+ }
1822
+ catch (e) {
1823
+ alert(`Couldn't open folder: ${e?.message || e}`);
1824
+ }
1825
+ });
1826
+ // Q62: open today's log file.
1827
+ document.getElementById("btn-open-log")?.addEventListener("click", async () => {
1828
+ const settingsDropdown = document.getElementById("settings-dropdown");
1829
+ if (settingsDropdown)
1830
+ settingsDropdown.hidden = true;
1831
+ try {
1832
+ const { openLocalPath } = await import("./lib/api-client.js");
1833
+ await openLocalPath("log");
1834
+ }
1835
+ catch (e) {
1836
+ alert(`Couldn't open log: ${e?.message || e}`);
1837
+ }
1838
+ });
1609
1839
  async function openJsoncEditor(initialFile) {
1610
1840
  const { readJsoncFile, writeJsoncFile, readConfigHelp } = await import("./lib/api-client.js");
1611
1841
  const backdrop = document.createElement("div");
@@ -1994,8 +2224,12 @@ async function openAboutDialog() {
1994
2224
  const storage = v.storage || {};
1995
2225
  const isApp = typeof mailxapi !== "undefined" && mailxapi?.isApp;
1996
2226
  const platform = isApp ? (mailxapi?.platform || "app") : "browser";
2227
+ const versionText = v.version ? `v${v.version}` : "unknown";
2228
+ const versionHtml = v.version
2229
+ ? `<a href="https://github.com/BobFrankston/mailx/releases/tag/v${v.version}" target="_blank" rel="noopener">${versionText}</a>`
2230
+ : versionText;
1997
2231
  const rows = [
1998
- ["Version", v.version ? `v${v.version}` : "unknown"],
2232
+ ["Version", versionHtml],
1999
2233
  ["Platform", platform],
2000
2234
  ["Storage", storage.provider || "local"],
2001
2235
  ];
@@ -2007,9 +2241,11 @@ async function openAboutDialog() {
2007
2241
  rows.push(["User agent", navigator.userAgent]);
2008
2242
  rows.push(["Screen", `${screen.width}×${screen.height}`]);
2009
2243
  rows.push(["Window", `${window.innerWidth}×${window.innerHeight}`]);
2244
+ // Version row contains an anchor tag; all other rows are plain text
2245
+ // and must be escaped. Treat row[0]==="Version" as pre-formatted HTML.
2010
2246
  body.innerHTML = `
2011
2247
  <dl class="mailx-about-dl">
2012
- ${rows.map(([k, val]) => `<dt>${k}</dt><dd>${escapeHtml(val)}</dd>`).join("")}
2248
+ ${rows.map(([k, val]) => `<dt>${k}</dt><dd>${k === "Version" ? val : escapeHtml(val)}</dd>`).join("")}
2013
2249
  </dl>
2014
2250
  ${(accounts || []).length ? `
2015
2251
  <div class="mailx-about-accounts">
@@ -2119,7 +2355,15 @@ function persistAi(mutator) {
2119
2355
  }
2120
2356
  optAutocomplete?.addEventListener("change", () => persistAi((ac) => { ac.enabled = optAutocomplete.checked; }));
2121
2357
  optAiTranslate?.addEventListener("change", () => persistAi((ac) => { ac.translateEnabled = optAiTranslate.checked; }));
2122
- optAiProofread?.addEventListener("change", () => persistAi((ac) => { ac.proofreadEnabled = optAiProofread.checked; }));
2358
+ optAiProofread?.addEventListener("change", () => {
2359
+ persistAi((ac) => { ac.proofreadEnabled = optAiProofread.checked; });
2360
+ // Mirror to localStorage so the compose editor (separate page/iframe with
2361
+ // its own getSettings cycle) can read it synchronously.
2362
+ try {
2363
+ localStorage.setItem("mailx-ai-proofread-enabled", String(optAiProofread.checked));
2364
+ }
2365
+ catch { /* */ }
2366
+ });
2123
2367
  const isApp = typeof mailxapi !== "undefined" && mailxapi?.isApp;
2124
2368
  // Wait for server ready signal, then fetch version
2125
2369
  const versionPromise = getVersion();
@@ -2262,6 +2506,89 @@ setInterval(async () => {
2262
2506
  }
2263
2507
  catch { /* */ }
2264
2508
  })();
2509
+ // Q64: pop-out a message into a floating overlay (real-OS-window pending C44).
2510
+ document.addEventListener("mailx-popout-message", (async (e) => {
2511
+ const { accountId, uid, folderId, subject } = e.detail || {};
2512
+ if (!accountId || !uid)
2513
+ return;
2514
+ const { getMessage } = await import("./lib/api-client.js");
2515
+ let msg;
2516
+ try {
2517
+ msg = await getMessage(accountId, uid, false, folderId);
2518
+ }
2519
+ catch (err) {
2520
+ alert(`Couldn't load message: ${err?.message || err}`);
2521
+ return;
2522
+ }
2523
+ const wrapper = document.createElement("div");
2524
+ wrapper.className = "popout-overlay";
2525
+ wrapper.style.cssText = "position:fixed;top:5vh;right:5vw;width:min(900px,60vw);height:min(800px,80vh);z-index:1500;background:var(--color-bg);border:1px solid var(--color-border);border-radius:6px;box-shadow:0 8px 32px rgba(0,0,0,0.3);display:flex;flex-direction:column;resize:both;overflow:hidden;";
2526
+ const header = document.createElement("div");
2527
+ header.style.cssText = "display:flex;align-items:center;gap:8px;padding:8px 12px;background:var(--color-bg-surface);border-bottom:1px solid var(--color-border);font-weight:600;cursor:move;";
2528
+ const title = document.createElement("span");
2529
+ title.textContent = subject || "(no subject)";
2530
+ title.style.cssText = "flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;";
2531
+ const closeBtn = document.createElement("button");
2532
+ closeBtn.textContent = "×";
2533
+ closeBtn.style.cssText = "background:none;border:none;font-size:1.4rem;cursor:pointer;padding:0 8px;";
2534
+ closeBtn.addEventListener("click", () => wrapper.remove());
2535
+ header.appendChild(title);
2536
+ header.appendChild(closeBtn);
2537
+ const meta = document.createElement("div");
2538
+ meta.style.cssText = "padding:8px 12px;border-bottom:1px solid var(--color-border);font-size:0.9rem;color:var(--color-text-muted);";
2539
+ meta.innerHTML = `<div><b>From:</b> ${escapeHtmlBasic(msg.from?.name || "")} &lt;${escapeHtmlBasic(msg.from?.address || "")}&gt;</div>
2540
+ <div><b>To:</b> ${(msg.to || []).map((a) => escapeHtmlBasic(`${a.name || ""} <${a.address}>`)).join(", ")}</div>
2541
+ ${msg.cc?.length ? `<div><b>Cc:</b> ${msg.cc.map((a) => escapeHtmlBasic(`${a.name || ""} <${a.address}>`)).join(", ")}</div>` : ""}
2542
+ <div><b>Date:</b> ${new Date(msg.date).toLocaleString()}</div>`;
2543
+ const body = document.createElement("iframe");
2544
+ body.style.cssText = "flex:1;border:none;width:100%;background:#fff;";
2545
+ body.sandbox.add("allow-same-origin");
2546
+ wrapper.appendChild(header);
2547
+ wrapper.appendChild(meta);
2548
+ wrapper.appendChild(body);
2549
+ document.body.appendChild(wrapper);
2550
+ body.srcdoc = msg.bodyHtml || `<pre style="white-space:pre-wrap;font-family:ui-sans-serif">${escapeHtmlBasic(msg.bodyText || "(no body)")}</pre>`;
2551
+ // Drag-to-move.
2552
+ let dragX = 0, dragY = 0, dragging = false;
2553
+ header.addEventListener("mousedown", (de) => {
2554
+ if (de.target.tagName === "BUTTON")
2555
+ return;
2556
+ dragging = true;
2557
+ const rect = wrapper.getBoundingClientRect();
2558
+ dragX = de.clientX - rect.left;
2559
+ dragY = de.clientY - rect.top;
2560
+ de.preventDefault();
2561
+ });
2562
+ document.addEventListener("mousemove", (de) => {
2563
+ if (!dragging)
2564
+ return;
2565
+ wrapper.style.left = `${de.clientX - dragX}px`;
2566
+ wrapper.style.top = `${de.clientY - dragY}px`;
2567
+ wrapper.style.right = "auto";
2568
+ });
2569
+ document.addEventListener("mouseup", () => { dragging = false; });
2570
+ }));
2571
+ function escapeHtmlBasic(s) {
2572
+ return (s || "").replace(/[&<>"']/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", "\"": "&quot;", "'": "&#39;" }[c]));
2573
+ }
2574
+ // Click the status-queue pill to open the outbox view (pink-row list).
2575
+ document.getElementById("status-queue")?.addEventListener("click", async () => {
2576
+ try {
2577
+ const { openOutboxView } = await import("./components/outbox-view.js");
2578
+ openOutboxView();
2579
+ }
2580
+ catch (e) {
2581
+ console.error("Outbox view failed:", e);
2582
+ }
2583
+ });
2584
+ // Make it look clickable.
2585
+ (() => {
2586
+ const el = document.getElementById("status-queue");
2587
+ if (el) {
2588
+ el.style.cursor = "pointer";
2589
+ el.title = "Click to view queued messages";
2590
+ }
2591
+ })();
2265
2592
  console.log("mailx client initialized, location:", location.href);
2266
2593
  updateNewMessageCount();
2267
2594
  // ── Midnight refresh — update date display when day changes ──