@bobfrankston/mailx 1.0.253 → 1.0.260

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.
Files changed (34) hide show
  1. package/bin/mailx.js +112 -0
  2. package/client/.msger-window.json +1 -1
  3. package/client/components/message-viewer.js +82 -7
  4. package/package.json +9 -8
  5. package/packages/mailx-imap/index.d.ts +6 -0
  6. package/packages/mailx-imap/index.js +244 -57
  7. package/packages/mailx-imap/package.json +2 -1
  8. package/packages/mailx-imap/providers/gmail-api.d.ts +5 -29
  9. package/packages/mailx-imap/providers/gmail-api.js +5 -286
  10. package/packages/mailx-imap/providers/types.d.ts +6 -59
  11. package/packages/mailx-imap/providers/types.js +5 -2
  12. package/packages/mailx-service/index.d.ts +0 -4
  13. package/packages/mailx-service/index.js +18 -62
  14. package/packages/mailx-store-web/android-bootstrap.js +37 -22
  15. package/packages/mailx-store-web/db.js +8 -7
  16. package/packages/mailx-store-web/gmail-api-web.d.ts +7 -33
  17. package/packages/mailx-store-web/gmail-api-web.js +7 -258
  18. package/packages/mailx-store-web/imap-web-provider.d.ts +1 -1
  19. package/packages/mailx-store-web/imap-web-provider.js +2 -2
  20. package/packages/mailx-store-web/main-thread-host.d.ts +15 -0
  21. package/packages/mailx-store-web/main-thread-host.js +287 -0
  22. package/packages/mailx-store-web/package.json +2 -1
  23. package/packages/mailx-store-web/provider-types.d.ts +4 -47
  24. package/packages/mailx-store-web/provider-types.js +3 -3
  25. package/packages/mailx-store-web/sync-manager.d.ts +61 -0
  26. package/packages/mailx-store-web/sync-manager.js +422 -0
  27. package/packages/mailx-store-web/web-service.d.ts +0 -4
  28. package/packages/mailx-store-web/web-service.js +1 -59
  29. package/packages/mailx-store-web/worker-entry.d.ts +8 -0
  30. package/packages/mailx-store-web/worker-entry.js +187 -0
  31. package/packages/mailx-store-web/worker-tcp-transport.d.ts +28 -0
  32. package/packages/mailx-store-web/worker-tcp-transport.js +98 -0
  33. package/packages/mailx-types/index.d.ts +14 -0
  34. package/packages/mailx-types/index.js +96 -1
@@ -1259,11 +1259,15 @@ export class ImapManager extends EventEmitter {
1259
1259
  // which gives instant push — the STATUS poll is just a fallback
1260
1260
  // in case IDLE silently dropped.
1261
1261
  const isGmail = this.isGmailAccount(accountId);
1262
- // Both Gmail API and IMAP accounts have IDLE running for instant
1263
- // push. The STATUS poll is just a safety net for silent IDLE drops.
1264
- // Keep it infrequent to avoid hammering Gmail 429 rate limits
1265
- // and Dovecot connection limits are both real constraints.
1266
- const interval = isGmail ? 120000 : 300000; // Gmail: 2min; IMAP: 5min
1262
+ // IMAP accounts: IDLE gives instant push; STATUS poll is just a
1263
+ // safety net for silent IDLE drops — keep it infrequent.
1264
+ // Gmail accounts: no IDLE (Gmail API doesn't expose it), so the
1265
+ // quick poll IS the primary path to new-mail latency. Drop to 30s
1266
+ // so Gmail mail appears in ~15s average. Gmail quota budget is
1267
+ // huge (250 units/sec per user, 1.2B/day) — 120 polls/hour × 5
1268
+ // units ≈ 600/hour, trivial. Dovecot accounts stay at 5min to
1269
+ // respect connection limits (each poll = fresh connection).
1270
+ const interval = isGmail ? 30000 : 300000; // Gmail: 30s; IMAP: 5min
1267
1271
  const timer = setInterval(() => {
1268
1272
  this.quickInboxCheckAccount(accountId).catch(() => { });
1269
1273
  }, interval);
@@ -1422,8 +1426,18 @@ export class ImapManager extends EventEmitter {
1422
1426
  // fetchOne returned null — message doesn't exist on the server anymore
1423
1427
  throw makeNotFoundError(accountId, folderId, uid);
1424
1428
  }
1425
- if (!msg.source)
1429
+ if (!msg.source) {
1430
+ // Gmail returned a message object but no raw bytes. Seen when:
1431
+ // (a) the message exists but is larger than the format=raw cap (~10MB),
1432
+ // (b) UID→Gmail-ID resolution picked a collision and the target
1433
+ // exists only as a stub, or (c) the listMessageIds top-1000
1434
+ // didn't include our UID and fetchOne returned null above —
1435
+ // wait, that would hit the !msg branch. So (a)/(b) remain.
1436
+ // Log enough to distinguish; surface the reason up via a non-null
1437
+ // return so the UI stops showing a generic "fetch returned nothing".
1438
+ console.error(` [api] Body fetch empty source (${accountId}/${uid}): Gmail returned no raw body — likely too-large-for-format-raw or UID hash collision`);
1426
1439
  return null;
1440
+ }
1427
1441
  const raw = Buffer.from(msg.source, "utf-8");
1428
1442
  const bodyPath = await this.bodyStore.putMessage(accountId, folderId, uid, raw);
1429
1443
  this.db.updateBodyPath(accountId, uid, bodyPath);
@@ -1443,77 +1457,223 @@ export class ImapManager extends EventEmitter {
1443
1457
  * stale row locally and keep going. Only unrelated errors (network,
1444
1458
  * auth, rate limits) count against the error budget, and the budget is
1445
1459
  * generous so a few transient failures don't kill the whole run. */
1460
+ /** Guard against concurrent prefetchBodies for the same account — mirror of
1461
+ * `sendingAccounts`. Without this, every periodic-sync tick spawns a new
1462
+ * prefetch session alongside any still in flight, blowing through Gmail's
1463
+ * per-minute quota and racing on disk writes. One prefetch per account. */
1464
+ prefetchingAccounts = new Set();
1446
1465
  async prefetchBodies(accountId) {
1447
- let totalFetched = 0;
1448
- let deleted = 0;
1449
- let errors = 0;
1450
- let rateLimited = false;
1466
+ if (this.prefetchingAccounts.has(accountId))
1467
+ return;
1468
+ this.prefetchingAccounts.add(accountId);
1469
+ try {
1470
+ await this._prefetchBodies(accountId);
1471
+ }
1472
+ finally {
1473
+ this.prefetchingAccounts.delete(accountId);
1474
+ }
1475
+ }
1476
+ async _prefetchBodies(accountId) {
1477
+ const counters = { totalFetched: 0, deleted: 0, errors: 0 };
1451
1478
  const ERROR_BUDGET = 20;
1452
- // Pace body fetches to avoid slamming Gmail's rate limit. Without a
1453
- // delay, 500+ body-fetch API calls fire in a burst, every one hits 429,
1454
- // and the error budget drains before any bodies land.
1455
- const FETCH_DELAY_MS = this.isGmailAccount(accountId) ? 1000 : 200;
1456
1479
  const RATE_LIMIT_PAUSE_MS = 30000;
1480
+ const BATCH_SIZE = 100;
1481
+ const isGmail = this.isGmailAccount(accountId);
1482
+ // Gmail still uses per-message fetch (HTTP /batch is a separate TODO in this file's
1483
+ // governing unit). IMAP uses the batched `fetchBodiesBatch` path via iflow-direct —
1484
+ // one SELECT + one UID FETCH per folder per tick instead of N round trips.
1485
+ let announced = false;
1457
1486
  while (true) {
1458
- const missing = this.db.getMessagesWithoutBody(accountId, 100);
1487
+ const missing = this.db.getMessagesWithoutBody(accountId, BATCH_SIZE);
1459
1488
  if (missing.length === 0)
1460
1489
  break;
1461
- if (totalFetched === 0 && deleted === 0)
1490
+ if (!announced) {
1462
1491
  console.log(` [prefetch] ${accountId}: ${missing.length}+ bodies to fetch`);
1492
+ announced = true;
1493
+ }
1463
1494
  let madeProgress = false;
1464
- for (const msg of missing) {
1465
- // If we hit a rate limit, pause before the next fetch
1466
- if (rateLimited) {
1467
- console.log(` [prefetch] ${accountId}: rate-limited pausing ${RATE_LIMIT_PAUSE_MS / 1000}s`);
1468
- await new Promise(r => setTimeout(r, RATE_LIMIT_PAUSE_MS));
1469
- rateLimited = false;
1495
+ if (isGmail) {
1496
+ // Gmail batch path: group by label (what mailx calls "folder"),
1497
+ // list once per label, bounded-concurrency fetch. Far fewer
1498
+ // HTTP round trips than the old one-listMessageIds-per-body path.
1499
+ // Note on the model: Gmail has labels, not folders. A message in
1500
+ // multiple labels gets fetched twice under current grouping. A
1501
+ // deeper label-native redesign is tracked as a separate TODO.
1502
+ const byFolder = new Map();
1503
+ for (const m of missing) {
1504
+ let arr = byFolder.get(m.folderId);
1505
+ if (!arr) {
1506
+ arr = [];
1507
+ byFolder.set(m.folderId, arr);
1508
+ }
1509
+ arr.push(m.uid);
1510
+ }
1511
+ const folders = this.db.getFolders(accountId);
1512
+ const api = this.getGmailProvider(accountId);
1513
+ try {
1514
+ for (const [folderId, uidsInFolder] of byFolder) {
1515
+ const folder = folders.find(f => f.id === folderId);
1516
+ if (!folder)
1517
+ continue;
1518
+ const received = new Set();
1519
+ const pending = [];
1520
+ let batchSucceeded = false;
1521
+ try {
1522
+ await api.fetchBodiesBatch(folder.path, uidsInFolder, (uid, source) => {
1523
+ received.add(uid);
1524
+ pending.push((async () => {
1525
+ try {
1526
+ const raw = Buffer.from(source, "utf-8");
1527
+ const bodyPath = await this.bodyStore.putMessage(accountId, folderId, uid, raw);
1528
+ this.db.updateBodyPath(accountId, uid, bodyPath);
1529
+ counters.totalFetched++;
1530
+ madeProgress = true;
1531
+ }
1532
+ catch (e) {
1533
+ console.error(` [prefetch] ${accountId}/${uid}: store write failed: ${e.message}`);
1534
+ }
1535
+ })());
1536
+ });
1537
+ batchSucceeded = true;
1538
+ }
1539
+ catch (e) {
1540
+ const isRate = /429|rate|too many/i.test(String(e?.message || ""));
1541
+ if (isRate) {
1542
+ console.log(` [prefetch] ${accountId}: rate-limited — pausing ${RATE_LIMIT_PAUSE_MS / 1000}s`);
1543
+ await new Promise(r => setTimeout(r, RATE_LIMIT_PAUSE_MS));
1544
+ }
1545
+ else {
1546
+ console.error(` [prefetch] ${accountId} folder ${folder.path}: Gmail batch fetch failed: ${e.message}`);
1547
+ counters.errors++;
1548
+ }
1549
+ }
1550
+ await Promise.all(pending);
1551
+ // CRITICAL: only prune as "server-deleted" when the batch
1552
+ // actually completed. If the batch threw (403, 429, network
1553
+ // error, etc.) NOTHING was received, and treating every
1554
+ // requested UID as deleted silently wipes 100 messages per
1555
+ // batch. That's a data-loss bug. Earlier version did this
1556
+ // and pruned 296 messages on a 403 auth error.
1557
+ if (batchSucceeded) {
1558
+ for (const uid of uidsInFolder) {
1559
+ if (received.has(uid))
1560
+ continue;
1561
+ try {
1562
+ this.db.deleteMessage(accountId, uid);
1563
+ this.bodyStore.deleteMessage(accountId, folderId, uid).catch(() => { });
1564
+ counters.deleted++;
1565
+ madeProgress = true;
1566
+ }
1567
+ catch { /* ignore */ }
1568
+ }
1569
+ }
1570
+ if (counters.errors >= ERROR_BUDGET)
1571
+ break;
1572
+ }
1573
+ }
1574
+ finally {
1575
+ try {
1576
+ await api.close();
1577
+ }
1578
+ catch { /* ignore */ }
1579
+ }
1580
+ if (counters.errors >= ERROR_BUDGET) {
1581
+ console.error(` [prefetch] ${accountId}: stopping after ${counters.errors} errors (${counters.totalFetched} cached, ${counters.deleted} pruned)`);
1582
+ return;
1470
1583
  }
1471
- else if (FETCH_DELAY_MS > 0) {
1472
- await new Promise(r => setTimeout(r, FETCH_DELAY_MS));
1584
+ }
1585
+ else {
1586
+ // IMAP batch path: group by folder, one UID FETCH per folder.
1587
+ const byFolder = new Map();
1588
+ for (const m of missing) {
1589
+ let arr = byFolder.get(m.folderId);
1590
+ if (!arr) {
1591
+ arr = [];
1592
+ byFolder.set(m.folderId, arr);
1593
+ }
1594
+ arr.push(m.uid);
1473
1595
  }
1596
+ const folders = this.db.getFolders(accountId);
1597
+ let client = null;
1474
1598
  try {
1475
- const result = await this.fetchMessageBody(accountId, msg.folderId, msg.uid);
1476
- if (result) {
1477
- totalFetched++;
1478
- madeProgress = true;
1599
+ client = await this.createClientWithLimit(accountId);
1600
+ for (const [folderId, uids] of byFolder) {
1601
+ const folder = folders.find(f => f.id === folderId);
1602
+ if (!folder)
1603
+ continue;
1604
+ const received = new Set();
1605
+ // onBody fires synchronously as each message streams in from the server.
1606
+ // Disk/DB writes are kicked off fire-and-forget; we await them after the
1607
+ // batch command finishes. This keeps streaming throughput high while
1608
+ // still giving us a single await point for progress accounting.
1609
+ const pending = [];
1610
+ let batchSucceeded = false;
1611
+ try {
1612
+ await client.fetchBodiesBatch(folder.path, uids, (uid, source) => {
1613
+ received.add(uid);
1614
+ pending.push((async () => {
1615
+ try {
1616
+ const raw = Buffer.from(source, "utf-8");
1617
+ const bodyPath = await this.bodyStore.putMessage(accountId, folderId, uid, raw);
1618
+ this.db.updateBodyPath(accountId, uid, bodyPath);
1619
+ counters.totalFetched++;
1620
+ madeProgress = true;
1621
+ }
1622
+ catch (e) {
1623
+ // EBUSY / disk error — non-fatal per message
1624
+ console.error(` [prefetch] ${accountId}/${uid}: store write failed: ${e.message}`);
1625
+ }
1626
+ })());
1627
+ });
1628
+ batchSucceeded = true;
1629
+ }
1630
+ catch (e) {
1631
+ console.error(` [prefetch] ${accountId} folder ${folder.path}: batch fetch failed: ${e.message}`);
1632
+ counters.errors++;
1633
+ if (counters.errors >= ERROR_BUDGET)
1634
+ break;
1635
+ }
1636
+ await Promise.all(pending);
1637
+ // CRITICAL: only prune when the batch actually completed.
1638
+ // A thrown batch means NOTHING was received and we must
1639
+ // not treat absence-from-received as server-deletion.
1640
+ if (batchSucceeded)
1641
+ for (const uid of uids) {
1642
+ if (received.has(uid))
1643
+ continue;
1644
+ try {
1645
+ this.db.deleteMessage(accountId, uid);
1646
+ this.bodyStore.deleteMessage(accountId, folderId, uid).catch(() => { });
1647
+ counters.deleted++;
1648
+ madeProgress = true;
1649
+ }
1650
+ catch { /* ignore */ }
1651
+ }
1479
1652
  }
1480
1653
  }
1481
- catch (e) {
1482
- if (e?.isNotFound) {
1483
- // Message deleted on the server — drop the stale row so
1484
- // we stop re-asking. This also moves the loop forward
1485
- // (next getMessagesWithoutBody call won't return it).
1654
+ finally {
1655
+ if (client) {
1486
1656
  try {
1487
- this.db.deleteMessage(accountId, msg.uid);
1488
- this.bodyStore.deleteMessage(accountId, msg.folderId, msg.uid).catch(() => { });
1489
- deleted++;
1490
- madeProgress = true;
1657
+ await client.logout();
1491
1658
  }
1492
1659
  catch { /* ignore */ }
1493
- continue;
1494
- }
1495
- // If the error is a rate limit (429), don't count against
1496
- // the budget — just slow down. The API will accept requests
1497
- // again after a brief pause.
1498
- if (/429|rate|too many/i.test(String(e?.message || ""))) {
1499
- rateLimited = true;
1500
- }
1501
- else {
1502
- errors++;
1503
- }
1504
- if (errors >= ERROR_BUDGET) {
1505
- console.error(` [prefetch] ${accountId}: stopping after ${errors} errors (${totalFetched} cached, ${deleted} pruned)`);
1506
- return;
1507
1660
  }
1508
1661
  }
1662
+ if (counters.errors >= ERROR_BUDGET) {
1663
+ console.error(` [prefetch] ${accountId}: stopping after ${counters.errors} errors (${counters.totalFetched} cached, ${counters.deleted} pruned)`);
1664
+ return;
1665
+ }
1509
1666
  }
1510
- // Safety: if we made zero progress this iteration, bail otherwise
1511
- // we'd loop forever on rows that keep failing without isNotFound.
1667
+ // Safety: zero progress this tick bail rather than loop forever.
1512
1668
  if (!madeProgress)
1513
1669
  break;
1670
+ // Emit so the UI refreshes the open-circle → filled-teal indicator
1671
+ // without waiting for the next sync cycle.
1672
+ this.emit("folderCountsChanged", accountId, {});
1514
1673
  }
1515
- if (totalFetched > 0 || deleted > 0) {
1516
- console.log(` [prefetch] ${accountId}: ${totalFetched} bodies cached, ${deleted} stale rows pruned (done)`);
1674
+ if (counters.totalFetched > 0 || counters.deleted > 0) {
1675
+ console.log(` [prefetch] ${accountId}: ${counters.totalFetched} bodies cached, ${counters.deleted} stale rows pruned (done)`);
1676
+ this.emit("folderCountsChanged", accountId, {});
1517
1677
  }
1518
1678
  }
1519
1679
  /** Get the body store for direct access */
@@ -1781,8 +1941,35 @@ export class ImapManager extends EventEmitter {
1781
1941
  console.error(` [drafts] searchByHeader for ${draftId} failed: ${e.message}`);
1782
1942
  }
1783
1943
  }
1784
- // Append new draft
1785
- const result = await client.appendMessage(drafts.path, rawMessage, ["\\Draft", "\\Seen"]);
1944
+ // Append new draft. If the server returns [TRYCREATE] (RFC 3501 §7.1),
1945
+ // the folder doesn't exist on the server even though mailx's DB has
1946
+ // it — happens when the folder was never created, or when the local
1947
+ // special-folder detection latched onto a path that doesn't match
1948
+ // the server's actual name. Create it then retry. Logs the path so
1949
+ // we can diagnose a mis-detected Drafts folder.
1950
+ let result;
1951
+ try {
1952
+ result = await client.appendMessage(drafts.path, rawMessage, ["\\Draft", "\\Seen"]);
1953
+ }
1954
+ catch (e) {
1955
+ const msg = String(e?.message || e);
1956
+ if (/TRYCREATE/i.test(msg)) {
1957
+ console.log(` [drafts] APPEND got TRYCREATE for "${drafts.path}" — creating folder and retrying`);
1958
+ try {
1959
+ await client.createmailbox(drafts.path);
1960
+ }
1961
+ catch (ce) {
1962
+ // "already exists" is benign; others we surface
1963
+ if (!/already exists/i.test(String(ce?.message || ""))) {
1964
+ console.error(` [drafts] Folder create failed for "${drafts.path}": ${ce.message}`);
1965
+ }
1966
+ }
1967
+ result = await client.appendMessage(drafts.path, rawMessage, ["\\Draft", "\\Seen"]);
1968
+ }
1969
+ else {
1970
+ throw e;
1971
+ }
1972
+ }
1786
1973
  // APPENDUID returns the UID directly; imapflow returns { destination, uid }
1787
1974
  const uid = typeof result === "number" ? result : result?.uid || null;
1788
1975
  return uid;
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx-imap",
3
- "version": "0.1.5",
3
+ "version": "0.1.11",
4
4
  "type": "module",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",
@@ -15,6 +15,7 @@
15
15
  "@bobfrankston/iflow-direct": "file:../../../MailApps/iflow-direct",
16
16
  "@bobfrankston/tcp-transport": "file:../../../MailApps/tcp-transport",
17
17
  "@bobfrankston/smtp-direct": "file:../../../MailApps/smtp-direct",
18
+ "@bobfrankston/mailx-sync": "file:../../../MailApps/mailx-sync",
18
19
  "@bobfrankston/oauthsupport": "file:../../../../projects/oauth/oauthsupport"
19
20
  },
20
21
  "repository": {
@@ -1,32 +1,8 @@
1
1
  /**
2
- * Gmail API provider replaces IMAP for Gmail accounts.
3
- * Uses REST API for fast, reliable sync without connection limit issues.
2
+ * Back-compat re-export. The canonical Gmail provider lives in
3
+ * @bobfrankston/mailx-sync. mailx-imap re-exports it under its old name so
4
+ * call sites here keep compiling. Both desktop (this package) and Android
5
+ * (mailx-store-web) consume the same single implementation now.
4
6
  */
5
- import type { MailProvider, ProviderFolder, ProviderMessage, FetchOptions } from "./types.js";
6
- export declare class GmailApiProvider implements MailProvider {
7
- private tokenProvider;
8
- constructor(tokenProvider: () => Promise<string>);
9
- private fetch;
10
- listFolders(): Promise<ProviderFolder[]>;
11
- /** List message IDs matching a query, handling pagination.
12
- * IMPORTANT: on any error we throw — do NOT return a partial list, because
13
- * callers use this for sync reconciliation and a short list would delete
14
- * real messages from the local DB. Returning [] silently caused the
15
- * "INBOX empty in mailx" bug when a rate-limit hit mid-pagination. */
16
- private listMessageIds;
17
- /** Batch-fetch message metadata or full content */
18
- private batchFetch;
19
- /** Parse a Gmail API message response into ProviderMessage */
20
- private parseMessage;
21
- fetchSince(folder: string, sinceUid: number, options?: FetchOptions): Promise<ProviderMessage[]>;
22
- fetchByDate(folder: string, since: Date, before: Date, options?: FetchOptions, onChunk?: (msgs: ProviderMessage[]) => void): Promise<ProviderMessage[]>;
23
- fetchByUids(folder: string, uids: number[], options?: FetchOptions): Promise<ProviderMessage[]>;
24
- fetchOne(folder: string, uid: number, options?: FetchOptions): Promise<ProviderMessage | null>;
25
- getUids(folder: string): Promise<number[]>;
26
- close(): Promise<void>;
27
- /** Map folder path to Gmail label query term */
28
- private folderToLabel;
29
- /** Format date for Gmail query (YYYY/MM/DD) */
30
- private formatDate;
31
- }
7
+ export { GmailApiProvider } from "@bobfrankston/mailx-sync";
32
8
  //# sourceMappingURL=gmail-api.d.ts.map