@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/bin/mailx.js +87 -7
- package/client/app.js +413 -32
- package/client/components/address-book.js +199 -0
- package/client/components/calendar.js +217 -0
- package/client/components/folder-tree.js +62 -16
- package/client/components/message-list.js +9 -0
- package/client/components/message-viewer.js +41 -8
- package/client/components/outbox-view.js +104 -0
- package/client/components/tasks.js +256 -0
- package/client/compose/compose.html +2 -2
- package/client/compose/compose.js +87 -39
- package/client/compose/editor.js +67 -0
- package/client/index.html +8 -6
- package/client/lib/api-client.js +21 -0
- package/client/lib/mailxapi.js +15 -0
- package/client/styles/components.css +354 -0
- package/package.json +3 -3
- package/packages/mailx-imap/index.d.ts +24 -0
- package/packages/mailx-imap/index.js +132 -6
- package/packages/mailx-service/index.d.ts +25 -0
- package/packages/mailx-service/index.js +142 -5
- package/packages/mailx-service/jsonrpc.js +20 -1
- package/packages/mailx-settings/index.js +18 -3
- package/packages/mailx-store/db.d.ts +17 -0
- package/packages/mailx-store/db.js +122 -4
- package/packages/mailx-types/index.d.ts +1 -0
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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,
|
|
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
|
-
|
|
1105
|
-
|
|
1272
|
+
hidePop();
|
|
1273
|
+
return;
|
|
1106
1274
|
}
|
|
1107
|
-
|
|
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
|
-
|
|
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 =
|
|
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 ===
|
|
1306
|
+
if (f.contentWindow === source) {
|
|
1121
1307
|
iframeRect = f.getBoundingClientRect();
|
|
1122
1308
|
break;
|
|
1123
1309
|
}
|
|
1124
1310
|
}
|
|
1125
|
-
const r =
|
|
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 (
|
|
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
|
|
1500
|
-
if (
|
|
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
|
|
1507
|
-
|
|
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
|
-
|
|
1510
|
-
|
|
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",
|
|
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", () =>
|
|
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 || "")} <${escapeHtmlBasic(msg.from?.address || "")}></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 => ({ "&": "&", "<": "<", ">": ">", "\"": """, "'": "'" }[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 ──
|