@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 +27 -8
- package/client/app.js +12 -5
- package/client/components/folder-tree.js +10 -5
- package/client/index.html +2 -2
- package/client/lib/mailxapi.js +2 -2
- package/package.json +2 -2
- package/packages/mailx-imap/index.d.ts +18 -17
- package/packages/mailx-imap/index.js +186 -356
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
container.
|
|
610
|
-
|
|
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"
|
|
131
|
+
<span id="status-sync">Syncing...</span>
|
|
132
132
|
<span id="status-pending"></span>
|
|
133
|
-
<span id="status-queue"
|
|
133
|
+
<span id="status-queue"></span>
|
|
134
134
|
</footer>
|
|
135
135
|
|
|
136
136
|
<div id="startup-overlay" class="startup-overlay">
|
package/client/lib/mailxapi.js
CHANGED
|
@@ -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.
|
|
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.
|
|
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
|
-
/**
|
|
56
|
-
private
|
|
57
|
-
/**
|
|
58
|
-
private
|
|
59
|
-
/**
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
|
|
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
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
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
|
-
/**
|
|
240
|
-
async
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
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
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
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
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
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
|
|
271
|
-
|
|
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
|
-
|
|
284
|
+
return new CompatImapClient(config, () => new NodeTransport({ rejectUnauthorized: config.rejectUnauthorized !== false }));
|
|
296
285
|
}
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
const
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
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
|
-
|
|
320
|
-
|
|
321
|
-
|
|
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
|
-
/**
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
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
|
-
|
|
620
|
-
//
|
|
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
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
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
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
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
|
-
|
|
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
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
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
|
-
|
|
759
|
-
|
|
760
|
-
|
|
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
|
-
|
|
768
|
-
|
|
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
|
-
|
|
784
|
-
|
|
785
|
-
|
|
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
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
987
|
-
|
|
988
|
-
const
|
|
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
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1731
|
-
for (const [
|
|
1732
|
-
|
|
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
|