@bobfrankston/mailx 1.0.251 → 1.0.256

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.
@@ -1 +1 @@
1
- {"height":1344,"width":2151,"x":380,"y":73}
1
+ {"height":1344,"width":2151,"x":616,"y":113}
@@ -6,8 +6,8 @@
6
6
  <title>mailx</title>
7
7
  <link rel="icon" type="image/svg+xml" href="favicon.svg">
8
8
  <link rel="stylesheet" href="styles/variables.css">
9
- <link rel="stylesheet" href="styles/layout.css">
10
9
  <link rel="stylesheet" href="styles/components.css">
10
+ <link rel="stylesheet" href="styles/layout.css">
11
11
  <!-- Import map for Android — resolves @bobfrankston packages to bundled assets -->
12
12
  <script type="importmap">
13
13
  {
package/client/index.html CHANGED
@@ -6,8 +6,8 @@
6
6
  <title>mailx</title>
7
7
  <link rel="icon" type="image/svg+xml" href="favicon.svg">
8
8
  <link rel="stylesheet" href="styles/variables.css">
9
- <link rel="stylesheet" href="styles/layout.css">
10
9
  <link rel="stylesheet" href="styles/components.css">
10
+ <link rel="stylesheet" href="styles/layout.css">
11
11
  <script type="module" src="app.js"></script>
12
12
  </head>
13
13
  <body>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx",
3
- "version": "1.0.251",
3
+ "version": "1.0.256",
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.15",
23
+ "@bobfrankston/iflow-direct": "^0.1.16",
24
24
  "@bobfrankston/iflow-node": "^0.1.5",
25
25
  "@bobfrankston/miscinfo": "^1.0.8",
26
- "@bobfrankston/oauthsupport": "^1.0.22",
27
- "@bobfrankston/msger": "^0.1.313",
26
+ "@bobfrankston/oauthsupport": "^1.0.24",
27
+ "@bobfrankston/msger": "^0.1.315",
28
28
  "@capacitor/android": "^8.3.0",
29
29
  "@capacitor/cli": "^8.3.0",
30
30
  "@capacitor/core": "^8.3.0",
@@ -80,11 +80,11 @@
80
80
  },
81
81
  ".transformedSnapshot": {
82
82
  "dependencies": {
83
- "@bobfrankston/iflow-direct": "^0.1.15",
83
+ "@bobfrankston/iflow-direct": "^0.1.16",
84
84
  "@bobfrankston/iflow-node": "^0.1.5",
85
85
  "@bobfrankston/miscinfo": "^1.0.8",
86
- "@bobfrankston/oauthsupport": "^1.0.22",
87
- "@bobfrankston/msger": "^0.1.313",
86
+ "@bobfrankston/oauthsupport": "^1.0.24",
87
+ "@bobfrankston/msger": "^0.1.315",
88
88
  "@capacitor/android": "^8.3.0",
89
89
  "@capacitor/cli": "^8.3.0",
90
90
  "@capacitor/core": "^8.3.0",
@@ -1444,76 +1444,196 @@ export class ImapManager extends EventEmitter {
1444
1444
  * auth, rate limits) count against the error budget, and the budget is
1445
1445
  * generous so a few transient failures don't kill the whole run. */
1446
1446
  async prefetchBodies(accountId) {
1447
- let totalFetched = 0;
1448
- let deleted = 0;
1449
- let errors = 0;
1450
- let rateLimited = false;
1447
+ const counters = { totalFetched: 0, deleted: 0, errors: 0 };
1451
1448
  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
1449
  const RATE_LIMIT_PAUSE_MS = 30000;
1450
+ const BATCH_SIZE = 100;
1451
+ const isGmail = this.isGmailAccount(accountId);
1452
+ // Gmail still uses per-message fetch (HTTP /batch is a separate TODO in this file's
1453
+ // governing unit). IMAP uses the batched `fetchBodiesBatch` path via iflow-direct —
1454
+ // one SELECT + one UID FETCH per folder per tick instead of N round trips.
1455
+ let announced = false;
1457
1456
  while (true) {
1458
- const missing = this.db.getMessagesWithoutBody(accountId, 100);
1457
+ const missing = this.db.getMessagesWithoutBody(accountId, BATCH_SIZE);
1459
1458
  if (missing.length === 0)
1460
1459
  break;
1461
- if (totalFetched === 0 && deleted === 0)
1460
+ if (!announced) {
1462
1461
  console.log(` [prefetch] ${accountId}: ${missing.length}+ bodies to fetch`);
1462
+ announced = true;
1463
+ }
1463
1464
  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;
1465
+ if (isGmail) {
1466
+ // Gmail batch path: group by label (what mailx calls "folder"),
1467
+ // list once per label, bounded-concurrency fetch. Far fewer
1468
+ // HTTP round trips than the old one-listMessageIds-per-body path.
1469
+ // Note on the model: Gmail has labels, not folders. A message in
1470
+ // multiple labels gets fetched twice under current grouping. A
1471
+ // deeper label-native redesign is tracked as a separate TODO.
1472
+ const byFolder = new Map();
1473
+ for (const m of missing) {
1474
+ let arr = byFolder.get(m.folderId);
1475
+ if (!arr) {
1476
+ arr = [];
1477
+ byFolder.set(m.folderId, arr);
1478
+ }
1479
+ arr.push(m.uid);
1480
+ }
1481
+ const folders = this.db.getFolders(accountId);
1482
+ const api = this.getGmailProvider(accountId);
1483
+ try {
1484
+ for (const [folderId, uidsInFolder] of byFolder) {
1485
+ const folder = folders.find(f => f.id === folderId);
1486
+ if (!folder)
1487
+ continue;
1488
+ const received = new Set();
1489
+ const pending = [];
1490
+ try {
1491
+ await api.fetchBodiesBatch(folder.path, uidsInFolder, (uid, source) => {
1492
+ received.add(uid);
1493
+ pending.push((async () => {
1494
+ try {
1495
+ const raw = Buffer.from(source, "utf-8");
1496
+ const bodyPath = await this.bodyStore.putMessage(accountId, folderId, uid, raw);
1497
+ this.db.updateBodyPath(accountId, uid, bodyPath);
1498
+ counters.totalFetched++;
1499
+ madeProgress = true;
1500
+ }
1501
+ catch (e) {
1502
+ console.error(` [prefetch] ${accountId}/${uid}: store write failed: ${e.message}`);
1503
+ }
1504
+ })());
1505
+ });
1506
+ }
1507
+ catch (e) {
1508
+ const isRate = /429|rate|too many/i.test(String(e?.message || ""));
1509
+ if (isRate) {
1510
+ console.log(` [prefetch] ${accountId}: rate-limited — pausing ${RATE_LIMIT_PAUSE_MS / 1000}s`);
1511
+ await new Promise(r => setTimeout(r, RATE_LIMIT_PAUSE_MS));
1512
+ }
1513
+ else {
1514
+ console.error(` [prefetch] ${accountId} folder ${folder.path}: Gmail batch fetch failed: ${e.message}`);
1515
+ counters.errors++;
1516
+ }
1517
+ }
1518
+ await Promise.all(pending);
1519
+ // UIDs we asked for but didn't receive are either gone
1520
+ // server-side OR aren't in this label (can happen if the
1521
+ // folder DB row was mis-labeled). Drop them so the loop
1522
+ // moves forward instead of spinning.
1523
+ for (const uid of uidsInFolder) {
1524
+ if (received.has(uid))
1525
+ continue;
1526
+ try {
1527
+ this.db.deleteMessage(accountId, uid);
1528
+ this.bodyStore.deleteMessage(accountId, folderId, uid).catch(() => { });
1529
+ counters.deleted++;
1530
+ madeProgress = true;
1531
+ }
1532
+ catch { /* ignore */ }
1533
+ }
1534
+ if (counters.errors >= ERROR_BUDGET)
1535
+ break;
1536
+ }
1537
+ }
1538
+ finally {
1539
+ try {
1540
+ await api.close();
1541
+ }
1542
+ catch { /* ignore */ }
1470
1543
  }
1471
- else if (FETCH_DELAY_MS > 0) {
1472
- await new Promise(r => setTimeout(r, FETCH_DELAY_MS));
1544
+ if (counters.errors >= ERROR_BUDGET) {
1545
+ console.error(` [prefetch] ${accountId}: stopping after ${counters.errors} errors (${counters.totalFetched} cached, ${counters.deleted} pruned)`);
1546
+ return;
1473
1547
  }
1548
+ }
1549
+ else {
1550
+ // IMAP batch path: group by folder, one UID FETCH per folder.
1551
+ const byFolder = new Map();
1552
+ for (const m of missing) {
1553
+ let arr = byFolder.get(m.folderId);
1554
+ if (!arr) {
1555
+ arr = [];
1556
+ byFolder.set(m.folderId, arr);
1557
+ }
1558
+ arr.push(m.uid);
1559
+ }
1560
+ const folders = this.db.getFolders(accountId);
1561
+ let client = null;
1474
1562
  try {
1475
- const result = await this.fetchMessageBody(accountId, msg.folderId, msg.uid);
1476
- if (result) {
1477
- totalFetched++;
1478
- madeProgress = true;
1563
+ client = await this.createClientWithLimit(accountId);
1564
+ for (const [folderId, uids] of byFolder) {
1565
+ const folder = folders.find(f => f.id === folderId);
1566
+ if (!folder)
1567
+ continue;
1568
+ const received = new Set();
1569
+ // onBody fires synchronously as each message streams in from the server.
1570
+ // Disk/DB writes are kicked off fire-and-forget; we await them after the
1571
+ // batch command finishes. This keeps streaming throughput high while
1572
+ // still giving us a single await point for progress accounting.
1573
+ const pending = [];
1574
+ try {
1575
+ await client.fetchBodiesBatch(folder.path, uids, (uid, source) => {
1576
+ received.add(uid);
1577
+ pending.push((async () => {
1578
+ try {
1579
+ const raw = Buffer.from(source, "utf-8");
1580
+ const bodyPath = await this.bodyStore.putMessage(accountId, folderId, uid, raw);
1581
+ this.db.updateBodyPath(accountId, uid, bodyPath);
1582
+ counters.totalFetched++;
1583
+ madeProgress = true;
1584
+ }
1585
+ catch (e) {
1586
+ // EBUSY / disk error — non-fatal per message
1587
+ console.error(` [prefetch] ${accountId}/${uid}: store write failed: ${e.message}`);
1588
+ }
1589
+ })());
1590
+ });
1591
+ }
1592
+ catch (e) {
1593
+ console.error(` [prefetch] ${accountId} folder ${folder.path}: batch fetch failed: ${e.message}`);
1594
+ counters.errors++;
1595
+ if (counters.errors >= ERROR_BUDGET)
1596
+ break;
1597
+ }
1598
+ await Promise.all(pending);
1599
+ // UIDs we asked for but didn't receive were deleted server-side.
1600
+ // Drop their stale DB rows so they stop coming back next batch.
1601
+ for (const uid of uids) {
1602
+ if (received.has(uid))
1603
+ continue;
1604
+ try {
1605
+ this.db.deleteMessage(accountId, uid);
1606
+ this.bodyStore.deleteMessage(accountId, folderId, uid).catch(() => { });
1607
+ counters.deleted++;
1608
+ madeProgress = true;
1609
+ }
1610
+ catch { /* ignore */ }
1611
+ }
1479
1612
  }
1480
1613
  }
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).
1614
+ finally {
1615
+ if (client) {
1486
1616
  try {
1487
- this.db.deleteMessage(accountId, msg.uid);
1488
- this.bodyStore.deleteMessage(accountId, msg.folderId, msg.uid).catch(() => { });
1489
- deleted++;
1490
- madeProgress = true;
1617
+ await client.logout();
1491
1618
  }
1492
1619
  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
1620
  }
1508
1621
  }
1622
+ if (counters.errors >= ERROR_BUDGET) {
1623
+ console.error(` [prefetch] ${accountId}: stopping after ${counters.errors} errors (${counters.totalFetched} cached, ${counters.deleted} pruned)`);
1624
+ return;
1625
+ }
1509
1626
  }
1510
- // Safety: if we made zero progress this iteration, bail otherwise
1511
- // we'd loop forever on rows that keep failing without isNotFound.
1627
+ // Safety: zero progress this tick bail rather than loop forever.
1512
1628
  if (!madeProgress)
1513
1629
  break;
1630
+ // Emit so the UI refreshes the open-circle → filled-teal indicator
1631
+ // without waiting for the next sync cycle.
1632
+ this.emit("folderCountsChanged", accountId, {});
1514
1633
  }
1515
- if (totalFetched > 0 || deleted > 0) {
1516
- console.log(` [prefetch] ${accountId}: ${totalFetched} bodies cached, ${deleted} stale rows pruned (done)`);
1634
+ if (counters.totalFetched > 0 || counters.deleted > 0) {
1635
+ console.log(` [prefetch] ${accountId}: ${counters.totalFetched} bodies cached, ${counters.deleted} stale rows pruned (done)`);
1636
+ this.emit("folderCountsChanged", accountId, {});
1517
1637
  }
1518
1638
  }
1519
1639
  /** Get the body store for direct access */
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx-imap",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
4
4
  "type": "module",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",
@@ -21,6 +21,17 @@ export declare class GmailApiProvider implements MailProvider {
21
21
  fetchSince(folder: string, sinceUid: number, options?: FetchOptions): Promise<ProviderMessage[]>;
22
22
  fetchByDate(folder: string, since: Date, before: Date, options?: FetchOptions, onChunk?: (msgs: ProviderMessage[]) => void): Promise<ProviderMessage[]>;
23
23
  fetchByUids(folder: string, uids: number[], options?: FetchOptions): Promise<ProviderMessage[]>;
24
+ /** Bulk-fetch raw bodies for many UIDs in one "folder" (Gmail label).
25
+ * Lists the label once, builds UID→ID map, then streams bodies through
26
+ * `onBody` with bounded concurrency (lets Gmail's HTTP/2 stream multiplex;
27
+ * `fetch()`'s built-in 429/5xx retry handles backoff automatically).
28
+ *
29
+ * NOTE: Gmail's model is labels, not folders — a single message can be in
30
+ * many labels. Treating each label as a folder causes duplicate fetches
31
+ * across labels. Proper fix tracked as separate TODO ("Gmail label-native
32
+ * model"). For now we mirror the IMAP folder grouping, accepting duplicate
33
+ * fetches of multi-labeled messages. */
34
+ fetchBodiesBatch(folder: string, uids: number[], onBody: (uid: number, source: string) => void): Promise<void>;
24
35
  fetchOne(folder: string, uid: number, options?: FetchOptions): Promise<ProviderMessage | null>;
25
36
  getUids(folder: string): Promise<number[]>;
26
37
  close(): Promise<void>;
@@ -234,6 +234,56 @@ export class GmailApiProvider {
234
234
  const matchingIds = ids.filter(id => uidSet.has(idToUid(id)));
235
235
  return this.batchFetch(matchingIds, options);
236
236
  }
237
+ /** Bulk-fetch raw bodies for many UIDs in one "folder" (Gmail label).
238
+ * Lists the label once, builds UID→ID map, then streams bodies through
239
+ * `onBody` with bounded concurrency (lets Gmail's HTTP/2 stream multiplex;
240
+ * `fetch()`'s built-in 429/5xx retry handles backoff automatically).
241
+ *
242
+ * NOTE: Gmail's model is labels, not folders — a single message can be in
243
+ * many labels. Treating each label as a folder causes duplicate fetches
244
+ * across labels. Proper fix tracked as separate TODO ("Gmail label-native
245
+ * model"). For now we mirror the IMAP folder grouping, accepting duplicate
246
+ * fetches of multi-labeled messages. */
247
+ async fetchBodiesBatch(folder, uids, onBody) {
248
+ if (uids.length === 0)
249
+ return;
250
+ const query = `in:${this.folderToLabel(folder)}`;
251
+ const ids = await this.listMessageIds(query, 10000);
252
+ const uidToId = new Map();
253
+ for (const id of ids)
254
+ uidToId.set(idToUid(id), id);
255
+ const wanted = [];
256
+ for (const uid of uids) {
257
+ const id = uidToId.get(uid);
258
+ if (id)
259
+ wanted.push({ uid, id });
260
+ }
261
+ if (wanted.length === 0)
262
+ return;
263
+ // Bounded concurrency — 10 in-flight is safe under Gmail's per-user rate
264
+ // limit (250 quota units/sec, messages.get = 5 units each = 50/sec cap).
265
+ const CONCURRENCY = 10;
266
+ let cursor = 0;
267
+ const worker = async () => {
268
+ while (cursor < wanted.length) {
269
+ const idx = cursor++;
270
+ const { uid, id } = wanted[idx];
271
+ try {
272
+ const msg = await this.fetch(`/messages/${id}?format=raw`);
273
+ if (!msg?.raw)
274
+ continue;
275
+ const base64 = msg.raw.replace(/-/g, "+").replace(/_/g, "/");
276
+ const source = new TextDecoder().decode(Uint8Array.from(atob(base64), c => c.charCodeAt(0)));
277
+ onBody(uid, source);
278
+ }
279
+ catch (e) {
280
+ // Per-message failure is non-fatal; keep worker alive for the rest.
281
+ console.error(` [gmail batch] UID ${uid}: ${e.message}`);
282
+ }
283
+ }
284
+ };
285
+ await Promise.all(Array.from({ length: Math.min(CONCURRENCY, wanted.length) }, () => worker()));
286
+ }
237
287
  async fetchOne(folder, uid, options = {}) {
238
288
  // Need to find the Gmail ID from the UID — search all messages in folder
239
289
  const query = `in:${this.folderToLabel(folder)}`;
@@ -6,10 +6,6 @@
6
6
  import { MailxDB } from "@bobfrankston/mailx-store";
7
7
  import { ImapManager } from "@bobfrankston/mailx-imap";
8
8
  import type { Folder, AutocompleteRequest, AutocompleteResponse, AutocompleteSettings } from "@bobfrankston/mailx-types";
9
- export declare function sanitizeHtml(html: string): {
10
- html: string;
11
- hasRemoteContent: boolean;
12
- };
13
9
  export declare class MailxService {
14
10
  private db;
15
11
  private imapManager;
@@ -7,43 +7,8 @@ import * as dns from "node:dns/promises";
7
7
  import * as fs from "node:fs";
8
8
  import * as path from "node:path";
9
9
  import { loadSettings, saveSettings, loadAccounts, loadAccountsAsync, saveAccounts, initCloudConfig, loadAllowlist, saveAllowlist, loadAutocomplete, saveAutocomplete, getStorePath, getStorageInfo, getConfigDir } from "@bobfrankston/mailx-settings";
10
+ import { sanitizeHtml, encodeQuotedPrintable } from "@bobfrankston/mailx-types";
10
11
  import { simpleParser } from "mailparser";
11
- // ── Quoted-printable encoding (readable in debug .eml files) ──
12
- function encodeQuotedPrintable(text) {
13
- const bytes = Buffer.from(text, "utf-8");
14
- let line = "";
15
- let result = "";
16
- for (let i = 0; i < bytes.length; i++) {
17
- const b = bytes[i];
18
- let encoded;
19
- if (b === 0x0D && bytes[i + 1] === 0x0A) {
20
- // CRLF — output as-is
21
- result += line + "\r\n";
22
- line = "";
23
- i++; // skip LF
24
- continue;
25
- }
26
- else if (b === 0x0A) {
27
- // Bare LF — normalize to CRLF
28
- result += line + "\r\n";
29
- line = "";
30
- continue;
31
- }
32
- else if ((b >= 33 && b <= 126 && b !== 61) || b === 9 || b === 32) {
33
- encoded = String.fromCharCode(b);
34
- }
35
- else {
36
- encoded = "=" + b.toString(16).toUpperCase().padStart(2, "0");
37
- }
38
- if (line.length + encoded.length > 75) {
39
- result += line + "=\r\n";
40
- line = "";
41
- }
42
- line += encoded;
43
- }
44
- result += line;
45
- return result;
46
- }
47
12
  // ── Email provider detection (MX-based) ──
48
13
  const GOOGLE_DOMAINS = ["gmail.com", "googlemail.com"];
49
14
  const MS_DOMAINS = ["outlook.com", "hotmail.com", "live.com"];
@@ -67,30 +32,7 @@ async function detectEmailProvider(domain) {
67
32
  catch { /* DNS lookup failed */ }
68
33
  return null;
69
34
  }
70
- // ── Sanitize ──
71
- export function sanitizeHtml(html) {
72
- let hasRemoteContent = false;
73
- let clean = html.replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, "");
74
- clean = clean.replace(/\s+on\w+\s*=\s*("[^"]*"|'[^']*'|[^\s>]+)/gi, "");
75
- clean = clean.replace(/<img\b([^>]*)\bsrc\s*=\s*("[^"]*"|'[^']*')/gi, (match, before, src) => {
76
- const url = src.slice(1, -1);
77
- if (url.startsWith("data:") || url.startsWith("cid:"))
78
- return match;
79
- hasRemoteContent = true;
80
- return `<img${before}src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='20' height='20'%3E%3Crect fill='%23888' width='20' height='20' rx='3'/%3E%3Ctext x='10' y='14' text-anchor='middle' fill='white' font-size='12'%3E⊘%3C/text%3E%3C/svg%3E" data-blocked-src=${src} title="Remote image blocked"`;
81
- });
82
- clean = clean.replace(/<link\b[^>]*rel\s*=\s*["']stylesheet["'][^>]*>/gi, (match) => {
83
- hasRemoteContent = true;
84
- return `<!-- blocked: ${match.replace(/--/g, "")} -->`;
85
- });
86
- clean = clean.replace(/url\s*\(\s*(['"]?)(https?:\/\/[^)]+)\1\s*\)/gi, (_match, _q, url) => {
87
- hasRemoteContent = true;
88
- return `url("") /* blocked: ${url} */`;
89
- });
90
- clean = clean.replace(/<\/?form\b[^>]*>/gi, "");
91
- clean = clean.replace(/<iframe\b[^>]*>[\s\S]*?<\/iframe>/gi, "");
92
- return { html: clean, hasRemoteContent };
93
- }
35
+ // sanitizeHtml and encodeQuotedPrintable imported from @bobfrankston/mailx-types (shared with Android)
94
36
  // ── Service ──
95
37
  export class MailxService {
96
38
  db;
@@ -110,25 +110,30 @@ class AndroidSyncManager {
110
110
  async syncAll() {
111
111
  const accounts = this.db.getAccounts();
112
112
  vlog(`syncAll: ${accounts.length} accounts in DB: ${accounts.map(a => a.id).join(",")}`);
113
+ // Phase 1: Sync INBOX for every account first — user sees new mail fast.
113
114
  for (const account of accounts) {
114
- const hasProvider = this.providers.has(account.id);
115
- vlog(`syncAll: ${account.id} hasProvider=${hasProvider}`);
115
+ if (!this.providers.has(account.id))
116
+ continue;
116
117
  try {
117
118
  const folders = await this.syncFolders(account.id);
118
- vlog(`syncAll: ${account.id} got ${folders.length} folders`);
119
- const sorted = [...folders].sort((a, b) => {
120
- if (a.specialUse === "inbox")
121
- return -1;
122
- if (b.specialUse === "inbox")
123
- return 1;
124
- return 0;
125
- });
126
- // Sync every folder, not just the first five — the old slice(0, 5)
127
- // meant subfolders past the cutoff (e.g. _spam, custom labels)
128
- // never picked up moves made on other clients, and those moves
129
- // also stayed visible in the source folder because reconcile
130
- // (below in syncFolder) never ran for the target.
131
- for (const folder of sorted) {
119
+ const inbox = folders.find(f => f.specialUse === "inbox");
120
+ if (inbox) {
121
+ await this.syncFolder(account.id, inbox.id);
122
+ emitEvent({ type: "syncComplete", accountId: account.id });
123
+ }
124
+ }
125
+ catch (e) {
126
+ console.error(`[sync] ${account.id} inbox: ${e.message}`);
127
+ }
128
+ }
129
+ // Phase 2: Remaining folders (sent, drafts, trash, then everything else).
130
+ for (const account of accounts) {
131
+ if (!this.providers.has(account.id))
132
+ continue;
133
+ try {
134
+ const folders = this.db.getFolders(account.id);
135
+ const remaining = folders.filter(f => f.specialUse !== "inbox");
136
+ for (const folder of remaining) {
132
137
  try {
133
138
  await this.syncFolder(account.id, folder.id);
134
139
  }
@@ -788,6 +793,14 @@ export async function initAndroid() {
788
793
  vlog("periodic sync poll");
789
794
  syncManager.syncAll().catch(e => console.error(`[android] Periodic sync error: ${e.message}`));
790
795
  }, SYNC_INTERVAL_MS);
796
+ // Immediate sync when app comes back to foreground (e.g. user switches from
797
+ // another app). Without this, new messages wait up to 2 minutes after resume.
798
+ document.addEventListener("visibilitychange", () => {
799
+ if (document.visibilityState === "visible") {
800
+ console.log("[sync] resume poll");
801
+ syncManager.syncAll().catch(e => console.error(`[android] Resume sync error: ${e.message}`));
802
+ }
803
+ });
791
804
  console.log("[android] Initialization complete");
792
805
  emitEvent({ type: "connected" });
793
806
  }
@@ -7,6 +7,7 @@
7
7
  * Bodies are stored in IndexedDB via WebMessageStore (not filesystem).
8
8
  */
9
9
  import initSqlJs from "sql.js";
10
+ import { parseSearchQuery } from "@bobfrankston/mailx-types";
10
11
  const SCHEMA = `
11
12
  CREATE TABLE IF NOT EXISTS accounts (
12
13
  id TEXT PRIMARY KEY,
@@ -433,20 +434,20 @@ export class WebMailxDB {
433
434
  // ── Search ──
434
435
  searchMessages(query, page = 1, pageSize = 50, accountId, folderId) {
435
436
  const offset = (page - 1) * pageSize;
436
- const term = `%${query}%`;
437
- let where = "(subject LIKE ? OR from_name LIKE ? OR from_address LIKE ? OR preview LIKE ?)";
438
- const params = [term, term, term, term];
437
+ const parsed = parseSearchQuery(query);
438
+ const allParams = [...parsed.params];
439
+ let where = parsed.conditions.length > 0 ? parsed.conditions.join(" AND ") : "1=0";
439
440
  if (accountId && folderId) {
440
441
  where += " AND account_id = ? AND folder_id = ?";
441
- params.push(accountId, folderId);
442
+ allParams.push(accountId, folderId);
442
443
  }
443
444
  else if (accountId) {
444
445
  where += " AND account_id = ?";
445
- params.push(accountId);
446
+ allParams.push(accountId);
446
447
  }
447
- const countRow = this.get(`SELECT COUNT(*) as cnt FROM messages WHERE ${where}`, params);
448
+ const countRow = this.get(`SELECT COUNT(*) as cnt FROM messages WHERE ${where}`, allParams);
448
449
  const total = countRow?.cnt || 0;
449
- const rows = this.all(`SELECT * FROM messages WHERE ${where} ORDER BY date DESC LIMIT ? OFFSET ?`, [...params, pageSize, offset]);
450
+ const rows = this.all(`SELECT * FROM messages WHERE ${where} ORDER BY date DESC LIMIT ? OFFSET ?`, [...allParams, pageSize, offset]);
450
451
  return { items: rows.map(r => this.rowToEnvelope(r)), total, page, pageSize };
451
452
  }
452
453
  // ── Sync Actions ──
@@ -24,6 +24,10 @@ export declare class GmailApiWebProvider implements MailProvider {
24
24
  fetchOne(folder: string, uid: number, options?: FetchOptions): Promise<ProviderMessage | null>;
25
25
  getUids(folder: string): Promise<number[]>;
26
26
  close(): Promise<void>;
27
+ /** Bulk-fetch raw bodies for many UIDs in one Gmail label. Lists the label
28
+ * once, builds UID→ID map, streams bodies via `onBody` with bounded
29
+ * concurrency. Mirrors GmailApiProvider.fetchBodiesBatch on desktop. */
30
+ fetchBodiesBatch(folder: string, uids: number[], onBody: (uid: number, source: string) => void): Promise<void>;
27
31
  /** Send an RFC 2822 message via Gmail API users.messages.send. The server
28
32
  * handles SMTP — we just hand it the raw bytes base64url-encoded. Auto-files
29
33
  * a copy into the Sent label, so caller does NOT need to APPEND to Sent. */
@@ -227,6 +227,46 @@ export class GmailApiWebProvider {
227
227
  return ids.map(idToUid);
228
228
  }
229
229
  async close() { }
230
+ /** Bulk-fetch raw bodies for many UIDs in one Gmail label. Lists the label
231
+ * once, builds UID→ID map, streams bodies via `onBody` with bounded
232
+ * concurrency. Mirrors GmailApiProvider.fetchBodiesBatch on desktop. */
233
+ async fetchBodiesBatch(folder, uids, onBody) {
234
+ if (uids.length === 0)
235
+ return;
236
+ const query = `in:${this.folderToLabel(folder)}`;
237
+ const ids = await this.listMessageIds(query, 10000);
238
+ const uidToId = new Map();
239
+ for (const id of ids)
240
+ uidToId.set(idToUid(id), id);
241
+ const wanted = [];
242
+ for (const uid of uids) {
243
+ const id = uidToId.get(uid);
244
+ if (id)
245
+ wanted.push({ uid, id });
246
+ }
247
+ if (wanted.length === 0)
248
+ return;
249
+ const CONCURRENCY = 10;
250
+ let cursor = 0;
251
+ const worker = async () => {
252
+ while (cursor < wanted.length) {
253
+ const idx = cursor++;
254
+ const { uid, id } = wanted[idx];
255
+ try {
256
+ const msg = await this.apiFetch(`/messages/${id}?format=raw`);
257
+ if (!msg?.raw)
258
+ continue;
259
+ const base64 = msg.raw.replace(/-/g, "+").replace(/_/g, "/");
260
+ const source = new TextDecoder().decode(Uint8Array.from(atob(base64), c => c.charCodeAt(0)));
261
+ onBody(uid, source);
262
+ }
263
+ catch (e) {
264
+ console.error(`[gmail batch] UID ${uid}: ${e.message}`);
265
+ }
266
+ }
267
+ };
268
+ await Promise.all(Array.from({ length: Math.min(CONCURRENCY, wanted.length) }, () => worker()));
269
+ }
230
270
  /** Send an RFC 2822 message via Gmail API users.messages.send. The server
231
271
  * handles SMTP — we just hand it the raw bytes base64url-encoded. Auto-files
232
272
  * a copy into the Sent label, so caller does NOT need to APPEND to Sent. */
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx-store-web",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "type": "module",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",
@@ -12,10 +12,6 @@
12
12
  import type { WebMailxDB } from "./db.js";
13
13
  import type { WebMessageStore } from "./web-message-store.js";
14
14
  import type { Folder, AutocompleteSettings } from "@bobfrankston/mailx-types";
15
- export declare function sanitizeHtml(html: string): {
16
- html: string;
17
- hasRemoteContent: boolean;
18
- };
19
15
  export interface WebSyncManager {
20
16
  syncAll(): Promise<void>;
21
17
  syncFolders(accountId: string): Promise<Folder[]>;
@@ -9,31 +9,8 @@
9
9
  * - Settings via IndexedDB + GDrive API instead of filesystem
10
10
  * - No dns.resolveMx — provider detection is static (Gmail/Outlook/Yahoo/iCloud)
11
11
  */
12
+ import { sanitizeHtml, encodeQuotedPrintable } from "@bobfrankston/mailx-types";
12
13
  import { loadSettings, saveSettings, loadAllowlist, saveAllowlist, loadAutocomplete, saveAutocomplete, getStorageInfo } from "./web-settings.js";
13
- // ── HTML sanitizer (same logic as desktop) ──
14
- export function sanitizeHtml(html) {
15
- let hasRemoteContent = false;
16
- let clean = html.replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, "");
17
- clean = clean.replace(/\s+on\w+\s*=\s*("[^"]*"|'[^']*'|[^\s>]+)/gi, "");
18
- clean = clean.replace(/<img\b([^>]*)\bsrc\s*=\s*("[^"]*"|'[^']*')/gi, (match, before, src) => {
19
- const url = src.slice(1, -1);
20
- if (url.startsWith("data:") || url.startsWith("cid:"))
21
- return match;
22
- hasRemoteContent = true;
23
- return `<img${before}src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='20' height='20'%3E%3Crect fill='%23888' width='20' height='20' rx='3'/%3E%3Ctext x='10' y='14' text-anchor='middle' fill='white' font-size='12'%3E⊘%3C/text%3E%3C/svg%3E" data-blocked-src=${src} title="Remote image blocked"`;
24
- });
25
- clean = clean.replace(/<link\b[^>]*rel\s*=\s*["']stylesheet["'][^>]*>/gi, (match) => {
26
- hasRemoteContent = true;
27
- return `<!-- blocked: ${match.replace(/--/g, "")} -->`;
28
- });
29
- clean = clean.replace(/url\s*\(\s*(['"]?)(https?:\/\/[^)]+)\1\s*\)/gi, (_match, _q, url) => {
30
- hasRemoteContent = true;
31
- return `url("") /* blocked: ${url} */`;
32
- });
33
- clean = clean.replace(/<\/?form\b[^>]*>/gi, "");
34
- clean = clean.replace(/<iframe\b[^>]*>[\s\S]*?<\/iframe>/gi, "");
35
- return { html: clean, hasRemoteContent };
36
- }
37
14
  /** Parse an RFC 2822 message from raw bytes. Handles basic MIME. */
38
15
  function parseEmailSource(raw) {
39
16
  const headers = new Map();
@@ -191,41 +168,6 @@ function decodeBody(body, encoding, charset = "utf-8") {
191
168
  return new TextDecoder("utf-8").decode(bytes);
192
169
  }
193
170
  }
194
- // ── Quoted-printable encoding (for compose/send) ──
195
- function encodeQuotedPrintable(text) {
196
- const encoder = new TextEncoder();
197
- const bytes = encoder.encode(text);
198
- let line = "";
199
- let result = "";
200
- for (let i = 0; i < bytes.length; i++) {
201
- const b = bytes[i];
202
- let encoded;
203
- if (b === 0x0D && bytes[i + 1] === 0x0A) {
204
- result += line + "\r\n";
205
- line = "";
206
- i++;
207
- continue;
208
- }
209
- else if (b === 0x0A) {
210
- result += line + "\r\n";
211
- line = "";
212
- continue;
213
- }
214
- else if ((b >= 33 && b <= 126 && b !== 61) || b === 9 || b === 32) {
215
- encoded = String.fromCharCode(b);
216
- }
217
- else {
218
- encoded = "=" + b.toString(16).toUpperCase().padStart(2, "0");
219
- }
220
- if (line.length + encoded.length > 75) {
221
- result += line + "=\r\n";
222
- line = "";
223
- }
224
- line += encoded;
225
- }
226
- result += line;
227
- return result;
228
- }
229
171
  // ── Service ──
230
172
  export class WebMailxService {
231
173
  db;
@@ -236,4 +236,18 @@ export interface MessageStore {
236
236
  deleteMessage(accountId: string, folderId: number, uid: number): Promise<void>;
237
237
  hasMessage(accountId: string, folderId: number, uid: number): Promise<boolean>;
238
238
  }
239
+ /** Sanitize HTML for safe display — strips scripts, inline handlers, remote images, forms, iframes. */
240
+ export declare function sanitizeHtml(html: string): {
241
+ html: string;
242
+ hasRemoteContent: boolean;
243
+ };
244
+ /** Encode text as RFC 2045 quoted-printable. */
245
+ export declare function encodeQuotedPrintable(text: string): string;
246
+ /** Parse search query into structured conditions.
247
+ * Supports qualifiers: from:, to:, subject: (unqualified terms search everything).
248
+ * Returns { conditions, params } for SQL WHERE clause with LIKE. */
249
+ export declare function parseSearchQuery(query: string): {
250
+ conditions: string[];
251
+ params: string[];
252
+ };
239
253
  //# sourceMappingURL=index.d.ts.map
@@ -3,5 +3,100 @@
3
3
  * Shared type definitions for the mailx email client.
4
4
  * This is the contract between client and server.
5
5
  */
6
- export {};
6
+ // ── Shared Utilities ──
7
+ // Pure functions used by both desktop (mailx-service) and Android (web-service).
8
+ // Kept here to avoid duplication — both platforms import from mailx-types.
9
+ /** Sanitize HTML for safe display — strips scripts, inline handlers, remote images, forms, iframes. */
10
+ export function sanitizeHtml(html) {
11
+ let hasRemoteContent = false;
12
+ let clean = html.replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, "");
13
+ clean = clean.replace(/\s+on\w+\s*=\s*("[^"]*"|'[^']*'|[^\s>]+)/gi, "");
14
+ clean = clean.replace(/<img\b([^>]*)\bsrc\s*=\s*("[^"]*"|'[^']*')/gi, (match, before, src) => {
15
+ const url = src.slice(1, -1);
16
+ if (url.startsWith("data:") || url.startsWith("cid:"))
17
+ return match;
18
+ hasRemoteContent = true;
19
+ return `<img${before}src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='20' height='20'%3E%3Crect fill='%23888' width='20' height='20' rx='3'/%3E%3Ctext x='10' y='14' text-anchor='middle' fill='white' font-size='12'%3E⊘%3C/text%3E%3C/svg%3E" data-blocked-src=${src} title="Remote image blocked"`;
20
+ });
21
+ clean = clean.replace(/<link\b[^>]*rel\s*=\s*["']stylesheet["'][^>]*>/gi, (match) => {
22
+ hasRemoteContent = true;
23
+ return `<!-- blocked: ${match.replace(/--/g, "")} -->`;
24
+ });
25
+ clean = clean.replace(/url\s*\(\s*(['"]?)(https?:\/\/[^)]+)\1\s*\)/gi, (_match, _q, url) => {
26
+ hasRemoteContent = true;
27
+ return `url("") /* blocked: ${url} */`;
28
+ });
29
+ clean = clean.replace(/<\/?form\b[^>]*>/gi, "");
30
+ clean = clean.replace(/<iframe\b[^>]*>[\s\S]*?<\/iframe>/gi, "");
31
+ return { html: clean, hasRemoteContent };
32
+ }
33
+ /** Encode text as RFC 2045 quoted-printable. */
34
+ export function encodeQuotedPrintable(text) {
35
+ const encoder = new TextEncoder();
36
+ const bytes = encoder.encode(text);
37
+ let line = "";
38
+ let result = "";
39
+ for (let i = 0; i < bytes.length; i++) {
40
+ const b = bytes[i];
41
+ let encoded;
42
+ if (b === 0x0D && bytes[i + 1] === 0x0A) {
43
+ result += line + "\r\n";
44
+ line = "";
45
+ i++;
46
+ continue;
47
+ }
48
+ else if (b === 0x0A) {
49
+ result += line + "\r\n";
50
+ line = "";
51
+ continue;
52
+ }
53
+ else if ((b >= 33 && b <= 126 && b !== 61) || b === 9 || b === 32) {
54
+ encoded = String.fromCharCode(b);
55
+ }
56
+ else {
57
+ encoded = "=" + b.toString(16).toUpperCase().padStart(2, "0");
58
+ }
59
+ if (line.length + encoded.length > 75) {
60
+ result += line + "=\r\n";
61
+ line = "";
62
+ }
63
+ line += encoded;
64
+ }
65
+ result += line;
66
+ return result;
67
+ }
68
+ /** Parse search query into structured conditions.
69
+ * Supports qualifiers: from:, to:, subject: (unqualified terms search everything).
70
+ * Returns { conditions, params } for SQL WHERE clause with LIKE. */
71
+ export function parseSearchQuery(query) {
72
+ const parts = query.match(/(?:[^\s"]+|"[^"]*")+/g) || [];
73
+ const conditions = [];
74
+ const params = [];
75
+ for (const part of parts) {
76
+ const fromMatch = part.match(/^from:(.+)$/i);
77
+ const toMatch = part.match(/^to:(.+)$/i);
78
+ const subjectMatch = part.match(/^subject:(.+)$/i);
79
+ if (fromMatch) {
80
+ const term = `%${fromMatch[1].replace(/"/g, "")}%`;
81
+ conditions.push("(from_name LIKE ? OR from_address LIKE ?)");
82
+ params.push(term, term);
83
+ }
84
+ else if (toMatch) {
85
+ const term = `%${toMatch[1].replace(/"/g, "")}%`;
86
+ conditions.push("(to_json LIKE ? OR cc_json LIKE ?)");
87
+ params.push(term, term);
88
+ }
89
+ else if (subjectMatch) {
90
+ const term = `%${subjectMatch[1].replace(/"/g, "")}%`;
91
+ conditions.push("subject LIKE ?");
92
+ params.push(term);
93
+ }
94
+ else {
95
+ const term = `%${part}%`;
96
+ conditions.push("(subject LIKE ? OR from_name LIKE ? OR from_address LIKE ? OR preview LIKE ?)");
97
+ params.push(term, term, term, term);
98
+ }
99
+ }
100
+ return { conditions, params };
101
+ }
7
102
  //# sourceMappingURL=index.js.map