proton-mail-bridge-client 2.0.0 → 2.0.2
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/README.md +2 -2
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +6 -2
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +83 -37
- package/dist/index.js.map +1 -1
- package/dist/services/delivery-queue-service.d.ts +5 -0
- package/dist/services/delivery-queue-service.d.ts.map +1 -1
- package/dist/services/delivery-queue-service.js +110 -15
- package/dist/services/delivery-queue-service.js.map +1 -1
- package/dist/services/draft-store-service.d.ts +3 -0
- package/dist/services/draft-store-service.d.ts.map +1 -1
- package/dist/services/draft-store-service.js +64 -1
- package/dist/services/draft-store-service.js.map +1 -1
- package/dist/services/local-index-service.d.ts +5 -0
- package/dist/services/local-index-service.d.ts.map +1 -1
- package/dist/services/local-index-service.js +191 -18
- package/dist/services/local-index-service.js.map +1 -1
- package/dist/services/simple-imap-service.d.ts +7 -1
- package/dist/services/simple-imap-service.d.ts.map +1 -1
- package/dist/services/simple-imap-service.js +163 -23
- package/dist/services/simple-imap-service.js.map +1 -1
- package/dist/services/snooze-service.d.ts +3 -0
- package/dist/services/snooze-service.d.ts.map +1 -1
- package/dist/services/snooze-service.js +103 -7
- package/dist/services/snooze-service.js.map +1 -1
- package/dist/services/template-service.d.ts +1 -0
- package/dist/services/template-service.d.ts.map +1 -1
- package/dist/services/template-service.js +8 -0
- package/dist/services/template-service.js.map +1 -1
- package/dist/types/index.d.ts +7 -1
- package/dist/types/index.d.ts.map +1 -1
- package/dist/utils/account-identity.d.ts +8 -0
- package/dist/utils/account-identity.d.ts.map +1 -0
- package/dist/utils/account-identity.js +78 -0
- package/dist/utils/account-identity.js.map +1 -0
- package/dist/utils/file-lock.d.ts +1 -0
- package/dist/utils/file-lock.d.ts.map +1 -1
- package/dist/utils/file-lock.js +24 -4
- package/dist/utils/file-lock.js.map +1 -1
- package/package.json +2 -2
|
@@ -2,6 +2,7 @@ import Database from "better-sqlite3";
|
|
|
2
2
|
import { chmodSync } from "node:fs";
|
|
3
3
|
import { mkdir, readFile, rm } from "node:fs/promises";
|
|
4
4
|
import { dirname, join } from "node:path";
|
|
5
|
+
import { ensureAccountIdentityMatches } from "../utils/account-identity.js";
|
|
5
6
|
import { dedupeEmails, extractDomain, extractMessageIdList, lowerCaseAddress, nextDay, normalizeMailboxLabel, normalizeMessageId, normalizeSubjectForThread, sortEmailsByNewest, } from "../utils/helpers.js";
|
|
6
7
|
import { logger } from "../utils/logger.js";
|
|
7
8
|
const DB_SCHEMA_VERSION = 3;
|
|
@@ -401,8 +402,17 @@ export class LocalIndexService {
|
|
|
401
402
|
return this.getStatus();
|
|
402
403
|
}
|
|
403
404
|
async getStatus() {
|
|
404
|
-
const
|
|
405
|
+
const db = await this.ensureDb();
|
|
406
|
+
const { ownerEmail, updatedAt, folders, indexedFolders } = this.loadFoldersAndMetadata(db);
|
|
407
|
+
const syncCheckpoints = this.loadCheckpointsSync(db);
|
|
405
408
|
const counts = await this.realMessageCounts();
|
|
409
|
+
// threadCount/labelCount (below, via toStatus -> buildThreads/buildMailboxMessages)
|
|
410
|
+
// genuinely need message content — thread grouping via thread_id/References/In-Reply-To
|
|
411
|
+
// and per-message label normalization aren't derivable from folders/sync_state alone.
|
|
412
|
+
// Reuse the same deliberately-capped message query loadSnapshot() uses elsewhere for
|
|
413
|
+
// this; every other field above is now sourced without touching the messages table.
|
|
414
|
+
const messages = this.loadMessages(db, {});
|
|
415
|
+
const snapshot = { ownerEmail, updatedAt, folders, indexedFolders, syncCheckpoints, messages };
|
|
406
416
|
return this.toStatus(snapshot, counts);
|
|
407
417
|
}
|
|
408
418
|
// loadSnapshot() caps snapshot.messages at DEFAULT_SNAPSHOT_LIMIT (5000) —
|
|
@@ -549,7 +559,28 @@ export class LocalIndexService {
|
|
|
549
559
|
.slice(0, limit);
|
|
550
560
|
}
|
|
551
561
|
async getThreads(input = {}) {
|
|
552
|
-
const
|
|
562
|
+
const db = await this.ensureDb();
|
|
563
|
+
const { ownerEmail, updatedAt, folders, indexedFolders } = this.loadFoldersAndMetadata(db);
|
|
564
|
+
const syncCheckpoints = this.loadCheckpointsSync(db);
|
|
565
|
+
let messages;
|
|
566
|
+
let messagesCapped = false;
|
|
567
|
+
const hasFilter = Boolean(input.folder || input.label || input.query);
|
|
568
|
+
if (hasFilter) {
|
|
569
|
+
// Filter/search in SQL first — mirroring how search() builds its SQL query
|
|
570
|
+
// before any snapshot-size limiting — so a thread whose messages sit entirely
|
|
571
|
+
// outside the DEFAULT_SNAPSHOT_LIMIT-capped snapshot is never silently missed
|
|
572
|
+
// just because a folder/label/query filter was given.
|
|
573
|
+
messages = this.loadThreadCandidateMessages(db, input);
|
|
574
|
+
}
|
|
575
|
+
else {
|
|
576
|
+
// No filter at all: a huge mailbox still needs SOME cap on what's fetched, so
|
|
577
|
+
// keep the deliberate DEFAULT_SNAPSHOT_LIMIT cap here — but surface it via
|
|
578
|
+
// messagesCapped instead of silently truncating, since real pagination over an
|
|
579
|
+
// un-scoped "every thread" view isn't possible without a separate thread index.
|
|
580
|
+
messages = this.loadMessages(db, {});
|
|
581
|
+
messagesCapped = messages.length >= DEFAULT_SNAPSHOT_LIMIT;
|
|
582
|
+
}
|
|
583
|
+
const snapshot = { ownerEmail, updatedAt, folders, indexedFolders, syncCheckpoints, messages };
|
|
553
584
|
const threads = this.buildThreads(snapshot).filter((thread) => {
|
|
554
585
|
if (input.label) {
|
|
555
586
|
const labelNeedle = input.label.toLowerCase();
|
|
@@ -579,9 +610,37 @@ export class LocalIndexService {
|
|
|
579
610
|
hasMore: totalCount > offset + limit,
|
|
580
611
|
threads: threads.slice(offset, offset + limit),
|
|
581
612
|
...this.indexFreshnessFields(snapshot.updatedAt),
|
|
613
|
+
...(messagesCapped ? { messagesCapped: true } : {}),
|
|
582
614
|
};
|
|
583
615
|
}
|
|
584
616
|
async getThreadById(threadId) {
|
|
617
|
+
const db = await this.ensureDb();
|
|
618
|
+
const imapPrefix = "imap:";
|
|
619
|
+
if (threadId.startsWith(imapPrefix)) {
|
|
620
|
+
const rawThreadId = threadId.slice(imapPrefix.length);
|
|
621
|
+
// A thread keyed as "imap:<id>" was grouped directly off the persisted
|
|
622
|
+
// messages.thread_id column (see threadKeyForEmail/resolveThreadKey below) —
|
|
623
|
+
// its full membership can be queried by that column directly, unbounded by
|
|
624
|
+
// the DEFAULT_SNAPSHOT_LIMIT cap. This is what lets a threadId that was valid
|
|
625
|
+
// when the index was smaller keep resolving after the index grows past 5000,
|
|
626
|
+
// instead of throwing "Thread not found" just because its messages aged out
|
|
627
|
+
// of the capped snapshot below.
|
|
628
|
+
const rows = db.prepare(`SELECT * FROM messages WHERE thread_id = ?`).all(rawThreadId);
|
|
629
|
+
if (rows.length > 0) {
|
|
630
|
+
const { ownerEmail, updatedAt, folders, indexedFolders } = this.loadFoldersAndMetadata(db);
|
|
631
|
+
const syncCheckpoints = this.loadCheckpointsSync(db);
|
|
632
|
+
const messages = rows.map((row) => this.rowToEmailSummary(row));
|
|
633
|
+
const snapshot = { ownerEmail, updatedAt, folders, indexedFolders, syncCheckpoints, messages };
|
|
634
|
+
const thread = this.buildThreads(snapshot, true).find((entry) => entry.id === threadId);
|
|
635
|
+
if (thread) {
|
|
636
|
+
return thread;
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
// Threads keyed off a References/In-Reply-To chain ("ref:...") or the
|
|
641
|
+
// participant-signature fallback have no persisted thread_id to query by —
|
|
642
|
+
// resolving them needs the cross-message reference graph, so this path stays
|
|
643
|
+
// scoped to the capped snapshot as before.
|
|
585
644
|
const snapshot = await this.loadSnapshot();
|
|
586
645
|
const thread = this.buildThreads(snapshot, true).find((entry) => entry.id === threadId);
|
|
587
646
|
if (!thread) {
|
|
@@ -654,8 +713,12 @@ export class LocalIndexService {
|
|
|
654
713
|
};
|
|
655
714
|
}
|
|
656
715
|
async getSyncCheckpointMap() {
|
|
657
|
-
|
|
658
|
-
|
|
716
|
+
// Query sync_state directly — no need to pay loadSnapshot()'s up-to-5000-row
|
|
717
|
+
// message deserialization just to read checkpoints, which never touch a
|
|
718
|
+
// message row at all.
|
|
719
|
+
const db = await this.ensureDb();
|
|
720
|
+
const checkpoints = this.loadCheckpointsSync(db);
|
|
721
|
+
return Object.fromEntries(checkpoints.map((checkpoint) => [checkpoint.folder, checkpoint]));
|
|
659
722
|
}
|
|
660
723
|
async getInboxDigest(input = {}) {
|
|
661
724
|
const snapshot = await this.loadSnapshot();
|
|
@@ -890,6 +953,12 @@ export class LocalIndexService {
|
|
|
890
953
|
if (this.db && this.initialized) {
|
|
891
954
|
return this.db;
|
|
892
955
|
}
|
|
956
|
+
// Refuse to open this dataDir's SQLite index if it belongs to a
|
|
957
|
+
// different account than the one currently configured (see
|
|
958
|
+
// account-identity.ts) — must run before the Database constructor below
|
|
959
|
+
// ever touches the file, so a mismatched account can never read a
|
|
960
|
+
// single row of the previous account's index.
|
|
961
|
+
await ensureAccountIdentityMatches(this.config.dataDir, this.config.smtp.username);
|
|
893
962
|
await mkdir(dirname(this.dbPath), { recursive: true, mode: 0o700 });
|
|
894
963
|
const isFirstOpen = !this.db;
|
|
895
964
|
const db = this.db ?? new Database(this.dbPath);
|
|
@@ -1006,12 +1075,13 @@ export class LocalIndexService {
|
|
|
1006
1075
|
attachments_json = excluded.attachments_json,
|
|
1007
1076
|
attachment_text = COALESCE(excluded.attachment_text, messages.attachment_text),
|
|
1008
1077
|
labels_json = excluded.labels_json
|
|
1078
|
+
RETURNING preview, attachment_text
|
|
1009
1079
|
`);
|
|
1010
1080
|
const upsertSyncState = db.prepare(`
|
|
1011
1081
|
INSERT INTO sync_state (
|
|
1012
|
-
folder, uid_validity, uid_next, highest_uid, last_sync_at, last_full_sync_at, strategy, changed, fetched, total, backfilled_to_uid
|
|
1082
|
+
folder, uid_validity, uid_next, highest_uid, last_sync_at, last_full_sync_at, strategy, changed, fetched, total, backfilled_to_uid, incremental_resume_uid
|
|
1013
1083
|
) VALUES (
|
|
1014
|
-
@folder, @uid_validity, @uid_next, @highest_uid, @last_sync_at, @last_full_sync_at, @strategy, @changed, @fetched, @total, @backfilled_to_uid
|
|
1084
|
+
@folder, @uid_validity, @uid_next, @highest_uid, @last_sync_at, @last_full_sync_at, @strategy, @changed, @fetched, @total, @backfilled_to_uid, @incremental_resume_uid
|
|
1015
1085
|
)
|
|
1016
1086
|
ON CONFLICT(folder) DO UPDATE SET
|
|
1017
1087
|
uid_validity = excluded.uid_validity,
|
|
@@ -1023,7 +1093,8 @@ export class LocalIndexService {
|
|
|
1023
1093
|
changed = excluded.changed,
|
|
1024
1094
|
fetched = excluded.fetched,
|
|
1025
1095
|
total = excluded.total,
|
|
1026
|
-
backfilled_to_uid = excluded.backfilled_to_uid
|
|
1096
|
+
backfilled_to_uid = excluded.backfilled_to_uid,
|
|
1097
|
+
incremental_resume_uid = excluded.incremental_resume_uid
|
|
1027
1098
|
`);
|
|
1028
1099
|
const deleteFts = db.prepare(`DELETE FROM messages_fts WHERE email_id = ?`);
|
|
1029
1100
|
const insertFts = db.prepare(`
|
|
@@ -1066,6 +1137,19 @@ export class LocalIndexService {
|
|
|
1066
1137
|
foldersWithResetCheckpoint.add(folderStat.folder);
|
|
1067
1138
|
}
|
|
1068
1139
|
}
|
|
1140
|
+
// The "empty" strategy carries no UID range at all, so the range-scoped
|
|
1141
|
+
// expunge-detection below (cleanupExpunged) never runs for it — a folder
|
|
1142
|
+
// genuinely emptied on the server would otherwise keep its previously-
|
|
1143
|
+
// indexed messages forever. folderObservedEmpty is only set when the
|
|
1144
|
+
// server reported exists === 0 on a successful SELECT, never on a
|
|
1145
|
+
// failed/interrupted fetch, so this purge only fires on a real
|
|
1146
|
+
// observation. Scoped strictly to that one folder.
|
|
1147
|
+
for (const folderStat of input.folderStats) {
|
|
1148
|
+
if (folderStat.folderObservedEmpty) {
|
|
1149
|
+
deleteFtsForFolder.run(folderStat.folder);
|
|
1150
|
+
deleteMessagesForFolder.run(folderStat.folder);
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
1069
1153
|
for (const folder of input.folders) {
|
|
1070
1154
|
const folderStat = input.folderStats.find((entry) => entry.folder === folder.path);
|
|
1071
1155
|
upsertFolder.run({
|
|
@@ -1099,10 +1183,18 @@ export class LocalIndexService {
|
|
|
1099
1183
|
fetched: folderStat.fetched ?? null,
|
|
1100
1184
|
total: folderStat.total ?? null,
|
|
1101
1185
|
backfilled_to_uid: resetCheckpoint ? null : folderStat.backfilledToUid ?? null,
|
|
1186
|
+
incremental_resume_uid: resetCheckpoint ? null : folderStat.incrementalResumeUid ?? null,
|
|
1102
1187
|
});
|
|
1103
1188
|
}
|
|
1104
1189
|
for (const email of input.emails) {
|
|
1105
|
-
|
|
1190
|
+
// RETURNING gives back the post-COALESCE stored values, not the raw
|
|
1191
|
+
// incoming ones — a flags-only sync omits source/preview, and the
|
|
1192
|
+
// messages table upsert already preserves the prior indexed preview/
|
|
1193
|
+
// attachment_text in that case (IMAP content for a fixed UID is
|
|
1194
|
+
// immutable). Without this, the FTS row below would be rebuilt from
|
|
1195
|
+
// the incoming (empty) values and lose body-text searchability on
|
|
1196
|
+
// every metadata-only refresh, even though the stored row is intact.
|
|
1197
|
+
const persisted = upsertMessage.get({
|
|
1106
1198
|
email_id: email.id,
|
|
1107
1199
|
folder: email.folder,
|
|
1108
1200
|
uid: email.uid,
|
|
@@ -1129,9 +1221,10 @@ export class LocalIndexService {
|
|
|
1129
1221
|
attachment_text: email.attachmentText ?? null,
|
|
1130
1222
|
labels_json: JSON.stringify(email.labels),
|
|
1131
1223
|
});
|
|
1132
|
-
const
|
|
1224
|
+
const mergedPreview = persisted.preview ?? "";
|
|
1225
|
+
const search = emailToSearchParts({ ...email, attachmentText: persisted.attachment_text ?? undefined });
|
|
1133
1226
|
deleteFts.run(email.id);
|
|
1134
|
-
insertFts.run(email.id, email.subject,
|
|
1227
|
+
insertFts.run(email.id, email.subject, mergedPreview, email.folder, search.labels, search.participants, search.attachmentNames);
|
|
1135
1228
|
}
|
|
1136
1229
|
if (cleanupExpunged) {
|
|
1137
1230
|
createSnapshotUidTable.run();
|
|
@@ -1259,7 +1352,8 @@ export class LocalIndexService {
|
|
|
1259
1352
|
changed INTEGER NOT NULL DEFAULT 0,
|
|
1260
1353
|
fetched INTEGER,
|
|
1261
1354
|
total INTEGER,
|
|
1262
|
-
backfilled_to_uid INTEGER
|
|
1355
|
+
backfilled_to_uid INTEGER,
|
|
1356
|
+
incremental_resume_uid INTEGER
|
|
1263
1357
|
);
|
|
1264
1358
|
|
|
1265
1359
|
CREATE INDEX IF NOT EXISTS idx_messages_folder ON messages(folder);
|
|
@@ -1294,6 +1388,9 @@ export class LocalIndexService {
|
|
|
1294
1388
|
if (!syncStateColumns.has("backfilled_to_uid")) {
|
|
1295
1389
|
db.exec(`ALTER TABLE sync_state ADD COLUMN backfilled_to_uid INTEGER`);
|
|
1296
1390
|
}
|
|
1391
|
+
if (!syncStateColumns.has("incremental_resume_uid")) {
|
|
1392
|
+
db.exec(`ALTER TABLE sync_state ADD COLUMN incremental_resume_uid INTEGER`);
|
|
1393
|
+
}
|
|
1297
1394
|
}
|
|
1298
1395
|
async maybeImportLegacyIndex(db) {
|
|
1299
1396
|
const hasMetadata = db.prepare(`SELECT value FROM metadata WHERE key = 'schemaVersion'`).get();
|
|
@@ -1460,8 +1557,10 @@ export class LocalIndexService {
|
|
|
1460
1557
|
};
|
|
1461
1558
|
}
|
|
1462
1559
|
}
|
|
1463
|
-
|
|
1464
|
-
|
|
1560
|
+
// Metadata/folders only — no messages table query at all. Split out of loadSnapshot()
|
|
1561
|
+
// so callers that don't need message rows (getStatus, getThreads, getThreadById) don't
|
|
1562
|
+
// pay for them.
|
|
1563
|
+
loadFoldersAndMetadata(db) {
|
|
1465
1564
|
const metadataRows = db
|
|
1466
1565
|
.prepare(`SELECT key, value FROM metadata`)
|
|
1467
1566
|
.all();
|
|
@@ -1485,9 +1584,20 @@ export class LocalIndexService {
|
|
|
1485
1584
|
lastIndexedAt: row.last_indexed_at,
|
|
1486
1585
|
lastIndexedCount: row.last_indexed_count,
|
|
1487
1586
|
}));
|
|
1488
|
-
|
|
1587
|
+
return {
|
|
1588
|
+
ownerEmail: metadata.ownerEmail || undefined,
|
|
1589
|
+
updatedAt: metadata.updatedAt || undefined,
|
|
1590
|
+
folders,
|
|
1591
|
+
indexedFolders,
|
|
1592
|
+
};
|
|
1593
|
+
}
|
|
1594
|
+
// sync_state only — no messages table query. Used directly by getSyncCheckpointMap()
|
|
1595
|
+
// (which never needs a message row) and by loadSnapshot()/getStatus()/getThreads()/
|
|
1596
|
+
// getThreadById() so none of them duplicate this query inline.
|
|
1597
|
+
loadCheckpointsSync(db) {
|
|
1598
|
+
return db
|
|
1489
1599
|
.prepare(`
|
|
1490
|
-
SELECT folder, uid_validity, uid_next, highest_uid, last_sync_at, last_full_sync_at, strategy, changed, fetched, total, backfilled_to_uid
|
|
1600
|
+
SELECT folder, uid_validity, uid_next, highest_uid, last_sync_at, last_full_sync_at, strategy, changed, fetched, total, backfilled_to_uid, incremental_resume_uid
|
|
1491
1601
|
FROM sync_state
|
|
1492
1602
|
ORDER BY folder ASC
|
|
1493
1603
|
`)
|
|
@@ -1511,7 +1621,18 @@ export class LocalIndexService {
|
|
|
1511
1621
|
// the first sync_emails(full:true) call after upgrading returned
|
|
1512
1622
|
// changed:false, fetched:0 instead of starting the newest window.
|
|
1513
1623
|
backfilledToUid: row.backfilled_to_uid ?? undefined,
|
|
1624
|
+
// Same NULL-vs-undefined pitfall as backfilledToUid above: planFolderSync
|
|
1625
|
+
// treats undefined as "no incremental catch-up in progress" and a real
|
|
1626
|
+
// number as "resume from here" — a null read back as null (not undefined)
|
|
1627
|
+
// would be indistinguishable from a legitimate resume-at-0 edge case in
|
|
1628
|
+
// some comparisons, so map it away explicitly here too.
|
|
1629
|
+
incrementalResumeUid: row.incremental_resume_uid ?? undefined,
|
|
1514
1630
|
}));
|
|
1631
|
+
}
|
|
1632
|
+
// The message-row query, capped at DEFAULT_SNAPSHOT_LIMIT by default — split out of
|
|
1633
|
+
// loadSnapshot() so getThreads() can call it directly (or substitute
|
|
1634
|
+
// loadThreadCandidateMessages() below) without re-fetching folders/checkpoints.
|
|
1635
|
+
loadMessages(db, options) {
|
|
1515
1636
|
const messageSqlParts = [`SELECT * FROM messages`];
|
|
1516
1637
|
const messageParams = [];
|
|
1517
1638
|
const messageConditions = [];
|
|
@@ -1540,13 +1661,65 @@ export class LocalIndexService {
|
|
|
1540
1661
|
const messageOffset = Math.max(0, options.offset ?? 0);
|
|
1541
1662
|
messageSqlParts.push(`ORDER BY COALESCE(internal_date, date) DESC, uid DESC LIMIT ? OFFSET ?`);
|
|
1542
1663
|
messageParams.push(messageLimit, messageOffset);
|
|
1543
|
-
|
|
1664
|
+
return db
|
|
1544
1665
|
.prepare(messageSqlParts.join(" "))
|
|
1545
1666
|
.all(...messageParams)
|
|
1546
1667
|
.map((row) => this.rowToEmailSummary(row));
|
|
1668
|
+
}
|
|
1669
|
+
// getThreads()'s filtered path: filter/search in SQL first (the same pattern
|
|
1670
|
+
// loadCandidateEmails()/search() already use — a safe LIKE-based superset over every
|
|
1671
|
+
// field the JS-level filter in getThreads() checks), then expand each match's
|
|
1672
|
+
// persisted thread_id membership directly by that column, unbounded by
|
|
1673
|
+
// DEFAULT_SNAPSHOT_LIMIT — so a thread is found and built complete even when most of
|
|
1674
|
+
// its messages sit outside the newest 5000.
|
|
1675
|
+
loadThreadCandidateMessages(db, input) {
|
|
1676
|
+
const conditions = [];
|
|
1677
|
+
const params = [];
|
|
1678
|
+
if (input.folder) {
|
|
1679
|
+
conditions.push(`folder = ?`);
|
|
1680
|
+
params.push(input.folder);
|
|
1681
|
+
}
|
|
1682
|
+
if (input.label) {
|
|
1683
|
+
const labelNeedle = escapeLike(input.label.toLowerCase());
|
|
1684
|
+
conditions.push(`(LOWER(folder) LIKE ? ESCAPE '\\' OR LOWER(labels_json) LIKE ? ESCAPE '\\')`);
|
|
1685
|
+
params.push(`%${labelNeedle}%`, `%${labelNeedle}%`);
|
|
1686
|
+
}
|
|
1687
|
+
if (input.query) {
|
|
1688
|
+
const needle = `%${escapeLike(input.query.toLowerCase())}%`;
|
|
1689
|
+
conditions.push(`(LOWER(subject) LIKE ? ESCAPE '\\' OR LOWER(from_json) LIKE ? ESCAPE '\\' OR LOWER(to_json) LIKE ? ESCAPE '\\' OR LOWER(cc_json) LIKE ? ESCAPE '\\' OR LOWER(bcc_json) LIKE ? ESCAPE '\\' OR LOWER(labels_json) LIKE ? ESCAPE '\\')`);
|
|
1690
|
+
params.push(needle, needle, needle, needle, needle, needle);
|
|
1691
|
+
}
|
|
1692
|
+
const sql = `SELECT * FROM messages${conditions.length > 0 ? ` WHERE ${conditions.join(" AND ")}` : ""}`;
|
|
1693
|
+
const candidates = db
|
|
1694
|
+
.prepare(sql)
|
|
1695
|
+
.all(...params)
|
|
1696
|
+
.map((row) => this.rowToEmailSummary(row));
|
|
1697
|
+
const threadIds = [...new Set(candidates.map((email) => email.threadId?.trim()).filter((id) => Boolean(id)))];
|
|
1698
|
+
if (threadIds.length === 0) {
|
|
1699
|
+
return candidates;
|
|
1700
|
+
}
|
|
1701
|
+
const byId = new Map(candidates.map((email) => [email.id, email]));
|
|
1702
|
+
const CHUNK_SIZE = 200;
|
|
1703
|
+
for (let i = 0; i < threadIds.length; i += CHUNK_SIZE) {
|
|
1704
|
+
const chunk = threadIds.slice(i, i + CHUNK_SIZE);
|
|
1705
|
+
const rows = db
|
|
1706
|
+
.prepare(`SELECT * FROM messages WHERE thread_id IN (${chunk.map(() => "?").join(", ")})`)
|
|
1707
|
+
.all(...chunk);
|
|
1708
|
+
for (const row of rows) {
|
|
1709
|
+
const email = this.rowToEmailSummary(row);
|
|
1710
|
+
byId.set(email.id, email);
|
|
1711
|
+
}
|
|
1712
|
+
}
|
|
1713
|
+
return [...byId.values()];
|
|
1714
|
+
}
|
|
1715
|
+
async loadSnapshot(options = {}) {
|
|
1716
|
+
const db = await this.ensureDb();
|
|
1717
|
+
const { ownerEmail, updatedAt, folders, indexedFolders } = this.loadFoldersAndMetadata(db);
|
|
1718
|
+
const syncCheckpoints = this.loadCheckpointsSync(db);
|
|
1719
|
+
const messages = this.loadMessages(db, options);
|
|
1547
1720
|
return {
|
|
1548
|
-
ownerEmail
|
|
1549
|
-
updatedAt
|
|
1721
|
+
ownerEmail,
|
|
1722
|
+
updatedAt,
|
|
1550
1723
|
folders,
|
|
1551
1724
|
indexedFolders,
|
|
1552
1725
|
syncCheckpoints,
|