@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 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}: ${event.phase} ${event.progress || 0}%`;
1457
+ statusSync.textContent = `Syncing ${event.accountId}: ${label}`;
1441
1458
  if (startupStatus)
1442
- startupStatus.textContent = `Syncing ${event.accountId}: ${event.phase}`;
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
- const s = await getOutboxStatus();
2556
- renderOutboxStatus(s);
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
- // "messages" change (sync reload) — don't touch the viewer
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>
@@ -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
  }
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx",
3
- "version": "1.0.361",
3
+ "version": "1.0.366",
4
4
  "description": "Local-first email client with IMAP sync and standalone native app",
5
5
  "type": "module",
6
6
  "main": "bin/mailx.js",
@@ -57,6 +57,8 @@ export declare function getMessage(params: {
57
57
  folderId: number;
58
58
  folderName?: string;
59
59
  uid: number;
60
+ uuid?: string;
61
+ pending?: boolean;
60
62
  messageId: string;
61
63
  inReplyTo: string;
62
64
  references: 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) */
@@ -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
- let consecutiveErrors = 0;
1090
- for (const folder of remaining) {
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
- continue;
1153
+ return;
1095
1154
  try {
1096
- client = await this.getOpsClient(accountId);
1097
- await this.syncFolder(accountId, folder.id, client);
1098
- consecutiveErrors = 0;
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
- // Connection is probably dead — reconnect
1108
- await this.reconnectOps(accountId);
1109
- }
1110
- // Too many consecutive errors = connection fundamentally broken
1111
- if (consecutiveErrors >= 3) {
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? If the file is on disk but body_path wasn't written to
1674
- // the DB (e.g. from an interrupted earlier run), the prefetch loop would
1675
- // otherwise keep returning the same missing rows forever once saw
1676
- // "gmail: 17266796 bodies cached" in the logs, which is the counter
1677
- // spinning on the same 100 rows.
1678
- if (await this.bodyStore.hasMessage(accountId, folderId, uid)) {
1679
- // COMINGLING GUARD: verify the cached body's Message-ID matches the
1680
- // DB row's messageId. If UIDVALIDITY changed server-side (mailbox
1681
- // recreated, server quirk) the same integer UID can point at a
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
- const rows = this.db.prepare(`SELECT * FROM messages WHERE ${where} ORDER BY ${sortCol} ${sortDir} LIMIT ? OFFSET ?`).all(...params, pageSize, offset);
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
- getMessageByUid(accountId, uid, folderId) {
526
- const sql = folderId != null
527
- ? "SELECT * FROM messages WHERE account_id = ? AND uid = ? AND folder_id = ?"
528
- : "SELECT * FROM messages WHERE account_id = ? AND uid = ?";
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
- * Messages stored as: {basePath}/{accountId}/{folderId}/{uid}.eml
4
- * Folder IDs are numeric (from SQLite), not names.
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
- private messagePath;
11
- /** Public lookup of the on-disk path without touching the file. */
12
- getMessagePath(accountId: string, folderId: number, uid: number): string;
13
- putMessage(accountId: string, folderId: number, uid: number, raw: Buffer): Promise<string>;
14
- getMessage(accountId: string, folderId: number, uid: number): Promise<Buffer>;
15
- deleteMessage(accountId: string, folderId: number, uid: number): Promise<void>;
16
- hasMessage(accountId: string, folderId: number, uid: number): Promise<boolean>;
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
- * Messages stored as: {basePath}/{accountId}/{folderId}/{uid}.eml
4
- * Folder IDs are numeric (from SQLite), not names.
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
- messagePath(accountId, folderId, uid) {
15
- return path.join(this.basePath, accountId, String(folderId), `${uid}.eml`);
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
- /** Public lookup of the on-disk path without touching the file. */
18
- getMessagePath(accountId, folderId, uid) {
19
- return this.messagePath(accountId, folderId, uid);
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
- async putMessage(accountId, folderId, uid, raw) {
22
- const filePath = this.messagePath(accountId, folderId, uid);
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
- async getMessage(accountId, folderId, uid) {
28
- return fs.readFileSync(this.messagePath(accountId, folderId, uid));
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 deleteMessage(accountId, folderId, uid) {
31
- const filePath = this.messagePath(accountId, folderId, uid);
32
- if (fs.existsSync(filePath))
33
- fs.unlinkSync(filePath);
52
+ async hasByPath(fullPath) {
53
+ if (!this.inStore(fullPath))
54
+ return false;
55
+ return fs.existsSync(fullPath);
34
56
  }
35
- async hasMessage(accountId, folderId, uid) {
36
- return fs.existsSync(this.messagePath(accountId, folderId, uid));
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 {
package/todo.json ADDED
@@ -0,0 +1,13 @@
1
+ {
2
+ "url": "file:///Y:/dev/email/mailx/TODO.md",
3
+ "size": {
4
+ "width": 1000,
5
+ "height": 1200
6
+ },
7
+ "pos": {
8
+ "x": 100,
9
+ "y": 100,
10
+ "screen": 1
11
+ },
12
+ "detach": true
13
+ }