@bobfrankston/mailx 1.0.362 → 1.0.368
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 +27 -0
- package/packages/mailx-imap/index.js +88 -16
- package/packages/mailx-service/index.d.ts +3 -0
- package/packages/mailx-service/index.js +13 -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-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
|
@@ -57,6 +57,7 @@ export declare function getMessage(params: {
|
|
|
57
57
|
folderId: number;
|
|
58
58
|
folderName?: string;
|
|
59
59
|
uid: number;
|
|
60
|
+
uuid?: string;
|
|
60
61
|
messageId: string;
|
|
61
62
|
inReplyTo: string;
|
|
62
63
|
references: string[];
|
|
@@ -72,6 +73,7 @@ export declare function getMessage(params: {
|
|
|
72
73
|
preview: string;
|
|
73
74
|
bodyPath?: string;
|
|
74
75
|
providerId?: string;
|
|
76
|
+
pending?: boolean;
|
|
75
77
|
}>;
|
|
76
78
|
export declare function updateFlags(params: {
|
|
77
79
|
accountId: string;
|
|
@@ -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) */
|
|
@@ -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();
|
|
@@ -1080,47 +1122,77 @@ export class ImapManager extends EventEmitter {
|
|
|
1080
1122
|
else {
|
|
1081
1123
|
console.log(` [sync] ${accountId}: no INBOX folder found`);
|
|
1082
1124
|
}
|
|
1083
|
-
// 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.
|
|
1084
1138
|
const remaining = folders.filter(f => f.specialUse !== "inbox");
|
|
1085
1139
|
remaining.sort((a, b) => {
|
|
1086
1140
|
const pa = priorityOrder.indexOf(a.specialUse || "") >= 0 ? priorityOrder.indexOf(a.specialUse || "") : 5;
|
|
1087
1141
|
const pb = priorityOrder.indexOf(b.specialUse || "") >= 0 ? priorityOrder.indexOf(b.specialUse || "") : 5;
|
|
1088
1142
|
return pa - pb;
|
|
1089
1143
|
});
|
|
1090
|
-
|
|
1091
|
-
|
|
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) => {
|
|
1092
1150
|
const isTrashChild = folder.path.includes("/") && folder.path.toLowerCase().startsWith("trash");
|
|
1093
1151
|
const highestUid = this.db.getHighestUid(accountId, folder.id);
|
|
1094
1152
|
if (isTrashChild && highestUid === 0)
|
|
1095
|
-
|
|
1153
|
+
return;
|
|
1096
1154
|
try {
|
|
1097
|
-
|
|
1098
|
-
await
|
|
1099
|
-
|
|
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
|
+
]);
|
|
1100
1160
|
}
|
|
1101
1161
|
catch (e) {
|
|
1102
|
-
consecutiveErrors++;
|
|
1103
1162
|
if (e.responseText?.includes("doesn't exist")) {
|
|
1104
1163
|
this.db.deleteFolder(folder.id);
|
|
1105
1164
|
}
|
|
1106
1165
|
else {
|
|
1107
1166
|
console.error(` Skipping ${folder.path}: ${e.message}`);
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
console.error(` [sync] ${accountId}: ${consecutiveErrors} consecutive errors — aborting sync`);
|
|
1114
|
-
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(() => { });
|
|
1115
1172
|
}
|
|
1116
1173
|
}
|
|
1117
|
-
}
|
|
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()));
|
|
1118
1189
|
this.accountErrorShown.delete(accountId);
|
|
1119
1190
|
this.emit("syncComplete", accountId);
|
|
1120
1191
|
}
|
|
1121
1192
|
catch (e) {
|
|
1122
1193
|
const errMsg = imapError(e);
|
|
1123
1194
|
this.emit("syncError", accountId, errMsg);
|
|
1195
|
+
this.recordError(accountId, errMsg);
|
|
1124
1196
|
console.error(`Sync error for ${accountId}: ${errMsg}`);
|
|
1125
1197
|
this.handleSyncError(accountId, errMsg);
|
|
1126
1198
|
}
|
|
@@ -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() {
|
|
@@ -822,6 +827,14 @@ export class MailxService {
|
|
|
822
827
|
if (!folder)
|
|
823
828
|
throw new Error("Folder not found");
|
|
824
829
|
this.db.deleteAllMessages(accountId, folderId);
|
|
830
|
+
// Recalc + broadcast so the folder-tree badge drops to 0 immediately.
|
|
831
|
+
// Without this, the badge kept showing the old unread count even
|
|
832
|
+
// though the list was empty (user-reported bug).
|
|
833
|
+
this.db.recalcFolderCounts(folderId);
|
|
834
|
+
try {
|
|
835
|
+
this.imapManager.emit?.("folderCountsChanged", accountId, {});
|
|
836
|
+
}
|
|
837
|
+
catch { /* non-fatal */ }
|
|
825
838
|
const client = this.imapManager.createPublicClient(accountId);
|
|
826
839
|
try {
|
|
827
840
|
const uids = await client.getUids(folder.path);
|
|
@@ -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 || "";
|
|
@@ -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 {
|