@bobfrankston/mailx 1.0.265 → 1.0.278
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 +146 -46
- package/client/.msger-window.json +1 -1
- package/client/app.js +72 -14
- package/client/components/folder-tree.js +2 -2
- package/client/components/message-list.js +43 -3
- package/client/components/message-viewer.js +29 -6
- package/client/lib/api-client.js +2 -2
- package/client/lib/mailxapi.js +2 -2
- package/package.json +15 -15
- package/packages/mailx-core/index.d.ts +1 -0
- package/packages/mailx-core/index.js +1 -0
- package/packages/mailx-imap/index.d.ts +22 -1
- package/packages/mailx-imap/index.js +244 -42
- package/packages/mailx-service/index.d.ts +1 -1
- package/packages/mailx-service/index.js +44 -8
- package/packages/mailx-service/jsonrpc.js +1 -1
- package/packages/mailx-settings/cloud.d.ts +11 -2
- package/packages/mailx-settings/cloud.js +66 -39
- package/packages/mailx-settings/index.d.ts +11 -2
- package/packages/mailx-settings/index.js +64 -15
- package/packages/mailx-store/db.js +6 -0
- package/packages/mailx-store-web/web-service.js +13 -2
- package/packages/mailx-types/index.d.ts +4 -0
- package/tsconfig.base.json +1 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bobfrankston/mailx",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.278",
|
|
4
4
|
"description": "Local-first email client with IMAP sync and standalone native app",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "bin/mailx.js",
|
|
@@ -20,11 +20,11 @@
|
|
|
20
20
|
"postinstall": "node bin/postinstall.js"
|
|
21
21
|
},
|
|
22
22
|
"dependencies": {
|
|
23
|
-
"@bobfrankston/iflow-direct": "^0.1.
|
|
24
|
-
"@bobfrankston/iflow-node": "^0.1.
|
|
23
|
+
"@bobfrankston/iflow-direct": "^0.1.22",
|
|
24
|
+
"@bobfrankston/iflow-node": "^0.1.6",
|
|
25
25
|
"@bobfrankston/miscinfo": "^1.0.8",
|
|
26
26
|
"@bobfrankston/oauthsupport": "^1.0.24",
|
|
27
|
-
"@bobfrankston/msger": "^0.1.
|
|
27
|
+
"@bobfrankston/msger": "^0.1.320",
|
|
28
28
|
"@capacitor/android": "^8.3.0",
|
|
29
29
|
"@capacitor/cli": "^8.3.0",
|
|
30
30
|
"@capacitor/core": "^8.3.0",
|
|
@@ -35,10 +35,10 @@
|
|
|
35
35
|
"quill": "^2.0.3",
|
|
36
36
|
"ws": "^8.18.0",
|
|
37
37
|
"sql.js": "^1.14.1",
|
|
38
|
-
"@bobfrankston/tcp-transport": "^0.1.
|
|
39
|
-
"@bobfrankston/node-tcp-transport": "^0.1.
|
|
40
|
-
"@bobfrankston/smtp-direct": "^0.1.
|
|
41
|
-
"@bobfrankston/mailx-sync": "^0.1.
|
|
38
|
+
"@bobfrankston/tcp-transport": "^0.1.4",
|
|
39
|
+
"@bobfrankston/node-tcp-transport": "^0.1.3",
|
|
40
|
+
"@bobfrankston/smtp-direct": "^0.1.3",
|
|
41
|
+
"@bobfrankston/mailx-sync": "^0.1.6"
|
|
42
42
|
},
|
|
43
43
|
"devDependencies": {
|
|
44
44
|
"@types/mailparser": "^3.4.6"
|
|
@@ -82,11 +82,11 @@
|
|
|
82
82
|
},
|
|
83
83
|
".transformedSnapshot": {
|
|
84
84
|
"dependencies": {
|
|
85
|
-
"@bobfrankston/iflow-direct": "^0.1.
|
|
86
|
-
"@bobfrankston/iflow-node": "^0.1.
|
|
85
|
+
"@bobfrankston/iflow-direct": "^0.1.22",
|
|
86
|
+
"@bobfrankston/iflow-node": "^0.1.6",
|
|
87
87
|
"@bobfrankston/miscinfo": "^1.0.8",
|
|
88
88
|
"@bobfrankston/oauthsupport": "^1.0.24",
|
|
89
|
-
"@bobfrankston/msger": "^0.1.
|
|
89
|
+
"@bobfrankston/msger": "^0.1.320",
|
|
90
90
|
"@capacitor/android": "^8.3.0",
|
|
91
91
|
"@capacitor/cli": "^8.3.0",
|
|
92
92
|
"@capacitor/core": "^8.3.0",
|
|
@@ -97,10 +97,10 @@
|
|
|
97
97
|
"quill": "^2.0.3",
|
|
98
98
|
"ws": "^8.18.0",
|
|
99
99
|
"sql.js": "^1.14.1",
|
|
100
|
-
"@bobfrankston/tcp-transport": "^0.1.
|
|
101
|
-
"@bobfrankston/node-tcp-transport": "^0.1.
|
|
102
|
-
"@bobfrankston/smtp-direct": "^0.1.
|
|
103
|
-
"@bobfrankston/mailx-sync": "^0.1.
|
|
100
|
+
"@bobfrankston/tcp-transport": "^0.1.4",
|
|
101
|
+
"@bobfrankston/node-tcp-transport": "^0.1.3",
|
|
102
|
+
"@bobfrankston/smtp-direct": "^0.1.3",
|
|
103
|
+
"@bobfrankston/mailx-sync": "^0.1.6"
|
|
104
104
|
}
|
|
105
105
|
}
|
|
106
106
|
}
|
|
@@ -28,6 +28,7 @@ export declare function getMessages(params: {
|
|
|
28
28
|
sort?: string;
|
|
29
29
|
sortDir?: string;
|
|
30
30
|
search?: string;
|
|
31
|
+
flaggedOnly?: boolean;
|
|
31
32
|
}): import("@bobfrankston/mailx-types").PagedResult<import("@bobfrankston/mailx-types").MessageEnvelope>;
|
|
32
33
|
export declare function getUnifiedInbox(params: {
|
|
33
34
|
page?: number;
|
|
@@ -19,6 +19,9 @@ export interface ImapManagerEvents {
|
|
|
19
19
|
}>) => void;
|
|
20
20
|
accountError: (accountId: string, error: string, hint: string, isOAuth: boolean) => void;
|
|
21
21
|
configChanged: (filename: string) => void;
|
|
22
|
+
/** Fired after a message body has been written to the local store — lets
|
|
23
|
+
* the UI flip a row's "not-downloaded" indicator without re-rendering. */
|
|
24
|
+
bodyCached: (accountId: string, uid: number) => void;
|
|
22
25
|
}
|
|
23
26
|
export declare class ImapManager extends EventEmitter {
|
|
24
27
|
private configs;
|
|
@@ -55,13 +58,31 @@ export declare class ImapManager extends EventEmitter {
|
|
|
55
58
|
private opsClients;
|
|
56
59
|
/** Operation queues — ensures sequential access per account */
|
|
57
60
|
private opsQueues;
|
|
61
|
+
/** Persistent body-fetch connections — separate from ops so on-demand
|
|
62
|
+
* body reads never queue behind a slow sync operation (bobma's IMAP
|
|
63
|
+
* SEARCH can sit idle for 300s during backfill). */
|
|
64
|
+
private bodyClients;
|
|
65
|
+
/** Per-account backoff after the IMAP server rejected a connection with
|
|
66
|
+
* the per-user+IP cap (Dovecot mail_max_userip_connections). Subsequent
|
|
67
|
+
* body fetches short-circuit until the timestamp passes. */
|
|
68
|
+
private bodyBackoff;
|
|
58
69
|
/** Get (or create) the persistent operational connection for an account.
|
|
59
70
|
* logout() is wrapped as a no-op so legacy callers don't close it. */
|
|
60
71
|
private getOpsClient;
|
|
61
72
|
/** Run an operation on the account's connection — queued, sequential, no concurrency */
|
|
62
73
|
withConnection<T>(accountId: string, fn: (client: any) => Promise<T>): Promise<T>;
|
|
63
|
-
/**
|
|
74
|
+
/** Open IMAP clients per account, used to trace who's opening sockets
|
|
75
|
+
* when we hit the Dovecot per-user+IP connection cap. */
|
|
76
|
+
private openClients;
|
|
77
|
+
/** Create a new IMAP client (internal — callers use getOpsClient or withConnection).
|
|
78
|
+
* `purpose` is a short tag printed alongside the `[conn+]` log so we can tell
|
|
79
|
+
* which code path (sync/idle/body/outbox/move/…) opened each connection. */
|
|
64
80
|
private newClient;
|
|
81
|
+
/** Get (or lazily create) the persistent body-fetch client. Separate from
|
|
82
|
+
* the ops client so body reads never wait on a slow sync command. */
|
|
83
|
+
private getBodyClient;
|
|
84
|
+
/** Drop the body-fetch connection (e.g. after a socket error). */
|
|
85
|
+
private dropBodyClient;
|
|
65
86
|
/** Disconnect the persistent operational connection for an account */
|
|
66
87
|
disconnectOps(accountId: string): Promise<void>;
|
|
67
88
|
/** Legacy API — callers that still create/destroy connections.
|
|
@@ -216,7 +216,7 @@ export class ImapManager extends EventEmitter {
|
|
|
216
216
|
}
|
|
217
217
|
/** Delete a message directly on the IMAP server (for stuck outbox messages not in local DB) */
|
|
218
218
|
async deleteOnServer(accountId, folderPath, uid) {
|
|
219
|
-
const client = this.createClient(accountId);
|
|
219
|
+
const client = this.createClient(accountId, "delete-server");
|
|
220
220
|
try {
|
|
221
221
|
await client.deleteMessageByUid(folderPath, uid);
|
|
222
222
|
console.log(` Deleted UID ${uid} from ${folderPath} on server`);
|
|
@@ -256,19 +256,27 @@ export class ImapManager extends EventEmitter {
|
|
|
256
256
|
opsClients = new Map();
|
|
257
257
|
/** Operation queues — ensures sequential access per account */
|
|
258
258
|
opsQueues = new Map();
|
|
259
|
+
/** Persistent body-fetch connections — separate from ops so on-demand
|
|
260
|
+
* body reads never queue behind a slow sync operation (bobma's IMAP
|
|
261
|
+
* SEARCH can sit idle for 300s during backfill). */
|
|
262
|
+
bodyClients = new Map();
|
|
263
|
+
/** Per-account backoff after the IMAP server rejected a connection with
|
|
264
|
+
* the per-user+IP cap (Dovecot mail_max_userip_connections). Subsequent
|
|
265
|
+
* body fetches short-circuit until the timestamp passes. */
|
|
266
|
+
bodyBackoff = new Map();
|
|
259
267
|
/** Get (or create) the persistent operational connection for an account.
|
|
260
268
|
* logout() is wrapped as a no-op so legacy callers don't close it. */
|
|
261
269
|
async getOpsClient(accountId) {
|
|
262
270
|
let client = this.opsClients.get(accountId);
|
|
263
271
|
if (client)
|
|
264
272
|
return client;
|
|
265
|
-
client = this.newClient(accountId);
|
|
266
|
-
// Wrap logout as no-op — this is a persistent connection
|
|
273
|
+
client = this.newClient(accountId, "ops");
|
|
274
|
+
// Wrap logout as no-op — this is a persistent connection. The
|
|
275
|
+
// newClient wrapper's close-counter runs on `_realLogout`.
|
|
267
276
|
const realLogout = client.logout.bind(client);
|
|
268
277
|
client.logout = async () => { };
|
|
269
|
-
client._realLogout = realLogout;
|
|
278
|
+
client._realLogout = realLogout;
|
|
270
279
|
this.opsClients.set(accountId, client);
|
|
271
|
-
console.log(` [conn] ${accountId}: connected`);
|
|
272
280
|
return client;
|
|
273
281
|
}
|
|
274
282
|
/** Run an operation on the account's connection — queued, sequential, no concurrency */
|
|
@@ -312,8 +320,13 @@ export class ImapManager extends EventEmitter {
|
|
|
312
320
|
this.opsQueues.set(accountId, next.catch(() => { }));
|
|
313
321
|
return next;
|
|
314
322
|
}
|
|
315
|
-
/**
|
|
316
|
-
|
|
323
|
+
/** Open IMAP clients per account, used to trace who's opening sockets
|
|
324
|
+
* when we hit the Dovecot per-user+IP connection cap. */
|
|
325
|
+
openClients = new Map();
|
|
326
|
+
/** Create a new IMAP client (internal — callers use getOpsClient or withConnection).
|
|
327
|
+
* `purpose` is a short tag printed alongside the `[conn+]` log so we can tell
|
|
328
|
+
* which code path (sync/idle/body/outbox/move/…) opened each connection. */
|
|
329
|
+
newClient(accountId, purpose = "?") {
|
|
317
330
|
if (this.reauthenticating.has(accountId))
|
|
318
331
|
throw new Error(`Account ${accountId} is re-authenticating`);
|
|
319
332
|
const backoffUntil = this.connectionBackoff.get(accountId);
|
|
@@ -323,7 +336,73 @@ export class ImapManager extends EventEmitter {
|
|
|
323
336
|
const config = this.configs.get(accountId);
|
|
324
337
|
if (!config)
|
|
325
338
|
throw new Error(`No config for account ${accountId}`);
|
|
326
|
-
|
|
339
|
+
const client = new CompatImapClient(config, this.transportFactory);
|
|
340
|
+
let open = this.openClients.get(accountId);
|
|
341
|
+
if (!open) {
|
|
342
|
+
open = new Set();
|
|
343
|
+
this.openClients.set(accountId, open);
|
|
344
|
+
}
|
|
345
|
+
open.add(client);
|
|
346
|
+
console.log(` [conn+] ${accountId} (${purpose}) — ${open.size} open`);
|
|
347
|
+
let closed = false;
|
|
348
|
+
const markClosed = (how) => {
|
|
349
|
+
if (closed)
|
|
350
|
+
return;
|
|
351
|
+
closed = true;
|
|
352
|
+
open.delete(client);
|
|
353
|
+
console.log(` [conn-] ${accountId} (${purpose}/${how}) — ${open.size} open`);
|
|
354
|
+
};
|
|
355
|
+
const origLogout = client.logout?.bind(client);
|
|
356
|
+
if (origLogout) {
|
|
357
|
+
client.logout = async () => {
|
|
358
|
+
try {
|
|
359
|
+
await origLogout();
|
|
360
|
+
}
|
|
361
|
+
finally {
|
|
362
|
+
markClosed("logout");
|
|
363
|
+
}
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
const origDestroy = client.destroy?.bind(client);
|
|
367
|
+
if (origDestroy) {
|
|
368
|
+
client.destroy = () => {
|
|
369
|
+
try {
|
|
370
|
+
origDestroy();
|
|
371
|
+
}
|
|
372
|
+
finally {
|
|
373
|
+
markClosed("destroy");
|
|
374
|
+
}
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
return client;
|
|
378
|
+
}
|
|
379
|
+
/** Get (or lazily create) the persistent body-fetch client. Separate from
|
|
380
|
+
* the ops client so body reads never wait on a slow sync command. */
|
|
381
|
+
async getBodyClient(accountId) {
|
|
382
|
+
let client = this.bodyClients.get(accountId);
|
|
383
|
+
if (client)
|
|
384
|
+
return client;
|
|
385
|
+
client = this.newClient(accountId, "body");
|
|
386
|
+
const realLogout = client.logout.bind(client);
|
|
387
|
+
client.logout = async () => { };
|
|
388
|
+
client._realLogout = realLogout;
|
|
389
|
+
this.bodyClients.set(accountId, client);
|
|
390
|
+
return client;
|
|
391
|
+
}
|
|
392
|
+
/** Drop the body-fetch connection (e.g. after a socket error). */
|
|
393
|
+
async dropBodyClient(accountId) {
|
|
394
|
+
const client = this.bodyClients.get(accountId);
|
|
395
|
+
if (!client)
|
|
396
|
+
return;
|
|
397
|
+
this.bodyClients.delete(accountId);
|
|
398
|
+
try {
|
|
399
|
+
await (client._realLogout || client.logout)();
|
|
400
|
+
}
|
|
401
|
+
catch { /* */ }
|
|
402
|
+
try {
|
|
403
|
+
client.destroy?.();
|
|
404
|
+
}
|
|
405
|
+
catch { /* */ }
|
|
327
406
|
}
|
|
328
407
|
/** Disconnect the persistent operational connection for an account */
|
|
329
408
|
async disconnectOps(accountId) {
|
|
@@ -350,9 +429,9 @@ export class ImapManager extends EventEmitter {
|
|
|
350
429
|
async createClientWithLimit(accountId) {
|
|
351
430
|
return this.getOpsClient(accountId);
|
|
352
431
|
}
|
|
353
|
-
createClient(accountId) {
|
|
432
|
+
createClient(accountId, purpose = "misc") {
|
|
354
433
|
// Return a fresh disposable client (used by IDLE watcher and one-off operations)
|
|
355
|
-
return this.newClient(accountId);
|
|
434
|
+
return this.newClient(accountId, purpose);
|
|
356
435
|
}
|
|
357
436
|
trackLogout(_accountId) { }
|
|
358
437
|
/** Number of registered IMAP accounts */
|
|
@@ -605,11 +684,14 @@ export class ImapManager extends EventEmitter {
|
|
|
605
684
|
if (missingUids.length > 0 && missingUids.length <= 5000) {
|
|
606
685
|
console.log(` ${folder.path}: gap detected — ${missingUids.length} missing UIDs in range ${lowestUid}..${highestUid}`);
|
|
607
686
|
const chunkSize = 500;
|
|
687
|
+
let recoveredTotal = 0;
|
|
608
688
|
for (let i = 0; i < missingUids.length; i += chunkSize) {
|
|
609
689
|
const chunk = missingUids.slice(i, i + chunkSize);
|
|
610
690
|
const range = chunk.join(",");
|
|
611
691
|
const recovered = await client.fetchMessages(folder.path, range, { source: false });
|
|
612
692
|
messages.push(...recovered);
|
|
693
|
+
recoveredTotal += recovered.length;
|
|
694
|
+
console.log(` ${folder.path}: gap-fill ${recoveredTotal}/${missingUids.length}`);
|
|
613
695
|
}
|
|
614
696
|
}
|
|
615
697
|
else if (missingUids.length > 5000) {
|
|
@@ -803,7 +885,16 @@ export class ImapManager extends EventEmitter {
|
|
|
803
885
|
// has a 10-minute wall-clock timeout that killed it first — so
|
|
804
886
|
// prefetch never ran.
|
|
805
887
|
const syncAndPrefetch = async (accountId) => {
|
|
806
|
-
|
|
888
|
+
try {
|
|
889
|
+
await this.syncAccount(accountId, priorityOrder);
|
|
890
|
+
}
|
|
891
|
+
catch {
|
|
892
|
+
// syncAccount already logs + emits syncError. Don't follow a
|
|
893
|
+
// failed sync with a body-prefetch storm into the same API:
|
|
894
|
+
// a 429 on listLabels means Gmail is in cooldown, so firing
|
|
895
|
+
// prefetch next would just re-trigger the same limit.
|
|
896
|
+
return;
|
|
897
|
+
}
|
|
807
898
|
if (getPrefetch()) {
|
|
808
899
|
this.prefetchBodies(accountId).catch(e => console.error(` [prefetch] ${accountId}: ${e.message}`));
|
|
809
900
|
}
|
|
@@ -837,6 +928,16 @@ export class ImapManager extends EventEmitter {
|
|
|
837
928
|
await this.syncFolder(accountId, inbox.id, client);
|
|
838
929
|
console.log(` [sync] ${accountId}: INBOX sync complete`);
|
|
839
930
|
inboxDone = true;
|
|
931
|
+
// Kick off prefetch as soon as INBOX is fresh, not
|
|
932
|
+
// after all 105 folders finish — bobma's full sync
|
|
933
|
+
// can take 30+ minutes on a wide folder tree, and
|
|
934
|
+
// INBOX is the only folder the user is staring at.
|
|
935
|
+
// Uses the body client (separate connection from
|
|
936
|
+
// ops), so it runs concurrently with the rest of the
|
|
937
|
+
// folder sync without contending for the same socket.
|
|
938
|
+
if (getPrefetch()) {
|
|
939
|
+
this.prefetchBodies(accountId).catch(e => console.error(` [prefetch] ${accountId}: ${e.message}`));
|
|
940
|
+
}
|
|
840
941
|
}
|
|
841
942
|
catch (e) {
|
|
842
943
|
console.error(` Inbox sync error for ${accountId} (attempt ${attempt}/${maxAttempts}): ${e.message}`);
|
|
@@ -943,6 +1044,9 @@ export class ImapManager extends EventEmitter {
|
|
|
943
1044
|
this.emit("syncError", accountId, errMsg);
|
|
944
1045
|
console.error(` [api] Sync error for ${accountId}: ${errMsg}`);
|
|
945
1046
|
this.handleSyncError(accountId, errMsg);
|
|
1047
|
+
// Propagate so the caller skips the prefetch that would otherwise
|
|
1048
|
+
// fire straight into the same 429/cooldown and make it worse.
|
|
1049
|
+
throw e;
|
|
946
1050
|
}
|
|
947
1051
|
}
|
|
948
1052
|
/** Sync a single folder via Gmail/Outlook API */
|
|
@@ -1374,44 +1478,98 @@ export class ImapManager extends EventEmitter {
|
|
|
1374
1478
|
if (this.isGmailAccount(accountId)) {
|
|
1375
1479
|
return this.fetchMessageBodyViaApi(accountId, folderId, uid, folder.path);
|
|
1376
1480
|
}
|
|
1377
|
-
// IMAP:
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
if (!msg.source)
|
|
1481
|
+
// IMAP: use the persistent body-fetch client (separate from ops so we
|
|
1482
|
+
// don't wait behind a slow sync command), serialized via enqueueFetch
|
|
1483
|
+
// so overlapping requests share one socket instead of spawning fresh
|
|
1484
|
+
// ones and blowing past the Dovecot connection cap.
|
|
1485
|
+
return this.enqueueFetch(accountId, async () => {
|
|
1486
|
+
// Cap-error backoff: when Dovecot has rejected us recently with
|
|
1487
|
+
// "Maximum number of connections", short-circuit instead of
|
|
1488
|
+
// queueing every body request behind a doomed login attempt.
|
|
1489
|
+
const backoffUntil = this.bodyBackoff.get(accountId) || 0;
|
|
1490
|
+
if (backoffUntil > Date.now()) {
|
|
1491
|
+
const wait = Math.round((backoffUntil - Date.now()) / 1000);
|
|
1492
|
+
console.warn(` Body fetch (${accountId}/${uid}) skipped — server connection cap, retry in ${wait}s`);
|
|
1390
1493
|
return null;
|
|
1391
|
-
|
|
1392
|
-
const
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1494
|
+
}
|
|
1495
|
+
const attempt = async () => {
|
|
1496
|
+
const client = await this.getBodyClient(accountId);
|
|
1497
|
+
const msg = await client.fetchMessageByUid(folder.path, uid, { source: true });
|
|
1498
|
+
if (!msg)
|
|
1499
|
+
throw makeNotFoundError(accountId, folderId, uid);
|
|
1500
|
+
if (!msg.source)
|
|
1501
|
+
return null;
|
|
1502
|
+
return Buffer.from(msg.source, "utf-8");
|
|
1503
|
+
};
|
|
1504
|
+
const classify = (msg) => ({
|
|
1505
|
+
staleSocket: /Not connected|ECONNRESET|socket hang up|EPIPE|write after end|ended|closed/i.test(msg),
|
|
1506
|
+
connCap: /UNAVAILABLE|Maximum number of connections|too many connections/i.test(msg),
|
|
1507
|
+
});
|
|
1508
|
+
let raw;
|
|
1509
|
+
try {
|
|
1510
|
+
raw = await attempt();
|
|
1511
|
+
}
|
|
1512
|
+
catch (e) {
|
|
1513
|
+
if (e?.isNotFound)
|
|
1514
|
+
throw e;
|
|
1515
|
+
const msg = e?.message || "";
|
|
1516
|
+
const { staleSocket, connCap } = classify(msg);
|
|
1517
|
+
// Always drop the cached client on any failure — keeping a
|
|
1518
|
+
// half-broken client poisoned every subsequent request.
|
|
1519
|
+
await this.dropBodyClient(accountId);
|
|
1520
|
+
if (connCap) {
|
|
1521
|
+
// The dedicated body socket is locked out by the server's
|
|
1522
|
+
// per-user+IP cap (something else holds the slots). Fall
|
|
1523
|
+
// back to the already-open ops connection — slower (queues
|
|
1524
|
+
// behind sync commands), but actually works.
|
|
1525
|
+
this.bodyBackoff.set(accountId, Date.now() + 30_000);
|
|
1526
|
+
console.warn(` Body fetch (${accountId}/${uid}): connection cap — falling back to ops connection`);
|
|
1399
1527
|
try {
|
|
1400
|
-
await
|
|
1528
|
+
const fallbackRaw = await this.withConnection(accountId, async (client) => {
|
|
1529
|
+
const msg = await client.fetchMessageByUid(folder.path, uid, { source: true });
|
|
1530
|
+
if (!msg)
|
|
1531
|
+
throw makeNotFoundError(accountId, folderId, uid);
|
|
1532
|
+
if (!msg.source)
|
|
1533
|
+
return null;
|
|
1534
|
+
return Buffer.from(msg.source, "utf-8");
|
|
1535
|
+
});
|
|
1536
|
+
if (!fallbackRaw)
|
|
1537
|
+
return null;
|
|
1538
|
+
const bodyPath = await this.bodyStore.putMessage(accountId, folderId, uid, fallbackRaw);
|
|
1539
|
+
this.db.updateBodyPath(accountId, uid, bodyPath);
|
|
1540
|
+
this.emit("bodyCached", accountId, uid);
|
|
1541
|
+
return fallbackRaw;
|
|
1542
|
+
}
|
|
1543
|
+
catch (e3) {
|
|
1544
|
+
if (e3?.isNotFound)
|
|
1545
|
+
throw e3;
|
|
1546
|
+
console.error(` Body fetch fallback failed (${accountId}/${uid}): ${e3?.message}`);
|
|
1547
|
+
return null;
|
|
1401
1548
|
}
|
|
1402
|
-
catch { /* */ }
|
|
1403
1549
|
}
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1550
|
+
if (!staleSocket) {
|
|
1551
|
+
console.error(` Body fetch error (${accountId}/${uid}): ${msg}`);
|
|
1552
|
+
return null;
|
|
1553
|
+
}
|
|
1554
|
+
// Stale socket — try once more with a fresh client.
|
|
1408
1555
|
try {
|
|
1409
|
-
await
|
|
1556
|
+
raw = await attempt();
|
|
1557
|
+
}
|
|
1558
|
+
catch (e2) {
|
|
1559
|
+
if (e2?.isNotFound)
|
|
1560
|
+
throw e2;
|
|
1561
|
+
await this.dropBodyClient(accountId);
|
|
1562
|
+
console.error(` Body fetch error (${accountId}/${uid}) after reconnect: ${e2?.message}`);
|
|
1563
|
+
return null;
|
|
1410
1564
|
}
|
|
1411
|
-
catch { /* */ }
|
|
1412
1565
|
}
|
|
1413
|
-
|
|
1414
|
-
|
|
1566
|
+
if (!raw)
|
|
1567
|
+
return null;
|
|
1568
|
+
const bodyPath = await this.bodyStore.putMessage(accountId, folderId, uid, raw);
|
|
1569
|
+
this.db.updateBodyPath(accountId, uid, bodyPath);
|
|
1570
|
+
this.emit("bodyCached", accountId, uid);
|
|
1571
|
+
return raw;
|
|
1572
|
+
});
|
|
1415
1573
|
}
|
|
1416
1574
|
/** Fetch message body via Gmail/Outlook API.
|
|
1417
1575
|
* Throws `MessageNotFoundError` when the server says the message is gone
|
|
@@ -1441,6 +1599,7 @@ export class ImapManager extends EventEmitter {
|
|
|
1441
1599
|
const raw = Buffer.from(msg.source, "utf-8");
|
|
1442
1600
|
const bodyPath = await this.bodyStore.putMessage(accountId, folderId, uid, raw);
|
|
1443
1601
|
this.db.updateBodyPath(accountId, uid, bodyPath);
|
|
1602
|
+
this.emit("bodyCached", accountId, uid);
|
|
1444
1603
|
return raw;
|
|
1445
1604
|
}
|
|
1446
1605
|
catch (e) {
|
|
@@ -1526,6 +1685,7 @@ export class ImapManager extends EventEmitter {
|
|
|
1526
1685
|
const raw = Buffer.from(source, "utf-8");
|
|
1527
1686
|
const bodyPath = await this.bodyStore.putMessage(accountId, folderId, uid, raw);
|
|
1528
1687
|
this.db.updateBodyPath(accountId, uid, bodyPath);
|
|
1688
|
+
this.emit("bodyCached", accountId, uid);
|
|
1529
1689
|
counters.totalFetched++;
|
|
1530
1690
|
madeProgress = true;
|
|
1531
1691
|
}
|
|
@@ -1616,6 +1776,7 @@ export class ImapManager extends EventEmitter {
|
|
|
1616
1776
|
const raw = Buffer.from(source, "utf-8");
|
|
1617
1777
|
const bodyPath = await this.bodyStore.putMessage(accountId, folderId, uid, raw);
|
|
1618
1778
|
this.db.updateBodyPath(accountId, uid, bodyPath);
|
|
1779
|
+
this.emit("bodyCached", accountId, uid);
|
|
1619
1780
|
counters.totalFetched++;
|
|
1620
1781
|
madeProgress = true;
|
|
1621
1782
|
}
|
|
@@ -1824,6 +1985,47 @@ export class ImapManager extends EventEmitter {
|
|
|
1824
1985
|
if (actions.length === 0)
|
|
1825
1986
|
return;
|
|
1826
1987
|
const folders = this.db.getFolders(accountId);
|
|
1988
|
+
// Gmail path: push flag/label changes through the REST provider so
|
|
1989
|
+
// they actually reach the server. Earlier this method always went
|
|
1990
|
+
// through withConnection → IMAP, which silently no-op'd for Gmail
|
|
1991
|
+
// accounts (REST-only, no IMAP connection) and left local-only stars
|
|
1992
|
+
// that vanished on the next full sync.
|
|
1993
|
+
if (this.isGmailAccount(accountId)) {
|
|
1994
|
+
const api = this.getGmailProvider(accountId);
|
|
1995
|
+
try {
|
|
1996
|
+
for (const action of actions) {
|
|
1997
|
+
const folder = folders.find(f => f.id === action.folderId);
|
|
1998
|
+
if (!folder) {
|
|
1999
|
+
this.db.completeSyncAction(action.id);
|
|
2000
|
+
continue;
|
|
2001
|
+
}
|
|
2002
|
+
try {
|
|
2003
|
+
if (action.action === "flags" && api.setFlags) {
|
|
2004
|
+
await api.setFlags(folder.path, action.uid, action.flags || []);
|
|
2005
|
+
console.log(` [api] ${accountId}: flags synced UID ${action.uid}`);
|
|
2006
|
+
}
|
|
2007
|
+
else {
|
|
2008
|
+
// move/delete/append via API not implemented yet — leave queued
|
|
2009
|
+
continue;
|
|
2010
|
+
}
|
|
2011
|
+
this.db.completeSyncAction(action.id);
|
|
2012
|
+
}
|
|
2013
|
+
catch (e) {
|
|
2014
|
+
console.error(` [api] ${accountId}: flag sync failed UID ${action.uid}: ${e.message}`);
|
|
2015
|
+
this.db.failSyncAction(action.id, e.message);
|
|
2016
|
+
if (action.attempts >= 5)
|
|
2017
|
+
this.db.completeSyncAction(action.id);
|
|
2018
|
+
}
|
|
2019
|
+
}
|
|
2020
|
+
}
|
|
2021
|
+
finally {
|
|
2022
|
+
try {
|
|
2023
|
+
await api.close();
|
|
2024
|
+
}
|
|
2025
|
+
catch { /* */ }
|
|
2026
|
+
}
|
|
2027
|
+
return;
|
|
2028
|
+
}
|
|
1827
2029
|
await this.withConnection(accountId, async (client) => {
|
|
1828
2030
|
for (const action of actions) {
|
|
1829
2031
|
const folder = folders.find(f => f.id === action.folderId);
|
|
@@ -13,7 +13,7 @@ export declare class MailxService {
|
|
|
13
13
|
getAccounts(): any[];
|
|
14
14
|
getFolders(accountId: string): Folder[];
|
|
15
15
|
getUnifiedInbox(page?: number, pageSize?: number): any;
|
|
16
|
-
getMessages(accountId: string, folderId: number, page?: number, pageSize?: number, sort?: string, sortDir?: string, search?: string): any;
|
|
16
|
+
getMessages(accountId: string, folderId: number, page?: number, pageSize?: number, sort?: string, sortDir?: string, search?: string, flaggedOnly?: boolean): any;
|
|
17
17
|
getMessage(accountId: string, uid: number, allowRemote?: boolean, folderId?: number): Promise<any>;
|
|
18
18
|
updateFlags(accountId: string, uid: number, flags: string[]): Promise<void>;
|
|
19
19
|
allowRemoteContent(type: "sender" | "domain" | "recipient", value: string): Promise<void>;
|
|
@@ -67,8 +67,8 @@ export class MailxService {
|
|
|
67
67
|
getUnifiedInbox(page = 1, pageSize = 50) {
|
|
68
68
|
return this.db.getUnifiedInbox(page, pageSize);
|
|
69
69
|
}
|
|
70
|
-
getMessages(accountId, folderId, page = 1, pageSize = 50, sort = "date", sortDir = "desc", search) {
|
|
71
|
-
return this.db.getMessages({ accountId, folderId, page, pageSize, sort: sort, sortDir: sortDir, search });
|
|
70
|
+
getMessages(accountId, folderId, page = 1, pageSize = 50, sort = "date", sortDir = "desc", search, flaggedOnly = false) {
|
|
71
|
+
return this.db.getMessages({ accountId, folderId, page, pageSize, sort: sort, sortDir: sortDir, search, flaggedOnly });
|
|
72
72
|
}
|
|
73
73
|
async getMessage(accountId, uid, allowRemote = false, folderId) {
|
|
74
74
|
const envelope = this.db.getMessageByUid(accountId, uid, folderId);
|
|
@@ -670,9 +670,7 @@ export class MailxService {
|
|
|
670
670
|
return;
|
|
671
671
|
}
|
|
672
672
|
const { cloudWrite } = await import("@bobfrankston/mailx-settings");
|
|
673
|
-
|
|
674
|
-
if (!ok)
|
|
675
|
-
throw new Error(`Failed to write ${name}`);
|
|
673
|
+
await cloudWrite(name, content); // throws on failure with descriptive error
|
|
676
674
|
}
|
|
677
675
|
// ── Settings ──
|
|
678
676
|
getSettings() {
|
|
@@ -717,17 +715,27 @@ export class MailxService {
|
|
|
717
715
|
return { ok: true, message: `Loaded ${accounts.length} existing account(s) from cloud.` };
|
|
718
716
|
}
|
|
719
717
|
// No existing accounts — create new one
|
|
718
|
+
const isGoogle = ["gmail.com", "googlemail.com"].includes(domain) || detected?.cloud === "gdrive";
|
|
719
|
+
const isOAuth = ["gmail.com", "googlemail.com", "outlook.com", "hotmail.com", "live.com"].includes(domain);
|
|
720
720
|
const account = { email, name: name || email.split("@")[0] };
|
|
721
721
|
if (password)
|
|
722
722
|
account.password = password;
|
|
723
|
-
if (detected && !
|
|
723
|
+
if (detected && !isOAuth) {
|
|
724
724
|
account.imap = { host: detected.imapHost, port: 993, tls: true, auth: detected.auth, user: email };
|
|
725
725
|
account.smtp = { host: detected.smtpHost, port: 587, tls: true, auth: detected.auth, user: email };
|
|
726
726
|
}
|
|
727
727
|
account.id = domain.split(".")[0] || "account";
|
|
728
|
-
|
|
728
|
+
// Save provisional account so addAccount can register it. Cloud failures
|
|
729
|
+
// surface via onCloudError listeners (UI banner) — don't fail setup itself.
|
|
730
|
+
try {
|
|
731
|
+
await saveAccounts([account]);
|
|
732
|
+
}
|
|
733
|
+
catch (e) {
|
|
734
|
+
console.error(` [setup] saveAccounts failed: ${e.message}`);
|
|
735
|
+
return { ok: false, error: `Failed to save account: ${e.message}` };
|
|
736
|
+
}
|
|
729
737
|
// Re-read normalized settings and register
|
|
730
|
-
|
|
738
|
+
let settings = loadSettings();
|
|
731
739
|
for (const acct of settings.accounts) {
|
|
732
740
|
if (!acct.enabled)
|
|
733
741
|
continue;
|
|
@@ -739,6 +747,34 @@ export class MailxService {
|
|
|
739
747
|
console.error(` Account ${acct.id} error: ${e.message}`);
|
|
740
748
|
}
|
|
741
749
|
}
|
|
750
|
+
// For Google accounts where the user didn't supply a name, fetch the
|
|
751
|
+
// display name from the People API now that addAccount has authenticated.
|
|
752
|
+
// contacts.readonly is in the Gmail OAuth scope, so the same token works.
|
|
753
|
+
if (!name && isGoogle) {
|
|
754
|
+
try {
|
|
755
|
+
const tok = await this.imapManager.getOAuthToken(account.id);
|
|
756
|
+
if (tok) {
|
|
757
|
+
const { getGoogleProfile } = await import("@bobfrankston/mailx-settings/cloud.js");
|
|
758
|
+
const profile = await getGoogleProfile(tok);
|
|
759
|
+
if (profile?.name && profile.name !== account.name) {
|
|
760
|
+
console.log(` [setup] Display name from Google: ${profile.name}`);
|
|
761
|
+
account.name = profile.name;
|
|
762
|
+
// Re-save with the resolved name (best-effort; cloud errors
|
|
763
|
+
// surface via onCloudError).
|
|
764
|
+
try {
|
|
765
|
+
await saveAccounts([account]);
|
|
766
|
+
}
|
|
767
|
+
catch (e) {
|
|
768
|
+
console.error(` [setup] re-saveAccounts with profile name failed: ${e.message}`);
|
|
769
|
+
}
|
|
770
|
+
settings = loadSettings();
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
catch (e) {
|
|
775
|
+
console.error(` [setup] getGoogleProfile failed: ${e.message}`);
|
|
776
|
+
}
|
|
777
|
+
}
|
|
742
778
|
this.imapManager.syncAll().catch(() => { });
|
|
743
779
|
return { ok: true, message: `${settings.accounts.length} account(s) configured and syncing.` };
|
|
744
780
|
}
|
|
@@ -29,7 +29,7 @@ async function dispatchAction(svc, action, p) {
|
|
|
29
29
|
return svc.getFolders(p.accountId);
|
|
30
30
|
// Messages
|
|
31
31
|
case "getMessages":
|
|
32
|
-
return svc.getMessages(p.accountId, p.folderId, p.page, p.pageSize);
|
|
32
|
+
return svc.getMessages(p.accountId, p.folderId, p.page, p.pageSize, p.sort, p.sortDir, p.search, p.flaggedOnly);
|
|
33
33
|
case "getUnifiedInbox":
|
|
34
34
|
return svc.getUnifiedInbox(p.page || 1, p.pageSize || 50);
|
|
35
35
|
case "getMessage":
|