@bobfrankston/mailx 1.0.253 → 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.
- package/client/.msger-window.json +1 -1
- package/package.json +7 -7
- package/packages/mailx-imap/index.js +169 -49
- package/packages/mailx-imap/package.json +1 -1
- package/packages/mailx-imap/providers/gmail-api.d.ts +11 -0
- package/packages/mailx-imap/providers/gmail-api.js +50 -0
- package/packages/mailx-service/index.d.ts +0 -4
- package/packages/mailx-service/index.js +2 -60
- package/packages/mailx-store-web/android-bootstrap.js +29 -16
- package/packages/mailx-store-web/db.js +8 -7
- package/packages/mailx-store-web/gmail-api-web.d.ts +4 -0
- package/packages/mailx-store-web/gmail-api-web.js +40 -0
- package/packages/mailx-store-web/package.json +1 -1
- package/packages/mailx-store-web/web-service.d.ts +0 -4
- package/packages/mailx-store-web/web-service.js +1 -59
- package/packages/mailx-types/index.d.ts +14 -0
- package/packages/mailx-types/index.js +96 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"height":1344,"width":2151,"x":
|
|
1
|
+
{"height":1344,"width":2151,"x":616,"y":113}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bobfrankston/mailx",
|
|
3
|
-
"version": "1.0.
|
|
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.
|
|
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.
|
|
27
|
-
"@bobfrankston/msger": "^0.1.
|
|
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.
|
|
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.
|
|
87
|
-
"@bobfrankston/msger": "^0.1.
|
|
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
|
-
|
|
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,
|
|
1457
|
+
const missing = this.db.getMessagesWithoutBody(accountId, BATCH_SIZE);
|
|
1459
1458
|
if (missing.length === 0)
|
|
1460
1459
|
break;
|
|
1461
|
-
if (
|
|
1460
|
+
if (!announced) {
|
|
1462
1461
|
console.log(` [prefetch] ${accountId}: ${missing.length}+ bodies to fetch`);
|
|
1462
|
+
announced = true;
|
|
1463
|
+
}
|
|
1463
1464
|
let madeProgress = false;
|
|
1464
|
-
|
|
1465
|
-
//
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
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
|
-
|
|
1472
|
-
|
|
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
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
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
|
-
|
|
1482
|
-
if (
|
|
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
|
-
|
|
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:
|
|
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 */
|
|
@@ -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
|
-
//
|
|
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
|
-
|
|
115
|
-
|
|
115
|
+
if (!this.providers.has(account.id))
|
|
116
|
+
continue;
|
|
116
117
|
try {
|
|
117
118
|
const folders = await this.syncFolders(account.id);
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
});
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
|
437
|
-
|
|
438
|
-
|
|
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
|
-
|
|
442
|
+
allParams.push(accountId, folderId);
|
|
442
443
|
}
|
|
443
444
|
else if (accountId) {
|
|
444
445
|
where += " AND account_id = ?";
|
|
445
|
-
|
|
446
|
+
allParams.push(accountId);
|
|
446
447
|
}
|
|
447
|
-
const countRow = this.get(`SELECT COUNT(*) as cnt FROM messages WHERE ${where}`,
|
|
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 ?`, [...
|
|
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. */
|
|
@@ -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
|
-
|
|
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
|