proton-mail-bridge-client 2.6.0 → 2.7.1
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 +19 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/services/local-index-service.d.ts +4 -0
- package/dist/services/local-index-service.d.ts.map +1 -1
- package/dist/services/local-index-service.js +193 -121
- package/dist/services/local-index-service.js.map +1 -1
- package/package.json +1 -1
|
@@ -137,7 +137,11 @@ export declare class LocalIndexService {
|
|
|
137
137
|
private loadCheckpointsSync;
|
|
138
138
|
private loadMessages;
|
|
139
139
|
private loadThreadCandidateMessages;
|
|
140
|
+
private addThreadsFromMap;
|
|
141
|
+
private loadThreadByKey;
|
|
140
142
|
private loadThreadMap;
|
|
143
|
+
private loadThreadStats;
|
|
144
|
+
private loadThreadIndex;
|
|
141
145
|
private loadMessagesByEmailIds;
|
|
142
146
|
private hasUnresolvedReferenceCandidate;
|
|
143
147
|
private loadAllMessages;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"local-index-service.d.ts","sourceRoot":"","sources":["../../src/services/local-index-service.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EACV,uBAAuB,EACvB,YAAY,EACZ,UAAU,EACV,YAAY,EACZ,cAAc,EAEd,gBAAgB,EAChB,qBAAqB,EACrB,gBAAgB,EAChB,iBAAiB,EACjB,YAAY,EACZ,aAAa,EACb,gBAAgB,EACjB,MAAM,mBAAmB,CAAC;AAqB3B,OAAO,EAAU,KAAK,MAAM,EAAE,MAAM,oBAAoB,CAAC;AAoFzD,eAAO,MAAM,uBAAuB,KAAK,CAAC;AAC1C,eAAO,MAAM,sBAAsB,KAAK,CAAC;AACzC,eAAO,MAAM,iBAAiB,KAAK,CAAC;AACpC,eAAO,MAAM,sBAAsB,IAAI,CAAC;AACxC,eAAO,MAAM,wBAAwB,MAAM,CAAC;AAE5C,wBAAgB,kBAAkB,CAAC,CAAC,SAAS;IAAE,YAAY,CAAC,EAAE,OAAO,EAAE,CAAC;IAAC,UAAU,CAAC,EAAE,MAAM,EAAE,CAAC;IAAC,gBAAgB,CAAC,EAAE,MAAM,EAAE,CAAC;IAAC,UAAU,CAAC,EAAE,OAAO,EAAE,CAAC;IAAC,aAAa,CAAC,EAAE,MAAM,CAAA;CAAE,EAC3K,MAAM,EAAE,CAAC,GAAG;IAAE,QAAQ,CAAC,EAAE,OAAO,CAAA;CAAE,GACjC,IAAI,CAAC,CAAC,EAAE,UAAU,CAAC,GAAG;IACvB,qBAAqB,CAAC,EAAE,IAAI,CAAC;IAC7B,mBAAmB,CAAC,EAAE,IAAI,CAAC;IAC3B,eAAe,CAAC,EAAE,IAAI,CAAC;CACxB,CAuBA;
|
|
1
|
+
{"version":3,"file":"local-index-service.d.ts","sourceRoot":"","sources":["../../src/services/local-index-service.ts"],"names":[],"mappings":"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"}
|
|
@@ -374,28 +374,19 @@ function isLikelyAutomatedSender(message) {
|
|
|
374
374
|
}
|
|
375
375
|
return message.from.some((address) => AUTOMATED_SENDER_PATTERN.test(address.address ?? ""));
|
|
376
376
|
}
|
|
377
|
-
function
|
|
378
|
-
const latestMessage = thread.messages[thread.messages.length - 1];
|
|
379
|
-
const latestIsOutgoing = latestMessage ? isOutgoingMessage(latestMessage, ownerEmail) : false;
|
|
380
|
-
const pendingOn = latestMessage
|
|
381
|
-
? latestIsOutgoing
|
|
382
|
-
? "them"
|
|
383
|
-
: isLikelyAutomatedSender(latestMessage)
|
|
384
|
-
? "unknown"
|
|
385
|
-
: "you"
|
|
386
|
-
: "unknown";
|
|
377
|
+
function actionableScore(stat) {
|
|
387
378
|
let score = 0;
|
|
388
|
-
score +=
|
|
389
|
-
if (pendingOn === "you") {
|
|
379
|
+
score += stat.unreadCount * 10;
|
|
380
|
+
if (stat.pendingOn === "you") {
|
|
390
381
|
score += 8;
|
|
391
382
|
}
|
|
392
|
-
if (
|
|
383
|
+
if (stat.isStarred) {
|
|
393
384
|
score += 4;
|
|
394
385
|
}
|
|
395
|
-
if (
|
|
386
|
+
if (stat.hasAttachments) {
|
|
396
387
|
score += 2;
|
|
397
388
|
}
|
|
398
|
-
const latestTime = new Date(
|
|
389
|
+
const latestTime = new Date(stat.latestDate || 0).getTime();
|
|
399
390
|
if (latestTime > 0) {
|
|
400
391
|
const ageHours = (Date.now() - latestTime) / (60 * 60 * 1000);
|
|
401
392
|
if (ageHours > 24) {
|
|
@@ -405,8 +396,47 @@ function actionableThreadScore(thread, ownerEmail) {
|
|
|
405
396
|
score += 1;
|
|
406
397
|
}
|
|
407
398
|
}
|
|
399
|
+
return score;
|
|
400
|
+
}
|
|
401
|
+
function pendingOnForThread(thread, ownerEmail) {
|
|
402
|
+
const latestMessage = thread.messages[thread.messages.length - 1];
|
|
403
|
+
const latestIsOutgoing = latestMessage ? isOutgoingMessage(latestMessage, ownerEmail) : false;
|
|
404
|
+
return latestMessage
|
|
405
|
+
? latestIsOutgoing
|
|
406
|
+
? "them"
|
|
407
|
+
: isLikelyAutomatedSender(latestMessage)
|
|
408
|
+
? "unknown"
|
|
409
|
+
: "you"
|
|
410
|
+
: "unknown";
|
|
411
|
+
}
|
|
412
|
+
function actionableThreadScore(thread, ownerEmail) {
|
|
413
|
+
const latestMessage = thread.messages[thread.messages.length - 1];
|
|
414
|
+
const pendingOn = pendingOnForThread(thread, ownerEmail);
|
|
415
|
+
const score = actionableScore({
|
|
416
|
+
latestDate: thread.latestDate,
|
|
417
|
+
unreadCount: thread.unreadCount,
|
|
418
|
+
pendingOn,
|
|
419
|
+
isStarred: Boolean(latestMessage?.isStarred),
|
|
420
|
+
hasAttachments: Boolean(latestMessage?.hasAttachments),
|
|
421
|
+
});
|
|
408
422
|
return { pendingOn, score };
|
|
409
423
|
}
|
|
424
|
+
// A thread with the fields the actionable lists add on top of it.
|
|
425
|
+
function toActionableSummary(thread, ownerEmail) {
|
|
426
|
+
const latestMessage = thread.messages[thread.messages.length - 1];
|
|
427
|
+
const { pendingOn, score } = actionableThreadScore(thread, ownerEmail);
|
|
428
|
+
return {
|
|
429
|
+
...thread,
|
|
430
|
+
latestEmailId: latestMessage?.primaryEmailId,
|
|
431
|
+
latestPreview: latestMessage?.preview,
|
|
432
|
+
latestFrom: latestMessage?.from ?? [],
|
|
433
|
+
latestIsRead: latestMessage?.isRead ?? true,
|
|
434
|
+
latestIsStarred: latestMessage?.isStarred ?? false,
|
|
435
|
+
latestHasAttachments: latestMessage?.hasAttachments ?? false,
|
|
436
|
+
pendingOn,
|
|
437
|
+
score,
|
|
438
|
+
};
|
|
439
|
+
}
|
|
410
440
|
function safeJsonParse(value, fallback) {
|
|
411
441
|
if (!value) {
|
|
412
442
|
return fallback;
|
|
@@ -626,7 +656,23 @@ export class LocalIndexService {
|
|
|
626
656
|
};
|
|
627
657
|
const limit = normalizedFilters.limit ?? 50;
|
|
628
658
|
const offset = 0;
|
|
629
|
-
|
|
659
|
+
const unrestrictedThreadLookup = !normalizedFilters.folder && normalizedFilters.isRead === undefined && !normalizedFilters.dateFrom;
|
|
660
|
+
if (normalizedFilters.threadId?.trim() && unrestrictedThreadLookup) {
|
|
661
|
+
// No folder/read/date restriction: the thread is built from the whole index, as getThreadById does.
|
|
662
|
+
const db = await this.ensureDb();
|
|
663
|
+
const thread = this.loadThreadByKey(db, normalizedFilters.threadId);
|
|
664
|
+
if (thread) {
|
|
665
|
+
const threadMessages = sortEmailsByNewest(thread.messages).filter((email) => matchesIndexedSearch(email, { ...normalizedFilters, threadId: undefined })).sort((left, right) => searchRelevanceScore(right, normalizedFilters) - searchRelevanceScore(left, normalizedFilters));
|
|
666
|
+
const totalCount = threadMessages.length;
|
|
667
|
+
return {
|
|
668
|
+
total: totalCount,
|
|
669
|
+
hasMore: totalCount > offset + limit,
|
|
670
|
+
emails: threadMessages.slice(offset, offset + limit),
|
|
671
|
+
...this.indexFreshnessFields(this.readLastSyncAt(db)),
|
|
672
|
+
};
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
else if (normalizedFilters.threadId?.trim()) {
|
|
630
676
|
const snapshot = await this.loadSnapshot({
|
|
631
677
|
folder: normalizedFilters.folder,
|
|
632
678
|
isRead: normalizedFilters.isRead,
|
|
@@ -928,11 +974,7 @@ export class LocalIndexService {
|
|
|
928
974
|
// newest 5000 messages — or one whose id only comes out right when its
|
|
929
975
|
// complete membership is considered — still resolves instead of throwing
|
|
930
976
|
// "Thread not found".
|
|
931
|
-
const
|
|
932
|
-
const syncCheckpoints = this.loadCheckpointsSync(db);
|
|
933
|
-
const messages = this.loadAllMessages(db);
|
|
934
|
-
const snapshot = { ownerEmail, updatedAt, folders, indexedFolders, syncCheckpoints, messages };
|
|
935
|
-
const thread = this.buildThreads(snapshot, true).find((entry) => entry.id === threadId);
|
|
977
|
+
const thread = this.loadThreadByKey(db, threadId);
|
|
936
978
|
if (!thread) {
|
|
937
979
|
throw new Error(`Thread not found for id ${threadId}`);
|
|
938
980
|
}
|
|
@@ -951,6 +993,7 @@ export class LocalIndexService {
|
|
|
951
993
|
// to narrow by.
|
|
952
994
|
const unreadOnly = input.unreadOnly !== false;
|
|
953
995
|
let messages;
|
|
996
|
+
let threadKeys;
|
|
954
997
|
let messagesCapped = false;
|
|
955
998
|
if (unreadOnly || input.label || input.query) {
|
|
956
999
|
// Same SQL-prefilter-then-expand-by-thread_id pattern getThreads() already uses:
|
|
@@ -979,7 +1022,9 @@ export class LocalIndexService {
|
|
|
979
1022
|
.prepare(sql)
|
|
980
1023
|
.all(...params)
|
|
981
1024
|
.map((row) => this.rowToEmailSummary(row));
|
|
982
|
-
|
|
1025
|
+
const expanded = this.expandCandidatesToFullThreads(db, candidateMessages);
|
|
1026
|
+
messages = expanded.messages;
|
|
1027
|
+
threadKeys = expanded.threadKeys;
|
|
983
1028
|
}
|
|
984
1029
|
else {
|
|
985
1030
|
// unreadOnly explicitly false and no label/query: a genuinely unfiltered "every
|
|
@@ -989,7 +1034,7 @@ export class LocalIndexService {
|
|
|
989
1034
|
messagesCapped = messages.length >= DEFAULT_SNAPSHOT_LIMIT;
|
|
990
1035
|
}
|
|
991
1036
|
const snapshot = { ownerEmail, updatedAt, folders, indexedFolders, syncCheckpoints, messages };
|
|
992
|
-
const actionable = this.buildThreads(snapshot, true)
|
|
1037
|
+
const actionable = this.buildThreads(snapshot, true, threadKeys)
|
|
993
1038
|
.map((thread) => {
|
|
994
1039
|
const latestMessage = thread.messages[thread.messages.length - 1];
|
|
995
1040
|
const { pendingOn, score } = actionableThreadScore(thread, snapshot.ownerEmail);
|
|
@@ -1098,40 +1143,19 @@ export class LocalIndexService {
|
|
|
1098
1143
|
// entirely out of recentSnapshot above. SQL-prefilter by date (any qualifying
|
|
1099
1144
|
// thread's latest message necessarily satisfies this condition itself), then expand
|
|
1100
1145
|
// to full, uncapped thread membership — mirrors getFollowUpCandidates()' fix.
|
|
1101
|
-
const
|
|
1102
|
-
|
|
1103
|
-
.
|
|
1104
|
-
.all(cutoffIso)
|
|
1105
|
-
.map((row) => this.rowToEmailSummary(row));
|
|
1106
|
-
const staleMessages = this.expandCandidatesToFullThreads(db, staleCandidateMessages);
|
|
1107
|
-
const staleSnapshot = { ownerEmail, updatedAt, folders, indexedFolders, syncCheckpoints, messages: staleMessages };
|
|
1108
|
-
const staleAwaitingYou = this.buildThreads(staleSnapshot, true)
|
|
1109
|
-
.map((thread) => {
|
|
1110
|
-
const latestMessage = thread.messages[thread.messages.length - 1];
|
|
1111
|
-
const { pendingOn, score } = actionableThreadScore(thread, ownerEmail);
|
|
1112
|
-
return {
|
|
1113
|
-
...thread,
|
|
1114
|
-
latestEmailId: latestMessage?.primaryEmailId,
|
|
1115
|
-
latestPreview: latestMessage?.preview,
|
|
1116
|
-
latestFrom: latestMessage?.from ?? [],
|
|
1117
|
-
latestIsRead: latestMessage?.isRead ?? true,
|
|
1118
|
-
latestIsStarred: latestMessage?.isStarred ?? false,
|
|
1119
|
-
latestHasAttachments: latestMessage?.hasAttachments ?? false,
|
|
1120
|
-
pendingOn,
|
|
1121
|
-
score,
|
|
1122
|
-
};
|
|
1123
|
-
})
|
|
1124
|
-
.filter((thread) => {
|
|
1125
|
-
if (thread.pendingOn !== "you" || !thread.latestDate) {
|
|
1146
|
+
const staleRanked = this.loadThreadStats(db, ownerEmail)
|
|
1147
|
+
.filter((stat) => {
|
|
1148
|
+
if (stat.pendingOn !== "you" || !stat.latestDate) {
|
|
1126
1149
|
return false;
|
|
1127
1150
|
}
|
|
1128
|
-
return now - new Date(
|
|
1151
|
+
return now - new Date(stat.latestDate).getTime() >= staleThresholdMs;
|
|
1129
1152
|
})
|
|
1153
|
+
.map((stat) => ({ stat, score: actionableScore(stat) }))
|
|
1130
1154
|
.sort((left, right) => {
|
|
1131
1155
|
if (right.score !== left.score) {
|
|
1132
1156
|
return right.score - left.score;
|
|
1133
1157
|
}
|
|
1134
|
-
return new Date(right.latestDate || 0).getTime() - new Date(left.latestDate || 0).getTime();
|
|
1158
|
+
return new Date(right.stat.latestDate || 0).getTime() - new Date(left.stat.latestDate || 0).getTime();
|
|
1135
1159
|
});
|
|
1136
1160
|
return {
|
|
1137
1161
|
generatedAt: new Date().toISOString(),
|
|
@@ -1143,7 +1167,7 @@ export class LocalIndexService {
|
|
|
1143
1167
|
pendingOnThem: allActionable.filter((thread) => thread.pendingOn === "them").length,
|
|
1144
1168
|
starredThreads: allActionable.filter((thread) => thread.latestIsStarred).length,
|
|
1145
1169
|
attachmentThreads: allActionable.filter((thread) => thread.latestHasAttachments).length,
|
|
1146
|
-
staleAwaitingYou:
|
|
1170
|
+
staleAwaitingYou: staleRanked.length,
|
|
1147
1171
|
},
|
|
1148
1172
|
// counts/topThreads come from the newest DEFAULT_SNAPSHOT_LIMIT messages only.
|
|
1149
1173
|
...(recentMessages.length >= DEFAULT_SNAPSHOT_LIMIT
|
|
@@ -1151,13 +1175,17 @@ export class LocalIndexService {
|
|
|
1151
1175
|
: {}),
|
|
1152
1176
|
...this.indexFreshnessFields(updatedAt),
|
|
1153
1177
|
topThreads: allActionable.slice(0, input.limit ?? 10).map((thread) => shapeThreadForList(thread)),
|
|
1154
|
-
staleAwaitingYou:
|
|
1178
|
+
staleAwaitingYou: staleRanked
|
|
1179
|
+
.slice(0, input.limit ?? 10)
|
|
1180
|
+
.flatMap(({ stat }) => {
|
|
1181
|
+
const thread = this.loadThreadByKey(db, stat.key);
|
|
1182
|
+
return thread ? [shapeThreadForList(toActionableSummary(thread, ownerEmail))] : [];
|
|
1183
|
+
}),
|
|
1155
1184
|
};
|
|
1156
1185
|
}
|
|
1157
1186
|
async getFollowUpCandidates(input = {}) {
|
|
1158
1187
|
const db = await this.ensureDb();
|
|
1159
|
-
const { ownerEmail, updatedAt
|
|
1160
|
-
const syncCheckpoints = this.loadCheckpointsSync(db);
|
|
1188
|
+
const { ownerEmail, updatedAt } = this.loadFoldersAndMetadata(db);
|
|
1161
1189
|
const minAgeHours = input.minAgeHours ?? 24;
|
|
1162
1190
|
const pendingOn = input.pendingOn ?? "you";
|
|
1163
1191
|
const thresholdMs = minAgeHours * 60 * 60 * 1000;
|
|
@@ -1172,39 +1200,20 @@ export class LocalIndexService {
|
|
|
1172
1200
|
// qualify below necessarily has that message satisfy this condition itself, so
|
|
1173
1201
|
// expanding every match to its full thread_id membership (expandCandidatesToFullThreads)
|
|
1174
1202
|
// is guaranteed not to miss a qualifying thread, unbounded by the snapshot cap.
|
|
1175
|
-
|
|
1176
|
-
const
|
|
1177
|
-
.
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
const ageHours = thread.latestDate
|
|
1187
|
-
? Math.max(0, Math.round((now - new Date(thread.latestDate).getTime()) / (60 * 60 * 1000)))
|
|
1188
|
-
: undefined;
|
|
1189
|
-
return {
|
|
1190
|
-
...thread,
|
|
1191
|
-
latestEmailId: latestMessage?.primaryEmailId,
|
|
1192
|
-
latestPreview: latestMessage?.preview,
|
|
1193
|
-
latestFrom: latestMessage?.from ?? [],
|
|
1194
|
-
latestIsRead: latestMessage?.isRead ?? true,
|
|
1195
|
-
latestIsStarred: latestMessage?.isStarred ?? false,
|
|
1196
|
-
latestHasAttachments: latestMessage?.hasAttachments ?? false,
|
|
1197
|
-
pendingOn: currentPendingOn,
|
|
1198
|
-
score,
|
|
1199
|
-
ageHours,
|
|
1200
|
-
suggestedAction: currentPendingOn === "you" ? "reply" : currentPendingOn === "them" ? "follow_up" : "review",
|
|
1201
|
-
};
|
|
1202
|
-
})
|
|
1203
|
-
.filter((thread) => {
|
|
1204
|
-
if (pendingOn !== "any" && thread.pendingOn !== pendingOn) {
|
|
1203
|
+
// Ranked from the per-thread stats of the whole index; only the page that is returned is built in full.
|
|
1204
|
+
const ranked = this.loadThreadStats(db, ownerEmail)
|
|
1205
|
+
.map((stat) => ({
|
|
1206
|
+
stat,
|
|
1207
|
+
score: actionableScore(stat),
|
|
1208
|
+
ageHours: stat.latestDate
|
|
1209
|
+
? Math.max(0, Math.round((now - new Date(stat.latestDate).getTime()) / (60 * 60 * 1000)))
|
|
1210
|
+
: undefined,
|
|
1211
|
+
}))
|
|
1212
|
+
.filter(({ stat }) => {
|
|
1213
|
+
if (pendingOn !== "any" && stat.pendingOn !== pendingOn) {
|
|
1205
1214
|
return false;
|
|
1206
1215
|
}
|
|
1207
|
-
if (
|
|
1216
|
+
if (stat.latestDate && now - new Date(stat.latestDate).getTime() < thresholdMs) {
|
|
1208
1217
|
return false;
|
|
1209
1218
|
}
|
|
1210
1219
|
return true;
|
|
@@ -1215,18 +1224,31 @@ export class LocalIndexService {
|
|
|
1215
1224
|
}
|
|
1216
1225
|
return right.score - left.score;
|
|
1217
1226
|
});
|
|
1218
|
-
const
|
|
1227
|
+
const page = ranked.slice(offset, offset + limit).flatMap(({ stat, ageHours }) => {
|
|
1228
|
+
const thread = this.loadThreadByKey(db, stat.key);
|
|
1229
|
+
if (!thread)
|
|
1230
|
+
return [];
|
|
1231
|
+
const summary = toActionableSummary(thread, ownerEmail);
|
|
1232
|
+
return [
|
|
1233
|
+
{
|
|
1234
|
+
...summary,
|
|
1235
|
+
ageHours,
|
|
1236
|
+
suggestedAction: summary.pendingOn === "you" ? "reply" : summary.pendingOn === "them" ? "follow_up" : "review",
|
|
1237
|
+
},
|
|
1238
|
+
];
|
|
1239
|
+
});
|
|
1240
|
+
const totalCount = ranked.length;
|
|
1219
1241
|
return {
|
|
1220
1242
|
generatedAt: new Date().toISOString(),
|
|
1221
|
-
indexUpdatedAt:
|
|
1243
|
+
indexUpdatedAt: updatedAt,
|
|
1222
1244
|
minAgeHours,
|
|
1223
1245
|
pendingOn,
|
|
1224
1246
|
total: totalCount,
|
|
1225
1247
|
hasMore: totalCount > offset + limit,
|
|
1226
1248
|
offset,
|
|
1227
1249
|
...(totalCount > offset + limit ? { nextOffset: offset + limit } : {}),
|
|
1228
|
-
...this.indexFreshnessFields(
|
|
1229
|
-
threads:
|
|
1250
|
+
...this.indexFreshnessFields(updatedAt),
|
|
1251
|
+
threads: page.map((thread) => shapeThreadForList(thread)),
|
|
1230
1252
|
};
|
|
1231
1253
|
}
|
|
1232
1254
|
async findDocumentThreads(input = {}) {
|
|
@@ -1263,9 +1285,9 @@ export class LocalIndexService {
|
|
|
1263
1285
|
.prepare(`SELECT * FROM messages WHERE has_attachments = 1 AND (${keywordConditions.join(" OR ")})`)
|
|
1264
1286
|
.all(...keywordParams)
|
|
1265
1287
|
.map((row) => this.rowToEmailSummary(row));
|
|
1266
|
-
const
|
|
1267
|
-
const snapshot = { ownerEmail, updatedAt, folders, indexedFolders, syncCheckpoints, messages };
|
|
1268
|
-
const matches = this.buildThreads(snapshot, true)
|
|
1288
|
+
const expanded = this.expandCandidatesToFullThreads(db, candidateMessages);
|
|
1289
|
+
const snapshot = { ownerEmail, updatedAt, folders, indexedFolders, syncCheckpoints, messages: expanded.messages };
|
|
1290
|
+
const matches = this.buildThreads(snapshot, true, expanded.threadKeys)
|
|
1269
1291
|
.map((thread) => {
|
|
1270
1292
|
const documents = thread.messages.flatMap((message) => message.attachments.filter((attachment) => {
|
|
1271
1293
|
const haystack = [
|
|
@@ -1341,6 +1363,7 @@ export class LocalIndexService {
|
|
|
1341
1363
|
params.push(needle, needle, needle);
|
|
1342
1364
|
}
|
|
1343
1365
|
let messages;
|
|
1366
|
+
let threadKeys;
|
|
1344
1367
|
let messagesCapped = false;
|
|
1345
1368
|
if (conditions.length > 0) {
|
|
1346
1369
|
// SQL-prefilter by participant text (a safe superset over the exact person/domain
|
|
@@ -1352,7 +1375,9 @@ export class LocalIndexService {
|
|
|
1352
1375
|
.prepare(sql)
|
|
1353
1376
|
.all(...params)
|
|
1354
1377
|
.map((row) => this.rowToEmailSummary(row));
|
|
1355
|
-
|
|
1378
|
+
const expanded = this.expandCandidatesToFullThreads(db, candidateMessages);
|
|
1379
|
+
messages = expanded.messages;
|
|
1380
|
+
threadKeys = expanded.threadKeys;
|
|
1356
1381
|
}
|
|
1357
1382
|
else {
|
|
1358
1383
|
// Neither person nor domain given: nothing SQL-expressible to narrow by — same
|
|
@@ -1361,7 +1386,7 @@ export class LocalIndexService {
|
|
|
1361
1386
|
messagesCapped = messages.length >= DEFAULT_SNAPSHOT_LIMIT;
|
|
1362
1387
|
}
|
|
1363
1388
|
const snapshot = { ownerEmail, updatedAt, folders, indexedFolders, syncCheckpoints, messages };
|
|
1364
|
-
const threads = this.buildThreads(snapshot, true)
|
|
1389
|
+
const threads = this.buildThreads(snapshot, true, threadKeys)
|
|
1365
1390
|
.filter((thread) => thread.participants.some((participant) => {
|
|
1366
1391
|
const participantText = `${participant.name ?? ""} ${participant.address ?? ""}`;
|
|
1367
1392
|
const participantDomain = extractDomain(participant.address || "");
|
|
@@ -2035,6 +2060,8 @@ export class LocalIndexService {
|
|
|
2035
2060
|
CREATE INDEX IF NOT EXISTS idx_messages_thread_id ON messages(thread_id);
|
|
2036
2061
|
CREATE INDEX IF NOT EXISTS idx_messages_internal_date ON messages(internal_date);
|
|
2037
2062
|
CREATE INDEX IF NOT EXISTS idx_messages_folder_date ON messages(folder, internal_date DESC);
|
|
2063
|
+
-- The newest-first order every capped read uses (see loadMessages); without it each call sorted the whole table.
|
|
2064
|
+
CREATE INDEX IF NOT EXISTS idx_messages_recent ON messages(COALESCE(internal_date, date) DESC, uid DESC);
|
|
2038
2065
|
|
|
2039
2066
|
CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5(
|
|
2040
2067
|
email_id UNINDEXED,
|
|
@@ -2426,34 +2453,66 @@ export class LocalIndexService {
|
|
|
2426
2453
|
// getThreadById() now uses for these threads — so the thread is always built
|
|
2427
2454
|
// from its complete membership, filtered query or not.
|
|
2428
2455
|
if (this.hasUnresolvedReferenceCandidate(db, candidates)) {
|
|
2429
|
-
|
|
2430
|
-
|
|
2431
|
-
const map = this.loadThreadMap(db);
|
|
2432
|
-
const wanted = new Set();
|
|
2433
|
-
for (const email of byId.values()) {
|
|
2434
|
-
const key = map.keyByMessage.get(canonicalMessageKey(email));
|
|
2435
|
-
if (key !== undefined)
|
|
2436
|
-
wanted.add(key);
|
|
2437
|
-
}
|
|
2438
|
-
const emailIds = [];
|
|
2439
|
-
for (const key of wanted) {
|
|
2440
|
-
for (const canonicalId of map.messagesByKey.get(key) ?? []) {
|
|
2441
|
-
emailIds.push(...(map.emailIdsByMessage.get(canonicalId) ?? []));
|
|
2442
|
-
}
|
|
2443
|
-
}
|
|
2444
|
-
for (const email of this.loadMessagesByEmailIds(db, emailIds)) {
|
|
2445
|
-
byId.set(email.id, email);
|
|
2446
|
-
}
|
|
2447
|
-
return { messages: [...byId.values()], threadKeys: map.keyByMessage };
|
|
2456
|
+
const threadKeys = this.addThreadsFromMap(db, byId);
|
|
2457
|
+
return { messages: [...byId.values()], threadKeys };
|
|
2448
2458
|
}
|
|
2449
2459
|
return { messages: [...byId.values()] };
|
|
2450
2460
|
}
|
|
2461
|
+
// Adds every message of the threads the given messages belong to (using the thread of every message in the
|
|
2462
|
+
// whole index, see loadThreadMap) instead of reading and grouping the entire index each time. Returns those
|
|
2463
|
+
// thread keys; buildThreads must use them, because on a partial set of messages it could land on another key.
|
|
2464
|
+
addThreadsFromMap(db, byId) {
|
|
2465
|
+
const map = this.loadThreadMap(db);
|
|
2466
|
+
const wanted = new Set();
|
|
2467
|
+
for (const email of byId.values()) {
|
|
2468
|
+
const key = map.keyByMessage.get(canonicalMessageKey(email));
|
|
2469
|
+
if (key !== undefined)
|
|
2470
|
+
wanted.add(key);
|
|
2471
|
+
}
|
|
2472
|
+
const emailIds = [];
|
|
2473
|
+
for (const key of wanted) {
|
|
2474
|
+
for (const canonicalId of map.messagesByKey.get(key) ?? []) {
|
|
2475
|
+
emailIds.push(...(map.emailIdsByMessage.get(canonicalId) ?? []));
|
|
2476
|
+
}
|
|
2477
|
+
}
|
|
2478
|
+
for (const email of this.loadMessagesByEmailIds(db, emailIds)) {
|
|
2479
|
+
byId.set(email.id, email);
|
|
2480
|
+
}
|
|
2481
|
+
return map.keyByMessage;
|
|
2482
|
+
}
|
|
2483
|
+
// One thread by its id (the thread key), built from its whole membership.
|
|
2484
|
+
loadThreadByKey(db, threadId) {
|
|
2485
|
+
const map = this.loadThreadMap(db);
|
|
2486
|
+
const members = map.messagesByKey.get(threadId);
|
|
2487
|
+
if (!members)
|
|
2488
|
+
return undefined;
|
|
2489
|
+
const emailIds = members.flatMap((canonicalId) => map.emailIdsByMessage.get(canonicalId) ?? []);
|
|
2490
|
+
const { ownerEmail, updatedAt, folders, indexedFolders } = this.loadFoldersAndMetadata(db);
|
|
2491
|
+
const snapshot = {
|
|
2492
|
+
ownerEmail,
|
|
2493
|
+
updatedAt,
|
|
2494
|
+
folders,
|
|
2495
|
+
indexedFolders,
|
|
2496
|
+
syncCheckpoints: this.loadCheckpointsSync(db),
|
|
2497
|
+
messages: this.loadMessagesByEmailIds(db, emailIds),
|
|
2498
|
+
};
|
|
2499
|
+
return this.buildThreads(snapshot, true, map.keyByMessage).find((entry) => entry.id === threadId);
|
|
2500
|
+
}
|
|
2451
2501
|
// The thread of every message in the index, worked out exactly as buildThreads does for a full read, but
|
|
2452
2502
|
// only the keys are kept. Valid until the index is written to.
|
|
2453
2503
|
loadThreadMap(db) {
|
|
2504
|
+
return this.loadThreadIndex(db, false).map;
|
|
2505
|
+
}
|
|
2506
|
+
// For every thread, what the follow-up and digest rankings need, in buildThreads order. Costs one full
|
|
2507
|
+
// read per index version, like the map it is built beside.
|
|
2508
|
+
loadThreadStats(db, ownerEmail) {
|
|
2509
|
+
return this.loadThreadIndex(db, true, ownerEmail).stats;
|
|
2510
|
+
}
|
|
2511
|
+
loadThreadIndex(db, withStats, ownerArg) {
|
|
2454
2512
|
const version = `${this.writeVersion}:${String(db.pragma("data_version", { simple: true }))}`;
|
|
2455
|
-
|
|
2456
|
-
|
|
2513
|
+
const cached = this.threadMapCache;
|
|
2514
|
+
if (cached?.version === version && (!withStats || cached.stats)) {
|
|
2515
|
+
return { map: cached.map, stats: cached.stats };
|
|
2457
2516
|
}
|
|
2458
2517
|
const { ownerEmail, updatedAt, folders, indexedFolders } = this.loadFoldersAndMetadata(db);
|
|
2459
2518
|
const snapshot = {
|
|
@@ -2478,8 +2537,22 @@ export class LocalIndexService {
|
|
|
2478
2537
|
messagesByKey.set(message.threadKey, [message.canonicalId]);
|
|
2479
2538
|
}
|
|
2480
2539
|
const map = { keyByMessage, messagesByKey, emailIdsByMessage };
|
|
2481
|
-
|
|
2482
|
-
|
|
2540
|
+
let stats;
|
|
2541
|
+
if (withStats) {
|
|
2542
|
+
stats = this.buildThreads(snapshot, true, keyByMessage).map((thread) => {
|
|
2543
|
+
const latestMessage = thread.messages[thread.messages.length - 1];
|
|
2544
|
+
return {
|
|
2545
|
+
key: thread.id,
|
|
2546
|
+
latestDate: thread.latestDate,
|
|
2547
|
+
unreadCount: thread.unreadCount,
|
|
2548
|
+
pendingOn: pendingOnForThread(thread, ownerEmail ?? ownerArg),
|
|
2549
|
+
isStarred: Boolean(latestMessage?.isStarred),
|
|
2550
|
+
hasAttachments: Boolean(latestMessage?.hasAttachments),
|
|
2551
|
+
};
|
|
2552
|
+
});
|
|
2553
|
+
}
|
|
2554
|
+
this.threadMapCache = { version, map, stats };
|
|
2555
|
+
return { map, stats };
|
|
2483
2556
|
}
|
|
2484
2557
|
loadMessagesByEmailIds(db, emailIds) {
|
|
2485
2558
|
const result = [];
|
|
@@ -2562,11 +2635,10 @@ export class LocalIndexService {
|
|
|
2562
2635
|
// why this must also check whether some OTHER message's In-Reply-To points at a
|
|
2563
2636
|
// header-less candidate, not just the candidate's own inReplyTo/references.
|
|
2564
2637
|
if (this.hasUnresolvedReferenceCandidate(db, candidates)) {
|
|
2565
|
-
|
|
2566
|
-
|
|
2567
|
-
}
|
|
2638
|
+
const threadKeys = this.addThreadsFromMap(db, byId);
|
|
2639
|
+
return { messages: [...byId.values()], threadKeys };
|
|
2568
2640
|
}
|
|
2569
|
-
return [...byId.values()];
|
|
2641
|
+
return { messages: [...byId.values()] };
|
|
2570
2642
|
}
|
|
2571
2643
|
async loadSnapshot(options = {}) {
|
|
2572
2644
|
const db = await this.ensureDb();
|