@bobfrankston/mailx 1.0.154 → 1.0.156

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
@@ -657,13 +657,10 @@ async function main() {
657
657
  // Pass server version to dispatch so getVersion returns it
658
658
  const rootPkg = JSON.parse(fs.readFileSync(path.join(import.meta.dirname, "..", "package.json"), "utf-8"));
659
659
  handle.onRequest(async (req) => {
660
- if (verbose)
661
- console.error(`[ipc] ← ${req._action} (${req._cbid})`);
662
- req._version = rootPkg.version;
660
+ console.log(`[ipc] ← ${req._action} (${req._cbid})`);
663
661
  try {
664
662
  const response = await dispatch(svc, req);
665
- if (verbose)
666
- console.error(`[ipc] → ${req._action} (${req._cbid}) ok`);
663
+ console.log(`[ipc] → ${req._action} (${req._cbid}) ok`);
667
664
  handle.send(response);
668
665
  }
669
666
  catch (e) {
@@ -671,12 +668,34 @@ async function main() {
671
668
  handle.send({ _cbid: req._cbid, error: e.message });
672
669
  }
673
670
  });
674
- // Wire IMAP events → push to WebView
671
+ // Wire IMAP events → push to WebView (throttled to avoid flooding stdin)
672
+ let pendingSyncProgress = {};
673
+ let syncProgressTimer = null;
675
674
  imapManager.on("syncProgress", (accountId, phase, progress) => {
676
- handle.send({ _event: "syncProgress", type: "syncProgress", accountId, phase, progress });
675
+ pendingSyncProgress[accountId] = { phase, progress };
676
+ if (!syncProgressTimer) {
677
+ syncProgressTimer = setTimeout(() => {
678
+ syncProgressTimer = null;
679
+ for (const [id, p] of Object.entries(pendingSyncProgress)) {
680
+ handle.send({ _event: "syncProgress", type: "syncProgress", accountId: id, phase: p.phase, progress: p.progress });
681
+ }
682
+ pendingSyncProgress = {};
683
+ }, 500); // batch sync events every 500ms
684
+ }
677
685
  });
686
+ let pendingCounts = {};
687
+ let countsTimer = null;
678
688
  imapManager.on("folderCountsChanged", (accountId, counts) => {
679
- handle.send({ _event: "folderCountsChanged", type: "folderCountsChanged", accountId, counts });
689
+ pendingCounts[accountId] = counts;
690
+ if (!countsTimer) {
691
+ countsTimer = setTimeout(() => {
692
+ countsTimer = null;
693
+ for (const [id, c] of Object.entries(pendingCounts)) {
694
+ handle.send({ _event: "folderCountsChanged", type: "folderCountsChanged", accountId: id, ...c });
695
+ }
696
+ pendingCounts = {};
697
+ }, 1000); // batch count updates every 1s
698
+ }
680
699
  });
681
700
  imapManager.on("syncError", (accountId, error) => {
682
701
  handle.send({ _event: "error", type: "error", message: `${accountId}: ${error}` });
package/client/app.js CHANGED
@@ -292,7 +292,8 @@ async function openCompose(mode) {
292
292
  }
293
293
  // Store init data for compose window to pick up
294
294
  sessionStorage.setItem("composeInit", JSON.stringify(init));
295
- window.open("/compose/compose.html", "_blank", "width=800,height=600,menubar=no,toolbar=no,status=no");
295
+ // Use relative URL so it works with both HTTP and custom protocol (msger://)
296
+ window.open("compose/compose.html", "_blank", "width=800,height=600,menubar=no,toolbar=no,status=no");
296
297
  }
297
298
  function quoteBody(msg) {
298
299
  const date = new Date(msg.date).toLocaleString();
@@ -898,14 +899,20 @@ optAutocomplete?.addEventListener("change", () => {
898
899
  }).catch(() => { });
899
900
  });
900
901
  const isApp = typeof mailxapi !== "undefined" && mailxapi?.isApp;
901
- // Retry getVersion — IPC may not be ready on first call
902
+ // Retry getVersion — first IPC calls may be lost before Rust process is ready
902
903
  async function getVersionWithRetry() {
904
+ // Wait for IPC to be established (first getAccounts succeeds around cbid 3)
905
+ await new Promise(r => setTimeout(r, 3000));
903
906
  for (let i = 0; i < 5; i++) {
904
907
  try {
905
- return await getVersion();
908
+ const result = await Promise.race([
909
+ getVersion(),
910
+ new Promise((_, reject) => setTimeout(() => reject(new Error("timeout")), 5000))
911
+ ]);
912
+ return result;
906
913
  }
907
914
  catch {
908
- await new Promise(r => setTimeout(r, 1000));
915
+ await new Promise(r => setTimeout(r, 2000));
909
916
  }
910
917
  }
911
918
  return { version: "?", storage: {} };
@@ -1017,7 +1024,7 @@ function scheduleMiddnightRefresh() {
1017
1024
  }
1018
1025
  scheduleMiddnightRefresh();
1019
1026
  // ── Apply theme from settings ──
1020
- getVersion().then((d) => {
1027
+ versionPromise.then((d) => {
1021
1028
  if (d.theme === "dark")
1022
1029
  document.documentElement.classList.add("theme-dark");
1023
1030
  else if (d.theme === "light")
@@ -603,11 +603,16 @@ async function loadFolderTree(container) {
603
603
  setTimeout(() => overlay?.remove(), 400);
604
604
  }
605
605
  catch (e) {
606
- const errEl = document.createElement("div");
607
- errEl.className = "folder-loading";
608
- errEl.textContent = `Error loading folders: ${e.message}`;
609
- container.replaceChildren(errEl);
610
- // Dismiss overlay on error too — show the error, not a spinner
606
+ // Don't destroy existing folder tree on error — just log it
607
+ console.error(`Folder tree error: ${e.message}`);
608
+ // Only show error if tree is completely empty (first load failure)
609
+ if (container.children.length === 0 || container.querySelector(".folder-loading")) {
610
+ const errEl = document.createElement("div");
611
+ errEl.className = "folder-loading";
612
+ errEl.textContent = `Error loading folders: ${e.message}`;
613
+ container.replaceChildren(errEl);
614
+ }
615
+ // Dismiss overlay on error too
611
616
  const overlay = document.getElementById("startup-overlay");
612
617
  if (overlay) {
613
618
  const status = document.getElementById("startup-status");
package/client/index.html CHANGED
@@ -128,9 +128,9 @@
128
128
 
129
129
  <footer class="status-bar" id="status-bar">
130
130
  <span id="status-accounts"></span>
131
- <span id="status-sync"></span>
131
+ <span id="status-sync">Syncing...</span>
132
132
  <span id="status-pending"></span>
133
- <span id="status-queue">Queue: empty</span>
133
+ <span id="status-queue"></span>
134
134
  </footer>
135
135
 
136
136
  <div id="startup-overlay" class="startup-overlay">
@@ -73,8 +73,8 @@
73
73
  getUnifiedInbox: function(page, pageSize) {
74
74
  return callNode("getUnifiedInbox", { page: page, pageSize: pageSize });
75
75
  },
76
- getMessage: function(accountId, uid, allowRemote) {
77
- return callNode("getMessage", { accountId: accountId, uid: uid, allowRemote: allowRemote });
76
+ getMessage: function(accountId, uid, allowRemote, folderId) {
77
+ return callNode("getMessage", { accountId: accountId, uid: uid, allowRemote: allowRemote, folderId: folderId });
78
78
  },
79
79
 
80
80
  // Actions
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx",
3
- "version": "1.0.154",
3
+ "version": "1.0.156",
4
4
  "description": "Local-first email client with IMAP sync and standalone native app",
5
5
  "type": "module",
6
6
  "main": "bin/mailx.js",
@@ -23,7 +23,7 @@
23
23
  "@bobfrankston/iflow": "^1.0.53",
24
24
  "@bobfrankston/miscinfo": "^1.0.7",
25
25
  "@bobfrankston/oauthsupport": "^1.0.20",
26
- "@bobfrankston/msger": "^0.1.204",
26
+ "@bobfrankston/msger": "^0.1.206",
27
27
  "@capacitor/android": "^8.3.0",
28
28
  "@capacitor/cli": "^8.3.0",
29
29
  "@capacitor/core": "^8.3.0",
@@ -34,9 +34,6 @@ export declare class ImapManager extends EventEmitter {
34
34
  useNativeClient: boolean;
35
35
  /** Accounts hitting connection limits — back off until this time */
36
36
  private connectionBackoff;
37
- /** Per-account connection semaphore — limits concurrent IMAP connections */
38
- private connectionSemaphore;
39
- private static MAX_CONNECTIONS;
40
37
  constructor(db: MailxDB);
41
38
  /** Get OAuth access token for an account (for SMTP auth) */
42
39
  getOAuthToken(accountId: string): Promise<string | null>;
@@ -52,20 +49,24 @@ export declare class ImapManager extends EventEmitter {
52
49
  searchOnServer(accountId: string, mailboxPath: string, criteria: any): Promise<number[]>;
53
50
  /** Create a fresh IMAP client for an account (public access for API endpoints) */
54
51
  createPublicClient(accountId: string): any;
55
- /** Track active IMAP connections for diagnostics */
56
- private activeConnections;
57
- /** Acquire a connection slot. Resolves when a slot is available. */
58
- private acquireConnection;
59
- /** Release a connection slot, unblocking the next waiter. */
60
- private releaseConnection;
61
- /** Create client with semaphore — acquires slot, wraps logout to release it. */
52
+ /** Persistent operational connections — one per account, reused for all operations */
53
+ private opsClients;
54
+ /** Operation queues ensures sequential access per account */
55
+ private opsQueues;
56
+ /** Get (or create) the persistent operational connection for an account.
57
+ * logout() is wrapped as a no-op so legacy callers don't close it. */
58
+ private getOpsClient;
59
+ /** Run an operation on the account's connection — queued, sequential, no concurrency */
60
+ withConnection<T>(accountId: string, fn: (client: any) => Promise<T>): Promise<T>;
61
+ /** Create a new IMAP client (internal — callers use getOpsClient or withConnection) */
62
+ private newClient;
63
+ /** Disconnect the persistent operational connection for an account */
64
+ disconnectOps(accountId: string): Promise<void>;
65
+ /** Legacy API — callers that still create/destroy connections.
66
+ * These return the persistent ops client. logout() is a no-op
67
+ * (the connection stays alive for reuse). */
62
68
  createClientWithLimit(accountId: string): Promise<any>;
63
- /** Create a fresh IMAP client for an account (disposable, single-use).
64
- * Returns CompatImapClient (native) or ImapClient (imapflow) based on useNativeClient flag.
65
- * The client's logout() is wrapped to auto-decrement the connection counter.
66
- * Prefer createClientWithLimit() for concurrent operations — it waits for a semaphore slot. */
67
69
  private createClient;
68
- /** Track client logout for connection counting (called automatically by wrapped logout) */
69
70
  private trackLogout;
70
71
  /** Number of registered IMAP accounts */
71
72
  getAccountCount(): number;
@@ -78,6 +79,8 @@ export declare class ImapManager extends EventEmitter {
78
79
  /** Sync all folders for all accounts */
79
80
  syncAll(): Promise<void>;
80
81
  private _syncAll;
82
+ /** Handle sync errors — classify and emit appropriate UI events */
83
+ private handleSyncError;
81
84
  /** Sync just INBOX for each account (fast check for new mail) */
82
85
  syncInbox(): Promise<void>;
83
86
  /** Quick inbox check — uses IMAP STATUS (single command, no mailbox open).
@@ -103,8 +106,6 @@ export declare class ImapManager extends EventEmitter {
103
106
  private fetchQueues;
104
107
  /** Serialize body fetch operations per account — prevents concurrent IMAP commands on same connection */
105
108
  private enqueueFetch;
106
- /** Get or create a persistent client for body fetching */
107
- private getFetchClient;
108
109
  /** Fetch a single message body on demand, caching in the store */
109
110
  fetchMessageBody(accountId: string, folderId: number, uid: number): Promise<Buffer | null>;
110
111
  /** Get the body store for direct access */
@@ -106,9 +106,7 @@ export class ImapManager extends EventEmitter {
106
106
  useNativeClient = false;
107
107
  /** Accounts hitting connection limits — back off until this time */
108
108
  connectionBackoff = new Map();
109
- /** Per-account connection semaphore limits concurrent IMAP connections */
110
- connectionSemaphore = new Map();
111
- static MAX_CONNECTIONS = 2; // 1 for all operations (sequential), 1 for IDLE
109
+ // Connection management: see withConnection() below no semaphore needed
112
110
  constructor(db) {
113
111
  super();
114
112
  this.db = db;
@@ -207,74 +205,74 @@ export class ImapManager extends EventEmitter {
207
205
  // Legacy fallback disabled — was doubling connections without helping.
208
206
  // To re-enable: uncomment legacyFallbacks logic in createClient and _syncAll.
209
207
  // private legacyFallbacks = new Set<string>();
210
- /** Track active IMAP connections for diagnostics */
211
- activeConnections = new Map(); // accountId count
212
- /** Acquire a connection slot. Resolves when a slot is available. */
213
- acquireConnection(accountId) {
214
- let sem = this.connectionSemaphore.get(accountId);
215
- if (!sem) {
216
- sem = { active: 0, waiting: [] };
217
- this.connectionSemaphore.set(accountId, sem);
218
- }
219
- if (sem.active < ImapManager.MAX_CONNECTIONS) {
220
- sem.active++;
221
- return Promise.resolve();
222
- }
223
- // At limit — queue and wait
224
- return new Promise((resolve) => {
225
- sem.waiting.push(() => { sem.active++; resolve(); });
226
- });
227
- }
228
- /** Release a connection slot, unblocking the next waiter. */
229
- releaseConnection(accountId) {
230
- const sem = this.connectionSemaphore.get(accountId);
231
- if (!sem)
232
- return;
233
- sem.active = Math.max(0, sem.active - 1);
234
- if (sem.waiting.length > 0 && sem.active < ImapManager.MAX_CONNECTIONS) {
235
- const next = sem.waiting.shift();
236
- next();
237
- }
208
+ // ── Connection management: one persistent connection per account ──
209
+ // All operations on an account are serialized through an operation queue.
210
+ // No semaphore, no pool, no per-operation connect/disconnect.
211
+ // IDLE uses a separate connection (see startWatching).
212
+ /** Persistent operational connections — one per account, reused for all operations */
213
+ opsClients = new Map();
214
+ /** Operation queues ensures sequential access per account */
215
+ opsQueues = new Map();
216
+ /** Get (or create) the persistent operational connection for an account.
217
+ * logout() is wrapped as a no-op so legacy callers don't close it. */
218
+ async getOpsClient(accountId) {
219
+ let client = this.opsClients.get(accountId);
220
+ if (client)
221
+ return client;
222
+ client = this.newClient(accountId);
223
+ // Wrap logout as no-op — this is a persistent connection
224
+ const realLogout = client.logout.bind(client);
225
+ client.logout = async () => { };
226
+ client._realLogout = realLogout; // stash for actual disconnect
227
+ this.opsClients.set(accountId, client);
228
+ console.log(` [conn] ${accountId}: connected`);
229
+ return client;
238
230
  }
239
- /** Create client with semaphore acquires slot, wraps logout to release it. */
240
- async createClientWithLimit(accountId) {
241
- await this.acquireConnection(accountId);
242
- try {
243
- const client = this.createClient(accountId);
244
- // Wrap logout to also release the semaphore slot
245
- const originalLogout = client.logout;
246
- let released = false;
247
- client.logout = async () => {
248
- await originalLogout.call(client);
249
- if (!released) {
250
- released = true;
251
- this.releaseConnection(accountId);
231
+ /** Run an operation on the account's connection queued, sequential, no concurrency */
232
+ async withConnection(accountId, fn) {
233
+ const prev = this.opsQueues.get(accountId) || Promise.resolve();
234
+ const next = prev.then(async () => {
235
+ try {
236
+ const client = await this.getOpsClient(accountId);
237
+ return await fn(client);
238
+ }
239
+ catch (e) {
240
+ // Connection broken — discard it so next operation reconnects
241
+ const stale = this.opsClients.get(accountId);
242
+ this.opsClients.delete(accountId);
243
+ if (stale) {
244
+ try {
245
+ await stale.logout();
246
+ }
247
+ catch { /* */ }
252
248
  }
253
- };
254
- // Safety: release slot if client is never logged out (leak protection)
255
- const leakRelease = setTimeout(() => {
256
- if (!released) {
257
- released = true;
258
- this.releaseConnection(accountId);
249
+ throw e;
250
+ }
251
+ }, async () => {
252
+ // Previous operation failed — still try this one with a fresh connection
253
+ try {
254
+ const client = await this.getOpsClient(accountId);
255
+ return await fn(client);
256
+ }
257
+ catch (e) {
258
+ const stale = this.opsClients.get(accountId);
259
+ this.opsClients.delete(accountId);
260
+ if (stale) {
261
+ try {
262
+ await stale.logout();
263
+ }
264
+ catch { /* */ }
259
265
  }
260
- }, 310000); // slightly after the 5min leak timer in createClient
261
- if (leakRelease.unref)
262
- leakRelease.unref();
263
- return client;
264
- }
265
- catch (e) {
266
- this.releaseConnection(accountId);
267
- throw e;
268
- }
266
+ throw e;
267
+ }
268
+ });
269
+ this.opsQueues.set(accountId, next.catch(() => { }));
270
+ return next;
269
271
  }
270
- /** Create a fresh IMAP client for an account (disposable, single-use).
271
- * Returns CompatImapClient (native) or ImapClient (imapflow) based on useNativeClient flag.
272
- * The client's logout() is wrapped to auto-decrement the connection counter.
273
- * Prefer createClientWithLimit() for concurrent operations — it waits for a semaphore slot. */
274
- createClient(accountId) {
272
+ /** Create a new IMAP client (internal callers use getOpsClient or withConnection) */
273
+ newClient(accountId) {
275
274
  if (this.reauthenticating.has(accountId))
276
275
  throw new Error(`Account ${accountId} is re-authenticating`);
277
- // Check connection backoff
278
276
  const backoffUntil = this.connectionBackoff.get(accountId);
279
277
  if (backoffUntil && Date.now() < backoffUntil) {
280
278
  throw new Error(`Account ${accountId} in connection backoff (${Math.round((backoffUntil - Date.now()) / 1000)}s remaining)`);
@@ -282,54 +280,34 @@ export class ImapManager extends EventEmitter {
282
280
  const config = this.configs.get(accountId);
283
281
  if (!config)
284
282
  throw new Error(`No config for account ${accountId}`);
285
- const count = (this.activeConnections.get(accountId) || 0) + 1;
286
- // Hard limit: warn if exceeding max, but still allow (callers should use createClientWithLimit)
287
- if (count > ImapManager.MAX_CONNECTIONS) {
288
- console.warn(` [conn] ${accountId}: WARNING exceeding limit (${count} > ${ImapManager.MAX_CONNECTIONS})`);
289
- }
290
- this.activeConnections.set(accountId, count);
291
- const clientType = this.useNativeClient ? "native" : "imapflow";
292
- console.log(` [conn] ${accountId}: +1 ${clientType} (${count} active)`);
293
- let client;
294
283
  if (this.useNativeClient) {
295
- client = new CompatImapClient(config, () => new NodeTransport({ rejectUnauthorized: config.rejectUnauthorized !== false }));
284
+ return new CompatImapClient(config, () => new NodeTransport({ rejectUnauthorized: config.rejectUnauthorized !== false }));
296
285
  }
297
- else {
298
- client = new ImapClient(config);
299
- }
300
- // Wrap logout to auto-decrement connection counter (prevents leaks from missed trackLogout calls)
301
- const originalLogout = client.logout.bind(client);
302
- let loggedOut = false;
303
- const doTrackLogout = () => {
304
- if (!loggedOut) {
305
- loggedOut = true;
306
- this.trackLogout(accountId);
307
- }
308
- };
309
- client.logout = async () => {
310
- await originalLogout();
311
- doTrackLogout();
312
- };
313
- // Safety net: if client isn't logged out within 5 minutes, assume it leaked
314
- const leakTimer = setTimeout(() => {
315
- if (!loggedOut) {
316
- console.warn(` [conn] ${accountId}: connection leaked (5min timeout) — forcing decrement`);
317
- doTrackLogout();
286
+ return new ImapClient(config);
287
+ }
288
+ /** Disconnect the persistent operational connection for an account */
289
+ async disconnectOps(accountId) {
290
+ const client = this.opsClients.get(accountId);
291
+ this.opsClients.delete(accountId);
292
+ if (client) {
293
+ try {
294
+ await (client._realLogout || client.logout)();
318
295
  }
319
- }, 300000);
320
- // Clear the timer if logout happens normally
321
- const origDoTrack = doTrackLogout;
322
- // Prevent timer from keeping process alive
323
- if (leakTimer.unref)
324
- leakTimer.unref();
325
- return client;
296
+ catch { /* */ }
297
+ console.log(` [conn] ${accountId}: disconnected`);
298
+ }
326
299
  }
327
- /** Track client logout for connection counting (called automatically by wrapped logout) */
328
- trackLogout(accountId) {
329
- const count = Math.max(0, (this.activeConnections.get(accountId) || 1) - 1);
330
- this.activeConnections.set(accountId, count);
331
- console.log(` [conn] ${accountId}: -1 (${count} active)`);
300
+ /** Legacy API callers that still create/destroy connections.
301
+ * These return the persistent ops client. logout() is a no-op
302
+ * (the connection stays alive for reuse). */
303
+ async createClientWithLimit(accountId) {
304
+ return this.getOpsClient(accountId);
332
305
  }
306
+ createClient(accountId) {
307
+ // Return a fresh disposable client (used by IDLE watcher and one-off operations)
308
+ return this.newClient(accountId);
309
+ }
310
+ trackLogout(_accountId) { }
333
311
  /** Number of registered IMAP accounts */
334
312
  getAccountCount() { return this.configs.size; }
335
313
  /** Register an account */
@@ -616,156 +594,87 @@ export class ImapManager extends EventEmitter {
616
594
  }
617
595
  }
618
596
  async _syncAll() {
619
- // Phase 1: Sync folder lists and inboxes for ALL accounts first
620
- // so every account has content visible quickly
621
- const accountFolders = new Map();
597
+ const priorityOrder = ["sent", "drafts", "archive", "junk", "trash"];
598
+ // Sync each account sequentially one connection each, reused for all operations
622
599
  for (const [accountId] of this.configs) {
623
- let client = null;
624
600
  try {
625
- const t0 = Date.now();
626
- client = await this.createClientWithLimit(accountId);
627
- const folders = await withTimeout(this.syncFolders(accountId, client), 30000, client, "Folder list");
628
- console.log(` [timing] ${accountId}: folder list ${Date.now() - t0}ms (${folders.length} folders)`);
629
- // Legacy fallback removed was doubling connections.
630
- // If native client has issues, set useNativeClient=false or use --legacy-imap flag.
631
- await client.logout();
632
- client = null;
633
- accountFolders.set(accountId, folders);
634
- // Sync inbox immediately
635
- const inbox = folders.find(f => f.specialUse === "inbox");
636
- if (inbox) {
637
- try {
638
- client = await this.createClientWithLimit(accountId);
639
- await withTimeout(this.syncFolder(accountId, inbox.id, client), 60000, client, "Inbox sync");
640
- await client.logout();
641
- client = null;
601
+ await this.withConnection(accountId, async (client) => {
602
+ // Step 1: Get folder list
603
+ const t0 = Date.now();
604
+ const folders = await withTimeout(this.syncFolders(accountId, client), 30000, client, "Folder list");
605
+ console.log(` [timing] ${accountId}: folder list ${Date.now() - t0}ms (${folders.length} folders)`);
606
+ // Step 2: Sync INBOX first (most important)
607
+ const inbox = folders.find(f => f.specialUse === "inbox");
608
+ if (inbox) {
609
+ try {
610
+ await this.syncFolder(accountId, inbox.id, client);
611
+ }
612
+ catch (e) {
613
+ console.error(` Inbox sync error for ${accountId}: ${e.message}`);
614
+ }
642
615
  }
643
- catch (e) {
644
- if (client) {
645
- try {
646
- await client.logout();
616
+ // Step 3: Sync remaining folders in priority order
617
+ const remaining = folders.filter(f => f.specialUse !== "inbox");
618
+ remaining.sort((a, b) => {
619
+ const pa = priorityOrder.indexOf(a.specialUse || "") >= 0 ? priorityOrder.indexOf(a.specialUse || "") : 5;
620
+ const pb = priorityOrder.indexOf(b.specialUse || "") >= 0 ? priorityOrder.indexOf(b.specialUse || "") : 5;
621
+ return pa - pb;
622
+ });
623
+ for (const folder of remaining) {
624
+ const isTrashChild = folder.path.includes("/") && folder.path.toLowerCase().startsWith("trash");
625
+ const highestUid = this.db.getHighestUid(accountId, folder.id);
626
+ if (isTrashChild && highestUid === 0)
627
+ continue; // defer trash subfolders on first sync
628
+ try {
629
+ await this.syncFolder(accountId, folder.id, client);
630
+ }
631
+ catch (e) {
632
+ if (e.responseText?.includes("doesn't exist")) {
633
+ this.db.deleteFolder(folder.id);
634
+ }
635
+ else {
636
+ console.error(` Skipping folder ${folder.path}: ${e.message}`);
647
637
  }
648
- catch { /* ignore */ }
649
- this.trackLogout(accountId);
650
- client = null;
651
638
  }
652
- console.error(` Inbox sync error for ${accountId}: ${e.message}`);
653
639
  }
654
- }
640
+ });
641
+ this.accountErrorShown.delete(accountId);
642
+ this.emit("syncComplete", accountId);
655
643
  }
656
644
  catch (e) {
657
645
  const errMsg = imapError(e);
658
646
  this.emit("syncError", accountId, errMsg);
659
647
  console.error(`Sync error for ${accountId}: ${errMsg}`);
660
- const config = this.configs.get(accountId);
661
- const isOAuth = !!config?.tokenProvider;
662
- // Connection limit — back off for 60 seconds
663
- if (errMsg.includes("max_userip_connections") || errMsg.includes("Too many simultaneous")) {
664
- this.connectionBackoff.set(accountId, Date.now() + 60000);
665
- console.log(` [backoff] ${accountId}: connection limit hit, backing off 60s`);
666
- }
667
- // Classify error: transient (timeout, connection) vs auth (credentials, token)
668
- const isTransient = /timeout|ECONNREFUSED|ECONNRESET|ETIMEDOUT|ENETUNREACH|Too many/i.test(errMsg);
669
- const isAuth = /auth|login|credential|token|AUTHENTICATIONFAILED/i.test(errMsg);
670
- if (isTransient) {
671
- // Transient: just log, will auto-retry on next sync cycle
672
- console.log(` [transient] ${accountId}: ${errMsg} — will retry next cycle`);
673
- }
674
- else if (isAuth && isOAuth) {
675
- // OAuth auth error: auto-reauth ONCE, then show banner
676
- const lastReauth = this.lastReauthAttempt.get(accountId) || 0;
677
- const cooldown = Date.now() - lastReauth > 300000; // 5 min cooldown
678
- if (cooldown && !this.reauthenticating.has(accountId)) {
679
- this.lastReauthAttempt.set(accountId, Date.now());
680
- console.log(` [auth] ${accountId}: attempting automatic re-authentication...`);
681
- this.reauthenticate(accountId).then(ok => {
682
- if (!ok && !this.accountErrorShown.has(accountId)) {
683
- this.accountErrorShown.add(accountId);
684
- this.emit("accountError", accountId, errMsg, "Authentication expired — re-authenticate", true);
685
- }
686
- }).catch(() => {
687
- if (!this.accountErrorShown.has(accountId)) {
688
- this.accountErrorShown.add(accountId);
689
- this.emit("accountError", accountId, errMsg, "Authentication expired — re-authenticate", true);
690
- }
691
- });
692
- }
693
- else if (!this.accountErrorShown.has(accountId)) {
694
- this.accountErrorShown.add(accountId);
695
- this.emit("accountError", accountId, errMsg, "Authentication expired — re-authenticate", true);
696
- }
697
- }
698
- else if (!this.accountErrorShown.has(accountId)) {
699
- // Non-transient, non-OAuth: show error banner
700
- this.accountErrorShown.add(accountId);
701
- this.emit("accountError", accountId, errMsg, isOAuth ? "Authentication may have expired" : "Check server connectivity", isOAuth);
702
- }
703
- }
704
- finally {
705
- if (client) {
706
- try {
707
- await client.logout();
708
- }
709
- catch { /* ignore */ }
710
- this.trackLogout(accountId);
711
- }
648
+ this.handleSyncError(accountId, errMsg);
712
649
  }
713
650
  }
714
- // Phase 2: Sync remaining folders — priority order, skip Trash subfolders on first sync
715
- const priorityOrder = ["sent", "drafts", "archive", "junk", "trash"];
716
- for (const [accountId, folders] of accountFolders) {
717
- // Sort: sent/drafts first, then regular, then trash subfolders last
718
- const remaining = folders.filter(f => f.specialUse !== "inbox");
719
- remaining.sort((a, b) => {
720
- const pa = priorityOrder.indexOf(a.specialUse || "") >= 0 ? priorityOrder.indexOf(a.specialUse || "") : 5;
721
- const pb = priorityOrder.indexOf(b.specialUse || "") >= 0 ? priorityOrder.indexOf(b.specialUse || "") : 5;
722
- return pa - pb;
723
- });
724
- // Reuse one IMAP connection per account for all folders (avoid 87+ TLS handshakes)
725
- let client = null;
726
- try {
727
- client = await this.createClientWithLimit(accountId);
728
- for (const folder of remaining) {
729
- // Skip Trash subfolders on first sync they're large and low priority
730
- const isTrashChild = folder.path.includes("/") && folder.path.toLowerCase().startsWith("trash");
731
- const highestUid = this.db.getHighestUid(accountId, folder.id);
732
- if (isTrashChild && highestUid === 0) {
733
- console.log(` Deferring first sync of ${folder.path} (Trash subfolder)`);
734
- continue;
735
- }
736
- // Longer timeout for folders we know are large (Trash, first sync)
737
- const timeout = highestUid === 0 ? 180000 : 60000;
738
- try {
739
- await withTimeout(this.syncFolder(accountId, folder.id, client), timeout, client, `Sync ${folder.path}`);
740
- }
741
- catch (e) {
742
- if (e.responseText?.includes("doesn't exist")) {
743
- console.log(` Removing non-existent folder: ${folder.path}`);
744
- this.db.deleteFolder(folder.id);
745
- }
746
- else {
747
- console.error(` Skipping folder ${folder.path}: ${e.message}`);
748
- // Connection may be broken — reconnect
749
- try {
750
- await client.logout();
751
- }
752
- catch { /* */ }
753
- client = await this.createClientWithLimit(accountId);
754
- }
755
- }
756
- }
651
+ }
652
+ /** Handle sync errors classify and emit appropriate UI events */
653
+ handleSyncError(accountId, errMsg) {
654
+ if (errMsg.includes("max_userip_connections") || errMsg.includes("Too many simultaneous")) {
655
+ this.connectionBackoff.set(accountId, Date.now() + 60000);
656
+ }
657
+ const config = this.configs.get(accountId);
658
+ const isOAuth = !!config?.tokenProvider;
659
+ const isTransient = /timeout|ECONNREFUSED|ECONNRESET|ETIMEDOUT|ENETUNREACH|Too many/i.test(errMsg);
660
+ const isAuth = /auth|login|credential|token|AUTHENTICATIONFAILED/i.test(errMsg);
661
+ if (isTransient) {
662
+ console.log(` [transient] ${accountId}: ${errMsg} — will retry next cycle`);
663
+ }
664
+ else if (isAuth && isOAuth) {
665
+ const lastReauth = this.lastReauthAttempt.get(accountId) || 0;
666
+ if (Date.now() - lastReauth > 300000 && !this.reauthenticating.has(accountId)) {
667
+ this.lastReauthAttempt.set(accountId, Date.now());
668
+ this.reauthenticate(accountId).catch(() => { });
757
669
  }
758
- finally {
759
- if (client) {
760
- try {
761
- await client.logout();
762
- }
763
- catch { /* */ }
764
- this.trackLogout(accountId);
765
- }
670
+ if (!this.accountErrorShown.has(accountId)) {
671
+ this.accountErrorShown.add(accountId);
672
+ this.emit("accountError", accountId, errMsg, "Authentication expired — re-authenticate", true);
766
673
  }
767
- this.accountErrorShown.delete(accountId);
768
- this.emit("syncComplete", accountId);
674
+ }
675
+ else if (!this.accountErrorShown.has(accountId)) {
676
+ this.accountErrorShown.add(accountId);
677
+ this.emit("accountError", accountId, errMsg, isOAuth ? "Authentication may have expired" : "Check server connectivity", isOAuth);
769
678
  }
770
679
  }
771
680
  /** Sync just INBOX for each account (fast check for new mail) */
@@ -775,42 +684,17 @@ export class ImapManager extends EventEmitter {
775
684
  this.inboxSyncing = true;
776
685
  try {
777
686
  for (const [accountId] of this.configs) {
778
- let client = null;
779
687
  try {
780
688
  const inbox = this.db.getFolders(accountId).find(f => f.specialUse === "inbox");
781
689
  if (!inbox)
782
690
  continue;
783
- // Try up to 2 times with fresh clients
784
- for (let attempt = 0; attempt < 2; attempt++) {
785
- try {
786
- client = await this.createClientWithLimit(accountId);
787
- await this.syncFolder(accountId, inbox.id, client);
788
- await client.logout();
789
- client = null;
790
- break;
791
- }
792
- catch (retryErr) {
793
- if (client)
794
- try {
795
- await client.logout();
796
- }
797
- catch { /* ignore */ }
798
- client = null;
799
- if (attempt === 1)
800
- throw retryErr;
801
- }
802
- }
691
+ await this.withConnection(accountId, async (client) => {
692
+ await this.syncFolder(accountId, inbox.id, client);
693
+ });
803
694
  }
804
695
  catch (e) {
805
696
  console.error(` [inbox] Sync error for ${accountId}: ${e.message}`);
806
697
  }
807
- finally {
808
- if (client)
809
- try {
810
- await client.logout();
811
- }
812
- catch { /* ignore */ }
813
- }
814
698
  }
815
699
  }
816
700
  finally {
@@ -827,40 +711,25 @@ export class ImapManager extends EventEmitter {
827
711
  return;
828
712
  if (this.reauthenticating.has(accountId))
829
713
  return;
830
- // Skip if at connection limit — don't queue, just skip this cycle
831
- const sem = this.connectionSemaphore.get(accountId);
832
- if (sem && sem.active >= ImapManager.MAX_CONNECTIONS)
833
- return;
834
714
  this.quickCheckRunning.add(accountId);
835
- let client = null;
836
715
  try {
837
716
  const inbox = this.db.getFolders(accountId).find(f => f.specialUse === "inbox");
838
717
  if (!inbox)
839
718
  return;
840
- client = await this.createClientWithLimit(accountId);
841
- const count = await client.getMessagesCount("INBOX");
842
- await client.logout();
843
- client = null;
844
- const prev = this.lastInboxCounts.get(accountId) ?? count;
845
- this.lastInboxCounts.set(accountId, count);
846
- if (count !== prev) {
847
- console.log(` [check] ${accountId} INBOX: ${prev} → ${count}`);
848
- client = await this.createClientWithLimit(accountId);
849
- await this.syncFolder(accountId, inbox.id, client);
850
- await client.logout();
851
- client = null;
852
- }
719
+ await this.withConnection(accountId, async (client) => {
720
+ const count = await client.getMessagesCount("INBOX");
721
+ const prev = this.lastInboxCounts.get(accountId) ?? count;
722
+ this.lastInboxCounts.set(accountId, count);
723
+ if (count !== prev) {
724
+ console.log(` [check] ${accountId} INBOX: ${prev} → ${count}`);
725
+ await this.syncFolder(accountId, inbox.id, client);
726
+ }
727
+ });
853
728
  }
854
729
  catch {
855
730
  // Lightweight check — silently ignore errors
856
731
  }
857
732
  finally {
858
- if (client) {
859
- try {
860
- await client.logout();
861
- }
862
- catch { /* ignore */ }
863
- }
864
733
  this.quickCheckRunning.delete(accountId);
865
734
  }
866
735
  }
@@ -885,8 +754,10 @@ export class ImapManager extends EventEmitter {
885
754
  this.syncIntervals.set(`quick:${accountId}`, timer);
886
755
  console.log(` [periodic] ${accountId}: STATUS check every ${interval / 1000}s (${this.isOAuthAccount(accountId) ? "OAuth" : "password"})`);
887
756
  }
888
- // Sync actions (sends + flags/deletes/moves) every 30 seconds
757
+ // Sync actions (sends + flags/deletes/moves) every 30 seconds — skip during active sync
889
758
  const actionsInterval = setInterval(async () => {
759
+ if (this.syncing)
760
+ return;
890
761
  for (const [accountId] of this.configs) {
891
762
  this.processSendActions(accountId).catch(() => { });
892
763
  this.processSyncActions(accountId).catch(() => { });
@@ -920,7 +791,9 @@ export class ImapManager extends EventEmitter {
920
791
  if (this.watchers.has(accountId))
921
792
  continue;
922
793
  try {
923
- const watchClient = await this.createClientWithLimit(accountId);
794
+ // IDLE uses createClient (not createClientWithLimit) — it's a persistent
795
+ // background connection that must NOT consume a semaphore slot
796
+ const watchClient = this.createClient(accountId);
924
797
  const stop = await watchClient.watchMailbox("INBOX", (newCount) => {
925
798
  console.log(` [idle] ${accountId}: ${newCount} new message(s)`);
926
799
  // Sync just INBOX for speed — full sync runs on the configured interval
@@ -957,15 +830,7 @@ export class ImapManager extends EventEmitter {
957
830
  this.fetchQueues.set(accountId, next);
958
831
  return next;
959
832
  }
960
- /** Get or create a persistent client for body fetching */
961
- async getFetchClient(accountId) {
962
- let client = this.fetchClients.get(accountId);
963
- if (!client) {
964
- client = await this.createClientWithLimit(accountId);
965
- this.fetchClients.set(accountId, client);
966
- }
967
- return client;
968
- }
833
+ // Body fetch uses withConnection no separate client needed
969
834
  /** Fetch a single message body on demand, caching in the store */
970
835
  async fetchMessageBody(accountId, folderId, uid) {
971
836
  // Already cached?
@@ -983,33 +848,21 @@ export class ImapManager extends EventEmitter {
983
848
  if (await this.bodyStore.hasMessage(accountId, folderId, uid)) {
984
849
  return this.bodyStore.getMessage(accountId, folderId, uid);
985
850
  }
986
- for (let attempt = 0; attempt < 2; attempt++) {
987
- try {
988
- const client = await this.getFetchClient(accountId);
989
- // 30s timeout — prevents hanging on stale connections
990
- const msg = await withTimeout(client.fetchMessageByUid(folder.path, uid, { source: true }), 30000, client, "Body fetch");
851
+ try {
852
+ return await this.withConnection(accountId, async (client) => {
853
+ const msg = await client.fetchMessageByUid(folder.path, uid, { source: true });
991
854
  if (!msg?.source)
992
855
  return null;
993
856
  const raw = Buffer.from(msg.source, "utf-8");
994
857
  const bodyPath = await this.bodyStore.putMessage(accountId, folderId, uid, raw);
995
858
  this.db.updateBodyPath(accountId, uid, bodyPath);
996
859
  return raw;
997
- }
998
- catch (e) {
999
- console.error(` Body fetch error (${accountId}/${uid} attempt ${attempt + 1}): ${e.message}`);
1000
- const stale = this.fetchClients.get(accountId);
1001
- this.fetchClients.delete(accountId);
1002
- if (stale) {
1003
- try {
1004
- await stale.logout();
1005
- }
1006
- catch { /* ignore */ }
1007
- }
1008
- if (attempt === 1)
1009
- return null;
1010
- }
860
+ });
861
+ }
862
+ catch (e) {
863
+ console.error(` Body fetch error (${accountId}/${uid}): ${e.message}`);
864
+ return null;
1011
865
  }
1012
- return null;
1013
866
  });
1014
867
  }
1015
868
  /** Get the body store for direct access */
@@ -1136,7 +989,8 @@ export class ImapManager extends EventEmitter {
1136
989
  async updateFlagsLocal(accountId, uid, folderId, flags) {
1137
990
  this.db.updateMessageFlags(accountId, uid, flags);
1138
991
  this.db.queueSyncAction(accountId, "flags", uid, folderId, { flags });
1139
- this.processSyncActions(accountId).catch(() => { });
992
+ // Don't process immediately — let the 30s timer batch actions
993
+ // (immediate processing during sync causes connection churn)
1140
994
  }
1141
995
  /** Process pending sync actions for an account */
1142
996
  async processSyncActions(accountId) {
@@ -1144,8 +998,7 @@ export class ImapManager extends EventEmitter {
1144
998
  if (actions.length === 0)
1145
999
  return;
1146
1000
  const folders = this.db.getFolders(accountId);
1147
- const client = await this.createClientWithLimit(accountId);
1148
- try {
1001
+ await this.withConnection(accountId, async (client) => {
1149
1002
  for (const action of actions) {
1150
1003
  const folder = folders.find(f => f.id === action.folderId);
1151
1004
  if (!folder) {
@@ -1198,13 +1051,7 @@ export class ImapManager extends EventEmitter {
1198
1051
  }
1199
1052
  }
1200
1053
  }
1201
- }
1202
- finally {
1203
- try {
1204
- await client.logout();
1205
- }
1206
- catch { /* ignore */ }
1207
- }
1054
+ });
1208
1055
  }
1209
1056
  /** Find a folder by specialUse, case-insensitive */
1210
1057
  findFolder(accountId, specialUse) {
@@ -1727,27 +1574,10 @@ export class ImapManager extends EventEmitter {
1727
1574
  this.stopPeriodicSync();
1728
1575
  this.stopOutboxWorker();
1729
1576
  await this.stopWatching();
1730
- // Disconnect all persistent fetch clients
1731
- for (const [, client] of this.fetchClients) {
1732
- try {
1733
- await client.logout();
1734
- }
1735
- catch { /* ignore */ }
1736
- }
1737
- this.fetchClients.clear();
1738
- // Force-release all semaphore slots to unblock any waiting operations
1739
- for (const [accountId, sem] of this.connectionSemaphore) {
1740
- sem.active = 0;
1741
- for (const waiter of sem.waiting) {
1742
- try {
1743
- waiter();
1744
- }
1745
- catch { /* */ }
1746
- }
1747
- sem.waiting.length = 0;
1577
+ // Disconnect all persistent operational connections
1578
+ for (const [accountId] of this.opsClients) {
1579
+ await this.disconnectOps(accountId);
1748
1580
  }
1749
- this.connectionSemaphore.clear();
1750
- this.activeConnections.clear();
1751
1581
  }
1752
1582
  }
1753
1583
  //# sourceMappingURL=index.js.map