@bobfrankston/mailx 1.0.155 → 1.0.157
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 +2 -1
- package/client/app.js +5 -15
- package/client/components/message-list.js +4 -2
- package/client/components/message-viewer.js +3 -3
- package/client/lib/mailxapi.js +24 -3
- package/package.json +2 -2
- package/packages/mailx-imap/index.d.ts +22 -17
- package/packages/mailx-imap/index.js +224 -359
package/bin/mailx.js
CHANGED
|
@@ -703,8 +703,9 @@ async function main() {
|
|
|
703
703
|
imapManager.on("accountError", (accountId, error, hint, isOAuth) => {
|
|
704
704
|
handle.send({ _event: "accountError", type: "accountError", accountId, error, hint, isOAuth });
|
|
705
705
|
});
|
|
706
|
-
// Wait for WebView2 initialization
|
|
706
|
+
// Wait for WebView2 initialization, then signal readiness
|
|
707
707
|
await new Promise(r => setTimeout(r, 2000));
|
|
708
|
+
handle.send({ _event: "ready", type: "ready" });
|
|
708
709
|
// Register all accounts (OAuth may open browser for Gmail — event loop stays free for IPC)
|
|
709
710
|
for (const account of settings.accounts) {
|
|
710
711
|
if (!account.enabled)
|
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,19 +899,8 @@ optAutocomplete?.addEventListener("change", () => {
|
|
|
898
899
|
}).catch(() => { });
|
|
899
900
|
});
|
|
900
901
|
const isApp = typeof mailxapi !== "undefined" && mailxapi?.isApp;
|
|
901
|
-
//
|
|
902
|
-
|
|
903
|
-
for (let i = 0; i < 5; i++) {
|
|
904
|
-
try {
|
|
905
|
-
return await getVersion();
|
|
906
|
-
}
|
|
907
|
-
catch {
|
|
908
|
-
await new Promise(r => setTimeout(r, 1000));
|
|
909
|
-
}
|
|
910
|
-
}
|
|
911
|
-
return { version: "?", storage: {} };
|
|
912
|
-
}
|
|
913
|
-
const versionPromise = getVersionWithRetry();
|
|
902
|
+
// Wait for server ready signal, then fetch version
|
|
903
|
+
const versionPromise = getVersion();
|
|
914
904
|
versionPromise.then((d) => {
|
|
915
905
|
const el = document.getElementById("app-version");
|
|
916
906
|
const storage = d.storage || {};
|
|
@@ -1017,7 +1007,7 @@ function scheduleMiddnightRefresh() {
|
|
|
1017
1007
|
}
|
|
1018
1008
|
scheduleMiddnightRefresh();
|
|
1019
1009
|
// ── Apply theme from settings ──
|
|
1020
|
-
|
|
1010
|
+
versionPromise.then((d) => {
|
|
1021
1011
|
if (d.theme === "dark")
|
|
1022
1012
|
document.documentElement.classList.add("theme-dark");
|
|
1023
1013
|
else if (d.theme === "light")
|
|
@@ -102,8 +102,7 @@ export async function loadUnifiedInbox(autoSelect = true) {
|
|
|
102
102
|
const result = await getUnifiedInbox(1);
|
|
103
103
|
totalMessages = result.total;
|
|
104
104
|
if (result.items.length === 0) {
|
|
105
|
-
body.innerHTML = `<div class="ml-empty">
|
|
106
|
-
clearViewer();
|
|
105
|
+
body.innerHTML = `<div class="ml-empty">${result.total > 0 ? `${result.total} messages syncing...` : "Syncing — messages will appear shortly"}</div>`;
|
|
107
106
|
return;
|
|
108
107
|
}
|
|
109
108
|
// Build new rows into a fragment, then swap atomically (no flash)
|
|
@@ -187,6 +186,9 @@ export async function loadSearchResults(query, scope = "all", accountId = "", fo
|
|
|
187
186
|
}
|
|
188
187
|
}
|
|
189
188
|
export async function loadMessages(accountId, folderId, page = 1, specialUse = "", autoSelect = true) {
|
|
189
|
+
// Clear viewer when navigating to a new folder (not on reloads)
|
|
190
|
+
if (autoSelect)
|
|
191
|
+
clearViewer();
|
|
190
192
|
searchMode = false;
|
|
191
193
|
unifiedMode = false;
|
|
192
194
|
showToInsteadOfFrom = ["sent", "drafts", "outbox"].includes(specialUse) ||
|
|
@@ -19,8 +19,8 @@ export async function showMessage(accountId, uid, folderId, specialUse, isRetry
|
|
|
19
19
|
const headerEl = document.getElementById("mv-header");
|
|
20
20
|
const bodyEl = document.getElementById("mv-body");
|
|
21
21
|
const attEl = document.getElementById("mv-attachments");
|
|
22
|
-
bodyEl.innerHTML = `<div class="mv-empty">
|
|
23
|
-
|
|
22
|
+
bodyEl.innerHTML = `<div class="mv-empty">Fetching message body...</div>`;
|
|
23
|
+
// Don't hide the header — keep previous header visible until new one loads
|
|
24
24
|
attEl.hidden = true;
|
|
25
25
|
try {
|
|
26
26
|
const msg = await getMessage(accountId, uid, false, folderId);
|
|
@@ -102,7 +102,7 @@ export async function showMessage(accountId, uid, folderId, specialUse, isRetry
|
|
|
102
102
|
draftFolderId: msg.folderId,
|
|
103
103
|
};
|
|
104
104
|
sessionStorage.setItem("composeInit", JSON.stringify(init));
|
|
105
|
-
window.open("
|
|
105
|
+
window.open("compose/compose.html", "_blank", "width=800,height=600,menubar=no,toolbar=no,status=no");
|
|
106
106
|
};
|
|
107
107
|
}
|
|
108
108
|
else {
|
package/client/lib/mailxapi.js
CHANGED
|
@@ -12,6 +12,8 @@
|
|
|
12
12
|
var _callbacks = {};
|
|
13
13
|
var _callbackId = 0;
|
|
14
14
|
var _eventHandlers = [];
|
|
15
|
+
var _ready = false;
|
|
16
|
+
var _pendingCalls = []; // buffered until server sends "ready"
|
|
15
17
|
|
|
16
18
|
function callNode(action, params) {
|
|
17
19
|
var id = String(++_callbackId);
|
|
@@ -22,10 +24,14 @@
|
|
|
22
24
|
}, 120000);
|
|
23
25
|
_callbacks[id] = { resolve: resolve, reject: reject, timer: timer };
|
|
24
26
|
var msg = Object.assign({ _action: action, _cbid: id }, params || {});
|
|
27
|
+
if (!_ready) {
|
|
28
|
+
// Buffer until server is ready (early calls are lost in the pipe)
|
|
29
|
+
_pendingCalls.push(msg);
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
25
32
|
if (window.ipc && window.ipc.postMessage) {
|
|
26
33
|
window.ipc.postMessage(JSON.stringify(msg));
|
|
27
34
|
} else {
|
|
28
|
-
// Fallback: should not happen in WebView
|
|
29
35
|
clearTimeout(timer);
|
|
30
36
|
delete _callbacks[id];
|
|
31
37
|
reject(new Error("No IPC channel available"));
|
|
@@ -33,6 +39,16 @@
|
|
|
33
39
|
});
|
|
34
40
|
}
|
|
35
41
|
|
|
42
|
+
function flushPending() {
|
|
43
|
+
_ready = true;
|
|
44
|
+
var pending = _pendingCalls.splice(0);
|
|
45
|
+
for (var i = 0; i < pending.length; i++) {
|
|
46
|
+
if (window.ipc && window.ipc.postMessage) {
|
|
47
|
+
window.ipc.postMessage(JSON.stringify(pending[i]));
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
36
52
|
// Called by Rust to resolve promises
|
|
37
53
|
window._mailxapiResolve = function(id, value) {
|
|
38
54
|
var cb = _callbacks[id];
|
|
@@ -53,6 +69,11 @@
|
|
|
53
69
|
|
|
54
70
|
// Called by Rust to push events (new mail, sync progress, etc.)
|
|
55
71
|
window._mailxapiEvent = function(event) {
|
|
72
|
+
// "ready" signal from server — flush buffered IPC calls
|
|
73
|
+
if (event && event.type === "ready") {
|
|
74
|
+
flushPending();
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
56
77
|
for (var i = 0; i < _eventHandlers.length; i++) {
|
|
57
78
|
try { _eventHandlers[i](event); } catch(e) { /* ignore */ }
|
|
58
79
|
}
|
|
@@ -73,8 +94,8 @@
|
|
|
73
94
|
getUnifiedInbox: function(page, pageSize) {
|
|
74
95
|
return callNode("getUnifiedInbox", { page: page, pageSize: pageSize });
|
|
75
96
|
},
|
|
76
|
-
getMessage: function(accountId, uid, allowRemote) {
|
|
77
|
-
return callNode("getMessage", { accountId: accountId, uid: uid, allowRemote: allowRemote });
|
|
97
|
+
getMessage: function(accountId, uid, allowRemote, folderId) {
|
|
98
|
+
return callNode("getMessage", { accountId: accountId, uid: uid, allowRemote: allowRemote, folderId: folderId });
|
|
78
99
|
},
|
|
79
100
|
|
|
80
101
|
// Actions
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bobfrankston/mailx",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.157",
|
|
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.207",
|
|
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,12 @@ export declare class ImapManager extends EventEmitter {
|
|
|
78
79
|
/** Sync all folders for all accounts */
|
|
79
80
|
syncAll(): Promise<void>;
|
|
80
81
|
private _syncAll;
|
|
82
|
+
/** Sync a single account — manages its own connection lifecycle */
|
|
83
|
+
private syncAccount;
|
|
84
|
+
/** Kill and recreate the persistent ops connection */
|
|
85
|
+
private reconnectOps;
|
|
86
|
+
/** Handle sync errors — classify and emit appropriate UI events */
|
|
87
|
+
private handleSyncError;
|
|
81
88
|
/** Sync just INBOX for each account (fast check for new mail) */
|
|
82
89
|
syncInbox(): Promise<void>;
|
|
83
90
|
/** Quick inbox check — uses IMAP STATUS (single command, no mailbox open).
|
|
@@ -103,8 +110,6 @@ export declare class ImapManager extends EventEmitter {
|
|
|
103
110
|
private fetchQueues;
|
|
104
111
|
/** Serialize body fetch operations per account — prevents concurrent IMAP commands on same connection */
|
|
105
112
|
private enqueueFetch;
|
|
106
|
-
/** Get or create a persistent client for body fetching */
|
|
107
|
-
private getFetchClient;
|
|
108
113
|
/** Fetch a single message body on demand, caching in the store */
|
|
109
114
|
fetchMessageBody(accountId: string, folderId: number, uid: number): Promise<Buffer | null>;
|
|
110
115
|
/** 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 */
|
|
@@ -400,7 +378,10 @@ export class ImapManager extends EventEmitter {
|
|
|
400
378
|
this.db.upsertFolder(accountId, folder.path, folder.name || folder.path.split(folder.delimiter || "/").pop() || folder.path, specialUse, folder.delimiter || "/");
|
|
401
379
|
}
|
|
402
380
|
this.emit("syncProgress", accountId, "folders", 100);
|
|
403
|
-
|
|
381
|
+
// Notify UI that folder structure changed — triggers tree re-render
|
|
382
|
+
const dbFolders = this.db.getFolders(accountId);
|
|
383
|
+
this.emit("folderCountsChanged", accountId, {});
|
|
384
|
+
return dbFolders;
|
|
404
385
|
}
|
|
405
386
|
/** Sync messages for a specific folder */
|
|
406
387
|
async syncFolder(accountId, folderId, client) {
|
|
@@ -616,158 +597,115 @@ export class ImapManager extends EventEmitter {
|
|
|
616
597
|
}
|
|
617
598
|
}
|
|
618
599
|
async _syncAll() {
|
|
619
|
-
|
|
620
|
-
//
|
|
621
|
-
const
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
client = await this.createClientWithLimit(accountId);
|
|
639
|
-
const inboxTimeout = this.db.getHighestUid(accountId, inbox.id) === 0 ? 300000 : 60000;
|
|
640
|
-
await withTimeout(this.syncFolder(accountId, inbox.id, client), inboxTimeout, client, "Inbox sync");
|
|
641
|
-
await client.logout();
|
|
642
|
-
client = null;
|
|
643
|
-
}
|
|
644
|
-
catch (e) {
|
|
645
|
-
if (client) {
|
|
646
|
-
try {
|
|
647
|
-
await client.logout();
|
|
648
|
-
}
|
|
649
|
-
catch { /* ignore */ }
|
|
650
|
-
this.trackLogout(accountId);
|
|
651
|
-
client = null;
|
|
652
|
-
}
|
|
653
|
-
console.error(` Inbox sync error for ${accountId}: ${e.message}`);
|
|
654
|
-
}
|
|
655
|
-
}
|
|
656
|
-
}
|
|
657
|
-
catch (e) {
|
|
658
|
-
const errMsg = imapError(e);
|
|
659
|
-
this.emit("syncError", accountId, errMsg);
|
|
660
|
-
console.error(`Sync error for ${accountId}: ${errMsg}`);
|
|
661
|
-
const config = this.configs.get(accountId);
|
|
662
|
-
const isOAuth = !!config?.tokenProvider;
|
|
663
|
-
// Connection limit — back off for 60 seconds
|
|
664
|
-
if (errMsg.includes("max_userip_connections") || errMsg.includes("Too many simultaneous")) {
|
|
665
|
-
this.connectionBackoff.set(accountId, Date.now() + 60000);
|
|
666
|
-
console.log(` [backoff] ${accountId}: connection limit hit, backing off 60s`);
|
|
667
|
-
}
|
|
668
|
-
// Classify error: transient (timeout, connection) vs auth (credentials, token)
|
|
669
|
-
const isTransient = /timeout|ECONNREFUSED|ECONNRESET|ETIMEDOUT|ENETUNREACH|Too many/i.test(errMsg);
|
|
670
|
-
const isAuth = /auth|login|credential|token|AUTHENTICATIONFAILED/i.test(errMsg);
|
|
671
|
-
if (isTransient) {
|
|
672
|
-
// Transient: just log, will auto-retry on next sync cycle
|
|
673
|
-
console.log(` [transient] ${accountId}: ${errMsg} — will retry next cycle`);
|
|
674
|
-
}
|
|
675
|
-
else if (isAuth && isOAuth) {
|
|
676
|
-
// OAuth auth error: auto-reauth ONCE, then show banner
|
|
677
|
-
const lastReauth = this.lastReauthAttempt.get(accountId) || 0;
|
|
678
|
-
const cooldown = Date.now() - lastReauth > 300000; // 5 min cooldown
|
|
679
|
-
if (cooldown && !this.reauthenticating.has(accountId)) {
|
|
680
|
-
this.lastReauthAttempt.set(accountId, Date.now());
|
|
681
|
-
console.log(` [auth] ${accountId}: attempting automatic re-authentication...`);
|
|
682
|
-
this.reauthenticate(accountId).then(ok => {
|
|
683
|
-
if (!ok && !this.accountErrorShown.has(accountId)) {
|
|
684
|
-
this.accountErrorShown.add(accountId);
|
|
685
|
-
this.emit("accountError", accountId, errMsg, "Authentication expired — re-authenticate", true);
|
|
686
|
-
}
|
|
687
|
-
}).catch(() => {
|
|
688
|
-
if (!this.accountErrorShown.has(accountId)) {
|
|
689
|
-
this.accountErrorShown.add(accountId);
|
|
690
|
-
this.emit("accountError", accountId, errMsg, "Authentication expired — re-authenticate", true);
|
|
691
|
-
}
|
|
692
|
-
});
|
|
693
|
-
}
|
|
694
|
-
else if (!this.accountErrorShown.has(accountId)) {
|
|
695
|
-
this.accountErrorShown.add(accountId);
|
|
696
|
-
this.emit("accountError", accountId, errMsg, "Authentication expired — re-authenticate", true);
|
|
697
|
-
}
|
|
698
|
-
}
|
|
699
|
-
else if (!this.accountErrorShown.has(accountId)) {
|
|
700
|
-
// Non-transient, non-OAuth: show error banner
|
|
701
|
-
this.accountErrorShown.add(accountId);
|
|
702
|
-
this.emit("accountError", accountId, errMsg, isOAuth ? "Authentication may have expired" : "Check server connectivity", isOAuth);
|
|
600
|
+
const priorityOrder = ["sent", "drafts", "archive", "junk", "trash"];
|
|
601
|
+
// Sync all accounts in parallel — each manages its own connection
|
|
602
|
+
const syncPromises = [...this.configs.keys()].map(accountId => this.syncAccount(accountId, priorityOrder));
|
|
603
|
+
await Promise.allSettled(syncPromises);
|
|
604
|
+
}
|
|
605
|
+
/** Sync a single account — manages its own connection lifecycle */
|
|
606
|
+
async syncAccount(accountId, priorityOrder) {
|
|
607
|
+
try {
|
|
608
|
+
// Step 1: Get folder list (fast — <1s typically)
|
|
609
|
+
let client = await this.getOpsClient(accountId);
|
|
610
|
+
const t0 = Date.now();
|
|
611
|
+
const folders = await this.syncFolders(accountId, client);
|
|
612
|
+
console.log(` [timing] ${accountId}: folder list ${Date.now() - t0}ms (${folders.length} folders)`);
|
|
613
|
+
// Step 2: Sync INBOX first
|
|
614
|
+
const inbox = folders.find(f => f.specialUse === "inbox");
|
|
615
|
+
if (inbox) {
|
|
616
|
+
try {
|
|
617
|
+
client = await this.getOpsClient(accountId);
|
|
618
|
+
await this.syncFolder(accountId, inbox.id, client);
|
|
703
619
|
}
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
try {
|
|
708
|
-
await client.logout();
|
|
709
|
-
}
|
|
710
|
-
catch { /* ignore */ }
|
|
711
|
-
this.trackLogout(accountId);
|
|
620
|
+
catch (e) {
|
|
621
|
+
console.error(` Inbox sync error for ${accountId}: ${e.message}`);
|
|
622
|
+
await this.reconnectOps(accountId);
|
|
712
623
|
}
|
|
713
624
|
}
|
|
714
|
-
|
|
715
|
-
// Phase 2: Sync remaining folders — priority order, skip Trash subfolders on first sync
|
|
716
|
-
const priorityOrder = ["sent", "drafts", "archive", "junk", "trash"];
|
|
717
|
-
for (const [accountId, folders] of accountFolders) {
|
|
718
|
-
// Sort: sent/drafts first, then regular, then trash subfolders last
|
|
625
|
+
// Step 3: Sync remaining folders
|
|
719
626
|
const remaining = folders.filter(f => f.specialUse !== "inbox");
|
|
720
627
|
remaining.sort((a, b) => {
|
|
721
628
|
const pa = priorityOrder.indexOf(a.specialUse || "") >= 0 ? priorityOrder.indexOf(a.specialUse || "") : 5;
|
|
722
629
|
const pb = priorityOrder.indexOf(b.specialUse || "") >= 0 ? priorityOrder.indexOf(b.specialUse || "") : 5;
|
|
723
630
|
return pa - pb;
|
|
724
631
|
});
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
await withTimeout(this.syncFolder(accountId, folder.id, client), timeout, client, `Sync ${folder.path}`);
|
|
632
|
+
let consecutiveErrors = 0;
|
|
633
|
+
for (const folder of remaining) {
|
|
634
|
+
const isTrashChild = folder.path.includes("/") && folder.path.toLowerCase().startsWith("trash");
|
|
635
|
+
const highestUid = this.db.getHighestUid(accountId, folder.id);
|
|
636
|
+
if (isTrashChild && highestUid === 0)
|
|
637
|
+
continue;
|
|
638
|
+
try {
|
|
639
|
+
client = await this.getOpsClient(accountId);
|
|
640
|
+
await this.syncFolder(accountId, folder.id, client);
|
|
641
|
+
consecutiveErrors = 0;
|
|
642
|
+
}
|
|
643
|
+
catch (e) {
|
|
644
|
+
consecutiveErrors++;
|
|
645
|
+
if (e.responseText?.includes("doesn't exist")) {
|
|
646
|
+
this.db.deleteFolder(folder.id);
|
|
741
647
|
}
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
}
|
|
747
|
-
else {
|
|
748
|
-
console.error(` Skipping folder ${folder.path}: ${e.message}`);
|
|
749
|
-
// Connection may be broken — reconnect
|
|
750
|
-
try {
|
|
751
|
-
await client.logout();
|
|
752
|
-
}
|
|
753
|
-
catch { /* */ }
|
|
754
|
-
client = await this.createClientWithLimit(accountId);
|
|
755
|
-
}
|
|
648
|
+
else {
|
|
649
|
+
console.error(` Skipping ${folder.path}: ${e.message}`);
|
|
650
|
+
// Connection is probably dead — reconnect
|
|
651
|
+
await this.reconnectOps(accountId);
|
|
756
652
|
}
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
try {
|
|
762
|
-
await client.logout();
|
|
653
|
+
// Too many consecutive errors = connection fundamentally broken
|
|
654
|
+
if (consecutiveErrors >= 3) {
|
|
655
|
+
console.error(` [sync] ${accountId}: ${consecutiveErrors} consecutive errors — aborting sync`);
|
|
656
|
+
break;
|
|
763
657
|
}
|
|
764
|
-
catch { /* */ }
|
|
765
|
-
this.trackLogout(accountId);
|
|
766
658
|
}
|
|
767
659
|
}
|
|
768
660
|
this.accountErrorShown.delete(accountId);
|
|
769
661
|
this.emit("syncComplete", accountId);
|
|
770
662
|
}
|
|
663
|
+
catch (e) {
|
|
664
|
+
const errMsg = imapError(e);
|
|
665
|
+
this.emit("syncError", accountId, errMsg);
|
|
666
|
+
console.error(`Sync error for ${accountId}: ${errMsg}`);
|
|
667
|
+
this.handleSyncError(accountId, errMsg);
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
/** Kill and recreate the persistent ops connection */
|
|
671
|
+
async reconnectOps(accountId) {
|
|
672
|
+
const old = this.opsClients.get(accountId);
|
|
673
|
+
this.opsClients.delete(accountId);
|
|
674
|
+
if (old) {
|
|
675
|
+
try {
|
|
676
|
+
await (old._realLogout || old.logout)();
|
|
677
|
+
}
|
|
678
|
+
catch { /* */ }
|
|
679
|
+
}
|
|
680
|
+
console.log(` [conn] ${accountId}: reconnecting`);
|
|
681
|
+
}
|
|
682
|
+
/** Handle sync errors — classify and emit appropriate UI events */
|
|
683
|
+
handleSyncError(accountId, errMsg) {
|
|
684
|
+
if (errMsg.includes("max_userip_connections") || errMsg.includes("Too many simultaneous")) {
|
|
685
|
+
this.connectionBackoff.set(accountId, Date.now() + 60000);
|
|
686
|
+
}
|
|
687
|
+
const config = this.configs.get(accountId);
|
|
688
|
+
const isOAuth = !!config?.tokenProvider;
|
|
689
|
+
const isTransient = /timeout|ECONNREFUSED|ECONNRESET|ETIMEDOUT|ENETUNREACH|Too many/i.test(errMsg);
|
|
690
|
+
const isAuth = /auth|login|credential|token|AUTHENTICATIONFAILED/i.test(errMsg);
|
|
691
|
+
if (isTransient) {
|
|
692
|
+
console.log(` [transient] ${accountId}: ${errMsg} — will retry next cycle`);
|
|
693
|
+
}
|
|
694
|
+
else if (isAuth && isOAuth) {
|
|
695
|
+
const lastReauth = this.lastReauthAttempt.get(accountId) || 0;
|
|
696
|
+
if (Date.now() - lastReauth > 300000 && !this.reauthenticating.has(accountId)) {
|
|
697
|
+
this.lastReauthAttempt.set(accountId, Date.now());
|
|
698
|
+
this.reauthenticate(accountId).catch(() => { });
|
|
699
|
+
}
|
|
700
|
+
if (!this.accountErrorShown.has(accountId)) {
|
|
701
|
+
this.accountErrorShown.add(accountId);
|
|
702
|
+
this.emit("accountError", accountId, errMsg, "Authentication expired — re-authenticate", true);
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
else if (!this.accountErrorShown.has(accountId)) {
|
|
706
|
+
this.accountErrorShown.add(accountId);
|
|
707
|
+
this.emit("accountError", accountId, errMsg, isOAuth ? "Authentication may have expired" : "Check server connectivity", isOAuth);
|
|
708
|
+
}
|
|
771
709
|
}
|
|
772
710
|
/** Sync just INBOX for each account (fast check for new mail) */
|
|
773
711
|
async syncInbox() {
|
|
@@ -776,42 +714,17 @@ export class ImapManager extends EventEmitter {
|
|
|
776
714
|
this.inboxSyncing = true;
|
|
777
715
|
try {
|
|
778
716
|
for (const [accountId] of this.configs) {
|
|
779
|
-
let client = null;
|
|
780
717
|
try {
|
|
781
718
|
const inbox = this.db.getFolders(accountId).find(f => f.specialUse === "inbox");
|
|
782
719
|
if (!inbox)
|
|
783
720
|
continue;
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
client = await this.createClientWithLimit(accountId);
|
|
788
|
-
await this.syncFolder(accountId, inbox.id, client);
|
|
789
|
-
await client.logout();
|
|
790
|
-
client = null;
|
|
791
|
-
break;
|
|
792
|
-
}
|
|
793
|
-
catch (retryErr) {
|
|
794
|
-
if (client)
|
|
795
|
-
try {
|
|
796
|
-
await client.logout();
|
|
797
|
-
}
|
|
798
|
-
catch { /* ignore */ }
|
|
799
|
-
client = null;
|
|
800
|
-
if (attempt === 1)
|
|
801
|
-
throw retryErr;
|
|
802
|
-
}
|
|
803
|
-
}
|
|
721
|
+
await this.withConnection(accountId, async (client) => {
|
|
722
|
+
await this.syncFolder(accountId, inbox.id, client);
|
|
723
|
+
});
|
|
804
724
|
}
|
|
805
725
|
catch (e) {
|
|
806
726
|
console.error(` [inbox] Sync error for ${accountId}: ${e.message}`);
|
|
807
727
|
}
|
|
808
|
-
finally {
|
|
809
|
-
if (client)
|
|
810
|
-
try {
|
|
811
|
-
await client.logout();
|
|
812
|
-
}
|
|
813
|
-
catch { /* ignore */ }
|
|
814
|
-
}
|
|
815
728
|
}
|
|
816
729
|
}
|
|
817
730
|
finally {
|
|
@@ -828,40 +741,25 @@ export class ImapManager extends EventEmitter {
|
|
|
828
741
|
return;
|
|
829
742
|
if (this.reauthenticating.has(accountId))
|
|
830
743
|
return;
|
|
831
|
-
// Skip if at connection limit — don't queue, just skip this cycle
|
|
832
|
-
const sem = this.connectionSemaphore.get(accountId);
|
|
833
|
-
if (sem && sem.active >= ImapManager.MAX_CONNECTIONS)
|
|
834
|
-
return;
|
|
835
744
|
this.quickCheckRunning.add(accountId);
|
|
836
|
-
let client = null;
|
|
837
745
|
try {
|
|
838
746
|
const inbox = this.db.getFolders(accountId).find(f => f.specialUse === "inbox");
|
|
839
747
|
if (!inbox)
|
|
840
748
|
return;
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
await this.syncFolder(accountId, inbox.id, client);
|
|
851
|
-
await client.logout();
|
|
852
|
-
client = null;
|
|
853
|
-
}
|
|
749
|
+
await this.withConnection(accountId, async (client) => {
|
|
750
|
+
const count = await client.getMessagesCount("INBOX");
|
|
751
|
+
const prev = this.lastInboxCounts.get(accountId) ?? count;
|
|
752
|
+
this.lastInboxCounts.set(accountId, count);
|
|
753
|
+
if (count !== prev) {
|
|
754
|
+
console.log(` [check] ${accountId} INBOX: ${prev} → ${count}`);
|
|
755
|
+
await this.syncFolder(accountId, inbox.id, client);
|
|
756
|
+
}
|
|
757
|
+
});
|
|
854
758
|
}
|
|
855
759
|
catch {
|
|
856
760
|
// Lightweight check — silently ignore errors
|
|
857
761
|
}
|
|
858
762
|
finally {
|
|
859
|
-
if (client) {
|
|
860
|
-
try {
|
|
861
|
-
await client.logout();
|
|
862
|
-
}
|
|
863
|
-
catch { /* ignore */ }
|
|
864
|
-
}
|
|
865
763
|
this.quickCheckRunning.delete(accountId);
|
|
866
764
|
}
|
|
867
765
|
}
|
|
@@ -923,7 +821,9 @@ export class ImapManager extends EventEmitter {
|
|
|
923
821
|
if (this.watchers.has(accountId))
|
|
924
822
|
continue;
|
|
925
823
|
try {
|
|
926
|
-
|
|
824
|
+
// IDLE uses createClient (not createClientWithLimit) — it's a persistent
|
|
825
|
+
// background connection that must NOT consume a semaphore slot
|
|
826
|
+
const watchClient = this.createClient(accountId);
|
|
927
827
|
const stop = await watchClient.watchMailbox("INBOX", (newCount) => {
|
|
928
828
|
console.log(` [idle] ${accountId}: ${newCount} new message(s)`);
|
|
929
829
|
// Sync just INBOX for speed — full sync runs on the configured interval
|
|
@@ -960,15 +860,7 @@ export class ImapManager extends EventEmitter {
|
|
|
960
860
|
this.fetchQueues.set(accountId, next);
|
|
961
861
|
return next;
|
|
962
862
|
}
|
|
963
|
-
|
|
964
|
-
async getFetchClient(accountId) {
|
|
965
|
-
let client = this.fetchClients.get(accountId);
|
|
966
|
-
if (!client) {
|
|
967
|
-
client = await this.createClientWithLimit(accountId);
|
|
968
|
-
this.fetchClients.set(accountId, client);
|
|
969
|
-
}
|
|
970
|
-
return client;
|
|
971
|
-
}
|
|
863
|
+
// Body fetch uses withConnection — no separate client needed
|
|
972
864
|
/** Fetch a single message body on demand, caching in the store */
|
|
973
865
|
async fetchMessageBody(accountId, folderId, uid) {
|
|
974
866
|
// Already cached?
|
|
@@ -986,33 +878,30 @@ export class ImapManager extends EventEmitter {
|
|
|
986
878
|
if (await this.bodyStore.hasMessage(accountId, folderId, uid)) {
|
|
987
879
|
return this.bodyStore.getMessage(accountId, folderId, uid);
|
|
988
880
|
}
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
}
|
|
1009
|
-
catch { /* ignore */ }
|
|
881
|
+
// Body fetch uses a fresh connection — never waits behind background sync
|
|
882
|
+
let client = null;
|
|
883
|
+
try {
|
|
884
|
+
client = this.newClient(accountId);
|
|
885
|
+
const msg = await client.fetchMessageByUid(folder.path, uid, { source: true });
|
|
886
|
+
await client.logout();
|
|
887
|
+
client = null;
|
|
888
|
+
if (!msg?.source)
|
|
889
|
+
return null;
|
|
890
|
+
const raw = Buffer.from(msg.source, "utf-8");
|
|
891
|
+
const bodyPath = await this.bodyStore.putMessage(accountId, folderId, uid, raw);
|
|
892
|
+
this.db.updateBodyPath(accountId, uid, bodyPath);
|
|
893
|
+
return raw;
|
|
894
|
+
}
|
|
895
|
+
catch (e) {
|
|
896
|
+
console.error(` Body fetch error (${accountId}/${uid}): ${e.message}`);
|
|
897
|
+
if (client) {
|
|
898
|
+
try {
|
|
899
|
+
await client.logout();
|
|
1010
900
|
}
|
|
1011
|
-
|
|
1012
|
-
return null;
|
|
901
|
+
catch { /* */ }
|
|
1013
902
|
}
|
|
903
|
+
return null;
|
|
1014
904
|
}
|
|
1015
|
-
return null;
|
|
1016
905
|
});
|
|
1017
906
|
}
|
|
1018
907
|
/** Get the body store for direct access */
|
|
@@ -1148,8 +1037,7 @@ export class ImapManager extends EventEmitter {
|
|
|
1148
1037
|
if (actions.length === 0)
|
|
1149
1038
|
return;
|
|
1150
1039
|
const folders = this.db.getFolders(accountId);
|
|
1151
|
-
|
|
1152
|
-
try {
|
|
1040
|
+
await this.withConnection(accountId, async (client) => {
|
|
1153
1041
|
for (const action of actions) {
|
|
1154
1042
|
const folder = folders.find(f => f.id === action.folderId);
|
|
1155
1043
|
if (!folder) {
|
|
@@ -1202,13 +1090,7 @@ export class ImapManager extends EventEmitter {
|
|
|
1202
1090
|
}
|
|
1203
1091
|
}
|
|
1204
1092
|
}
|
|
1205
|
-
}
|
|
1206
|
-
finally {
|
|
1207
|
-
try {
|
|
1208
|
-
await client.logout();
|
|
1209
|
-
}
|
|
1210
|
-
catch { /* ignore */ }
|
|
1211
|
-
}
|
|
1093
|
+
});
|
|
1212
1094
|
}
|
|
1213
1095
|
/** Find a folder by specialUse, case-insensitive */
|
|
1214
1096
|
findFolder(accountId, specialUse) {
|
|
@@ -1731,27 +1613,10 @@ export class ImapManager extends EventEmitter {
|
|
|
1731
1613
|
this.stopPeriodicSync();
|
|
1732
1614
|
this.stopOutboxWorker();
|
|
1733
1615
|
await this.stopWatching();
|
|
1734
|
-
// Disconnect all persistent
|
|
1735
|
-
for (const [
|
|
1736
|
-
|
|
1737
|
-
await client.logout();
|
|
1738
|
-
}
|
|
1739
|
-
catch { /* ignore */ }
|
|
1740
|
-
}
|
|
1741
|
-
this.fetchClients.clear();
|
|
1742
|
-
// Force-release all semaphore slots to unblock any waiting operations
|
|
1743
|
-
for (const [accountId, sem] of this.connectionSemaphore) {
|
|
1744
|
-
sem.active = 0;
|
|
1745
|
-
for (const waiter of sem.waiting) {
|
|
1746
|
-
try {
|
|
1747
|
-
waiter();
|
|
1748
|
-
}
|
|
1749
|
-
catch { /* */ }
|
|
1750
|
-
}
|
|
1751
|
-
sem.waiting.length = 0;
|
|
1616
|
+
// Disconnect all persistent operational connections
|
|
1617
|
+
for (const [accountId] of this.opsClients) {
|
|
1618
|
+
await this.disconnectOps(accountId);
|
|
1752
1619
|
}
|
|
1753
|
-
this.connectionSemaphore.clear();
|
|
1754
|
-
this.activeConnections.clear();
|
|
1755
1620
|
}
|
|
1756
1621
|
}
|
|
1757
1622
|
//# sourceMappingURL=index.js.map
|