@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx",
3
- "version": "1.0.265",
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.20",
24
- "@bobfrankston/iflow-node": "^0.1.5",
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.318",
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.3",
39
- "@bobfrankston/node-tcp-transport": "^0.1.1",
40
- "@bobfrankston/smtp-direct": "^0.1.2",
41
- "@bobfrankston/mailx-sync": "^0.1.4"
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.20",
86
- "@bobfrankston/iflow-node": "^0.1.5",
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.318",
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.3",
101
- "@bobfrankston/node-tcp-transport": "^0.1.1",
102
- "@bobfrankston/smtp-direct": "^0.1.2",
103
- "@bobfrankston/mailx-sync": "^0.1.4"
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;
@@ -122,6 +122,7 @@ export function getMessages(params) {
122
122
  sort: params.sort || "date",
123
123
  sortDir: params.sortDir || "desc",
124
124
  search: params.search,
125
+ flaggedOnly: params.flaggedOnly,
125
126
  });
126
127
  }
127
128
  export function getUnifiedInbox(params) {
@@ -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
- /** Create a new IMAP client (internal callers use getOpsClient or withConnection) */
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; // stash for actual disconnect
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
- /** Create a new IMAP client (internal callers use getOpsClient or withConnection) */
316
- newClient(accountId) {
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
- return new CompatImapClient(config, this.transportFactory);
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
- await this.syncAccount(accountId, priorityOrder);
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: fresh connection per on-demand fetch never queued behind prefetch
1378
- let client = null;
1379
- try {
1380
- client = this.newClient(accountId);
1381
- const msg = await client.fetchMessageByUid(folder.path, uid, { source: true });
1382
- await client.logout();
1383
- client = null;
1384
- if (!msg) {
1385
- // IMAP server says the UID is gone — message was deleted
1386
- // elsewhere. Raise NotFound so the caller can remove the row.
1387
- throw makeNotFoundError(accountId, folderId, uid);
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
- const raw = Buffer.from(msg.source, "utf-8");
1392
- const bodyPath = await this.bodyStore.putMessage(accountId, folderId, uid, raw);
1393
- this.db.updateBodyPath(accountId, uid, bodyPath);
1394
- return raw;
1395
- }
1396
- catch (e) {
1397
- if (e?.isNotFound) {
1398
- if (client) {
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 client.logout();
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
- throw e;
1405
- }
1406
- console.error(` Body fetch error (${accountId}/${uid}): ${e.message}`);
1407
- if (client) {
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 client.logout();
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
- return null;
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
- const ok = await cloudWrite(name, content);
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 && !["gmail.com", "googlemail.com", "outlook.com", "hotmail.com", "live.com"].includes(domain)) {
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
- await saveAccounts([account]);
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
- const settings = loadSettings();
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":