proton-mail-bridge-client 2.0.4 → 2.0.6

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.
@@ -279,13 +279,24 @@ function isOutgoingMessage(message, ownerEmail) {
279
279
  }
280
280
  return message.from.some((address) => lowerCaseAddress(address.address) === owner);
281
281
  }
282
+ // ponytail: cheap local-part heuristic, no schema change (no stored List-Unsubscribe/Precedence
283
+ // header). Catches the bulk of automated senders (auction/shipping/billing notifications, etc.)
284
+ // that were otherwise all counted as threads "pending on you" forever, since a one-way automated
285
+ // message is never replied to and never ages out. Upgrade path: store List-Unsubscribe/Precedence
286
+ // at index time and use that instead if this substring heuristic proves too coarse in practice.
287
+ const AUTOMATED_SENDER_PATTERN = /(^|[._-])(no-?reply|donotreply|do-not-reply|notification|powiadomien|mailer-daemon|postmaster|bounce)/i;
288
+ function isLikelyAutomatedSender(message) {
289
+ return message.from.some((address) => AUTOMATED_SENDER_PATTERN.test(address.address ?? ""));
290
+ }
282
291
  function actionableThreadScore(thread, ownerEmail) {
283
292
  const latestMessage = thread.messages[thread.messages.length - 1];
284
293
  const latestIsOutgoing = latestMessage ? isOutgoingMessage(latestMessage, ownerEmail) : false;
285
294
  const pendingOn = latestMessage
286
295
  ? latestIsOutgoing
287
296
  ? "them"
288
- : "you"
297
+ : isLikelyAutomatedSender(latestMessage)
298
+ ? "unknown"
299
+ : "you"
289
300
  : "unknown";
290
301
  let score = 0;
291
302
  score += thread.unreadCount * 10;
@@ -452,6 +463,13 @@ export class LocalIndexService {
452
463
  folder: normalizedFilters.folder,
453
464
  isRead: normalizedFilters.isRead,
454
465
  since: normalizedFilters.dateFrom,
466
+ // Same DEFAULT_SNAPSHOT_LIMIT-cap bug already fixed in getThreadById(): this
467
+ // snapshot fed straight into the thread lookup below, so a threadId whose
468
+ // messages sat outside the newest 5000 (mailbox-wide) resolved fine via
469
+ // getThreadById() but silently came back empty here. The folder/isRead/since
470
+ // conditions above already scope the SQL query, so removing the cap doesn't
471
+ // turn this into an unbounded "everything" read.
472
+ limit: Number.MAX_SAFE_INTEGER,
455
473
  });
456
474
  const thread = this.buildThreads(snapshot, true).find((entry) => entry.id === normalizedFilters.threadId);
457
475
  if (thread) {
@@ -489,6 +507,12 @@ export class LocalIndexService {
489
507
  };
490
508
  }
491
509
  async clear() {
510
+ // Unlike every other method here, clear() doesn't go through ensureDb()
511
+ // (it deletes the db file rather than opening it), so it must run the
512
+ // same account-identity guard itself — otherwise a mismatched account
513
+ // could delete another account's entire index with no prior open ever
514
+ // having checked identity. Must run before closeDb()/rm() below.
515
+ await ensureAccountIdentityMatches(this.config.dataDir, this.config.smtp.username);
492
516
  this.closeDb();
493
517
  let removed = false;
494
518
  for (const path of [this.dbPath, this.legacyIndexPath]) {
@@ -548,6 +572,74 @@ export class LocalIndexService {
548
572
  addCount(`label:${label.toLowerCase()}`, label, "label");
549
573
  }
550
574
  }
575
+ // Mirrors realMessageCounts()'s fix for the identical bug: messageCount/unreadCount
576
+ // above were derived from buildMailboxMessages(loadSnapshot()), so — just like
577
+ // storedMessageCount/dedupedMessageCount before that fix — they silently plateaued
578
+ // at DEFAULT_SNAPSHOT_LIMIT (5000) on a mailbox bigger than that. A "folder"/
579
+ // "special_use" label maps onto exactly one `folder` column value, so its true
580
+ // count is a cheap indexed COUNT(*)/GROUP BY away — recompute those here, unbounded
581
+ // by the snapshot cap. (Thread grouping and "label" (custom Proton label) counts
582
+ // still come from the capped scan above: thread_id/References resolution and
583
+ // labels_json membership aren't derivable from a folder-scoped SQL aggregate alone.)
584
+ const db = await this.ensureDb();
585
+ const realFolderCounts = db
586
+ .prepare(`SELECT folder, COUNT(*) AS count, SUM(CASE WHEN is_read = 0 THEN 1 ELSE 0 END) AS unread FROM messages GROUP BY folder`)
587
+ .all();
588
+ const realCountsByFolderPath = new Map(realFolderCounts.map((row) => [row.folder, { messageCount: Number(row.count), unreadCount: Number(row.unread) }]));
589
+ for (const folder of snapshot.folders) {
590
+ const real = realCountsByFolderPath.get(folder.path);
591
+ if (!real) {
592
+ continue;
593
+ }
594
+ const normalizedFolderName = normalizeMailboxLabel(folder.path) || folder.path;
595
+ const folderId = `folder:${normalizedFolderName.toLowerCase()}`;
596
+ const existingFolderEntry = counts.get(folderId);
597
+ if (existingFolderEntry) {
598
+ existingFolderEntry.label.messageCount = real.messageCount;
599
+ existingFolderEntry.label.unreadCount = real.unreadCount;
600
+ }
601
+ else {
602
+ // Every one of this folder's messages was aged out of the capped snapshot above
603
+ // (all older than the newest DEFAULT_SNAPSHOT_LIMIT mailbox-wide), so the loop
604
+ // above never discovered it at all. Synthesize its entry directly from the
605
+ // accurate SQL counts rather than silently omitting the folder.
606
+ counts.set(folderId, {
607
+ label: {
608
+ id: folderId,
609
+ name: normalizedFolderName,
610
+ type: "folder",
611
+ messageCount: real.messageCount,
612
+ unreadCount: real.unreadCount,
613
+ threadCount: 0,
614
+ specialUse: folder.specialUse,
615
+ },
616
+ threadIds: new Set(),
617
+ });
618
+ }
619
+ if (folder.specialUse) {
620
+ const friendly = friendlySpecialUse(folder.specialUse) || folder.specialUse;
621
+ const specialId = `special:${friendly.toLowerCase()}`;
622
+ const existingSpecialEntry = counts.get(specialId);
623
+ if (existingSpecialEntry) {
624
+ existingSpecialEntry.label.messageCount = real.messageCount;
625
+ existingSpecialEntry.label.unreadCount = real.unreadCount;
626
+ }
627
+ else {
628
+ counts.set(specialId, {
629
+ label: {
630
+ id: specialId,
631
+ name: friendly,
632
+ type: "special_use",
633
+ messageCount: real.messageCount,
634
+ unreadCount: real.unreadCount,
635
+ threadCount: 0,
636
+ specialUse: folder.specialUse,
637
+ },
638
+ threadIds: new Set(),
639
+ });
640
+ }
641
+ }
642
+ }
551
643
  return [...counts.values()]
552
644
  .map((entry) => entry.label)
553
645
  .sort((left, right) => {
@@ -656,10 +748,56 @@ export class LocalIndexService {
656
748
  return thread;
657
749
  }
658
750
  async getActionableThreads(input = {}) {
659
- const snapshot = await this.loadSnapshot();
751
+ const db = await this.ensureDb();
752
+ const { ownerEmail, updatedAt, folders, indexedFolders } = this.loadFoldersAndMetadata(db);
753
+ const syncCheckpoints = this.loadCheckpointsSync(db);
660
754
  const pendingFilter = input.pendingOn || "any";
661
755
  const limit = input.limit ?? 50;
662
756
  const offset = 0;
757
+ // unreadOnly defaults to true (mirrors the JS-level filter below) — the vast
758
+ // majority of calls are therefore SQL-prefilterable by is_read alone, same as
759
+ // label/query. Only unreadOnly:false with no label/query has nothing SQL-expressible
760
+ // to narrow by.
761
+ const unreadOnly = input.unreadOnly !== false;
762
+ let messages;
763
+ let messagesCapped = false;
764
+ if (unreadOnly || input.label || input.query) {
765
+ // Same SQL-prefilter-then-expand-by-thread_id pattern getThreads() already uses:
766
+ // any thread that could pass the unreadOnly/label/query filters below has at
767
+ // least one qualifying message found here, so expanding to each match's full
768
+ // thread_id membership (expandCandidatesToFullThreads) builds every such thread
769
+ // complete, unbounded by DEFAULT_SNAPSHOT_LIMIT — instead of only threads whose
770
+ // qualifying message also happened to be in the newest 5000 mailbox-wide.
771
+ const conditions = [];
772
+ const params = [];
773
+ if (unreadOnly) {
774
+ conditions.push(`is_read = 0`);
775
+ }
776
+ if (input.label) {
777
+ const labelNeedle = escapeLike(input.label.toLowerCase());
778
+ conditions.push(`(LOWER(folder) LIKE ? ESCAPE '\\' OR LOWER(labels_json) LIKE ? ESCAPE '\\')`);
779
+ params.push(`%${labelNeedle}%`, `%${labelNeedle}%`);
780
+ }
781
+ if (input.query) {
782
+ const needle = `%${escapeLike(input.query.toLowerCase())}%`;
783
+ conditions.push(`(LOWER(subject) LIKE ? ESCAPE '\\' OR LOWER(preview) LIKE ? ESCAPE '\\' OR LOWER(from_json) LIKE ? ESCAPE '\\' OR LOWER(labels_json) LIKE ? ESCAPE '\\')`);
784
+ params.push(needle, needle, needle, needle);
785
+ }
786
+ const sql = `SELECT * FROM messages${conditions.length > 0 ? ` WHERE ${conditions.join(" AND ")}` : ""}`;
787
+ const candidateMessages = db
788
+ .prepare(sql)
789
+ .all(...params)
790
+ .map((row) => this.rowToEmailSummary(row));
791
+ messages = this.expandCandidatesToFullThreads(db, candidateMessages);
792
+ }
793
+ else {
794
+ // unreadOnly explicitly false and no label/query: a genuinely unfiltered "every
795
+ // thread" view, same as getThreads()' unfiltered path — nothing SQL-expressible to
796
+ // narrow by, so keep the deliberate DEFAULT_SNAPSHOT_LIMIT cap but surface it.
797
+ messages = this.loadMessages(db, {});
798
+ messagesCapped = messages.length >= DEFAULT_SNAPSHOT_LIMIT;
799
+ }
800
+ const snapshot = { ownerEmail, updatedAt, folders, indexedFolders, syncCheckpoints, messages };
663
801
  const actionable = this.buildThreads(snapshot, true)
664
802
  .map((thread) => {
665
803
  const latestMessage = thread.messages[thread.messages.length - 1];
@@ -717,6 +855,7 @@ export class LocalIndexService {
717
855
  total: totalCount,
718
856
  hasMore: totalCount > offset + limit,
719
857
  threads: actionable.slice(offset, offset + limit),
858
+ ...(messagesCapped ? { messagesCapped: true } : {}),
720
859
  };
721
860
  }
722
861
  async getSyncCheckpointMap() {
@@ -728,12 +867,20 @@ export class LocalIndexService {
728
867
  return Object.fromEntries(checkpoints.map((checkpoint) => [checkpoint.folder, checkpoint]));
729
868
  }
730
869
  async getInboxDigest(input = {}) {
731
- const snapshot = await this.loadSnapshot();
870
+ const db = await this.ensureDb();
871
+ const { ownerEmail, updatedAt, folders, indexedFolders } = this.loadFoldersAndMetadata(db);
872
+ const syncCheckpoints = this.loadCheckpointsSync(db);
732
873
  const minAgeHours = input.minAgeHours ?? 24;
733
- const allActionable = this.buildThreads(snapshot, true)
874
+ // Recent-activity overview (topThreads and the counts below it): a "digest" is
875
+ // inherently about what's recent, so the deliberate DEFAULT_SNAPSHOT_LIMIT cap from
876
+ // loadMessages() is appropriate here — unlike the stale-awaiting-you section below,
877
+ // which specifically needs to find OLD stuff and must not be capped the same way.
878
+ const recentMessages = this.loadMessages(db, {});
879
+ const recentSnapshot = { ownerEmail, updatedAt, folders, indexedFolders, syncCheckpoints, messages: recentMessages };
880
+ const allActionable = this.buildThreads(recentSnapshot, true)
734
881
  .map((thread) => {
735
882
  const latestMessage = thread.messages[thread.messages.length - 1];
736
- const { pendingOn, score } = actionableThreadScore(thread, snapshot.ownerEmail);
883
+ const { pendingOn, score } = actionableThreadScore(thread, ownerEmail);
737
884
  return {
738
885
  ...thread,
739
886
  latestEmailId: latestMessage?.primaryEmailId,
@@ -754,15 +901,49 @@ export class LocalIndexService {
754
901
  });
755
902
  const now = Date.now();
756
903
  const staleThresholdMs = minAgeHours * 60 * 60 * 1000;
757
- const staleAwaitingYou = allActionable.filter((thread) => {
904
+ // Stale-awaiting-you: the exact same "find OLD stuff" problem getFollowUpCandidates()
905
+ // has — a thread whose latest message is old enough to qualify may have aged
906
+ // entirely out of recentSnapshot above. SQL-prefilter by date (any qualifying
907
+ // thread's latest message necessarily satisfies this condition itself), then expand
908
+ // to full, uncapped thread membership — mirrors getFollowUpCandidates()' fix.
909
+ const cutoffIso = new Date(now - staleThresholdMs).toISOString();
910
+ const staleCandidateMessages = db
911
+ .prepare(`SELECT * FROM messages WHERE COALESCE(internal_date, date) < ?`)
912
+ .all(cutoffIso)
913
+ .map((row) => this.rowToEmailSummary(row));
914
+ const staleMessages = this.expandCandidatesToFullThreads(db, staleCandidateMessages);
915
+ const staleSnapshot = { ownerEmail, updatedAt, folders, indexedFolders, syncCheckpoints, messages: staleMessages };
916
+ const staleAwaitingYou = this.buildThreads(staleSnapshot, true)
917
+ .map((thread) => {
918
+ const latestMessage = thread.messages[thread.messages.length - 1];
919
+ const { pendingOn, score } = actionableThreadScore(thread, ownerEmail);
920
+ return {
921
+ ...thread,
922
+ latestEmailId: latestMessage?.primaryEmailId,
923
+ latestPreview: latestMessage?.preview,
924
+ latestFrom: latestMessage?.from ?? [],
925
+ latestIsRead: latestMessage?.isRead ?? true,
926
+ latestIsStarred: latestMessage?.isStarred ?? false,
927
+ latestHasAttachments: latestMessage?.hasAttachments ?? false,
928
+ pendingOn,
929
+ score,
930
+ };
931
+ })
932
+ .filter((thread) => {
758
933
  if (thread.pendingOn !== "you" || !thread.latestDate) {
759
934
  return false;
760
935
  }
761
936
  return now - new Date(thread.latestDate).getTime() >= staleThresholdMs;
937
+ })
938
+ .sort((left, right) => {
939
+ if (right.score !== left.score) {
940
+ return right.score - left.score;
941
+ }
942
+ return new Date(right.latestDate || 0).getTime() - new Date(left.latestDate || 0).getTime();
762
943
  });
763
944
  return {
764
945
  generatedAt: new Date().toISOString(),
765
- indexUpdatedAt: snapshot.updatedAt,
946
+ indexUpdatedAt: updatedAt,
766
947
  counts: {
767
948
  totalThreads: allActionable.length,
768
949
  unreadThreads: allActionable.filter((thread) => thread.unreadCount > 0).length,
@@ -777,13 +958,30 @@ export class LocalIndexService {
777
958
  };
778
959
  }
779
960
  async getFollowUpCandidates(input = {}) {
780
- const snapshot = await this.loadSnapshot();
961
+ const db = await this.ensureDb();
962
+ const { ownerEmail, updatedAt, folders, indexedFolders } = this.loadFoldersAndMetadata(db);
963
+ const syncCheckpoints = this.loadCheckpointsSync(db);
781
964
  const minAgeHours = input.minAgeHours ?? 24;
782
965
  const pendingOn = input.pendingOn ?? "you";
783
966
  const thresholdMs = minAgeHours * 60 * 60 * 1000;
784
967
  const now = Date.now();
785
968
  const limit = input.limit ?? 25;
786
969
  const offset = 0;
970
+ // This method's entire purpose is finding OLD threads (minAgeHours), which is the
971
+ // exact opposite of what the DEFAULT_SNAPSHOT_LIMIT-capped loadSnapshot() gives you
972
+ // (the newest 5000 messages mailbox-wide) — a mailbox with more than 5000 recent
973
+ // messages made this structurally incapable of ever surfacing an old candidate.
974
+ // SQL-prefilter by date instead: any thread whose latest message is old enough to
975
+ // qualify below necessarily has that message satisfy this condition itself, so
976
+ // expanding every match to its full thread_id membership (expandCandidatesToFullThreads)
977
+ // is guaranteed not to miss a qualifying thread, unbounded by the snapshot cap.
978
+ const cutoffIso = new Date(now - thresholdMs).toISOString();
979
+ const candidateMessages = db
980
+ .prepare(`SELECT * FROM messages WHERE COALESCE(internal_date, date) < ?`)
981
+ .all(cutoffIso)
982
+ .map((row) => this.rowToEmailSummary(row));
983
+ const messages = this.expandCandidatesToFullThreads(db, candidateMessages);
984
+ const snapshot = { ownerEmail, updatedAt, folders, indexedFolders, syncCheckpoints, messages };
787
985
  const candidates = this.buildThreads(snapshot, true)
788
986
  .map((thread) => {
789
987
  const latestMessage = thread.messages[thread.messages.length - 1];
@@ -832,7 +1030,9 @@ export class LocalIndexService {
832
1030
  };
833
1031
  }
834
1032
  async findDocumentThreads(input = {}) {
835
- const snapshot = await this.loadSnapshot();
1033
+ const db = await this.ensureDb();
1034
+ const { ownerEmail, updatedAt, folders, indexedFolders } = this.loadFoldersAndMetadata(db);
1035
+ const syncCheckpoints = this.loadCheckpointsSync(db);
836
1036
  const category = input.category || "document";
837
1037
  const limit = input.limit ?? 25;
838
1038
  const keywordMap = {
@@ -843,6 +1043,25 @@ export class LocalIndexService {
843
1043
  calendar: ["calendar", "invite", "meeting", "ics"],
844
1044
  };
845
1045
  const keywords = keywordMap[category] ?? keywordMap.document;
1046
+ // SQL-prefilter by the same keyword haystack the JS-level document filter below
1047
+ // checks per attachment (attachments_json carries filename/contentType/kind; subject/
1048
+ // preview/attachment_text cover the rest) — a safe, over-inclusive superset, same
1049
+ // pattern as getThreads()' fix — then expand every match to its full thread_id
1050
+ // membership so a matching document thread outside DEFAULT_SNAPSHOT_LIMIT is never
1051
+ // silently missed. A message with no attachments can never contribute a document
1052
+ // match regardless of its text, so has_attachments = 1 narrows this further.
1053
+ const keywordConditions = keywords.map(() => `(LOWER(attachments_json) LIKE ? ESCAPE '\\' OR LOWER(subject) LIKE ? ESCAPE '\\' OR LOWER(preview) LIKE ? ESCAPE '\\' OR LOWER(attachment_text) LIKE ? ESCAPE '\\')`);
1054
+ const keywordParams = [];
1055
+ for (const keyword of keywords) {
1056
+ const needle = `%${escapeLike(keyword)}%`;
1057
+ keywordParams.push(needle, needle, needle, needle);
1058
+ }
1059
+ const candidateMessages = db
1060
+ .prepare(`SELECT * FROM messages WHERE has_attachments = 1 AND (${keywordConditions.join(" OR ")})`)
1061
+ .all(...keywordParams)
1062
+ .map((row) => this.rowToEmailSummary(row));
1063
+ const messages = this.expandCandidatesToFullThreads(db, candidateMessages);
1064
+ const snapshot = { ownerEmail, updatedAt, folders, indexedFolders, syncCheckpoints, messages };
846
1065
  const matches = this.buildThreads(snapshot, true)
847
1066
  .map((thread) => {
848
1067
  const documents = thread.messages.flatMap((message) => message.attachments.filter((attachment) => {
@@ -902,10 +1121,45 @@ export class LocalIndexService {
902
1121
  };
903
1122
  }
904
1123
  async getMeetingPrep(input) {
905
- const snapshot = await this.loadSnapshot();
1124
+ const db = await this.ensureDb();
1125
+ const { ownerEmail, updatedAt, folders, indexedFolders } = this.loadFoldersAndMetadata(db);
1126
+ const syncCheckpoints = this.loadCheckpointsSync(db);
906
1127
  const personNeedle = input.person?.toLowerCase();
907
1128
  const domainNeedle = input.domain?.toLowerCase();
908
1129
  const limit = input.limit ?? 10;
1130
+ const conditions = [];
1131
+ const params = [];
1132
+ if (personNeedle) {
1133
+ const needle = `%${escapeLike(personNeedle)}%`;
1134
+ conditions.push(`(LOWER(from_json) LIKE ? ESCAPE '\\' OR LOWER(to_json) LIKE ? ESCAPE '\\' OR LOWER(cc_json) LIKE ? ESCAPE '\\')`);
1135
+ params.push(needle, needle, needle);
1136
+ }
1137
+ if (domainNeedle) {
1138
+ const needle = `%@${escapeLike(domainNeedle)}%`;
1139
+ conditions.push(`(LOWER(from_json) LIKE ? ESCAPE '\\' OR LOWER(to_json) LIKE ? ESCAPE '\\' OR LOWER(cc_json) LIKE ? ESCAPE '\\')`);
1140
+ params.push(needle, needle, needle);
1141
+ }
1142
+ let messages;
1143
+ let messagesCapped = false;
1144
+ if (conditions.length > 0) {
1145
+ // SQL-prefilter by participant text (a safe superset over the exact person/domain
1146
+ // match applied below), then expand every match to its full thread_id membership —
1147
+ // same pattern as getThreads()' fix — so a meeting-prep match outside
1148
+ // DEFAULT_SNAPSHOT_LIMIT is never silently missed.
1149
+ const sql = `SELECT * FROM messages WHERE ${conditions.join(" OR ")}`;
1150
+ const candidateMessages = db
1151
+ .prepare(sql)
1152
+ .all(...params)
1153
+ .map((row) => this.rowToEmailSummary(row));
1154
+ messages = this.expandCandidatesToFullThreads(db, candidateMessages);
1155
+ }
1156
+ else {
1157
+ // Neither person nor domain given: nothing SQL-expressible to narrow by — same
1158
+ // unavoidable bound as getThreads()' unfiltered path.
1159
+ messages = this.loadMessages(db, {});
1160
+ messagesCapped = messages.length >= DEFAULT_SNAPSHOT_LIMIT;
1161
+ }
1162
+ const snapshot = { ownerEmail, updatedAt, folders, indexedFolders, syncCheckpoints, messages };
909
1163
  const threads = this.buildThreads(snapshot, true)
910
1164
  .filter((thread) => thread.participants.some((participant) => {
911
1165
  const participantText = `${participant.name ?? ""} ${participant.address ?? ""}`.toLowerCase();
@@ -940,6 +1194,7 @@ export class LocalIndexService {
940
1194
  date: message.internalDate || message.date,
941
1195
  preview: message.preview,
942
1196
  })),
1197
+ ...(messagesCapped ? { messagesCapped: true } : {}),
943
1198
  };
944
1199
  }
945
1200
  async runIntegrityCheck() {
@@ -1221,9 +1476,27 @@ export class LocalIndexService {
1221
1476
  // the server's UIDVALIDITY no longer matches what was stored, so any
1222
1477
  // old-format row still found at this point is guaranteed to be the
1223
1478
  // same generation, not a stale one.
1479
+ //
1480
+ // parseEmailId documents a THIRD, even older shape on top of the
1481
+ // 3-field one above: <encodedFolder>::<uid>, no checksum at all —
1482
+ // from before the checksum suffix existed. A row can still be
1483
+ // sitting under that shape (an index that predates the checksum
1484
+ // feature and was never fully re-synced for this folder), and it
1485
+ // needs the exact same reconcile-then-delete treatment, or it's
1486
+ // never found and a duplicate row appears alongside it. Mirror
1487
+ // createEmailId's folder encoding but without a checksum suffix —
1488
+ // this is exactly what parseEmailId's legacy (no-checksum) branch
1489
+ // expects to parse back apart.
1224
1490
  let legacyPreview = null;
1225
1491
  let legacyAttachmentText = null;
1226
1492
  const legacyEmailId = createEmailId(email.folder, email.uid);
1493
+ const legacyEmailId2Field = `${encodeURIComponent(email.folder)}::${email.uid}`;
1494
+ // Check the more-recent (3-field) shape first, then the older
1495
+ // (2-field) one — a partially-migrated-through-both-stages index is
1496
+ // a genuinely degenerate case, but checking newest-first and
1497
+ // deleting whichever legacy rows are actually found still converges
1498
+ // to exactly one surviving row, never a crash or a lingering
1499
+ // duplicate, regardless of which (or both) exist.
1227
1500
  if (legacyEmailId !== email.id) {
1228
1501
  const legacyRow = findLegacyRow.get(legacyEmailId);
1229
1502
  if (legacyRow) {
@@ -1233,6 +1506,15 @@ export class LocalIndexService {
1233
1506
  deleteFts.run(legacyEmailId);
1234
1507
  }
1235
1508
  }
1509
+ if (legacyEmailId2Field !== email.id) {
1510
+ const legacyRow2Field = findLegacyRow.get(legacyEmailId2Field);
1511
+ if (legacyRow2Field) {
1512
+ legacyPreview = legacyPreview ?? legacyRow2Field.preview;
1513
+ legacyAttachmentText = legacyAttachmentText ?? legacyRow2Field.attachment_text;
1514
+ deleteLegacyRow.run(legacyEmailId2Field);
1515
+ deleteFts.run(legacyEmailId2Field);
1516
+ }
1517
+ }
1236
1518
  // RETURNING gives back the post-COALESCE stored values, not the raw
1237
1519
  // incoming ones — a flags-only sync omits source/preview, and the
1238
1520
  // messages table upsert already preserves the prior indexed preview/
@@ -1786,6 +2068,39 @@ export class LocalIndexService {
1786
2068
  .all()
1787
2069
  .map((row) => this.rowToEmailSummary(row));
1788
2070
  }
2071
+ // Shared second half of the SQL-prefilter-then-expand pattern getThreads()'
2072
+ // loadThreadCandidateMessages() introduced: given a set of candidate messages found
2073
+ // by some SQL condition, pull in the rest of each candidate's persisted thread_id
2074
+ // membership (unbounded by DEFAULT_SNAPSHOT_LIMIT), and — for a candidate whose
2075
+ // thread is only resolvable via a References/In-Reply-To chain (no persisted
2076
+ // thread_id) — fall back to the full, uncapped message set so that thread is always
2077
+ // built from its complete membership rather than a partial view with a different
2078
+ // synthetic id (see loadThreadCandidateMessages()'s comment on this same risk).
2079
+ // Used by getFollowUpCandidates(), getActionableThreads(), getInboxDigest()'s
2080
+ // stale-awaiting-you section, findDocumentThreads(), and getMeetingPrep() below —
2081
+ // each has its own SQL candidate query but needs the identical expansion afterward.
2082
+ expandCandidatesToFullThreads(db, candidates) {
2083
+ const byId = new Map(candidates.map((email) => [email.id, email]));
2084
+ const threadIds = [...new Set(candidates.map((email) => email.threadId?.trim()).filter((id) => Boolean(id)))];
2085
+ const CHUNK_SIZE = 200;
2086
+ for (let i = 0; i < threadIds.length; i += CHUNK_SIZE) {
2087
+ const chunk = threadIds.slice(i, i + CHUNK_SIZE);
2088
+ const rows = db
2089
+ .prepare(`SELECT * FROM messages WHERE thread_id IN (${chunk.map(() => "?").join(", ")})`)
2090
+ .all(...chunk);
2091
+ for (const row of rows) {
2092
+ const email = this.rowToEmailSummary(row);
2093
+ byId.set(email.id, email);
2094
+ }
2095
+ }
2096
+ const hasUnresolvedReferenceCandidate = candidates.some((email) => !email.threadId?.trim() && (email.inReplyTo || (email.references?.length ?? 0) > 0));
2097
+ if (hasUnresolvedReferenceCandidate) {
2098
+ for (const email of this.loadAllMessages(db)) {
2099
+ byId.set(email.id, email);
2100
+ }
2101
+ }
2102
+ return [...byId.values()];
2103
+ }
1789
2104
  async loadSnapshot(options = {}) {
1790
2105
  const db = await this.ensureDb();
1791
2106
  const { ownerEmail, updatedAt, folders, indexedFolders } = this.loadFoldersAndMetadata(db);