@bobfrankston/mailx 1.0.339 → 1.0.348

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/client/app.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.
@@ -1335,6 +1522,9 @@ onWsEvent((event) => {
1335
1522
  statusSync.textContent = `Error: ${event.message}`;
1336
1523
  showAlert(event.message, "ws-error");
1337
1524
  break;
1525
+ case "outboxStatus":
1526
+ renderOutboxStatus(event);
1527
+ break;
1338
1528
  case "accountError": {
1339
1529
  // Show actual error + hint in banner
1340
1530
  const msg = `${event.accountId}: ${event.error}`;
@@ -1446,8 +1636,15 @@ document.addEventListener("keydown", (e) => {
1446
1636
  mlBody.querySelectorAll(".ml-row").forEach(r => r.classList.add("selected"));
1447
1637
  }
1448
1638
  }
1449
- // 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.
1450
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;
1451
1648
  e.preventDefault();
1452
1649
  deleteSelectedMessages();
1453
1650
  }
@@ -1488,26 +1685,36 @@ document.addEventListener("keydown", (e) => {
1488
1685
  row.classList.toggle("unread", !newFlags.includes("\\Seen"));
1489
1686
  }).catch(() => { });
1490
1687
  }
1491
- // Arrow keys — navigate message list
1492
- 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)) {
1493
1690
  const active = document.activeElement;
1494
1691
  if (active && (active.tagName === "INPUT" || active.tagName === "TEXTAREA" || active.tagName === "SELECT"))
1495
1692
  return;
1496
1693
  const body = document.getElementById("ml-body");
1497
1694
  if (!body)
1498
1695
  return;
1499
- const selected = body.querySelector(".ml-row.selected");
1500
- if (!selected) {
1501
- const first = body.querySelector(".ml-row");
1502
- if (first)
1503
- first.click();
1696
+ const rows = Array.from(body.querySelectorAll(".ml-row"));
1697
+ if (rows.length === 0)
1504
1698
  return;
1505
- }
1506
- const next = (e.key === "ArrowDown" ? selected.nextElementSibling : selected.previousElementSibling);
1507
- 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)) {
1508
1715
  e.preventDefault();
1509
- next.click();
1510
- next.scrollIntoView({ block: "nearest" });
1716
+ target.click();
1717
+ target.scrollIntoView({ block: "nearest" });
1511
1718
  }
1512
1719
  }
1513
1720
  });
@@ -1603,6 +1810,32 @@ document.getElementById("btn-edit-jsonc")?.addEventListener("click", async () =>
1603
1810
  settingsDropdown.hidden = true;
1604
1811
  await openJsoncEditor("accounts.jsonc");
1605
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
+ });
1606
1839
  async function openJsoncEditor(initialFile) {
1607
1840
  const { readJsoncFile, writeJsoncFile, readConfigHelp } = await import("./lib/api-client.js");
1608
1841
  const backdrop = document.createElement("div");
@@ -1991,8 +2224,12 @@ async function openAboutDialog() {
1991
2224
  const storage = v.storage || {};
1992
2225
  const isApp = typeof mailxapi !== "undefined" && mailxapi?.isApp;
1993
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;
1994
2231
  const rows = [
1995
- ["Version", v.version ? `v${v.version}` : "unknown"],
2232
+ ["Version", versionHtml],
1996
2233
  ["Platform", platform],
1997
2234
  ["Storage", storage.provider || "local"],
1998
2235
  ];
@@ -2004,9 +2241,11 @@ async function openAboutDialog() {
2004
2241
  rows.push(["User agent", navigator.userAgent]);
2005
2242
  rows.push(["Screen", `${screen.width}×${screen.height}`]);
2006
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.
2007
2246
  body.innerHTML = `
2008
2247
  <dl class="mailx-about-dl">
2009
- ${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("")}
2010
2249
  </dl>
2011
2250
  ${(accounts || []).length ? `
2012
2251
  <div class="mailx-about-accounts">
@@ -2116,7 +2355,15 @@ function persistAi(mutator) {
2116
2355
  }
2117
2356
  optAutocomplete?.addEventListener("change", () => persistAi((ac) => { ac.enabled = optAutocomplete.checked; }));
2118
2357
  optAiTranslate?.addEventListener("change", () => persistAi((ac) => { ac.translateEnabled = optAiTranslate.checked; }));
2119
- 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
+ });
2120
2367
  const isApp = typeof mailxapi !== "undefined" && mailxapi?.isApp;
2121
2368
  // Wait for server ready signal, then fetch version
2122
2369
  const versionPromise = getVersion();
@@ -2208,6 +2455,140 @@ else
2208
2455
  }
2209
2456
  }
2210
2457
  }, 5000);
2458
+ // ── Outbox queue indicator (status-queue span) ──
2459
+ // Event-driven in IPC mode (service pushes outboxStatus on every mutation).
2460
+ // Plus a 15s poll safety net for both modes so a missed event doesn't leave
2461
+ // the user staring at stale numbers. Idempotent — renderOutboxStatus just
2462
+ // overwrites the text.
2463
+ function renderOutboxStatus(s) {
2464
+ const el = document.getElementById("status-queue");
2465
+ if (!el)
2466
+ return;
2467
+ if (!s || !s.total || s.total === 0) {
2468
+ el.textContent = "";
2469
+ el.title = "";
2470
+ el.style.color = "";
2471
+ return;
2472
+ }
2473
+ const parts = [`✉ ${s.total} queued`];
2474
+ if (s.claimed > 0)
2475
+ parts.push(`${s.claimed} sending`);
2476
+ if (s.retrying > 0)
2477
+ parts.push(`${s.retrying} retrying (×${s.maxAttempts})`);
2478
+ if (s.oldestAgeSec >= 60) {
2479
+ const age = s.oldestAgeSec >= 3600
2480
+ ? `${Math.floor(s.oldestAgeSec / 3600)}h`
2481
+ : `${Math.floor(s.oldestAgeSec / 60)}m`;
2482
+ parts.push(`oldest ${age}`);
2483
+ }
2484
+ el.textContent = parts.join(" · ");
2485
+ const perAcct = s.perAccount || {};
2486
+ const detail = Object.keys(perAcct).sort().map(a => `${a}: ${perAcct[a].total} total, ${perAcct[a].claimed} sending, ${perAcct[a].retrying} retrying`).join("\n");
2487
+ el.title = detail || "";
2488
+ // Orange when retrying, red when stuck >5min, else muted.
2489
+ el.style.color = s.oldestAgeSec > 300 ? "oklch(0.65 0.2 25)"
2490
+ : s.retrying > 0 ? "oklch(0.75 0.15 60)"
2491
+ : "";
2492
+ }
2493
+ setInterval(async () => {
2494
+ try {
2495
+ const { getOutboxStatus } = await import("./lib/api-client.js");
2496
+ const s = await getOutboxStatus();
2497
+ renderOutboxStatus(s);
2498
+ }
2499
+ catch { /* service unreachable */ }
2500
+ }, 15000);
2501
+ // First read on startup so the bar isn't blank.
2502
+ (async () => {
2503
+ try {
2504
+ const { getOutboxStatus } = await import("./lib/api-client.js");
2505
+ renderOutboxStatus(await getOutboxStatus());
2506
+ }
2507
+ catch { /* */ }
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
+ })();
2211
2592
  console.log("mailx client initialized, location:", location.href);
2212
2593
  updateNewMessageCount();
2213
2594
  // ── Midnight refresh — update date display when day changes ──