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.
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +39 -10
- package/dist/index.js.map +1 -1
- package/dist/services/delivery-queue-service.d.ts.map +1 -1
- package/dist/services/delivery-queue-service.js +17 -0
- package/dist/services/delivery-queue-service.js.map +1 -1
- package/dist/services/draft-store-service.d.ts.map +1 -1
- package/dist/services/draft-store-service.js +12 -0
- package/dist/services/draft-store-service.js.map +1 -1
- package/dist/services/local-index-service.d.ts +2 -0
- package/dist/services/local-index-service.d.ts.map +1 -1
- package/dist/services/local-index-service.js +325 -10
- package/dist/services/local-index-service.js.map +1 -1
- package/dist/services/simple-imap-service.d.ts +3 -0
- package/dist/services/simple-imap-service.d.ts.map +1 -1
- package/dist/services/simple-imap-service.js +54 -7
- package/dist/services/simple-imap-service.js.map +1 -1
- package/dist/services/smtp-service.d.ts.map +1 -1
- package/dist/services/smtp-service.js +9 -0
- package/dist/services/smtp-service.js.map +1 -1
- package/package.json +1 -1
|
@@ -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
|
-
:
|
|
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
|
|
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
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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:
|
|
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
|
|
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
|
|
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
|
|
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);
|