@bobfrankston/mailx 1.0.155 → 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/client/app.js +12 -5
- 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 +181 -355
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")
|
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,157 +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
|
-
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;
|
|
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
|
+
}
|
|
643
615
|
}
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
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}`);
|
|
648
637
|
}
|
|
649
|
-
catch { /* ignore */ }
|
|
650
|
-
this.trackLogout(accountId);
|
|
651
|
-
client = null;
|
|
652
638
|
}
|
|
653
|
-
console.error(` Inbox sync error for ${accountId}: ${e.message}`);
|
|
654
639
|
}
|
|
655
|
-
}
|
|
640
|
+
});
|
|
641
|
+
this.accountErrorShown.delete(accountId);
|
|
642
|
+
this.emit("syncComplete", accountId);
|
|
656
643
|
}
|
|
657
644
|
catch (e) {
|
|
658
645
|
const errMsg = imapError(e);
|
|
659
646
|
this.emit("syncError", accountId, errMsg);
|
|
660
647
|
console.error(`Sync error for ${accountId}: ${errMsg}`);
|
|
661
|
-
|
|
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);
|
|
703
|
-
}
|
|
704
|
-
}
|
|
705
|
-
finally {
|
|
706
|
-
if (client) {
|
|
707
|
-
try {
|
|
708
|
-
await client.logout();
|
|
709
|
-
}
|
|
710
|
-
catch { /* ignore */ }
|
|
711
|
-
this.trackLogout(accountId);
|
|
712
|
-
}
|
|
648
|
+
this.handleSyncError(accountId, errMsg);
|
|
713
649
|
}
|
|
714
650
|
}
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
if (isTrashChild && highestUid === 0) {
|
|
734
|
-
console.log(` Deferring first sync of ${folder.path} (Trash subfolder)`);
|
|
735
|
-
continue;
|
|
736
|
-
}
|
|
737
|
-
// Longer timeout for folders we know are large (Trash, first sync)
|
|
738
|
-
const timeout = highestUid === 0 ? 180000 : 60000;
|
|
739
|
-
try {
|
|
740
|
-
await withTimeout(this.syncFolder(accountId, folder.id, client), timeout, client, `Sync ${folder.path}`);
|
|
741
|
-
}
|
|
742
|
-
catch (e) {
|
|
743
|
-
if (e.responseText?.includes("doesn't exist")) {
|
|
744
|
-
console.log(` Removing non-existent folder: ${folder.path}`);
|
|
745
|
-
this.db.deleteFolder(folder.id);
|
|
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
|
-
}
|
|
756
|
-
}
|
|
757
|
-
}
|
|
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(() => { });
|
|
758
669
|
}
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
await client.logout();
|
|
763
|
-
}
|
|
764
|
-
catch { /* */ }
|
|
765
|
-
this.trackLogout(accountId);
|
|
766
|
-
}
|
|
670
|
+
if (!this.accountErrorShown.has(accountId)) {
|
|
671
|
+
this.accountErrorShown.add(accountId);
|
|
672
|
+
this.emit("accountError", accountId, errMsg, "Authentication expired — re-authenticate", true);
|
|
767
673
|
}
|
|
768
|
-
|
|
769
|
-
|
|
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);
|
|
770
678
|
}
|
|
771
679
|
}
|
|
772
680
|
/** Sync just INBOX for each account (fast check for new mail) */
|
|
@@ -776,42 +684,17 @@ export class ImapManager extends EventEmitter {
|
|
|
776
684
|
this.inboxSyncing = true;
|
|
777
685
|
try {
|
|
778
686
|
for (const [accountId] of this.configs) {
|
|
779
|
-
let client = null;
|
|
780
687
|
try {
|
|
781
688
|
const inbox = this.db.getFolders(accountId).find(f => f.specialUse === "inbox");
|
|
782
689
|
if (!inbox)
|
|
783
690
|
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
|
-
}
|
|
691
|
+
await this.withConnection(accountId, async (client) => {
|
|
692
|
+
await this.syncFolder(accountId, inbox.id, client);
|
|
693
|
+
});
|
|
804
694
|
}
|
|
805
695
|
catch (e) {
|
|
806
696
|
console.error(` [inbox] Sync error for ${accountId}: ${e.message}`);
|
|
807
697
|
}
|
|
808
|
-
finally {
|
|
809
|
-
if (client)
|
|
810
|
-
try {
|
|
811
|
-
await client.logout();
|
|
812
|
-
}
|
|
813
|
-
catch { /* ignore */ }
|
|
814
|
-
}
|
|
815
698
|
}
|
|
816
699
|
}
|
|
817
700
|
finally {
|
|
@@ -828,40 +711,25 @@ export class ImapManager extends EventEmitter {
|
|
|
828
711
|
return;
|
|
829
712
|
if (this.reauthenticating.has(accountId))
|
|
830
713
|
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
714
|
this.quickCheckRunning.add(accountId);
|
|
836
|
-
let client = null;
|
|
837
715
|
try {
|
|
838
716
|
const inbox = this.db.getFolders(accountId).find(f => f.specialUse === "inbox");
|
|
839
717
|
if (!inbox)
|
|
840
718
|
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
|
-
}
|
|
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
|
+
});
|
|
854
728
|
}
|
|
855
729
|
catch {
|
|
856
730
|
// Lightweight check — silently ignore errors
|
|
857
731
|
}
|
|
858
732
|
finally {
|
|
859
|
-
if (client) {
|
|
860
|
-
try {
|
|
861
|
-
await client.logout();
|
|
862
|
-
}
|
|
863
|
-
catch { /* ignore */ }
|
|
864
|
-
}
|
|
865
733
|
this.quickCheckRunning.delete(accountId);
|
|
866
734
|
}
|
|
867
735
|
}
|
|
@@ -923,7 +791,9 @@ export class ImapManager extends EventEmitter {
|
|
|
923
791
|
if (this.watchers.has(accountId))
|
|
924
792
|
continue;
|
|
925
793
|
try {
|
|
926
|
-
|
|
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);
|
|
927
797
|
const stop = await watchClient.watchMailbox("INBOX", (newCount) => {
|
|
928
798
|
console.log(` [idle] ${accountId}: ${newCount} new message(s)`);
|
|
929
799
|
// Sync just INBOX for speed — full sync runs on the configured interval
|
|
@@ -960,15 +830,7 @@ export class ImapManager extends EventEmitter {
|
|
|
960
830
|
this.fetchQueues.set(accountId, next);
|
|
961
831
|
return next;
|
|
962
832
|
}
|
|
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
|
-
}
|
|
833
|
+
// Body fetch uses withConnection — no separate client needed
|
|
972
834
|
/** Fetch a single message body on demand, caching in the store */
|
|
973
835
|
async fetchMessageBody(accountId, folderId, uid) {
|
|
974
836
|
// Already cached?
|
|
@@ -986,33 +848,21 @@ export class ImapManager extends EventEmitter {
|
|
|
986
848
|
if (await this.bodyStore.hasMessage(accountId, folderId, uid)) {
|
|
987
849
|
return this.bodyStore.getMessage(accountId, folderId, uid);
|
|
988
850
|
}
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
const
|
|
992
|
-
// 30s timeout — prevents hanging on stale connections
|
|
993
|
-
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 });
|
|
994
854
|
if (!msg?.source)
|
|
995
855
|
return null;
|
|
996
856
|
const raw = Buffer.from(msg.source, "utf-8");
|
|
997
857
|
const bodyPath = await this.bodyStore.putMessage(accountId, folderId, uid, raw);
|
|
998
858
|
this.db.updateBodyPath(accountId, uid, bodyPath);
|
|
999
859
|
return raw;
|
|
1000
|
-
}
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
if (stale) {
|
|
1006
|
-
try {
|
|
1007
|
-
await stale.logout();
|
|
1008
|
-
}
|
|
1009
|
-
catch { /* ignore */ }
|
|
1010
|
-
}
|
|
1011
|
-
if (attempt === 1)
|
|
1012
|
-
return null;
|
|
1013
|
-
}
|
|
860
|
+
});
|
|
861
|
+
}
|
|
862
|
+
catch (e) {
|
|
863
|
+
console.error(` Body fetch error (${accountId}/${uid}): ${e.message}`);
|
|
864
|
+
return null;
|
|
1014
865
|
}
|
|
1015
|
-
return null;
|
|
1016
866
|
});
|
|
1017
867
|
}
|
|
1018
868
|
/** Get the body store for direct access */
|
|
@@ -1148,8 +998,7 @@ export class ImapManager extends EventEmitter {
|
|
|
1148
998
|
if (actions.length === 0)
|
|
1149
999
|
return;
|
|
1150
1000
|
const folders = this.db.getFolders(accountId);
|
|
1151
|
-
|
|
1152
|
-
try {
|
|
1001
|
+
await this.withConnection(accountId, async (client) => {
|
|
1153
1002
|
for (const action of actions) {
|
|
1154
1003
|
const folder = folders.find(f => f.id === action.folderId);
|
|
1155
1004
|
if (!folder) {
|
|
@@ -1202,13 +1051,7 @@ export class ImapManager extends EventEmitter {
|
|
|
1202
1051
|
}
|
|
1203
1052
|
}
|
|
1204
1053
|
}
|
|
1205
|
-
}
|
|
1206
|
-
finally {
|
|
1207
|
-
try {
|
|
1208
|
-
await client.logout();
|
|
1209
|
-
}
|
|
1210
|
-
catch { /* ignore */ }
|
|
1211
|
-
}
|
|
1054
|
+
});
|
|
1212
1055
|
}
|
|
1213
1056
|
/** Find a folder by specialUse, case-insensitive */
|
|
1214
1057
|
findFolder(accountId, specialUse) {
|
|
@@ -1731,27 +1574,10 @@ export class ImapManager extends EventEmitter {
|
|
|
1731
1574
|
this.stopPeriodicSync();
|
|
1732
1575
|
this.stopOutboxWorker();
|
|
1733
1576
|
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;
|
|
1577
|
+
// Disconnect all persistent operational connections
|
|
1578
|
+
for (const [accountId] of this.opsClients) {
|
|
1579
|
+
await this.disconnectOps(accountId);
|
|
1752
1580
|
}
|
|
1753
|
-
this.connectionSemaphore.clear();
|
|
1754
|
-
this.activeConnections.clear();
|
|
1755
1581
|
}
|
|
1756
1582
|
}
|
|
1757
1583
|
//# sourceMappingURL=index.js.map
|