@bobfrankston/mailx 1.0.361 → 1.0.366
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 +21 -0
- package/client/app.js +59 -6
- package/client/components/message-list.js +9 -0
- package/client/components/message-viewer.js +11 -1
- package/client/index.html +1 -0
- package/client/lib/api-client.js +3 -0
- package/client/lib/mailxapi.js +1 -0
- package/client/styles/components.css +20 -0
- package/package.json +1 -1
- package/packages/mailx-core/index.d.ts +2 -0
- package/packages/mailx-imap/index.d.ts +31 -0
- package/packages/mailx-imap/index.js +116 -64
- package/packages/mailx-service/index.d.ts +3 -0
- package/packages/mailx-service/index.js +5 -0
- package/packages/mailx-service/jsonrpc.js +2 -0
- package/packages/mailx-store/db.d.ts +14 -0
- package/packages/mailx-store/db.js +108 -13
- package/packages/mailx-store/file-store.d.ts +27 -9
- package/packages/mailx-store/file-store.js +58 -17
- package/packages/mailx-types/index.d.ts +3 -1
- package/todo.json +13 -0
package/bin/mailx.js
CHANGED
|
@@ -1284,11 +1284,32 @@ RFC 5322 with CRLF line endings. Bodies are quoted-printable encoded (readable i
|
|
|
1284
1284
|
if (settings.accounts.some(a => a.enabled)) {
|
|
1285
1285
|
imapManager.syncAll()
|
|
1286
1286
|
.then(() => imapManager.startWatching())
|
|
1287
|
+
.then(() => {
|
|
1288
|
+
// Seed the contacts table from received messages so address
|
|
1289
|
+
// autocomplete works on the first compose without waiting for
|
|
1290
|
+
// the user to manually trigger it. Cheap — one grouped SELECT
|
|
1291
|
+
// + one INSERT per new sender. No-op if contact already exists.
|
|
1292
|
+
const added = db.seedContactsFromMessages();
|
|
1293
|
+
if (added > 0)
|
|
1294
|
+
console.log(` [contacts] seeded ${added} from message senders`);
|
|
1295
|
+
})
|
|
1287
1296
|
.catch(e => console.error(` Sync error: ${e.message}`));
|
|
1288
1297
|
}
|
|
1289
1298
|
imapManager.startPeriodicSync(settings.sync.intervalMinutes);
|
|
1290
1299
|
imapManager.startOutboxWorker();
|
|
1291
1300
|
imapManager.watchConfigFiles();
|
|
1301
|
+
// Re-seed contacts every 30 min so newly-received senders surface in
|
|
1302
|
+
// autocomplete without restarting mailx. Cheap; idempotent.
|
|
1303
|
+
setInterval(() => {
|
|
1304
|
+
try {
|
|
1305
|
+
const added = db.seedContactsFromMessages();
|
|
1306
|
+
if (added > 0)
|
|
1307
|
+
console.log(` [contacts] periodic seed added ${added} new senders`);
|
|
1308
|
+
}
|
|
1309
|
+
catch (e) {
|
|
1310
|
+
console.error(` [contacts] periodic seed error: ${e.message}`);
|
|
1311
|
+
}
|
|
1312
|
+
}, 30 * 60_000);
|
|
1292
1313
|
// Auto-update: periodically check npm for a newer version and push a
|
|
1293
1314
|
// notification to the WebView so the user can update with one click.
|
|
1294
1315
|
const UPDATE_CHECK_MS = 30 * 60_000; // 30 minutes
|
package/client/app.js
CHANGED
|
@@ -240,6 +240,11 @@ let currentFolderSpecialUse = "";
|
|
|
240
240
|
function clearViewer() {
|
|
241
241
|
messageState.select(null); // Deselect — viewer clears via subscription
|
|
242
242
|
}
|
|
243
|
+
// Anyone can ask the viewer to clear by dispatching a `mailx-clear-viewer`
|
|
244
|
+
// CustomEvent on document. Used by message-list's loadSearchResults so the
|
|
245
|
+
// stale preview from the prior selection doesn't linger over the new search
|
|
246
|
+
// results. Slice D will replace this with row-object-level `unfocus()`.
|
|
247
|
+
document.addEventListener("mailx-clear-viewer", () => clearViewer());
|
|
243
248
|
const folderTitleEl = document.getElementById("ml-folder-title");
|
|
244
249
|
let currentFolderName = "";
|
|
245
250
|
let currentFolderSyncedAt;
|
|
@@ -1436,10 +1441,22 @@ onWsEvent((event) => {
|
|
|
1436
1441
|
// Don't refresh folder tree on connect — it's already loaded by initFolderTree
|
|
1437
1442
|
break;
|
|
1438
1443
|
case "syncProgress": {
|
|
1444
|
+
// Aggregate folders phases ("folders:<path>" when starting a folder,
|
|
1445
|
+
// "folders-done" between folders) print as a proportion so the user
|
|
1446
|
+
// can see forward progress instead of a meaningless "47%". Older
|
|
1447
|
+
// phase strings ("sync:<path>", "folders") still render raw.
|
|
1448
|
+
let label = `${event.phase} ${event.progress || 0}%`;
|
|
1449
|
+
if (typeof event.phase === "string" && event.phase.startsWith("folders:")) {
|
|
1450
|
+
const folderPath = event.phase.slice("folders:".length);
|
|
1451
|
+
label = `folders — ${folderPath} (${event.progress || 0}%)`;
|
|
1452
|
+
}
|
|
1453
|
+
else if (event.phase === "folders-done") {
|
|
1454
|
+
label = `folders ${event.progress || 0}% done`;
|
|
1455
|
+
}
|
|
1439
1456
|
if (statusSync)
|
|
1440
|
-
statusSync.textContent = `Syncing ${event.accountId}: ${
|
|
1457
|
+
statusSync.textContent = `Syncing ${event.accountId}: ${label}`;
|
|
1441
1458
|
if (startupStatus)
|
|
1442
|
-
startupStatus.textContent = `Syncing ${event.accountId}: ${
|
|
1459
|
+
startupStatus.textContent = `Syncing ${event.accountId}: ${label}`;
|
|
1443
1460
|
// Mark syncing folder in tree — bubble up to visible parent if collapsed
|
|
1444
1461
|
const syncPath = event.phase?.startsWith("sync:") ? event.phase.slice(5) : null;
|
|
1445
1462
|
// Clear previous syncing markers for this account
|
|
@@ -2551,20 +2568,56 @@ function renderOutboxStatus(s) {
|
|
|
2551
2568
|
}
|
|
2552
2569
|
setInterval(async () => {
|
|
2553
2570
|
try {
|
|
2554
|
-
const { getOutboxStatus } = await import("./lib/api-client.js");
|
|
2555
|
-
|
|
2556
|
-
|
|
2571
|
+
const { getOutboxStatus, getDiagnostics } = await import("./lib/api-client.js");
|
|
2572
|
+
renderOutboxStatus(await getOutboxStatus());
|
|
2573
|
+
renderDiagnosticsBadge(await getDiagnostics());
|
|
2557
2574
|
}
|
|
2558
2575
|
catch { /* service unreachable */ }
|
|
2559
2576
|
}, 15000);
|
|
2560
2577
|
// First read on startup so the bar isn't blank.
|
|
2561
2578
|
(async () => {
|
|
2562
2579
|
try {
|
|
2563
|
-
const { getOutboxStatus } = await import("./lib/api-client.js");
|
|
2580
|
+
const { getOutboxStatus, getDiagnostics } = await import("./lib/api-client.js");
|
|
2564
2581
|
renderOutboxStatus(await getOutboxStatus());
|
|
2582
|
+
renderDiagnosticsBadge(await getDiagnostics());
|
|
2565
2583
|
}
|
|
2566
2584
|
catch { /* */ }
|
|
2567
2585
|
})();
|
|
2586
|
+
/** Render the ⚠ "something's wrong" badge next to status-sync. Shown when
|
|
2587
|
+
* any account has non-zero diagnostic counters (inactivity timeouts,
|
|
2588
|
+
* connection-cap hits, rate-limit waits). Tooltip breaks down per-account. */
|
|
2589
|
+
function renderDiagnosticsBadge(snapshot) {
|
|
2590
|
+
const host = document.getElementById("status-diag");
|
|
2591
|
+
if (!host)
|
|
2592
|
+
return;
|
|
2593
|
+
const issues = (snapshot || []).filter(d => d.inactivityTimeouts > 0 || d.connCapHits > 0 || d.rateLimitWaits > 0);
|
|
2594
|
+
if (issues.length === 0) {
|
|
2595
|
+
host.hidden = true;
|
|
2596
|
+
host.textContent = "";
|
|
2597
|
+
host.title = "";
|
|
2598
|
+
return;
|
|
2599
|
+
}
|
|
2600
|
+
host.hidden = false;
|
|
2601
|
+
host.textContent = "⚠";
|
|
2602
|
+
const totalTimeouts = issues.reduce((a, d) => a + d.inactivityTimeouts, 0);
|
|
2603
|
+
const totalCapHits = issues.reduce((a, d) => a + d.connCapHits, 0);
|
|
2604
|
+
const totalRateLimits = issues.reduce((a, d) => a + d.rateLimitWaits, 0);
|
|
2605
|
+
const summary = [
|
|
2606
|
+
totalTimeouts > 0 ? `${totalTimeouts} IMAP inactivity timeout${totalTimeouts === 1 ? "" : "s"}` : null,
|
|
2607
|
+
totalCapHits > 0 ? `${totalCapHits} conn-cap rejection${totalCapHits === 1 ? "" : "s"}` : null,
|
|
2608
|
+
totalRateLimits > 0 ? `${totalRateLimits} rate-limit wait${totalRateLimits === 1 ? "" : "s"}` : null,
|
|
2609
|
+
].filter(Boolean).join("; ");
|
|
2610
|
+
const detail = issues.map(d => {
|
|
2611
|
+
const parts = [
|
|
2612
|
+
d.inactivityTimeouts > 0 ? `${d.inactivityTimeouts} timeout${d.inactivityTimeouts === 1 ? "" : "s"}` : null,
|
|
2613
|
+
d.connCapHits > 0 ? `${d.connCapHits} conn-cap` : null,
|
|
2614
|
+
d.rateLimitWaits > 0 ? `${d.rateLimitWaits} rate-limit` : null,
|
|
2615
|
+
].filter(Boolean).join(", ");
|
|
2616
|
+
const last = d.lastCommand ? `\n last: ${d.lastCommand}` : "";
|
|
2617
|
+
return `${d.accountId}: ${parts}${last}`;
|
|
2618
|
+
}).join("\n");
|
|
2619
|
+
host.title = `Connection issues — ${summary}\n\n${detail}`;
|
|
2620
|
+
}
|
|
2568
2621
|
// Q64: pop-out a message into a floating overlay (real-OS-window pending C44).
|
|
2569
2622
|
document.addEventListener("mailx-popout-message", (async (e) => {
|
|
2570
2623
|
const { accountId, uid, folderId, subject } = e.detail || {};
|
|
@@ -220,6 +220,11 @@ export async function loadSearchResults(query, scope = "all", accountId = "", fo
|
|
|
220
220
|
const body = document.getElementById("ml-body");
|
|
221
221
|
if (!body)
|
|
222
222
|
return;
|
|
223
|
+
// Clear the preview pane — old preview from the prior selection lingers
|
|
224
|
+
// until the user clicks a search result. Atomic clear-on-list-mutation
|
|
225
|
+
// is what Slice D's row-objects-own-preview will do generally; this is
|
|
226
|
+
// the band-aid for the search-specific case until that lands.
|
|
227
|
+
document.dispatchEvent(new CustomEvent("mailx-clear-viewer"));
|
|
223
228
|
body.innerHTML = `<div class="ml-empty">Searching...</div>`;
|
|
224
229
|
try {
|
|
225
230
|
// Regex search: filter client-side
|
|
@@ -455,6 +460,10 @@ function appendMessages(body, accountId, items) {
|
|
|
455
460
|
row.classList.add("flagged");
|
|
456
461
|
if (!msg.bodyPath)
|
|
457
462
|
row.classList.add("not-downloaded");
|
|
463
|
+
// Pink-row visible reconciliation state (S1 slice C): a queued local
|
|
464
|
+
// action (move/flag/delete) hasn't been ACK'd by the server yet.
|
|
465
|
+
if (msg.pending)
|
|
466
|
+
row.classList.add("pending-reconcile");
|
|
458
467
|
row.dataset.uid = String(msg.uid);
|
|
459
468
|
row.dataset.accountId = msgAccountId;
|
|
460
469
|
row.dataset.folderId = String(msg.folderId);
|
|
@@ -34,7 +34,17 @@ export function initViewer() {
|
|
|
34
34
|
clearViewer();
|
|
35
35
|
}
|
|
36
36
|
}
|
|
37
|
-
|
|
37
|
+
else if (change === "messages") {
|
|
38
|
+
// List was replaced (search, folder switch, sync reload). If the
|
|
39
|
+
// currently-displayed message is no longer in the list, clear the
|
|
40
|
+
// viewer — otherwise the user sees a preview that doesn't match
|
|
41
|
+
// any visible row. (The "search-clears-preview" bug class.)
|
|
42
|
+
// setMessages already deselects in this case; we just need to
|
|
43
|
+
// notice and clear here since the viewer ignored "messages" before.
|
|
44
|
+
if (currentMessage && !state.getSelected()) {
|
|
45
|
+
clearViewer();
|
|
46
|
+
}
|
|
47
|
+
}
|
|
38
48
|
});
|
|
39
49
|
}
|
|
40
50
|
// Zoom is persisted across messages via localStorage
|
package/client/index.html
CHANGED
|
@@ -157,6 +157,7 @@
|
|
|
157
157
|
<footer class="status-bar" id="status-bar">
|
|
158
158
|
<span id="status-accounts"></span>
|
|
159
159
|
<span id="status-sync">Syncing...</span>
|
|
160
|
+
<span id="status-diag" class="status-diag" hidden title=""></span>
|
|
160
161
|
<span id="status-pending"></span>
|
|
161
162
|
<span id="status-queue"></span>
|
|
162
163
|
<span class="app-version" id="status-version">mailx</span>
|
package/client/lib/api-client.js
CHANGED
|
@@ -131,6 +131,9 @@ export function reauthenticate(accountId) {
|
|
|
131
131
|
export function getSyncPending() {
|
|
132
132
|
return ipc().getSyncPending();
|
|
133
133
|
}
|
|
134
|
+
export function getDiagnostics() {
|
|
135
|
+
return ipc().getDiagnostics?.() ?? Promise.resolve([]);
|
|
136
|
+
}
|
|
134
137
|
export function getOutboxStatus() {
|
|
135
138
|
return ipc().getOutboxStatus();
|
|
136
139
|
}
|
package/client/lib/mailxapi.js
CHANGED
|
@@ -159,6 +159,7 @@
|
|
|
159
159
|
syncAccount: function(accountId) { return callNode("syncAccount", { accountId: accountId }); },
|
|
160
160
|
getSyncPending: function() { return callNode("getSyncPending"); },
|
|
161
161
|
getOutboxStatus: function() { return callNode("getOutboxStatus"); },
|
|
162
|
+
getDiagnostics: function() { return callNode("getDiagnostics"); },
|
|
162
163
|
listQueuedOutgoing: function() { return callNode("listQueuedOutgoing"); },
|
|
163
164
|
cancelQueuedOutgoing: function(p) { return callNode("cancelQueuedOutgoing", { path: p }); },
|
|
164
165
|
reauthenticate: function(accountId) { return callNode("reauthenticate", { accountId: accountId }); },
|
|
@@ -779,6 +779,17 @@ button.tb-menu-item { background: none; border: none; color: inherit; width: 100
|
|
|
779
779
|
opacity: 0.5;
|
|
780
780
|
}
|
|
781
781
|
|
|
782
|
+
/* S1 slice C — pink row when a local action (move/flag/delete) is queued
|
|
783
|
+
but the server hasn't ACK'd. Visible reconciliation state: "we did your
|
|
784
|
+
thing locally, the server hasn't agreed yet." Selection still wins
|
|
785
|
+
visually so the active row is identifiable. */
|
|
786
|
+
.ml-row.pending-reconcile {
|
|
787
|
+
background: oklch(0.96 0.04 350); /* pale pink */
|
|
788
|
+
}
|
|
789
|
+
.ml-row.pending-reconcile.selected {
|
|
790
|
+
background: oklch(0.85 0.10 350); /* deeper pink when selected */
|
|
791
|
+
}
|
|
792
|
+
|
|
782
793
|
.ml-empty {
|
|
783
794
|
grid-column: 1 / -1;
|
|
784
795
|
display: flex;
|
|
@@ -1072,6 +1083,15 @@ button.tb-menu-item { background: none; border: none; color: inherit; width: 100
|
|
|
1072
1083
|
font-size: var(--font-size-sm);
|
|
1073
1084
|
color: var(--color-text-muted);
|
|
1074
1085
|
}
|
|
1086
|
+
|
|
1087
|
+
/* Diagnostics ⚠ badge next to status-sync — inactivity timeouts and similar
|
|
1088
|
+
server-misbehavior counters. Hover for per-account detail. */
|
|
1089
|
+
.status-diag {
|
|
1090
|
+
color: oklch(0.70 0.18 55); /* amber */
|
|
1091
|
+
cursor: help;
|
|
1092
|
+
font-size: var(--font-size-base);
|
|
1093
|
+
margin-left: calc(var(--gap-xs) * -1);
|
|
1094
|
+
}
|
|
1075
1095
|
.status-action {
|
|
1076
1096
|
border: 1px solid oklch(0.65 0.15 25);
|
|
1077
1097
|
background: transparent;
|
package/package.json
CHANGED
|
@@ -28,6 +28,20 @@ export interface ImapManagerEvents {
|
|
|
28
28
|
* queue-status indicator without polling. Aggregate status across all
|
|
29
29
|
* accounts is included so the listener doesn't have to reassemble it. */
|
|
30
30
|
outboxStatus: (status: OutboxStatus) => void;
|
|
31
|
+
/** Per-account health counter update. Fires when an inactivity timeout,
|
|
32
|
+
* connection-cap hit, or rate-limit wait happens. */
|
|
33
|
+
diagnostics: (accountId: string, snapshot: AccountDiagnostics) => void;
|
|
34
|
+
}
|
|
35
|
+
/** Per-account diagnostic counters — tracks "something's wrong" events the
|
|
36
|
+
* user should be able to see without opening the log. */
|
|
37
|
+
export interface AccountDiagnostics {
|
|
38
|
+
accountId: string;
|
|
39
|
+
inactivityTimeouts: number;
|
|
40
|
+
connCapHits: number;
|
|
41
|
+
rateLimitWaits: number;
|
|
42
|
+
lastTimeoutAt: number;
|
|
43
|
+
lastCommand: string;
|
|
44
|
+
lastError: string;
|
|
31
45
|
}
|
|
32
46
|
/** Per-account outbox queue breakdown, plus totals for the UI. */
|
|
33
47
|
export interface OutboxStatus {
|
|
@@ -57,6 +71,19 @@ export declare class ImapManager extends EventEmitter {
|
|
|
57
71
|
useNativeClient: boolean;
|
|
58
72
|
/** Accounts hitting connection limits — back off until this time */
|
|
59
73
|
private connectionBackoff;
|
|
74
|
+
/** Per-account health counters. Incremented when the server misbehaves
|
|
75
|
+
* in ways that suggest a problem the user should know about (inactivity
|
|
76
|
+
* timeouts, connection-cap hits, rate-limit waits). Surfaced via a
|
|
77
|
+
* `diagnostics` event + `getDiagnostics` IPC so the UI can show a ⚠
|
|
78
|
+
* badge instead of burying the issue in the log. */
|
|
79
|
+
private diagnostics;
|
|
80
|
+
private getDiagnosticsEntry;
|
|
81
|
+
/** Classify an error message and bump the relevant counter; emit the
|
|
82
|
+
* updated diagnostics snapshot. Call this from every catch in the sync
|
|
83
|
+
* paths so the UI can count "something's wrong" in real time. */
|
|
84
|
+
private recordError;
|
|
85
|
+
/** Public read for the IPC surface: snapshot of all account diagnostics. */
|
|
86
|
+
getDiagnosticsSnapshot(): AccountDiagnostics[];
|
|
60
87
|
private transportFactory;
|
|
61
88
|
constructor(db: MailxDB, transportFactory: TransportFactory);
|
|
62
89
|
/** Get OAuth access token for an account (for SMTP auth) */
|
|
@@ -177,6 +204,10 @@ export declare class ImapManager extends EventEmitter {
|
|
|
177
204
|
* The persistent fetchClient can only handle one command at a time (IMAP protocol limitation). */
|
|
178
205
|
private fetchQueues;
|
|
179
206
|
/** Serialize body fetch operations per account — prevents concurrent IMAP commands on same connection */
|
|
207
|
+
/** Unlink the on-disk body file for a message by reading its `body_path`
|
|
208
|
+
* from the DB. Safe to call either before or after `db.deleteMessage`
|
|
209
|
+
* — read body_path first, store it, then unlink whenever. */
|
|
210
|
+
private unlinkBodyFile;
|
|
180
211
|
private enqueueFetch;
|
|
181
212
|
/** Fetch a single message body on demand, caching in the store.
|
|
182
213
|
* Uses its own fresh connection — never blocked by background prefetch. */
|
|
@@ -148,6 +148,48 @@ export class ImapManager extends EventEmitter {
|
|
|
148
148
|
/** Accounts hitting connection limits — back off until this time */
|
|
149
149
|
connectionBackoff = new Map();
|
|
150
150
|
// Connection management: see withConnection() below — no semaphore needed
|
|
151
|
+
/** Per-account health counters. Incremented when the server misbehaves
|
|
152
|
+
* in ways that suggest a problem the user should know about (inactivity
|
|
153
|
+
* timeouts, connection-cap hits, rate-limit waits). Surfaced via a
|
|
154
|
+
* `diagnostics` event + `getDiagnostics` IPC so the UI can show a ⚠
|
|
155
|
+
* badge instead of burying the issue in the log. */
|
|
156
|
+
diagnostics = new Map();
|
|
157
|
+
getDiagnosticsEntry(accountId) {
|
|
158
|
+
let d = this.diagnostics.get(accountId);
|
|
159
|
+
if (!d) {
|
|
160
|
+
d = { accountId, inactivityTimeouts: 0, connCapHits: 0, rateLimitWaits: 0, lastTimeoutAt: 0, lastCommand: "", lastError: "" };
|
|
161
|
+
this.diagnostics.set(accountId, d);
|
|
162
|
+
}
|
|
163
|
+
return d;
|
|
164
|
+
}
|
|
165
|
+
/** Classify an error message and bump the relevant counter; emit the
|
|
166
|
+
* updated diagnostics snapshot. Call this from every catch in the sync
|
|
167
|
+
* paths so the UI can count "something's wrong" in real time. */
|
|
168
|
+
recordError(accountId, errMsg) {
|
|
169
|
+
const d = this.getDiagnosticsEntry(accountId);
|
|
170
|
+
if (/inactivity timeout/i.test(errMsg)) {
|
|
171
|
+
d.inactivityTimeouts++;
|
|
172
|
+
d.lastTimeoutAt = Date.now();
|
|
173
|
+
const m = errMsg.match(/A\d+ [A-Z ]+.*$/);
|
|
174
|
+
if (m)
|
|
175
|
+
d.lastCommand = m[0].slice(0, 120);
|
|
176
|
+
}
|
|
177
|
+
else if (/UNAVAILABLE|Maximum number of connections|too many connections/i.test(errMsg)) {
|
|
178
|
+
d.connCapHits++;
|
|
179
|
+
}
|
|
180
|
+
else if (/429|rate limit/i.test(errMsg)) {
|
|
181
|
+
d.rateLimitWaits++;
|
|
182
|
+
}
|
|
183
|
+
else {
|
|
184
|
+
return; // not a known diagnostic class — don't emit
|
|
185
|
+
}
|
|
186
|
+
d.lastError = errMsg.slice(0, 200);
|
|
187
|
+
this.emit("diagnostics", accountId, { ...d });
|
|
188
|
+
}
|
|
189
|
+
/** Public read for the IPC surface: snapshot of all account diagnostics. */
|
|
190
|
+
getDiagnosticsSnapshot() {
|
|
191
|
+
return Array.from(this.diagnostics.values()).map(d => ({ ...d }));
|
|
192
|
+
}
|
|
151
193
|
transportFactory;
|
|
152
194
|
constructor(db, transportFactory) {
|
|
153
195
|
super();
|
|
@@ -939,8 +981,9 @@ export class ImapManager extends EventEmitter {
|
|
|
939
981
|
const localUids = this.db.getUidsForFolder(accountId, folderId);
|
|
940
982
|
for (const uid of localUids) {
|
|
941
983
|
if (!serverUids.has(uid)) {
|
|
984
|
+
// Read body_path BEFORE deleting the row, then unlink.
|
|
985
|
+
this.unlinkBodyFile(accountId, uid, folderId).catch(() => { });
|
|
942
986
|
this.db.deleteMessage(accountId, uid);
|
|
943
|
-
this.bodyStore.deleteMessage(accountId, folderId, uid).catch(() => { });
|
|
944
987
|
deletedCount++;
|
|
945
988
|
}
|
|
946
989
|
}
|
|
@@ -1079,47 +1122,77 @@ export class ImapManager extends EventEmitter {
|
|
|
1079
1122
|
else {
|
|
1080
1123
|
console.log(` [sync] ${accountId}: no INBOX folder found`);
|
|
1081
1124
|
}
|
|
1082
|
-
// Step 3: Sync remaining folders
|
|
1125
|
+
// Step 3: Sync remaining folders.
|
|
1126
|
+
//
|
|
1127
|
+
// Parallel pool (concurrency 2) with a per-folder wall-clock cap.
|
|
1128
|
+
// Previous serial loop meant one slow Dovecot UID FETCH could park
|
|
1129
|
+
// every other folder behind it for minutes — user observed "mailx
|
|
1130
|
+
// says synced but 90 folders are empty" because the loop never
|
|
1131
|
+
// progressed past the stalled FETCH before the next sync tick.
|
|
1132
|
+
//
|
|
1133
|
+
// Parallelism uses independent IMAP sockets from the ops-client
|
|
1134
|
+
// pool, so one stalled socket doesn't block the others. The 60s
|
|
1135
|
+
// timeout abandons a stalled command instead of waiting out
|
|
1136
|
+
// Dovecot's 300s server-side inactivity timer; the next sync tick
|
|
1137
|
+
// retries on a fresh socket.
|
|
1083
1138
|
const remaining = folders.filter(f => f.specialUse !== "inbox");
|
|
1084
1139
|
remaining.sort((a, b) => {
|
|
1085
1140
|
const pa = priorityOrder.indexOf(a.specialUse || "") >= 0 ? priorityOrder.indexOf(a.specialUse || "") : 5;
|
|
1086
1141
|
const pb = priorityOrder.indexOf(b.specialUse || "") >= 0 ? priorityOrder.indexOf(b.specialUse || "") : 5;
|
|
1087
1142
|
return pa - pb;
|
|
1088
1143
|
});
|
|
1089
|
-
|
|
1090
|
-
|
|
1144
|
+
const CONCURRENCY = 2;
|
|
1145
|
+
const PER_FOLDER_TIMEOUT_MS = 60_000;
|
|
1146
|
+
const total = remaining.length;
|
|
1147
|
+
let done = 0;
|
|
1148
|
+
let idx = 0;
|
|
1149
|
+
const syncOne = async (folder) => {
|
|
1091
1150
|
const isTrashChild = folder.path.includes("/") && folder.path.toLowerCase().startsWith("trash");
|
|
1092
1151
|
const highestUid = this.db.getHighestUid(accountId, folder.id);
|
|
1093
1152
|
if (isTrashChild && highestUid === 0)
|
|
1094
|
-
|
|
1153
|
+
return;
|
|
1095
1154
|
try {
|
|
1096
|
-
|
|
1097
|
-
await
|
|
1098
|
-
|
|
1155
|
+
const fresh = await this.getOpsClient(accountId);
|
|
1156
|
+
await Promise.race([
|
|
1157
|
+
this.syncFolder(accountId, folder.id, fresh),
|
|
1158
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error(`per-folder timeout (${PER_FOLDER_TIMEOUT_MS / 1000}s): ${folder.path}`)), PER_FOLDER_TIMEOUT_MS)),
|
|
1159
|
+
]);
|
|
1099
1160
|
}
|
|
1100
1161
|
catch (e) {
|
|
1101
|
-
consecutiveErrors++;
|
|
1102
1162
|
if (e.responseText?.includes("doesn't exist")) {
|
|
1103
1163
|
this.db.deleteFolder(folder.id);
|
|
1104
1164
|
}
|
|
1105
1165
|
else {
|
|
1106
1166
|
console.error(` Skipping ${folder.path}: ${e.message}`);
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
console.error(` [sync] ${accountId}: ${consecutiveErrors} consecutive errors — aborting sync`);
|
|
1113
|
-
break;
|
|
1167
|
+
this.recordError(accountId, e.message || String(e));
|
|
1168
|
+
// A timeout or stale-socket failure — drop the ops
|
|
1169
|
+
// client so the next iteration reconnects rather than
|
|
1170
|
+
// inheriting the doomed socket.
|
|
1171
|
+
await this.reconnectOps(accountId).catch(() => { });
|
|
1114
1172
|
}
|
|
1115
1173
|
}
|
|
1116
|
-
}
|
|
1174
|
+
};
|
|
1175
|
+
const worker = async () => {
|
|
1176
|
+
while (true) {
|
|
1177
|
+
const myIdx = idx++;
|
|
1178
|
+
if (myIdx >= remaining.length)
|
|
1179
|
+
return;
|
|
1180
|
+
const folder = remaining[myIdx];
|
|
1181
|
+
this.emit("syncProgress", accountId, `folders:${folder.path}`, Math.round((done / Math.max(total, 1)) * 100));
|
|
1182
|
+
await syncOne(folder);
|
|
1183
|
+
done++;
|
|
1184
|
+
this.emit("syncProgress", accountId, `folders-done`, Math.round((done / Math.max(total, 1)) * 100));
|
|
1185
|
+
console.log(` [sync] ${accountId}: folder ${done}/${total} done (${folder.path})`);
|
|
1186
|
+
}
|
|
1187
|
+
};
|
|
1188
|
+
await Promise.all(Array.from({ length: Math.min(CONCURRENCY, remaining.length) }, () => worker()));
|
|
1117
1189
|
this.accountErrorShown.delete(accountId);
|
|
1118
1190
|
this.emit("syncComplete", accountId);
|
|
1119
1191
|
}
|
|
1120
1192
|
catch (e) {
|
|
1121
1193
|
const errMsg = imapError(e);
|
|
1122
1194
|
this.emit("syncError", accountId, errMsg);
|
|
1195
|
+
this.recordError(accountId, errMsg);
|
|
1123
1196
|
console.error(`Sync error for ${accountId}: ${errMsg}`);
|
|
1124
1197
|
this.handleSyncError(accountId, errMsg);
|
|
1125
1198
|
}
|
|
@@ -1273,8 +1346,8 @@ export class ImapManager extends EventEmitter {
|
|
|
1273
1346
|
}
|
|
1274
1347
|
else {
|
|
1275
1348
|
for (const uid of toDelete) {
|
|
1349
|
+
this.unlinkBodyFile(accountId, uid, folder.id).catch(() => { });
|
|
1276
1350
|
this.db.deleteMessage(accountId, uid);
|
|
1277
|
-
this.bodyStore.deleteMessage(accountId, folder.id, uid).catch(() => { });
|
|
1278
1351
|
}
|
|
1279
1352
|
if (toDelete.length > 0)
|
|
1280
1353
|
console.log(` [api] ${accountId}/${folder.path}: ${toDelete.length} deleted`);
|
|
@@ -1660,6 +1733,18 @@ export class ImapManager extends EventEmitter {
|
|
|
1660
1733
|
* The persistent fetchClient can only handle one command at a time (IMAP protocol limitation). */
|
|
1661
1734
|
fetchQueues = new Map();
|
|
1662
1735
|
/** Serialize body fetch operations per account — prevents concurrent IMAP commands on same connection */
|
|
1736
|
+
/** Unlink the on-disk body file for a message by reading its `body_path`
|
|
1737
|
+
* from the DB. Safe to call either before or after `db.deleteMessage`
|
|
1738
|
+
* — read body_path first, store it, then unlink whenever. */
|
|
1739
|
+
async unlinkBodyFile(accountId, uid, folderId) {
|
|
1740
|
+
try {
|
|
1741
|
+
const row = this.db.getMessageByUid(accountId, uid, folderId);
|
|
1742
|
+
const p = row?.bodyPath;
|
|
1743
|
+
if (p)
|
|
1744
|
+
await this.bodyStore.unlinkByPath(p);
|
|
1745
|
+
}
|
|
1746
|
+
catch { /* row already gone / file already gone — both fine */ }
|
|
1747
|
+
}
|
|
1663
1748
|
enqueueFetch(accountId, fn) {
|
|
1664
1749
|
const prev = this.fetchQueues.get(accountId) || Promise.resolve();
|
|
1665
1750
|
const next = prev.then(fn, fn); // run fn after previous completes (regardless of success/failure)
|
|
@@ -1670,48 +1755,15 @@ export class ImapManager extends EventEmitter {
|
|
|
1670
1755
|
/** Fetch a single message body on demand, caching in the store.
|
|
1671
1756
|
* Uses its own fresh connection — never blocked by background prefetch. */
|
|
1672
1757
|
async fetchMessageBody(accountId, folderId, uid) {
|
|
1673
|
-
// Already cached?
|
|
1674
|
-
//
|
|
1675
|
-
//
|
|
1676
|
-
//
|
|
1677
|
-
//
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
// different message — the on-disk .eml becomes stale but hasMessage()
|
|
1683
|
-
// still returns true. User-reported: "Peter Hoddie letter comingled
|
|
1684
|
-
// with a much older letter." Check fixes it regardless of root cause.
|
|
1685
|
-
const cached = await this.bodyStore.getMessage(accountId, folderId, uid);
|
|
1686
|
-
const envelope = this.db.getMessageByUid(accountId, uid, folderId);
|
|
1687
|
-
const expectedId = envelope?.messageId || "";
|
|
1688
|
-
if (expectedId) {
|
|
1689
|
-
// Scan headers only — Message-ID should land in the first few KB.
|
|
1690
|
-
const head = cached.subarray(0, Math.min(cached.length, 16 * 1024)).toString("utf-8");
|
|
1691
|
-
const m = head.match(/^Message-ID:\s*<([^>\r\n]+)>/im);
|
|
1692
|
-
const cachedId = m ? `<${m[1]}>` : "";
|
|
1693
|
-
if (cachedId && expectedId && cachedId !== expectedId) {
|
|
1694
|
-
console.error(` [body] COMINGLING DETECTED ${accountId}/${folderId}/${uid}: expected ${expectedId}, cached ${cachedId} — dropping cache, re-fetching`);
|
|
1695
|
-
try {
|
|
1696
|
-
await this.bodyStore.deleteMessage(accountId, folderId, uid);
|
|
1697
|
-
}
|
|
1698
|
-
catch { /* */ }
|
|
1699
|
-
// fall through to re-fetch path
|
|
1700
|
-
}
|
|
1701
|
-
else {
|
|
1702
|
-
const existingPath = this.bodyStore.getMessagePath?.(accountId, folderId, uid);
|
|
1703
|
-
if (existingPath)
|
|
1704
|
-
this.db.updateBodyPath(accountId, uid, existingPath);
|
|
1705
|
-
return cached;
|
|
1706
|
-
}
|
|
1707
|
-
}
|
|
1708
|
-
else {
|
|
1709
|
-
// No messageId on the DB row (shouldn't happen but be permissive).
|
|
1710
|
-
const existingPath = this.bodyStore.getMessagePath?.(accountId, folderId, uid);
|
|
1711
|
-
if (existingPath)
|
|
1712
|
-
this.db.updateBodyPath(accountId, uid, existingPath);
|
|
1713
|
-
return cached;
|
|
1714
|
-
}
|
|
1758
|
+
// Already cached? Read the DB row's `body_path` and check the file
|
|
1759
|
+
// exists there. No more `(folderId, uid)` path reconstruction — that
|
|
1760
|
+
// was the source of the S49 comingling bug (UID reuse + folder move
|
|
1761
|
+
// pointing two messages at one file). `body_path` is the sole
|
|
1762
|
+
// authority on where a given message's body lives on disk.
|
|
1763
|
+
const envelope = this.db.getMessageByUid(accountId, uid, folderId);
|
|
1764
|
+
const storedPath = envelope?.bodyPath || "";
|
|
1765
|
+
if (storedPath && await this.bodyStore.hasByPath(storedPath)) {
|
|
1766
|
+
return this.bodyStore.readByPath(storedPath);
|
|
1715
1767
|
}
|
|
1716
1768
|
if (!this.configs.has(accountId))
|
|
1717
1769
|
return null;
|
|
@@ -1967,8 +2019,8 @@ export class ImapManager extends EventEmitter {
|
|
|
1967
2019
|
if (received.has(uid))
|
|
1968
2020
|
continue;
|
|
1969
2021
|
try {
|
|
2022
|
+
this.unlinkBodyFile(accountId, uid, folderId).catch(() => { });
|
|
1970
2023
|
this.db.deleteMessage(accountId, uid);
|
|
1971
|
-
this.bodyStore.deleteMessage(accountId, folderId, uid).catch(() => { });
|
|
1972
2024
|
counters.deleted++;
|
|
1973
2025
|
madeProgress = true;
|
|
1974
2026
|
}
|
|
@@ -2051,8 +2103,8 @@ export class ImapManager extends EventEmitter {
|
|
|
2051
2103
|
if (received.has(uid))
|
|
2052
2104
|
continue;
|
|
2053
2105
|
try {
|
|
2106
|
+
this.unlinkBodyFile(accountId, uid, folderId).catch(() => { });
|
|
2054
2107
|
this.db.deleteMessage(accountId, uid);
|
|
2055
|
-
this.bodyStore.deleteMessage(accountId, folderId, uid).catch(() => { });
|
|
2056
2108
|
counters.deleted++;
|
|
2057
2109
|
madeProgress = true;
|
|
2058
2110
|
}
|
|
@@ -2096,8 +2148,8 @@ export class ImapManager extends EventEmitter {
|
|
|
2096
2148
|
const trash = this.findFolder(accountId, "trash");
|
|
2097
2149
|
// Local first — remove all from DB immediately
|
|
2098
2150
|
for (const msg of messages) {
|
|
2151
|
+
this.unlinkBodyFile(accountId, msg.uid, msg.folderId).catch(() => { });
|
|
2099
2152
|
this.db.deleteMessage(accountId, msg.uid);
|
|
2100
|
-
this.bodyStore.deleteMessage(accountId, msg.folderId, msg.uid).catch(() => { });
|
|
2101
2153
|
}
|
|
2102
2154
|
console.log(` Deleted ${messages.length} messages locally`);
|
|
2103
2155
|
// Queue IMAP actions
|
|
@@ -2149,8 +2201,8 @@ export class ImapManager extends EventEmitter {
|
|
|
2149
2201
|
async trashMessage(accountId, folderId, uid) {
|
|
2150
2202
|
const trash = this.findFolder(accountId, "trash");
|
|
2151
2203
|
// Local first — remove from DB immediately
|
|
2204
|
+
this.unlinkBodyFile(accountId, uid, folderId).catch(() => { });
|
|
2152
2205
|
this.db.deleteMessage(accountId, uid);
|
|
2153
|
-
this.bodyStore.deleteMessage(accountId, folderId, uid).catch(() => { });
|
|
2154
2206
|
// Queue IMAP action + log the resolution so "I deleted a message and
|
|
2155
2207
|
// now it's in neither trash nor deleted" is diagnosable from the log.
|
|
2156
2208
|
if (trash && trash.id !== folderId) {
|
|
@@ -36,6 +36,9 @@ export declare class MailxService {
|
|
|
36
36
|
};
|
|
37
37
|
/** Outbox queue depth + retry status for the UI status bar. Cheap to call. */
|
|
38
38
|
getOutboxStatus(): any;
|
|
39
|
+
/** Per-account health snapshot: inactivity-timeout count, conn-cap hits,
|
|
40
|
+
* last failed IMAP command. Drives the diagnostics ⚠ badge in the UI. */
|
|
41
|
+
getDiagnostics(): any;
|
|
39
42
|
/** List queued outgoing messages with parsed envelope headers so the UI
|
|
40
43
|
* can render a pink-row "pending" view before IMAP APPEND succeeds. */
|
|
41
44
|
listQueuedOutgoing(): any[];
|
|
@@ -413,6 +413,11 @@ export class MailxService {
|
|
|
413
413
|
getOutboxStatus() {
|
|
414
414
|
return this.imapManager.getOutboxStatus();
|
|
415
415
|
}
|
|
416
|
+
/** Per-account health snapshot: inactivity-timeout count, conn-cap hits,
|
|
417
|
+
* last failed IMAP command. Drives the diagnostics ⚠ badge in the UI. */
|
|
418
|
+
getDiagnostics() {
|
|
419
|
+
return this.imapManager.getDiagnosticsSnapshot();
|
|
420
|
+
}
|
|
416
421
|
/** List queued outgoing messages with parsed envelope headers so the UI
|
|
417
422
|
* can render a pink-row "pending" view before IMAP APPEND succeeds. */
|
|
418
423
|
listQueuedOutgoing() {
|
|
@@ -94,6 +94,8 @@ async function dispatchAction(svc, action, p) {
|
|
|
94
94
|
return { ok: true };
|
|
95
95
|
case "getSyncPending":
|
|
96
96
|
return svc.getSyncPending();
|
|
97
|
+
case "getDiagnostics":
|
|
98
|
+
return svc.getDiagnostics();
|
|
97
99
|
case "getOutboxStatus":
|
|
98
100
|
return svc.getOutboxStatus();
|
|
99
101
|
case "listQueuedOutgoing":
|
|
@@ -7,6 +7,10 @@ import type { MessageEnvelope, Folder, EmailAddress, PagedResult, MessageQuery }
|
|
|
7
7
|
export declare class MailxDB {
|
|
8
8
|
private db;
|
|
9
9
|
constructor(dbDir: string);
|
|
10
|
+
/** One-time: assign UUIDs to every `messages` row that's missing one.
|
|
11
|
+
* Runs on every startup but the WHERE clause makes it a no-op after the
|
|
12
|
+
* first pass. */
|
|
13
|
+
private backfillUuids;
|
|
10
14
|
/** Has this Message-ID already been sent? Used to prevent the outbox from
|
|
11
15
|
* re-sending the same raw file across crash/restart cycles. */
|
|
12
16
|
hasSentMessage(messageId: string): boolean;
|
|
@@ -85,7 +89,17 @@ export declare class MailxDB {
|
|
|
85
89
|
getMessages(query: MessageQuery): PagedResult<MessageEnvelope>;
|
|
86
90
|
/** Unified inbox: all inbox folders across accounts, sorted by date, paginated in SQL */
|
|
87
91
|
getUnifiedInbox(page?: number, pageSize?: number): PagedResult<MessageEnvelope>;
|
|
92
|
+
/** Map a `messages` row to a MessageEnvelope. Exposes `uuid` (stable local
|
|
93
|
+
* identity) and `bodyPath` (authoritative on-disk location) in addition
|
|
94
|
+
* to the server-binding metadata. */
|
|
95
|
+
private rowToEnvelope;
|
|
88
96
|
getMessageByUid(accountId: string, uid: number, folderId?: number): MessageEnvelope;
|
|
97
|
+
/** Look up a message by its stable local UUID. Returned envelope includes
|
|
98
|
+
* the current (folder_id, uid) — these may have changed since the UUID
|
|
99
|
+
* was minted (folder move or server UID renumber) but the UUID itself
|
|
100
|
+
* is stable. Use this as the identity in any long-lived reference
|
|
101
|
+
* (compose in-reply-to, dally, undo stacks). */
|
|
102
|
+
getMessageByUuid(uuid: string): MessageEnvelope;
|
|
89
103
|
getMessageBodyPath(accountId: string, uid: number): string;
|
|
90
104
|
updateMessageFlags(accountId: string, uid: number, flags: string[]): void;
|
|
91
105
|
updateMessageFolder(accountId: string, uid: number, targetFolderId: number): void;
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* Message bodies are NOT here -- they live in the MessageStore backend.
|
|
5
5
|
*/
|
|
6
6
|
import { DatabaseSync } from "node:sqlite";
|
|
7
|
+
import { randomUUID } from "node:crypto";
|
|
7
8
|
import * as path from "node:path";
|
|
8
9
|
import * as fs from "node:fs";
|
|
9
10
|
const SCHEMA = `
|
|
@@ -166,6 +167,46 @@ export class MailxDB {
|
|
|
166
167
|
// directly instead of paginating listMessageIds for every body fetch
|
|
167
168
|
// — a UID-only path costs 2-3 rate-limited API calls per message.
|
|
168
169
|
this.addColumnIfMissing("messages", "provider_id", "TEXT");
|
|
170
|
+
// uuid: stable per-message local identity. Assigned the first time
|
|
171
|
+
// mailx sees the message and never changes — survives server UID
|
|
172
|
+
// renumbers, UIDVALIDITY bumps, and cross-folder moves (the sync
|
|
173
|
+
// rebinds the (folder_id, uid) tuple but keeps the UUID). All UI
|
|
174
|
+
// references SHOULD flow through uuid; (account_id, folder_id, uid)
|
|
175
|
+
// remains the server-binding metadata used only by sync.
|
|
176
|
+
this.addColumnIfMissing("messages", "uuid", "TEXT");
|
|
177
|
+
try {
|
|
178
|
+
this.db.exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_messages_uuid ON messages(uuid)");
|
|
179
|
+
}
|
|
180
|
+
catch { /* already exists */ }
|
|
181
|
+
// Backfill UUIDs for any pre-existing rows that were inserted before
|
|
182
|
+
// this column landed. One UPDATE + an id roundtrip per row — cheap
|
|
183
|
+
// at our row counts, runs once per DB upgrade.
|
|
184
|
+
this.backfillUuids();
|
|
185
|
+
}
|
|
186
|
+
/** One-time: assign UUIDs to every `messages` row that's missing one.
|
|
187
|
+
* Runs on every startup but the WHERE clause makes it a no-op after the
|
|
188
|
+
* first pass. */
|
|
189
|
+
backfillUuids() {
|
|
190
|
+
try {
|
|
191
|
+
const rows = this.db.prepare("SELECT id FROM messages WHERE uuid IS NULL OR uuid = ''").all();
|
|
192
|
+
if (rows.length === 0)
|
|
193
|
+
return;
|
|
194
|
+
console.log(` [db] backfilling ${rows.length} message UUIDs`);
|
|
195
|
+
const upd = this.db.prepare("UPDATE messages SET uuid = ? WHERE id = ?");
|
|
196
|
+
this.db.exec("BEGIN");
|
|
197
|
+
try {
|
|
198
|
+
for (const r of rows)
|
|
199
|
+
upd.run(randomUUID().replace(/-/g, ""), r.id);
|
|
200
|
+
this.db.exec("COMMIT");
|
|
201
|
+
}
|
|
202
|
+
catch (e) {
|
|
203
|
+
this.db.exec("ROLLBACK");
|
|
204
|
+
throw e;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
catch (e) {
|
|
208
|
+
console.error(` [db] backfillUuids failed: ${e.message}`);
|
|
209
|
+
}
|
|
169
210
|
}
|
|
170
211
|
// ── Sent-log (dedup) ──
|
|
171
212
|
/** Has this Message-ID already been sent? Used to prevent the outbox from
|
|
@@ -423,6 +464,24 @@ export class MailxDB {
|
|
|
423
464
|
}
|
|
424
465
|
return existing.id;
|
|
425
466
|
}
|
|
467
|
+
// Move-detection: if this Message-ID already exists for this account
|
|
468
|
+
// in a DIFFERENT folder, treat it as a server-side move rather than a
|
|
469
|
+
// new arrival. Rebind that row to (folder_id, uid) — keep the UUID,
|
|
470
|
+
// body_path, flags, all the local state. Saves a body re-fetch and
|
|
471
|
+
// preserves any local references (in_reply_to, dally entries, undo
|
|
472
|
+
// stacks) that point at the UUID. Only kicks in when messageId is
|
|
473
|
+
// present (servers usually include it; if not we fall through to a
|
|
474
|
+
// fresh insert which mints a new UUID).
|
|
475
|
+
if (msg.messageId) {
|
|
476
|
+
const moved = this.db.prepare("SELECT id, folder_id, uid FROM messages WHERE account_id = ? AND message_id = ? LIMIT 1").get(msg.accountId, msg.messageId);
|
|
477
|
+
if (moved) {
|
|
478
|
+
console.log(` [move-detect] ${msg.accountId} ${msg.messageId}: rebinding row ${moved.id} (folder ${moved.folder_id}/uid ${moved.uid} → folder ${msg.folderId}/uid ${msg.uid})`);
|
|
479
|
+
// Update folder_id + uid; preserve uuid, body_path, flags
|
|
480
|
+
// (server flags will catch up on the next full sync).
|
|
481
|
+
this.db.prepare("UPDATE messages SET folder_id = ?, uid = ?, cached_at = ? WHERE id = ?").run(msg.folderId, msg.uid, Date.now(), moved.id);
|
|
482
|
+
return moved.id;
|
|
483
|
+
}
|
|
484
|
+
}
|
|
426
485
|
const toText = msg.to.map(a => `${a.name} ${a.address}`).join(" ");
|
|
427
486
|
const ccText = msg.cc.map(a => `${a.name} ${a.address}`).join(" ");
|
|
428
487
|
// Thread id = oldest ancestor in the reference chain, or the in-reply-to
|
|
@@ -430,13 +489,17 @@ export class MailxDB {
|
|
|
430
489
|
// whether an existing row already has a thread_id for any of the refs,
|
|
431
490
|
// so late-arriving replies latch onto the same thread.
|
|
432
491
|
const threadId = this.computeThreadId(msg.accountId, msg.messageId, msg.inReplyTo, msg.references);
|
|
492
|
+
// Mint a per-message local identity UUID at first-sight. Stable for
|
|
493
|
+
// the life of the row — survives server UID renumbers, folder moves
|
|
494
|
+
// (sync rebinds folder_id/uid but keeps uuid), UIDVALIDITY bumps.
|
|
495
|
+
const uuid = randomUUID().replace(/-/g, "");
|
|
433
496
|
const result = this.db.prepare(`
|
|
434
497
|
INSERT INTO messages (
|
|
435
|
-
account_id, folder_id, uid, message_id, in_reply_to, refs, thread_id,
|
|
498
|
+
account_id, folder_id, uid, uuid, message_id, in_reply_to, refs, thread_id,
|
|
436
499
|
date, subject, from_address, from_name, to_json, cc_json,
|
|
437
500
|
flags_json, size, has_attachments, preview, body_path, cached_at, provider_id
|
|
438
|
-
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
439
|
-
`).run(msg.accountId, msg.folderId, msg.uid, msg.messageId, msg.inReplyTo, JSON.stringify(msg.references), threadId, msg.date, msg.subject, msg.from.address, msg.from.name, JSON.stringify(msg.to), JSON.stringify(msg.cc), JSON.stringify(msg.flags), msg.size, msg.hasAttachments ? 1 : 0, msg.preview, msg.bodyPath, Date.now(), msg.providerId || null);
|
|
501
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
502
|
+
`).run(msg.accountId, msg.folderId, msg.uid, uuid, msg.messageId, msg.inReplyTo, JSON.stringify(msg.references), threadId, msg.date, msg.subject, msg.from.address, msg.from.name, JSON.stringify(msg.to), JSON.stringify(msg.cc), JSON.stringify(msg.flags), msg.size, msg.hasAttachments ? 1 : 0, msg.preview, msg.bodyPath, Date.now(), msg.providerId || null);
|
|
440
503
|
const rowId = Number(result.lastInsertRowid);
|
|
441
504
|
// Index for full-text search
|
|
442
505
|
try {
|
|
@@ -466,12 +529,22 @@ export class MailxDB {
|
|
|
466
529
|
where += " AND flags_json LIKE '%\\\\Flagged%'";
|
|
467
530
|
}
|
|
468
531
|
const total = this.db.prepare(`SELECT COUNT(*) as cnt FROM messages WHERE ${where}`).get(...params).cnt;
|
|
469
|
-
|
|
532
|
+
// LEFT JOIN sync_actions so each row carries a `pending` flag —
|
|
533
|
+
// true when the user has a queued local action (move/flag/delete)
|
|
534
|
+
// not yet acknowledged by the server. UI renders these in pink so
|
|
535
|
+
// local-only state is visible (Slice C of S1).
|
|
536
|
+
const rows = this.db.prepare(`SELECT m.*, EXISTS(
|
|
537
|
+
SELECT 1 FROM sync_actions sa
|
|
538
|
+
WHERE sa.account_id = m.account_id AND sa.uid = m.uid
|
|
539
|
+
) AS pending
|
|
540
|
+
FROM messages m WHERE ${where.replace(/\b(account_id|folder_id|uid|date|subject|from_name|from_address|flags_json)\b/g, "m.$1")}
|
|
541
|
+
ORDER BY m.${sortCol} ${sortDir} LIMIT ? OFFSET ?`).all(...params, pageSize, offset);
|
|
470
542
|
const items = rows.map(r => ({
|
|
471
543
|
id: r.id,
|
|
472
544
|
accountId: r.account_id,
|
|
473
545
|
folderId: r.folder_id,
|
|
474
546
|
uid: r.uid,
|
|
547
|
+
uuid: r.uuid || "",
|
|
475
548
|
messageId: r.message_id || "",
|
|
476
549
|
inReplyTo: r.in_reply_to || "",
|
|
477
550
|
references: JSON.parse(r.refs || "[]"),
|
|
@@ -485,7 +558,8 @@ export class MailxDB {
|
|
|
485
558
|
size: r.size,
|
|
486
559
|
hasAttachments: !!r.has_attachments,
|
|
487
560
|
preview: r.preview,
|
|
488
|
-
bodyPath: r.body_path || ""
|
|
561
|
+
bodyPath: r.body_path || "",
|
|
562
|
+
pending: !!r.pending,
|
|
489
563
|
}));
|
|
490
564
|
return { items, total, page, pageSize };
|
|
491
565
|
}
|
|
@@ -522,19 +596,16 @@ export class MailxDB {
|
|
|
522
596
|
}));
|
|
523
597
|
return { items, total, page, pageSize };
|
|
524
598
|
}
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
const params = folderId != null ? [accountId, uid, folderId] : [accountId, uid];
|
|
530
|
-
const r = this.db.prepare(sql).get(...params);
|
|
531
|
-
if (!r)
|
|
532
|
-
return null;
|
|
599
|
+
/** Map a `messages` row to a MessageEnvelope. Exposes `uuid` (stable local
|
|
600
|
+
* identity) and `bodyPath` (authoritative on-disk location) in addition
|
|
601
|
+
* to the server-binding metadata. */
|
|
602
|
+
rowToEnvelope(r) {
|
|
533
603
|
return {
|
|
534
604
|
id: r.id,
|
|
535
605
|
accountId: r.account_id,
|
|
536
606
|
folderId: r.folder_id,
|
|
537
607
|
uid: r.uid,
|
|
608
|
+
uuid: r.uuid || "",
|
|
538
609
|
messageId: r.message_id || "",
|
|
539
610
|
inReplyTo: r.in_reply_to || "",
|
|
540
611
|
references: JSON.parse(r.refs || "[]"),
|
|
@@ -548,9 +619,33 @@ export class MailxDB {
|
|
|
548
619
|
size: r.size,
|
|
549
620
|
hasAttachments: !!r.has_attachments,
|
|
550
621
|
preview: r.preview,
|
|
622
|
+
bodyPath: r.body_path || "",
|
|
551
623
|
providerId: r.provider_id || undefined,
|
|
552
624
|
};
|
|
553
625
|
}
|
|
626
|
+
getMessageByUid(accountId, uid, folderId) {
|
|
627
|
+
const sql = folderId != null
|
|
628
|
+
? "SELECT * FROM messages WHERE account_id = ? AND uid = ? AND folder_id = ?"
|
|
629
|
+
: "SELECT * FROM messages WHERE account_id = ? AND uid = ?";
|
|
630
|
+
const params = folderId != null ? [accountId, uid, folderId] : [accountId, uid];
|
|
631
|
+
const r = this.db.prepare(sql).get(...params);
|
|
632
|
+
if (!r)
|
|
633
|
+
return null;
|
|
634
|
+
return this.rowToEnvelope(r);
|
|
635
|
+
}
|
|
636
|
+
/** Look up a message by its stable local UUID. Returned envelope includes
|
|
637
|
+
* the current (folder_id, uid) — these may have changed since the UUID
|
|
638
|
+
* was minted (folder move or server UID renumber) but the UUID itself
|
|
639
|
+
* is stable. Use this as the identity in any long-lived reference
|
|
640
|
+
* (compose in-reply-to, dally, undo stacks). */
|
|
641
|
+
getMessageByUuid(uuid) {
|
|
642
|
+
if (!uuid)
|
|
643
|
+
return null;
|
|
644
|
+
const r = this.db.prepare("SELECT * FROM messages WHERE uuid = ?").get(uuid);
|
|
645
|
+
if (!r)
|
|
646
|
+
return null;
|
|
647
|
+
return this.rowToEnvelope(r);
|
|
648
|
+
}
|
|
554
649
|
getMessageBodyPath(accountId, uid) {
|
|
555
650
|
const r = this.db.prepare("SELECT body_path FROM messages WHERE account_id = ? AND uid = ?").get(accountId, uid);
|
|
556
651
|
return r?.body_path || "";
|
|
@@ -1,18 +1,36 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* File-per-message body storage backend.
|
|
3
|
-
*
|
|
4
|
-
*
|
|
3
|
+
*
|
|
4
|
+
* Disk layout: {basePath}/{accountId}/<xx>/<uuid>.eml
|
|
5
|
+
* Filename is an opaque UUID. Two-char prefix dir for filesystem fan-out.
|
|
6
|
+
*
|
|
7
|
+
* CRITICAL: the on-disk filename carries NO semantic meaning — not folder
|
|
8
|
+
* id, not UID, not Message-ID. A new UUID is minted on every `putMessage`.
|
|
9
|
+
* Moves, UID renumbers, UIDVALIDITY bumps cannot shadow a body because no
|
|
10
|
+
* filename is ever reused. DB's `body_path` column is the sole authority
|
|
11
|
+
* on where a given message's body lives.
|
|
5
12
|
*/
|
|
6
13
|
import type { MessageStore } from "@bobfrankston/mailx-types";
|
|
7
14
|
export declare class FileMessageStore implements MessageStore {
|
|
8
15
|
private basePath;
|
|
9
16
|
constructor(basePath: string);
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
+
/** Fresh opaque path per call. No inputs from the caller affect the name. */
|
|
18
|
+
private newMessagePath;
|
|
19
|
+
/** Verify a given path resolves inside this store's basePath — refuses
|
|
20
|
+
* any value that doesn't (cheap directory-traversal guard). */
|
|
21
|
+
private inStore;
|
|
22
|
+
/** Write a new body. Always a fresh UUID path. Caller MUST persist the
|
|
23
|
+
* returned path in the DB (`body_path`) and use it for all reads. The
|
|
24
|
+
* (folderId, uid) args are kept for interface compatibility; they do
|
|
25
|
+
* NOT affect the filename. */
|
|
26
|
+
putMessage(accountId: string, _folderId: number, _uid: number, raw: Buffer): Promise<string>;
|
|
27
|
+
/** Read by absolute path (DB `body_path`). The primary read API. */
|
|
28
|
+
readByPath(fullPath: string): Promise<Buffer>;
|
|
29
|
+
hasByPath(fullPath: string): Promise<boolean>;
|
|
30
|
+
unlinkByPath(fullPath: string): Promise<void>;
|
|
31
|
+
getMessagePath(_accountId: string, _folderId: number, _uid: number): string;
|
|
32
|
+
getMessage(_accountId: string, _folderId: number, _uid: number): Promise<Buffer>;
|
|
33
|
+
hasMessage(_accountId: string, _folderId: number, _uid: number): Promise<boolean>;
|
|
34
|
+
deleteMessage(_accountId: string, _folderId: number, _uid: number): Promise<void>;
|
|
17
35
|
}
|
|
18
36
|
//# sourceMappingURL=file-store.d.ts.map
|
|
@@ -1,39 +1,80 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* File-per-message body storage backend.
|
|
3
|
-
*
|
|
4
|
-
*
|
|
3
|
+
*
|
|
4
|
+
* Disk layout: {basePath}/{accountId}/<xx>/<uuid>.eml
|
|
5
|
+
* Filename is an opaque UUID. Two-char prefix dir for filesystem fan-out.
|
|
6
|
+
*
|
|
7
|
+
* CRITICAL: the on-disk filename carries NO semantic meaning — not folder
|
|
8
|
+
* id, not UID, not Message-ID. A new UUID is minted on every `putMessage`.
|
|
9
|
+
* Moves, UID renumbers, UIDVALIDITY bumps cannot shadow a body because no
|
|
10
|
+
* filename is ever reused. DB's `body_path` column is the sole authority
|
|
11
|
+
* on where a given message's body lives.
|
|
5
12
|
*/
|
|
6
13
|
import * as fs from "node:fs";
|
|
7
14
|
import * as path from "node:path";
|
|
15
|
+
import { randomUUID } from "node:crypto";
|
|
8
16
|
export class FileMessageStore {
|
|
9
17
|
basePath;
|
|
10
18
|
constructor(basePath) {
|
|
11
19
|
this.basePath = basePath;
|
|
12
20
|
fs.mkdirSync(basePath, { recursive: true });
|
|
13
21
|
}
|
|
14
|
-
|
|
15
|
-
|
|
22
|
+
/** Fresh opaque path per call. No inputs from the caller affect the name. */
|
|
23
|
+
newMessagePath(accountId) {
|
|
24
|
+
const uuid = randomUUID().replace(/-/g, "");
|
|
25
|
+
const prefix = uuid.slice(0, 2);
|
|
26
|
+
return path.join(this.basePath, accountId, prefix, `${uuid}.eml`);
|
|
16
27
|
}
|
|
17
|
-
/**
|
|
18
|
-
|
|
19
|
-
|
|
28
|
+
/** Verify a given path resolves inside this store's basePath — refuses
|
|
29
|
+
* any value that doesn't (cheap directory-traversal guard). */
|
|
30
|
+
inStore(fullPath) {
|
|
31
|
+
if (!fullPath)
|
|
32
|
+
return false;
|
|
33
|
+
const rel = path.relative(path.resolve(this.basePath), path.resolve(fullPath));
|
|
34
|
+
return !rel.startsWith("..") && !path.isAbsolute(rel);
|
|
20
35
|
}
|
|
21
|
-
|
|
22
|
-
|
|
36
|
+
/** Write a new body. Always a fresh UUID path. Caller MUST persist the
|
|
37
|
+
* returned path in the DB (`body_path`) and use it for all reads. The
|
|
38
|
+
* (folderId, uid) args are kept for interface compatibility; they do
|
|
39
|
+
* NOT affect the filename. */
|
|
40
|
+
async putMessage(accountId, _folderId, _uid, raw) {
|
|
41
|
+
const filePath = this.newMessagePath(accountId);
|
|
23
42
|
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
24
43
|
fs.writeFileSync(filePath, raw);
|
|
25
44
|
return filePath;
|
|
26
45
|
}
|
|
27
|
-
|
|
28
|
-
|
|
46
|
+
/** Read by absolute path (DB `body_path`). The primary read API. */
|
|
47
|
+
async readByPath(fullPath) {
|
|
48
|
+
if (!this.inStore(fullPath))
|
|
49
|
+
throw new Error(`refusing to read outside store: ${fullPath}`);
|
|
50
|
+
return fs.readFileSync(fullPath);
|
|
29
51
|
}
|
|
30
|
-
async
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
52
|
+
async hasByPath(fullPath) {
|
|
53
|
+
if (!this.inStore(fullPath))
|
|
54
|
+
return false;
|
|
55
|
+
return fs.existsSync(fullPath);
|
|
34
56
|
}
|
|
35
|
-
async
|
|
36
|
-
|
|
57
|
+
async unlinkByPath(fullPath) {
|
|
58
|
+
if (!this.inStore(fullPath))
|
|
59
|
+
return;
|
|
60
|
+
if (fs.existsSync(fullPath))
|
|
61
|
+
fs.unlinkSync(fullPath);
|
|
62
|
+
}
|
|
63
|
+
// MessageStore interface compatibility (unused once all callers migrate to
|
|
64
|
+
// path-based reads). These used to compose {folderId}/{uid}.eml and they
|
|
65
|
+
// would resurrect the comingling bug if restored. Kept as throwing stubs
|
|
66
|
+
// so any accidental caller surfaces loudly rather than silently misbehave.
|
|
67
|
+
getMessagePath(_accountId, _folderId, _uid) {
|
|
68
|
+
throw new Error("FileMessageStore.getMessagePath is retired — read body_path from DB");
|
|
69
|
+
}
|
|
70
|
+
async getMessage(_accountId, _folderId, _uid) {
|
|
71
|
+
throw new Error("FileMessageStore.getMessage(folder,uid) is retired — use readByPath(body_path)");
|
|
72
|
+
}
|
|
73
|
+
async hasMessage(_accountId, _folderId, _uid) {
|
|
74
|
+
throw new Error("FileMessageStore.hasMessage(folder,uid) is retired — use hasByPath(body_path)");
|
|
75
|
+
}
|
|
76
|
+
async deleteMessage(_accountId, _folderId, _uid) {
|
|
77
|
+
throw new Error("FileMessageStore.deleteMessage(folder,uid) is retired — use unlinkByPath(body_path)");
|
|
37
78
|
}
|
|
38
79
|
}
|
|
39
80
|
//# sourceMappingURL=file-store.js.map
|
|
@@ -62,7 +62,8 @@ export interface MessageEnvelope {
|
|
|
62
62
|
accountId: string;
|
|
63
63
|
folderId: number;
|
|
64
64
|
folderName?: string; /** Leaf folder name; populated by cross-folder search so the UI can tag each hit */
|
|
65
|
-
uid: number; /** IMAP UID */
|
|
65
|
+
uid: number; /** IMAP UID (server-side identity; changes on move, UIDVALIDITY bump) */
|
|
66
|
+
uuid?: string; /** Stable local identity, minted once at first-sight; never changes */
|
|
66
67
|
messageId: string; /** RFC Message-ID header */
|
|
67
68
|
inReplyTo: string; /** For threading */
|
|
68
69
|
references: string[]; /** For threading */
|
|
@@ -78,6 +79,7 @@ export interface MessageEnvelope {
|
|
|
78
79
|
preview: string; /** First ~200 chars of body text */
|
|
79
80
|
bodyPath?: string; /** Local body location: "idb:..." or "gmail:<id>" */
|
|
80
81
|
providerId?: string; /** Native server id (Gmail hex id, Outlook Graph id) — bypasses UID→id pagination on body fetch */
|
|
82
|
+
pending?: boolean; /** True when a queued local action (move/flag/delete) hasn't been ACK'd by the server yet — UI renders pink */
|
|
81
83
|
}
|
|
82
84
|
/** Full message with body content */
|
|
83
85
|
export interface Message extends MessageEnvelope {
|