@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 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.362",
3
+ "version": "1.0.368",
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,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
- let consecutiveErrors = 0;
1091
- 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) => {
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
- continue;
1153
+ return;
1096
1154
  try {
1097
- client = await this.getOpsClient(accountId);
1098
- await this.syncFolder(accountId, folder.id, client);
1099
- 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
+ ]);
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
- // Connection is probably dead — reconnect
1109
- await this.reconnectOps(accountId);
1110
- }
1111
- // Too many consecutive errors = connection fundamentally broken
1112
- if (consecutiveErrors >= 3) {
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
- 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 || "";
@@ -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
+ }