@bobfrankston/mailx 1.0.449 → 1.0.451
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 +13 -1
- package/client/components/message-viewer.js +120 -0
- package/client/index.html +1 -0
- package/client/styles/layout.css +20 -0
- package/package.json +1 -1
- package/packages/mailx-imap/index.d.ts +1 -0
- package/packages/mailx-imap/index.js +34 -2
- package/packages/mailx-service/index.d.ts +16 -1
- package/packages/mailx-service/index.js +87 -8
- package/packages/mailx-store-web/android-bootstrap.js +4 -0
- package/packages/mailx-store-web/db.d.ts +10 -0
- package/packages/mailx-store-web/db.js +40 -0
- package/packages/mailx-store-web/main-thread-host.js +4 -0
- package/packages/mailx-store-web/web-jsonrpc.js +8 -0
- package/packages/mailx-store-web/web-service.d.ts +19 -0
- package/packages/mailx-store-web/web-service.js +23 -0
package/client/app.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import { initFolderTree, refreshFolderTree, updateFolderCounts, setFolderSynced, getFolderSynced, setOutboxTotal } from "./components/folder-tree.js";
|
|
6
6
|
import { initMessageList, loadMessages, loadUnifiedInbox, loadSearchResults, reloadCurrentFolder, clearSearchMode, getSelectedMessages, markBodiesCached } from "./components/message-list.js";
|
|
7
|
-
import { showMessage, getCurrentMessage, initViewer } from "./components/message-viewer.js";
|
|
7
|
+
import { showMessage, getCurrentMessage, initViewer, popOutCurrentMessage } from "./components/message-viewer.js";
|
|
8
8
|
import { connectWebSocket, onWsEvent, triggerSync, syncAccount, reauthenticate, getAccounts, getFolders, deleteMessages, undeleteMessage, restartServer, getSyncPending, getVersion, getSettings, saveSettings, getAutocompleteSettings, saveAutocompleteSettings, repairAccounts, updateFlags, markAsSpamMessages, logClientEvent, sendMessage as apiSendMessage } from "./lib/api-client.js";
|
|
9
9
|
import * as messageState from "./lib/message-state.js";
|
|
10
10
|
// ── New message badge (favicon + title) ──
|
|
@@ -409,6 +409,14 @@ document.getElementById("btn-folder-toggle")?.addEventListener("click", () => {
|
|
|
409
409
|
const backToList = (e) => {
|
|
410
410
|
e.preventDefault();
|
|
411
411
|
e.stopPropagation();
|
|
412
|
+
// If user is in full-screen-viewer mode, the first back tap should exit
|
|
413
|
+
// full-screen and return to the normal narrow split (list + active
|
|
414
|
+
// viewer). It shouldn't also deselect — that would yank the user out two
|
|
415
|
+
// levels in one tap.
|
|
416
|
+
if (document.body.classList.contains("viewer-fullscreen")) {
|
|
417
|
+
document.body.classList.remove("viewer-fullscreen");
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
412
420
|
document.getElementById("message-viewer")?.classList.remove("narrow-active");
|
|
413
421
|
document.getElementById("message-list")?.classList.remove("narrow-hidden");
|
|
414
422
|
// Deselect the message so the viewer component clears. Without this, a
|
|
@@ -420,6 +428,10 @@ document.getElementById("btn-back")?.addEventListener("click", backToList);
|
|
|
420
428
|
// Android WebView sometimes drops synthetic clicks after a touchend inside a
|
|
421
429
|
// header bar layered above the iframe — handle touchend explicitly too.
|
|
422
430
|
document.getElementById("btn-back")?.addEventListener("touchend", backToList);
|
|
431
|
+
// Pop-out viewer button — desktop spawns a floating overlay (multiple at
|
|
432
|
+
// once), mobile toggles `body.viewer-fullscreen` for full-screen reading.
|
|
433
|
+
// Threshold and behavior live in popOutCurrentMessage.
|
|
434
|
+
document.getElementById("mv-popout")?.addEventListener("click", () => popOutCurrentMessage());
|
|
423
435
|
// Close folder panel when a folder is selected (narrow mode)
|
|
424
436
|
// Also reset narrow navigation: show message list, hide viewer
|
|
425
437
|
document.getElementById("folder-tree")?.addEventListener("click", (e) => {
|
|
@@ -1168,4 +1168,124 @@ ${csp}
|
|
|
1168
1168
|
</script>
|
|
1169
1169
|
</head><body>${html}</body></html>`;
|
|
1170
1170
|
}
|
|
1171
|
+
/** Open the current message in a separate view: floating draggable overlay
|
|
1172
|
+
* on desktop (multiple at once, like compose), full-screen mode on mobile.
|
|
1173
|
+
* Threshold matches the layout.css responsive breakpoint so the experience
|
|
1174
|
+
* is consistent with other narrow-mode behavior. Snapshot in time — the
|
|
1175
|
+
* pop-out doesn't auto-update if the user clicks another message. */
|
|
1176
|
+
export function popOutCurrentMessage() {
|
|
1177
|
+
if (!currentMessage)
|
|
1178
|
+
return;
|
|
1179
|
+
const isNarrow = window.innerWidth <= 768;
|
|
1180
|
+
if (isNarrow) {
|
|
1181
|
+
document.body.classList.toggle("viewer-fullscreen");
|
|
1182
|
+
return;
|
|
1183
|
+
}
|
|
1184
|
+
spawnDesktopPopout(currentMessage, currentAccountId);
|
|
1185
|
+
}
|
|
1186
|
+
/** Build a floating overlay carrying a snapshot of the message: header
|
|
1187
|
+
* (subject, from, to, date) + sandboxed body iframe + attachment chips.
|
|
1188
|
+
* Reuses the compose-overlay drag/resize/close pattern. Independent of the
|
|
1189
|
+
* main viewer — opening pop-out for message A then switching the main pane
|
|
1190
|
+
* to message B leaves A visible in its overlay. */
|
|
1191
|
+
function spawnDesktopPopout(msg, accountId) {
|
|
1192
|
+
const wrapper = document.createElement("div");
|
|
1193
|
+
wrapper.className = "compose-overlay viewer-popout";
|
|
1194
|
+
wrapper.style.cssText = "position:fixed;top:60px;right:20px;width:min(720px,55vw);height:min(800px,80vh);z-index:1000;border:1px solid var(--color-border, #ccc);border-radius:8px;box-shadow:0 8px 32px rgba(0,0,0,0.3);display:flex;flex-direction:column;background:var(--color-bg, #fff);resize:both;overflow:hidden;";
|
|
1195
|
+
const titleBar = document.createElement("div");
|
|
1196
|
+
titleBar.style.cssText = "display:flex;align-items:center;justify-content:space-between;padding:6px 10px;background:var(--color-bg-alt, #e8ecf0);color:var(--color-text, #000);border-radius:8px 8px 0 0;cursor:move;user-select:none;flex-shrink:0;font-size:13px;";
|
|
1197
|
+
const titleText = document.createElement("span");
|
|
1198
|
+
titleText.style.cssText = "overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;font-weight:600;";
|
|
1199
|
+
titleText.textContent = msg.subject || "(no subject)";
|
|
1200
|
+
titleBar.appendChild(titleText);
|
|
1201
|
+
const closeBtn = document.createElement("button");
|
|
1202
|
+
closeBtn.textContent = "✕";
|
|
1203
|
+
closeBtn.title = "Close pop-out";
|
|
1204
|
+
closeBtn.style.cssText = "background:none;border:none;font-size:16px;cursor:pointer;color:#666;padding:2px 8px;border-radius:4px;flex-shrink:0;";
|
|
1205
|
+
closeBtn.addEventListener("mouseenter", () => closeBtn.style.color = "#c00");
|
|
1206
|
+
closeBtn.addEventListener("mouseleave", () => closeBtn.style.color = "#666");
|
|
1207
|
+
closeBtn.addEventListener("click", () => wrapper.remove());
|
|
1208
|
+
titleBar.appendChild(closeBtn);
|
|
1209
|
+
const headerInfo = document.createElement("div");
|
|
1210
|
+
headerInfo.style.cssText = "padding:8px 12px;border-bottom:1px solid var(--color-border, #ddd);font-size:13px;line-height:1.4;flex-shrink:0;";
|
|
1211
|
+
const formatAddrLocal = (a) => a.name ? `${a.name} <${a.address}>` : a.address;
|
|
1212
|
+
const fromStr = formatAddrLocal(msg.from || { address: "" });
|
|
1213
|
+
const toStr = (msg.to || []).map(formatAddrLocal).join(", ");
|
|
1214
|
+
const ccStr = msg.cc?.length ? ` Cc: ${msg.cc.map(formatAddrLocal).join(", ")}` : "";
|
|
1215
|
+
const dateStr = msg.date ? new Date(msg.date).toLocaleString() : "";
|
|
1216
|
+
headerInfo.innerHTML =
|
|
1217
|
+
`<div><strong>${escapeHtmlLocal(fromStr)}</strong></div>` +
|
|
1218
|
+
`<div style="color:var(--color-text-muted, #666)">To: ${escapeHtmlLocal(toStr)}${escapeHtmlLocal(ccStr)}</div>` +
|
|
1219
|
+
`<div style="color:var(--color-text-muted, #666);font-size:12px">${escapeHtmlLocal(dateStr)}</div>`;
|
|
1220
|
+
const bodyContainer = document.createElement("div");
|
|
1221
|
+
bodyContainer.style.cssText = "flex:1;overflow:hidden;display:flex;";
|
|
1222
|
+
if (msg.bodyHtml) {
|
|
1223
|
+
const iframe = document.createElement("iframe");
|
|
1224
|
+
iframe.sandbox.add("allow-same-origin");
|
|
1225
|
+
iframe.sandbox.add("allow-popups");
|
|
1226
|
+
iframe.sandbox.add("allow-popups-to-escape-sandbox");
|
|
1227
|
+
iframe.sandbox.add("allow-top-navigation-by-user-activation");
|
|
1228
|
+
iframe.sandbox.add("allow-scripts");
|
|
1229
|
+
iframe.srcdoc = wrapHtmlBody(msg.bodyHtml, msg.remoteAllowed);
|
|
1230
|
+
iframe.style.cssText = "flex:1;border:none;width:100%;background:#fff;";
|
|
1231
|
+
bodyContainer.appendChild(iframe);
|
|
1232
|
+
}
|
|
1233
|
+
else {
|
|
1234
|
+
const pre = document.createElement("pre");
|
|
1235
|
+
pre.style.cssText = "padding:12px;white-space:pre-wrap;word-break:break-word;margin:0;flex:1;overflow:auto;";
|
|
1236
|
+
pre.textContent = msg.bodyText || "(no content)";
|
|
1237
|
+
bodyContainer.appendChild(pre);
|
|
1238
|
+
}
|
|
1239
|
+
// Drag — same pattern as compose-overlay: pointer-events:none on the
|
|
1240
|
+
// iframe so cursor crossing into it doesn't lose drag events.
|
|
1241
|
+
let dragX = 0, dragY = 0;
|
|
1242
|
+
titleBar.addEventListener("mousedown", (e) => {
|
|
1243
|
+
if (e.target === closeBtn)
|
|
1244
|
+
return;
|
|
1245
|
+
e.preventDefault();
|
|
1246
|
+
const rect = wrapper.getBoundingClientRect();
|
|
1247
|
+
dragX = e.clientX - rect.left;
|
|
1248
|
+
dragY = e.clientY - rect.top;
|
|
1249
|
+
const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
|
|
1250
|
+
bodyContainer.style.pointerEvents = "none";
|
|
1251
|
+
document.body.style.userSelect = "none";
|
|
1252
|
+
const onMove = (ev) => {
|
|
1253
|
+
ev.preventDefault();
|
|
1254
|
+
const left = clamp(ev.clientX - dragX, 0, window.innerWidth - 40);
|
|
1255
|
+
const top = clamp(ev.clientY - dragY, 0, window.innerHeight - 40);
|
|
1256
|
+
wrapper.style.left = `${left}px`;
|
|
1257
|
+
wrapper.style.top = `${top}px`;
|
|
1258
|
+
wrapper.style.right = "auto";
|
|
1259
|
+
wrapper.style.bottom = "auto";
|
|
1260
|
+
};
|
|
1261
|
+
const onUp = () => {
|
|
1262
|
+
bodyContainer.style.pointerEvents = "";
|
|
1263
|
+
document.body.style.userSelect = "";
|
|
1264
|
+
document.removeEventListener("mousemove", onMove);
|
|
1265
|
+
document.removeEventListener("mouseup", onUp);
|
|
1266
|
+
};
|
|
1267
|
+
document.addEventListener("mousemove", onMove);
|
|
1268
|
+
document.addEventListener("mouseup", onUp);
|
|
1269
|
+
});
|
|
1270
|
+
// Bring to front on click — shared with compose-overlay so they all
|
|
1271
|
+
// restack uniformly.
|
|
1272
|
+
wrapper.addEventListener("mousedown", () => {
|
|
1273
|
+
document.querySelectorAll(".compose-overlay").forEach(el => el.style.zIndex = "1000");
|
|
1274
|
+
wrapper.style.zIndex = "1001";
|
|
1275
|
+
});
|
|
1276
|
+
// Cascade pop-outs so they don't all stack at the same coords.
|
|
1277
|
+
const existing = document.querySelectorAll(".viewer-popout").length;
|
|
1278
|
+
if (existing > 0) {
|
|
1279
|
+
wrapper.style.top = `${60 + existing * 28}px`;
|
|
1280
|
+
wrapper.style.right = `${20 + existing * 28}px`;
|
|
1281
|
+
}
|
|
1282
|
+
void accountId; // accountId reserved for future per-account actions on the popout
|
|
1283
|
+
wrapper.appendChild(titleBar);
|
|
1284
|
+
wrapper.appendChild(headerInfo);
|
|
1285
|
+
wrapper.appendChild(bodyContainer);
|
|
1286
|
+
document.body.appendChild(wrapper);
|
|
1287
|
+
}
|
|
1288
|
+
function escapeHtmlLocal(s) {
|
|
1289
|
+
return (s || "").replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
1290
|
+
}
|
|
1171
1291
|
//# sourceMappingURL=message-viewer.js.map
|
package/client/index.html
CHANGED
|
@@ -161,6 +161,7 @@
|
|
|
161
161
|
<button class="mv-action mv-action-primary" id="mv-edit-draft" hidden>Edit & Send</button>
|
|
162
162
|
<a class="mv-unsubscribe" id="mv-unsubscribe" hidden>Unsubscribe</a>
|
|
163
163
|
<button class="mv-action" id="mv-view-source" title="View source (.eml)" hidden>Source</button>
|
|
164
|
+
<button class="mv-action" id="mv-popout" title="Pop out (desktop) / Full-screen (mobile)">⤢</button>
|
|
164
165
|
<button class="mv-action" id="mv-toggle-details" title="Show/hide extra headers">Details</button>
|
|
165
166
|
</div>
|
|
166
167
|
<div class="mv-header-info">
|
package/client/styles/layout.css
CHANGED
|
@@ -127,6 +127,26 @@ body.calendar-sidebar-on {
|
|
|
127
127
|
.main-area.no-preview .message-viewer { display: none; }
|
|
128
128
|
.main-area.no-preview .message-list { border-right: none; }
|
|
129
129
|
|
|
130
|
+
/* Full-screen viewer mode (mobile/Android) — viewer takes the whole app
|
|
131
|
+
surface; folder rail, folder tree, message list and statusbar are hidden.
|
|
132
|
+
Toggle via `body.viewer-fullscreen` from the pop-out / full-screen button.
|
|
133
|
+
On desktop the same button instead spawns a floating overlay (handled in
|
|
134
|
+
message-viewer.ts) and never sets this class. */
|
|
135
|
+
body.viewer-fullscreen .folder-rail,
|
|
136
|
+
body.viewer-fullscreen .folder-tree,
|
|
137
|
+
body.viewer-fullscreen .message-list,
|
|
138
|
+
body.viewer-fullscreen .splitter,
|
|
139
|
+
body.viewer-fullscreen .toolbar,
|
|
140
|
+
body.viewer-fullscreen .statusbar { display: none !important; }
|
|
141
|
+
body.viewer-fullscreen .main-area {
|
|
142
|
+
grid-template-columns: 1fr !important;
|
|
143
|
+
grid-template-rows: 1fr !important;
|
|
144
|
+
}
|
|
145
|
+
body.viewer-fullscreen .message-viewer {
|
|
146
|
+
display: flex !important;
|
|
147
|
+
grid-column: 1 / -1 !important;
|
|
148
|
+
}
|
|
149
|
+
|
|
130
150
|
/* Splitter */
|
|
131
151
|
.splitter {
|
|
132
152
|
background: var(--color-border);
|
package/package.json
CHANGED
|
@@ -141,6 +141,7 @@ export declare class ImapManager extends EventEmitter {
|
|
|
141
141
|
* preempt a command mid-flight. */
|
|
142
142
|
withConnection<T>(accountId: string, fn: (client: any) => Promise<T>, opts?: {
|
|
143
143
|
slow?: boolean;
|
|
144
|
+
timeoutMs?: number;
|
|
144
145
|
}): Promise<T>;
|
|
145
146
|
/** Run the next queued task. Fast lane drains before slow.
|
|
146
147
|
* Idempotent — safe to call after each task completes; the running
|
|
@@ -383,15 +383,32 @@ export class ImapManager extends EventEmitter {
|
|
|
383
383
|
queue = { fast: [], slow: [], running: false };
|
|
384
384
|
this.opsQueues.set(accountId, queue);
|
|
385
385
|
}
|
|
386
|
+
// Per-task wall-clock cap. Without one, a wedged IMAP command (TCP
|
|
387
|
+
// half-open, server stalled mid-FETCH) keeps the queue's running flag
|
|
388
|
+
// set forever and every subsequent fast-lane task — including the
|
|
389
|
+
// retry button the user just hit — waits behind it. Default is
|
|
390
|
+
// generous; callers driving user-visible reads pass a tighter value.
|
|
391
|
+
const timeoutMs = opts.timeoutMs ?? 90_000;
|
|
386
392
|
return new Promise((resolve, reject) => {
|
|
387
393
|
const task = async () => {
|
|
394
|
+
let timer;
|
|
388
395
|
try {
|
|
389
396
|
const client = await this.getOpsClient(accountId);
|
|
390
|
-
|
|
397
|
+
const result = await Promise.race([
|
|
398
|
+
fn(client),
|
|
399
|
+
new Promise((_, rej) => {
|
|
400
|
+
timer = setTimeout(() => rej(new Error(`ops timeout after ${Math.round(timeoutMs / 1000)}s — discarding client`)), timeoutMs);
|
|
401
|
+
}),
|
|
402
|
+
]);
|
|
403
|
+
clearTimeout(timer);
|
|
404
|
+
resolve(result);
|
|
391
405
|
}
|
|
392
406
|
catch (e) {
|
|
407
|
+
clearTimeout(timer);
|
|
393
408
|
// Discard client on any error — keeping a half-broken
|
|
394
|
-
// socket poisoned every subsequent request.
|
|
409
|
+
// socket poisoned every subsequent request. Destroy
|
|
410
|
+
// synchronously kills the in-flight command's socket so
|
|
411
|
+
// the underlying promise rejects and stops holding state.
|
|
395
412
|
const stale = this.opsClients.get(accountId);
|
|
396
413
|
this.opsClients.delete(accountId);
|
|
397
414
|
if (stale) {
|
|
@@ -975,6 +992,14 @@ export class ImapManager extends EventEmitter {
|
|
|
975
992
|
this.db.updateMessageFlags(accountId, msg.uid, flags);
|
|
976
993
|
continue;
|
|
977
994
|
}
|
|
995
|
+
// Tombstone check — same reason as the streamy onChunk path
|
|
996
|
+
// at storeMessages: a locally-deleted message that the server
|
|
997
|
+
// hasn't EXPUNGEd yet would otherwise reappear on next sync.
|
|
998
|
+
// User-visible symptom: "I deleted it but it came back."
|
|
999
|
+
if (msg.messageId && this.db.hasTombstone(accountId, msg.messageId)) {
|
|
1000
|
+
console.log(` [tombstone] ${accountId}: skipping ${msg.messageId} in syncFolder (locally deleted)`);
|
|
1001
|
+
continue;
|
|
1002
|
+
}
|
|
978
1003
|
// Store body
|
|
979
1004
|
const source = msg.source || "";
|
|
980
1005
|
let bodyPath = "";
|
|
@@ -1462,6 +1487,13 @@ export class ImapManager extends EventEmitter {
|
|
|
1462
1487
|
// standalone — bad rows are logged and skipped.
|
|
1463
1488
|
for (const msg of msgs) {
|
|
1464
1489
|
try {
|
|
1490
|
+
// Tombstone check — Gmail API sync was missing this so a
|
|
1491
|
+
// locally-trashed Gmail message would reappear on the next
|
|
1492
|
+
// listMessages tick if Gmail's eventual-consistency hadn't
|
|
1493
|
+
// promoted the trash yet. Symmetric with the IMAP paths.
|
|
1494
|
+
if (msg.messageId && this.db.hasTombstone(accountId, msg.messageId)) {
|
|
1495
|
+
continue;
|
|
1496
|
+
}
|
|
1465
1497
|
const flags = [];
|
|
1466
1498
|
if (msg.seen)
|
|
1467
1499
|
flags.push("\\Seen");
|
|
@@ -320,8 +320,23 @@ export declare class MailxService {
|
|
|
320
320
|
* Matches a level-2 heading whose text equals the filename. Returns markdown. */
|
|
321
321
|
readConfigHelp(name: string): Promise<string>;
|
|
322
322
|
/** Write a JSONC config file. Validates that the content parses as JSONC
|
|
323
|
-
* (loosely — strips comments/trailing commas) before writing.
|
|
323
|
+
* (loosely — strips comments/trailing commas) before writing.
|
|
324
|
+
* Saves the prior content to a dated backup file first — manual edits
|
|
325
|
+
* occasionally have typos that survive validation (semantically wrong
|
|
326
|
+
* but syntactically OK), and a one-key undo isn't enough; the user
|
|
327
|
+
* asked to be able to recover yesterday's accounts.jsonc. Automatic
|
|
328
|
+
* saveAccounts/saveAllowlist paths skip backups (they're driven by
|
|
329
|
+
* trusted code, not the JSONC editor). */
|
|
324
330
|
writeJsoncFile(name: string, content: string): Promise<void>;
|
|
331
|
+
/** Read the current content of a config file (cloud or local) so it can
|
|
332
|
+
* be saved as a backup before being overwritten. Returns null if the
|
|
333
|
+
* file doesn't exist yet (first save — nothing to back up). */
|
|
334
|
+
private readJsoncForBackup;
|
|
335
|
+
/** Write the prior content to `<configDir>/backup/<name>.<ts>.bak` and
|
|
336
|
+
* prune so at most 10 backups per file remain AND none are older than 7
|
|
337
|
+
* days. Skipped when previous content is null (first write) or
|
|
338
|
+
* identical to the new content (no-op save). */
|
|
339
|
+
private backupJsoncIfChanged;
|
|
325
340
|
getSettings(): any;
|
|
326
341
|
saveSettings(settings: any): void;
|
|
327
342
|
getStorageInfo(): {
|
|
@@ -314,17 +314,16 @@ export class MailxService {
|
|
|
314
314
|
let bodyText = "";
|
|
315
315
|
let hasRemoteContent = false;
|
|
316
316
|
let attachments = [];
|
|
317
|
-
//
|
|
318
|
-
//
|
|
319
|
-
//
|
|
320
|
-
//
|
|
321
|
-
|
|
322
|
-
const BODY_FETCH_TIMEOUT_MS = 45_000;
|
|
317
|
+
// The per-account ops queue inside ImapManager has its own per-task
|
|
318
|
+
// timeout that destroys a wedged client and unblocks the queue. This
|
|
319
|
+
// outer race is a safety net only — the underlying timeout in
|
|
320
|
+
// withConnection should trigger first.
|
|
321
|
+
const BODY_FETCH_TIMEOUT_MS = 60_000;
|
|
323
322
|
let raw = null;
|
|
324
323
|
try {
|
|
325
324
|
raw = await Promise.race([
|
|
326
325
|
this.imapManager.fetchMessageBody(accountId, envelope.folderId, envelope.uid),
|
|
327
|
-
new Promise((_, reject) => setTimeout(() => reject(new Error("body fetch
|
|
326
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error("body fetch timed out — try again")), BODY_FETCH_TIMEOUT_MS)),
|
|
328
327
|
]);
|
|
329
328
|
}
|
|
330
329
|
catch (fetchErr) {
|
|
@@ -2093,7 +2092,13 @@ export class MailxService {
|
|
|
2093
2092
|
return out.join("\n").trim();
|
|
2094
2093
|
}
|
|
2095
2094
|
/** Write a JSONC config file. Validates that the content parses as JSONC
|
|
2096
|
-
* (loosely — strips comments/trailing commas) before writing.
|
|
2095
|
+
* (loosely — strips comments/trailing commas) before writing.
|
|
2096
|
+
* Saves the prior content to a dated backup file first — manual edits
|
|
2097
|
+
* occasionally have typos that survive validation (semantically wrong
|
|
2098
|
+
* but syntactically OK), and a one-key undo isn't enough; the user
|
|
2099
|
+
* asked to be able to recover yesterday's accounts.jsonc. Automatic
|
|
2100
|
+
* saveAccounts/saveAllowlist paths skip backups (they're driven by
|
|
2101
|
+
* trusted code, not the JSONC editor). */
|
|
2097
2102
|
async writeJsoncFile(name, content) {
|
|
2098
2103
|
const WHITELIST = ["accounts.jsonc", "allowlist.jsonc", "clients.jsonc", "config.jsonc", "contacts.jsonc"];
|
|
2099
2104
|
if (!WHITELIST.includes(name))
|
|
@@ -2105,6 +2110,8 @@ export class MailxService {
|
|
|
2105
2110
|
if (errors.length) {
|
|
2106
2111
|
throw new Error(`JSONC parse error: ${errors.map(e => e.error).join(", ")}`);
|
|
2107
2112
|
}
|
|
2113
|
+
const previous = await this.readJsoncForBackup(name);
|
|
2114
|
+
await this.backupJsoncIfChanged(name, previous, content);
|
|
2108
2115
|
if (name === "config.jsonc") {
|
|
2109
2116
|
const configPath = path.join(getConfigDir(), "config.jsonc");
|
|
2110
2117
|
fs.writeFileSync(configPath, content);
|
|
@@ -2113,6 +2120,78 @@ export class MailxService {
|
|
|
2113
2120
|
const { cloudWrite } = await import("@bobfrankston/mailx-settings");
|
|
2114
2121
|
await cloudWrite(name, content); // throws on failure with descriptive error
|
|
2115
2122
|
}
|
|
2123
|
+
/** Read the current content of a config file (cloud or local) so it can
|
|
2124
|
+
* be saved as a backup before being overwritten. Returns null if the
|
|
2125
|
+
* file doesn't exist yet (first save — nothing to back up). */
|
|
2126
|
+
async readJsoncForBackup(name) {
|
|
2127
|
+
if (name === "config.jsonc") {
|
|
2128
|
+
const configPath = path.join(getConfigDir(), "config.jsonc");
|
|
2129
|
+
try {
|
|
2130
|
+
return fs.readFileSync(configPath, "utf-8");
|
|
2131
|
+
}
|
|
2132
|
+
catch {
|
|
2133
|
+
return null;
|
|
2134
|
+
}
|
|
2135
|
+
}
|
|
2136
|
+
try {
|
|
2137
|
+
const { cloudRead } = await import("@bobfrankston/mailx-settings");
|
|
2138
|
+
return await cloudRead(name);
|
|
2139
|
+
}
|
|
2140
|
+
catch {
|
|
2141
|
+
return null;
|
|
2142
|
+
}
|
|
2143
|
+
}
|
|
2144
|
+
/** Write the prior content to `<configDir>/backup/<name>.<ts>.bak` and
|
|
2145
|
+
* prune so at most 10 backups per file remain AND none are older than 7
|
|
2146
|
+
* days. Skipped when previous content is null (first write) or
|
|
2147
|
+
* identical to the new content (no-op save). */
|
|
2148
|
+
async backupJsoncIfChanged(name, previous, next) {
|
|
2149
|
+
if (previous == null || previous === next)
|
|
2150
|
+
return;
|
|
2151
|
+
const backupDir = path.join(getConfigDir(), "backup");
|
|
2152
|
+
try {
|
|
2153
|
+
fs.mkdirSync(backupDir, { recursive: true });
|
|
2154
|
+
}
|
|
2155
|
+
catch { /* */ }
|
|
2156
|
+
// Filename-safe ISO timestamp (colons become hyphens on Windows).
|
|
2157
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
2158
|
+
const backupPath = path.join(backupDir, `${name}.${stamp}.bak`);
|
|
2159
|
+
try {
|
|
2160
|
+
fs.writeFileSync(backupPath, previous);
|
|
2161
|
+
}
|
|
2162
|
+
catch (e) {
|
|
2163
|
+
console.error(`[backup] failed to write ${backupPath}: ${e.message}`);
|
|
2164
|
+
return; // don't block the save just because backup failed
|
|
2165
|
+
}
|
|
2166
|
+
// Prune: keep at most 10 most-recent for this filename, drop anything
|
|
2167
|
+
// older than 7 days. Whichever cuts more wins.
|
|
2168
|
+
const MAX_KEEP = 10;
|
|
2169
|
+
const MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
|
|
2170
|
+
const now = Date.now();
|
|
2171
|
+
let entries;
|
|
2172
|
+
try {
|
|
2173
|
+
entries = fs.readdirSync(backupDir)
|
|
2174
|
+
.filter(f => f.startsWith(`${name}.`) && f.endsWith(".bak"))
|
|
2175
|
+
.map(f => {
|
|
2176
|
+
const p = path.join(backupDir, f);
|
|
2177
|
+
return { path: p, mtime: fs.statSync(p).mtimeMs };
|
|
2178
|
+
})
|
|
2179
|
+
.sort((a, b) => b.mtime - a.mtime); // newest first
|
|
2180
|
+
}
|
|
2181
|
+
catch {
|
|
2182
|
+
return;
|
|
2183
|
+
}
|
|
2184
|
+
for (let i = 0; i < entries.length; i++) {
|
|
2185
|
+
const tooOld = now - entries[i].mtime > MAX_AGE_MS;
|
|
2186
|
+
const tooMany = i >= MAX_KEEP;
|
|
2187
|
+
if (tooOld || tooMany) {
|
|
2188
|
+
try {
|
|
2189
|
+
fs.unlinkSync(entries[i].path);
|
|
2190
|
+
}
|
|
2191
|
+
catch { /* */ }
|
|
2192
|
+
}
|
|
2193
|
+
}
|
|
2194
|
+
}
|
|
2116
2195
|
// ── Settings ──
|
|
2117
2196
|
getSettings() {
|
|
2118
2197
|
return loadSettings();
|
|
@@ -1131,6 +1131,10 @@ function installBridge() {
|
|
|
1131
1131
|
},
|
|
1132
1132
|
searchMessages: (query, page, pageSize) => service.search(query, page, pageSize),
|
|
1133
1133
|
searchContacts: (query) => service.searchContacts(query),
|
|
1134
|
+
listContacts: (query, page = 1, pageSize = 100) => service.listContacts(query || "", page, pageSize),
|
|
1135
|
+
upsertContact: (name, email) => service.upsertContact(name || "", email),
|
|
1136
|
+
deleteContact: (email) => service.deleteContact(email),
|
|
1137
|
+
addContact: (name, email) => service.addContact(name || "", email),
|
|
1134
1138
|
hasCcHistoryTo: (email) => ({ hasCc: service.hasCcHistoryTo?.(email) ?? false }),
|
|
1135
1139
|
syncAll: async () => { await service.syncAll(); return { ok: true }; },
|
|
1136
1140
|
syncAccount: async (accountId) => { await service.syncAccount(accountId); return { ok: true }; },
|
|
@@ -94,6 +94,16 @@ export declare class WebMailxDB {
|
|
|
94
94
|
source: string;
|
|
95
95
|
useCount: number;
|
|
96
96
|
}[];
|
|
97
|
+
/** Address-book listing. Same shape as mailx-store/db.ts:listContacts so
|
|
98
|
+
* the address-book modal renders identically on desktop and Android. */
|
|
99
|
+
listContacts(query: string, page?: number, pageSize?: number): {
|
|
100
|
+
items: any[];
|
|
101
|
+
total: number;
|
|
102
|
+
page: number;
|
|
103
|
+
pageSize: number;
|
|
104
|
+
};
|
|
105
|
+
upsertContact(name: string, email: string): void;
|
|
106
|
+
deleteContactLocal(email: string): void;
|
|
97
107
|
/** Q49 heuristic: has the user ever sent to `recipientEmail` with a
|
|
98
108
|
* non-empty Cc? Scans Sent folder(s). Used by compose to auto-expand
|
|
99
109
|
* the Cc row on reply to a frequent-Cc'd recipient. */
|
|
@@ -491,6 +491,46 @@ export class WebMailxDB {
|
|
|
491
491
|
WHERE email LIKE ? OR name LIKE ?
|
|
492
492
|
ORDER BY use_count DESC, last_used DESC LIMIT ?`, [q, q, limit]);
|
|
493
493
|
}
|
|
494
|
+
/** Address-book listing. Same shape as mailx-store/db.ts:listContacts so
|
|
495
|
+
* the address-book modal renders identically on desktop and Android. */
|
|
496
|
+
listContacts(query, page = 1, pageSize = 100) {
|
|
497
|
+
query = (query || "").trim();
|
|
498
|
+
const hasQuery = !!query;
|
|
499
|
+
const q = `%${query}%`;
|
|
500
|
+
const whereClause = hasQuery ? "WHERE email LIKE ? OR name LIKE ?" : "";
|
|
501
|
+
const params = hasQuery ? [q, q] : [];
|
|
502
|
+
const totalRow = this.get(`SELECT COUNT(*) as c FROM contacts ${whereClause}`, params);
|
|
503
|
+
const offset = (page - 1) * pageSize;
|
|
504
|
+
const rows = this.all(`SELECT name, email, source, google_id, use_count, last_used FROM contacts
|
|
505
|
+
${whereClause}
|
|
506
|
+
ORDER BY use_count DESC, last_used DESC
|
|
507
|
+
LIMIT ? OFFSET ?`, [...params, pageSize, offset]);
|
|
508
|
+
return {
|
|
509
|
+
items: rows.map((r) => ({
|
|
510
|
+
name: r.name, email: r.email, source: r.source,
|
|
511
|
+
googleId: r.google_id || null,
|
|
512
|
+
useCount: r.use_count, lastUsed: r.last_used,
|
|
513
|
+
})),
|
|
514
|
+
total: totalRow?.c || 0,
|
|
515
|
+
page, pageSize,
|
|
516
|
+
};
|
|
517
|
+
}
|
|
518
|
+
upsertContact(name, email) {
|
|
519
|
+
if (!email || !/^[^\s<>@]+@[^\s<>@]+\.[^\s<>@]+$/.test(email)) {
|
|
520
|
+
throw new Error(`Invalid email: ${email}`);
|
|
521
|
+
}
|
|
522
|
+
const now = Date.now();
|
|
523
|
+
const existing = this.get("SELECT id FROM contacts WHERE email = ?", [email]);
|
|
524
|
+
if (existing) {
|
|
525
|
+
this.run("UPDATE contacts SET name = ?, updated_at = ? WHERE email = ?", [name || "", now, email]);
|
|
526
|
+
}
|
|
527
|
+
else {
|
|
528
|
+
this.run("INSERT INTO contacts (source, name, email, last_used, use_count, updated_at) VALUES ('manual', ?, ?, 0, 0, ?)", [name || "", email, now]);
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
deleteContactLocal(email) {
|
|
532
|
+
this.run("DELETE FROM contacts WHERE email = ?", [email]);
|
|
533
|
+
}
|
|
494
534
|
/** Q49 heuristic: has the user ever sent to `recipientEmail` with a
|
|
495
535
|
* non-empty Cc? Scans Sent folder(s). Used by compose to auto-expand
|
|
496
536
|
* the Cc row on reply to a frequent-Cc'd recipient. */
|
|
@@ -234,6 +234,10 @@ function installBridge() {
|
|
|
234
234
|
reauthenticate: (accountId) => sendRpc("reauthenticate", { accountId }),
|
|
235
235
|
searchMessages: (query, page, pageSize) => sendRpc("searchMessages", { query, page, pageSize }),
|
|
236
236
|
searchContacts: (query) => sendRpc("searchContacts", { query }),
|
|
237
|
+
listContacts: (query, page = 1, pageSize = 100) => sendRpc("listContacts", { query, page, pageSize }),
|
|
238
|
+
upsertContact: (name, email) => sendRpc("upsertContact", { name, email }),
|
|
239
|
+
deleteContact: (email) => sendRpc("deleteContact", { email }),
|
|
240
|
+
addContact: (name, email) => sendRpc("addContact", { name, email }),
|
|
237
241
|
getSettings: () => sendRpc("getSettings"),
|
|
238
242
|
saveSettings: (data) => sendRpc("saveSettingsData", data),
|
|
239
243
|
allowRemoteContent: (type, value) => sendRpc("allowRemoteContent", { type, value }),
|
|
@@ -69,6 +69,14 @@ async function dispatchAction(svc, action, p) {
|
|
|
69
69
|
return svc.search(p.query, p.page, p.pageSize, p.scope, p.accountId, p.folderId);
|
|
70
70
|
case "searchContacts":
|
|
71
71
|
return svc.searchContacts(p.query);
|
|
72
|
+
case "listContacts":
|
|
73
|
+
return svc.listContacts(p.query || "", p.page || 1, p.pageSize || 100);
|
|
74
|
+
case "upsertContact":
|
|
75
|
+
return svc.upsertContact(p.name || "", p.email);
|
|
76
|
+
case "deleteContact":
|
|
77
|
+
return svc.deleteContact(p.email);
|
|
78
|
+
case "addContact":
|
|
79
|
+
return svc.addContact(p.name || "", p.email);
|
|
72
80
|
case "getSettings":
|
|
73
81
|
return svc.getSettings();
|
|
74
82
|
case "saveSettingsData":
|
|
@@ -71,6 +71,25 @@ export declare class WebMailxService {
|
|
|
71
71
|
}>;
|
|
72
72
|
deleteDraft(accountId: string, draftUid: number): Promise<void>;
|
|
73
73
|
searchContacts(query: string): any[];
|
|
74
|
+
/** Address-book listing — paginated, filterable. Mirrors mailx-service's
|
|
75
|
+
* signature so the same client-side address-book modal works on Android
|
|
76
|
+
* without an "ipc(...).listContacts is not a function" crash. */
|
|
77
|
+
listContacts(query: string, page?: number, pageSize?: number): {
|
|
78
|
+
items: any[];
|
|
79
|
+
total: number;
|
|
80
|
+
page: number;
|
|
81
|
+
pageSize: number;
|
|
82
|
+
};
|
|
83
|
+
/** Manual upsert from the address-book UI. The desktop path queues a
|
|
84
|
+
* Google People sync; Android relies on the desktop pushing changes
|
|
85
|
+
* back, so this is local-only for now. */
|
|
86
|
+
upsertContact(name: string, email: string): {
|
|
87
|
+
ok: true;
|
|
88
|
+
};
|
|
89
|
+
deleteContact(email: string): {
|
|
90
|
+
ok: true;
|
|
91
|
+
};
|
|
92
|
+
addContact(name: string, email: string): boolean;
|
|
74
93
|
/** Q49 heuristic mirror: true if the user has ever sent a message to
|
|
75
94
|
* `recipientEmail` that had a non-empty Cc field. Compose uses this to
|
|
76
95
|
* decide whether to auto-expand the Cc row on reply. */
|
|
@@ -478,6 +478,29 @@ export class WebMailxService {
|
|
|
478
478
|
return [];
|
|
479
479
|
return this.db.searchContacts(query);
|
|
480
480
|
}
|
|
481
|
+
/** Address-book listing — paginated, filterable. Mirrors mailx-service's
|
|
482
|
+
* signature so the same client-side address-book modal works on Android
|
|
483
|
+
* without an "ipc(...).listContacts is not a function" crash. */
|
|
484
|
+
listContacts(query, page = 1, pageSize = 100) {
|
|
485
|
+
return this.db.listContacts(query || "", page, pageSize);
|
|
486
|
+
}
|
|
487
|
+
/** Manual upsert from the address-book UI. The desktop path queues a
|
|
488
|
+
* Google People sync; Android relies on the desktop pushing changes
|
|
489
|
+
* back, so this is local-only for now. */
|
|
490
|
+
upsertContact(name, email) {
|
|
491
|
+
this.db.upsertContact(name || "", email);
|
|
492
|
+
return { ok: true };
|
|
493
|
+
}
|
|
494
|
+
deleteContact(email) {
|
|
495
|
+
this.db.deleteContactLocal(email);
|
|
496
|
+
return { ok: true };
|
|
497
|
+
}
|
|
498
|
+
addContact(name, email) {
|
|
499
|
+
if (!email || !/^[^\s<>@]+@[^\s<>@]+\.[^\s<>@]+$/.test(email))
|
|
500
|
+
return false;
|
|
501
|
+
this.db.recordSentAddress(name || "", email);
|
|
502
|
+
return true;
|
|
503
|
+
}
|
|
481
504
|
/** Q49 heuristic mirror: true if the user has ever sent a message to
|
|
482
505
|
* `recipientEmail` that had a non-empty Cc field. Compose uses this to
|
|
483
506
|
* decide whether to auto-expand the Cc row on reply. */
|