@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 +37 -7
- package/client/app.js +359 -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 +16 -2
- 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 +83 -43
- package/client/compose/editor.js +67 -0
- package/client/index.html +8 -6
- package/client/lib/api-client.js +18 -0
- package/client/lib/mailxapi.js +14 -0
- package/client/styles/components.css +354 -0
- package/package.json +3 -3
- package/packages/mailx-imap/index.js +19 -2
- package/packages/mailx-service/index.d.ts +23 -0
- package/packages/mailx-service/index.js +123 -14
- package/packages/mailx-service/jsonrpc.js +18 -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/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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1291
|
-
|
|
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
|
-
|
|
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.
|
|
@@ -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 (
|
|
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
|
|
1503
|
-
if (
|
|
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
|
|
1510
|
-
|
|
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
|
-
|
|
1513
|
-
|
|
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",
|
|
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", () =>
|
|
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 || "")} <${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
|
+
})();
|
|
2265
2592
|
console.log("mailx client initialized, location:", location.href);
|
|
2266
2593
|
updateNewMessageCount();
|
|
2267
2594
|
// ── Midnight refresh — update date display when day changes ──
|