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.
Files changed (42) hide show
  1. package/README.md +2 -2
  2. package/dist/cli.d.ts.map +1 -1
  3. package/dist/cli.js +6 -2
  4. package/dist/cli.js.map +1 -1
  5. package/dist/index.d.ts.map +1 -1
  6. package/dist/index.js +83 -37
  7. package/dist/index.js.map +1 -1
  8. package/dist/services/delivery-queue-service.d.ts +5 -0
  9. package/dist/services/delivery-queue-service.d.ts.map +1 -1
  10. package/dist/services/delivery-queue-service.js +110 -15
  11. package/dist/services/delivery-queue-service.js.map +1 -1
  12. package/dist/services/draft-store-service.d.ts +3 -0
  13. package/dist/services/draft-store-service.d.ts.map +1 -1
  14. package/dist/services/draft-store-service.js +64 -1
  15. package/dist/services/draft-store-service.js.map +1 -1
  16. package/dist/services/local-index-service.d.ts +5 -0
  17. package/dist/services/local-index-service.d.ts.map +1 -1
  18. package/dist/services/local-index-service.js +191 -18
  19. package/dist/services/local-index-service.js.map +1 -1
  20. package/dist/services/simple-imap-service.d.ts +7 -1
  21. package/dist/services/simple-imap-service.d.ts.map +1 -1
  22. package/dist/services/simple-imap-service.js +163 -23
  23. package/dist/services/simple-imap-service.js.map +1 -1
  24. package/dist/services/snooze-service.d.ts +3 -0
  25. package/dist/services/snooze-service.d.ts.map +1 -1
  26. package/dist/services/snooze-service.js +103 -7
  27. package/dist/services/snooze-service.js.map +1 -1
  28. package/dist/services/template-service.d.ts +1 -0
  29. package/dist/services/template-service.d.ts.map +1 -1
  30. package/dist/services/template-service.js +8 -0
  31. package/dist/services/template-service.js.map +1 -1
  32. package/dist/types/index.d.ts +7 -1
  33. package/dist/types/index.d.ts.map +1 -1
  34. package/dist/utils/account-identity.d.ts +8 -0
  35. package/dist/utils/account-identity.d.ts.map +1 -0
  36. package/dist/utils/account-identity.js +78 -0
  37. package/dist/utils/account-identity.js.map +1 -0
  38. package/dist/utils/file-lock.d.ts +1 -0
  39. package/dist/utils/file-lock.d.ts.map +1 -1
  40. package/dist/utils/file-lock.js +24 -4
  41. package/dist/utils/file-lock.js.map +1 -1
  42. 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 snapshot = await this.loadSnapshot();
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 snapshot = await this.loadSnapshot({ folder: input.folder, label: input.label });
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
- const snapshot = await this.loadSnapshot();
658
- return Object.fromEntries(snapshot.syncCheckpoints.map((checkpoint) => [checkpoint.folder, checkpoint]));
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
- upsertMessage.run({
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 search = emailToSearchParts(email);
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, email.preview ?? "", email.folder, search.labels, search.participants, search.attachmentNames);
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
- async loadSnapshot(options = {}) {
1464
- const db = await this.ensureDb();
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
- const syncCheckpoints = db
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
- const messages = db
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: metadata.ownerEmail || undefined,
1549
- updatedAt: metadata.updatedAt || undefined,
1721
+ ownerEmail,
1722
+ updatedAt,
1550
1723
  folders,
1551
1724
  indexedFolders,
1552
1725
  syncCheckpoints,