proton-mail-bridge-client 2.2.4 → 2.3.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/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/services/local-index-service.d.ts +5 -0
- package/dist/services/local-index-service.d.ts.map +1 -1
- package/dist/services/local-index-service.js +134 -63
- package/dist/services/local-index-service.js.map +1 -1
- package/dist/utils/helpers.d.ts +4 -0
- package/dist/utils/helpers.d.ts.map +1 -1
- package/dist/utils/helpers.js +35 -1
- package/dist/utils/helpers.js.map +1 -1
- package/package.json +1 -1
|
@@ -34,6 +34,10 @@ export declare class LocalIndexService {
|
|
|
34
34
|
syncedAt: string;
|
|
35
35
|
folderStats: Array<MailboxSyncCheckpoint>;
|
|
36
36
|
}): Promise<LocalIndexStatus>;
|
|
37
|
+
getFreshness(): Promise<{
|
|
38
|
+
storedMessageCount: number;
|
|
39
|
+
isStale: boolean;
|
|
40
|
+
}>;
|
|
37
41
|
getStatus(): Promise<LocalIndexStatus>;
|
|
38
42
|
private realMessageCounts;
|
|
39
43
|
search(filters: SearchEmailsInput): Promise<{
|
|
@@ -119,6 +123,7 @@ export declare class LocalIndexService {
|
|
|
119
123
|
private ensureDb;
|
|
120
124
|
private chmodDbFiles;
|
|
121
125
|
private applySnapshot;
|
|
126
|
+
private rebuildFtsIfNeeded;
|
|
122
127
|
private runMigrations;
|
|
123
128
|
private ensureMessagesColumns;
|
|
124
129
|
private maybeImportLegacyIndex;
|
|
@@ -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,EACd,MAAM,mBAAmB,CAAC;
|
|
1
|
+
{"version":3,"file":"local-index-service.d.ts","sourceRoot":"","sources":["../../src/services/local-index-service.ts"],"names":[],"mappings":"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"}
|
|
@@ -3,10 +3,20 @@ import { chmodSync } from "node:fs";
|
|
|
3
3
|
import { mkdir, readFile, rm } from "node:fs/promises";
|
|
4
4
|
import { dirname, join } from "node:path";
|
|
5
5
|
import { ensureAccountIdentityMatches } from "../utils/account-identity.js";
|
|
6
|
-
import { createEmailId, dedupeEmails, extractDomain, extractMessageIdList, foldSearchText, lowerCaseAddress, nextDay, normalizeMailboxLabel, normalizeMessageId, normalizeSubjectForThread, sortEmailsByNewest, } from "../utils/helpers.js";
|
|
6
|
+
import { createEmailId, dedupeEmails, extractDomain, extractMessageIdList, foldSearchText, lowerCaseAddress, nextDay, normalizeMailboxLabel, normalizeMessageId, normalizeSubjectForThread, searchEquals, searchIncludes, searchKey, searchNeedles, sortEmailsByNewest, } from "../utils/helpers.js";
|
|
7
7
|
import { logger } from "../utils/logger.js";
|
|
8
8
|
const DB_SCHEMA_VERSION = 3;
|
|
9
|
+
// Version of what the full-text index stores. 2 = every column holds the text's searchKey (folded,
|
|
10
|
+
// with Müller also as "mueller"), so ł, ß, ø and the Mueller spelling are found. Bumping it makes the
|
|
11
|
+
// next open rebuild the index from the messages table, once.
|
|
12
|
+
const FTS_KEY_VERSION = "2";
|
|
9
13
|
const STALE_THRESHOLD_MINUTES = 60;
|
|
14
|
+
function indexAgeMinutes(updatedAt) {
|
|
15
|
+
return updatedAt ? Math.max(0, Math.round((Date.now() - new Date(updatedAt).getTime()) / 60_000)) : undefined;
|
|
16
|
+
}
|
|
17
|
+
function isIndexStale(ageMinutes) {
|
|
18
|
+
return typeof ageMinutes === "number" ? ageMinutes > STALE_THRESHOLD_MINUTES : true;
|
|
19
|
+
}
|
|
10
20
|
const DEFAULT_SNAPSHOT_LIMIT = 5000;
|
|
11
21
|
// Results older than this carry `stale:true` plus a warning. Deliberately much lower than
|
|
12
22
|
// STALE_THRESHOLD_MINUTES (which gates a full auto-refresh): the background sync runs every
|
|
@@ -48,6 +58,19 @@ export function shapeThreadForList(thread) {
|
|
|
48
58
|
function escapeLike(value) {
|
|
49
59
|
return value.replace(/\\/g, "\\\\").replace(/%/g, "\\%").replace(/_/g, "\\_");
|
|
50
60
|
}
|
|
61
|
+
// WHERE fragment: any of `columns` contains `needle`, ignoring case, accents and spelling variants
|
|
62
|
+
// (Müller/Mueller, see searchKey). FOLD() is the SQL function registered in ensureDb.
|
|
63
|
+
function foldedLike(columns, needle) {
|
|
64
|
+
const parts = [];
|
|
65
|
+
const params = [];
|
|
66
|
+
for (const form of searchNeedles(needle)) {
|
|
67
|
+
for (const column of columns) {
|
|
68
|
+
parts.push(`FOLD(${column}) LIKE ? ESCAPE '\\'`);
|
|
69
|
+
params.push(`%${escapeLike(form)}%`);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return { sql: `(${parts.join(" OR ")})`, params };
|
|
73
|
+
}
|
|
51
74
|
function parseSearchQuery(query) {
|
|
52
75
|
const parsed = { residualTerms: [] };
|
|
53
76
|
for (const rawToken of query?.trim().split(/\s+/) ?? []) {
|
|
@@ -100,9 +123,9 @@ function matchesIndexedSearch(email, filters) {
|
|
|
100
123
|
return false;
|
|
101
124
|
}
|
|
102
125
|
if (normalizedFilters.label) {
|
|
103
|
-
const
|
|
104
|
-
const folderMatch =
|
|
105
|
-
const labelMatch = email.labels.some((label) =>
|
|
126
|
+
const wanted = normalizedFilters.label;
|
|
127
|
+
const folderMatch = searchEquals(normalizeMailboxLabel(email.folder) ?? "", wanted);
|
|
128
|
+
const labelMatch = email.labels.some((label) => searchEquals(normalizeMailboxLabel(label) ?? "", wanted));
|
|
106
129
|
if (!folderMatch && !labelMatch) {
|
|
107
130
|
return false;
|
|
108
131
|
}
|
|
@@ -131,8 +154,8 @@ function matchesIndexedSearch(email, filters) {
|
|
|
131
154
|
return false;
|
|
132
155
|
}
|
|
133
156
|
if (normalizedFilters.attachmentName) {
|
|
134
|
-
const attachmentNeedle =
|
|
135
|
-
const match = email.attachments.some((attachment) =>
|
|
157
|
+
const attachmentNeedle = normalizedFilters.attachmentName;
|
|
158
|
+
const match = email.attachments.some((attachment) => searchIncludes(attachment.filename || "", attachmentNeedle));
|
|
136
159
|
if (!match) {
|
|
137
160
|
return false;
|
|
138
161
|
}
|
|
@@ -153,33 +176,33 @@ function matchesIndexedSearch(email, filters) {
|
|
|
153
176
|
...email.to.map((value) => `${value.name ?? ""} ${value.address ?? ""}`),
|
|
154
177
|
...email.cc.map((value) => `${value.name ?? ""} ${value.address ?? ""}`),
|
|
155
178
|
].join("\n");
|
|
156
|
-
const
|
|
179
|
+
const haystackKey = searchKey(haystacks);
|
|
157
180
|
// searchFtsIds() ANDs every whitespace-separated query token as its own quoted FTS5 term
|
|
158
181
|
// (so "invoice payment" matches a preview like "payment for the invoice is overdue"), but
|
|
159
182
|
// this post-filter used to require the ENTIRE joined query as one literal substring —
|
|
160
183
|
// rejecting anything FTS already matched whose words were merely out of order or separated
|
|
161
184
|
// by other words. Match FTS's own AND-of-terms semantics instead of a single-phrase substring.
|
|
162
185
|
if (normalizedFilters.query) {
|
|
163
|
-
const terms =
|
|
164
|
-
if (terms.length > 0 && !terms.every((term) =>
|
|
186
|
+
const terms = normalizedFilters.query.split(/\s+/).filter(Boolean);
|
|
187
|
+
if (terms.length > 0 && !terms.every((term) => searchNeedles(term).some((form) => haystackKey.includes(form)))) {
|
|
165
188
|
return false;
|
|
166
189
|
}
|
|
167
190
|
}
|
|
168
191
|
if (normalizedFilters.subject &&
|
|
169
|
-
!
|
|
192
|
+
!searchIncludes(email.subject, normalizedFilters.subject)) {
|
|
170
193
|
return false;
|
|
171
194
|
}
|
|
172
195
|
if (normalizedFilters.from) {
|
|
173
|
-
const fromNeedle =
|
|
174
|
-
const match = email.from.some((value) =>
|
|
196
|
+
const fromNeedle = normalizedFilters.from;
|
|
197
|
+
const match = email.from.some((value) => searchIncludes(`${value.name ?? ""} ${value.address ?? ""}`, fromNeedle));
|
|
175
198
|
if (!match) {
|
|
176
199
|
return false;
|
|
177
200
|
}
|
|
178
201
|
}
|
|
179
202
|
if (normalizedFilters.to) {
|
|
180
|
-
const toNeedle =
|
|
203
|
+
const toNeedle = normalizedFilters.to;
|
|
181
204
|
const recipients = [...email.to, ...email.cc, ...email.bcc];
|
|
182
|
-
const match = recipients.some((value) =>
|
|
205
|
+
const match = recipients.some((value) => searchIncludes(`${value.name ?? ""} ${value.address ?? ""}`, toNeedle));
|
|
183
206
|
if (!match) {
|
|
184
207
|
return false;
|
|
185
208
|
}
|
|
@@ -381,6 +404,11 @@ function safeJsonParse(value, fallback) {
|
|
|
381
404
|
return fallback;
|
|
382
405
|
}
|
|
383
406
|
}
|
|
407
|
+
// The six searchable columns of messages_fts, in table order after email_id, as their searchKey.
|
|
408
|
+
function ftsColumns(email, preview, attachmentText) {
|
|
409
|
+
const parts = emailToSearchParts({ ...email, attachmentText });
|
|
410
|
+
return [email.subject, preview, email.folder, parts.labels, parts.participants, parts.attachmentNames].map(searchKey);
|
|
411
|
+
}
|
|
384
412
|
function emailToSearchParts(email) {
|
|
385
413
|
return {
|
|
386
414
|
labels: [normalizeMailboxLabel(email.folder) || email.folder, ...email.labels.map((label) => normalizeMailboxLabel(label) || label)].join(" "),
|
|
@@ -482,6 +510,15 @@ export class LocalIndexService {
|
|
|
482
510
|
this.snapshotQueue = run.catch(() => undefined);
|
|
483
511
|
return run;
|
|
484
512
|
}
|
|
513
|
+
// Just what the refresh-before-search check needs: whether the index has any messages and whether it
|
|
514
|
+
// is older than the stale threshold. getStatus() also builds every thread and label from up to 5000
|
|
515
|
+
// messages (about 170 ms on a 57,000-message index), which this check ran before every search.
|
|
516
|
+
async getFreshness() {
|
|
517
|
+
const db = await this.ensureDb();
|
|
518
|
+
const { updatedAt } = this.loadFoldersAndMetadata(db);
|
|
519
|
+
const storedMessageCount = Number(db.prepare(`SELECT COUNT(*) AS count FROM messages`).get().count);
|
|
520
|
+
return { storedMessageCount, isStale: isIndexStale(indexAgeMinutes(updatedAt)) };
|
|
521
|
+
}
|
|
485
522
|
async getStatus() {
|
|
486
523
|
const db = await this.ensureDb();
|
|
487
524
|
const { ownerEmail, updatedAt, folders, indexedFolders } = this.loadFoldersAndMetadata(db);
|
|
@@ -762,19 +799,18 @@ export class LocalIndexService {
|
|
|
762
799
|
// get plain ThreadSummary shapes, not the full per-message detail.
|
|
763
800
|
const threads = this.buildThreads(snapshot, true).filter((thread) => {
|
|
764
801
|
if (input.label) {
|
|
765
|
-
const
|
|
766
|
-
if (!thread.normalizedLabels.some((label) =>
|
|
802
|
+
const wanted = input.label;
|
|
803
|
+
if (!thread.normalizedLabels.some((label) => searchEquals(label, wanted))) {
|
|
767
804
|
return false;
|
|
768
805
|
}
|
|
769
806
|
}
|
|
770
807
|
if (input.query) {
|
|
771
|
-
const
|
|
772
|
-
const threadLevelHaystack = foldSearchText([
|
|
808
|
+
const threadLevelHaystack = [
|
|
773
809
|
...thread.participants.map((participant) => `${participant.name ?? ""} ${participant.address ?? ""}`),
|
|
774
810
|
...thread.normalizedLabels,
|
|
775
|
-
].join("\n")
|
|
776
|
-
const matchesAnyMessage = thread.messages.some((message) =>
|
|
777
|
-
if (!matchesAnyMessage && !threadLevelHaystack.
|
|
811
|
+
].join("\n");
|
|
812
|
+
const matchesAnyMessage = thread.messages.some((message) => searchIncludes(message.subject, input.query));
|
|
813
|
+
if (!matchesAnyMessage && !searchIncludes(threadLevelHaystack, input.query)) {
|
|
778
814
|
return false;
|
|
779
815
|
}
|
|
780
816
|
}
|
|
@@ -865,14 +901,14 @@ export class LocalIndexService {
|
|
|
865
901
|
conditions.push(`is_read = 0`);
|
|
866
902
|
}
|
|
867
903
|
if (input.label) {
|
|
868
|
-
const
|
|
869
|
-
conditions.push(
|
|
870
|
-
params.push(
|
|
904
|
+
const like = foldedLike(["folder", "labels_json"], input.label);
|
|
905
|
+
conditions.push(like.sql);
|
|
906
|
+
params.push(...like.params);
|
|
871
907
|
}
|
|
872
908
|
if (input.query) {
|
|
873
|
-
const
|
|
874
|
-
conditions.push(
|
|
875
|
-
params.push(
|
|
909
|
+
const like = foldedLike(["subject", "preview", "from_json", "labels_json"], input.query);
|
|
910
|
+
conditions.push(like.sql);
|
|
911
|
+
params.push(...like.params);
|
|
876
912
|
}
|
|
877
913
|
const sql = `SELECT * FROM messages${conditions.length > 0 ? ` WHERE ${conditions.join(" AND ")}` : ""}`;
|
|
878
914
|
const candidateMessages = db
|
|
@@ -913,19 +949,19 @@ export class LocalIndexService {
|
|
|
913
949
|
return false;
|
|
914
950
|
}
|
|
915
951
|
if (input.label) {
|
|
916
|
-
const
|
|
917
|
-
if (!thread.normalizedLabels.some((label) =>
|
|
952
|
+
const wanted = input.label;
|
|
953
|
+
if (!thread.normalizedLabels.some((label) => searchEquals(label, wanted))) {
|
|
918
954
|
return false;
|
|
919
955
|
}
|
|
920
956
|
}
|
|
921
957
|
if (input.query) {
|
|
922
|
-
const haystack =
|
|
958
|
+
const haystack = [
|
|
923
959
|
thread.subject,
|
|
924
960
|
thread.latestPreview || "",
|
|
925
961
|
...thread.latestFrom.map((value) => `${value.name ?? ""} ${value.address ?? ""}`),
|
|
926
962
|
...thread.normalizedLabels,
|
|
927
|
-
].join("\n")
|
|
928
|
-
if (!haystack
|
|
963
|
+
].join("\n");
|
|
964
|
+
if (!searchIncludes(haystack, input.query)) {
|
|
929
965
|
return false;
|
|
930
966
|
}
|
|
931
967
|
}
|
|
@@ -1220,15 +1256,15 @@ export class LocalIndexService {
|
|
|
1220
1256
|
const db = await this.ensureDb();
|
|
1221
1257
|
const { ownerEmail, updatedAt, folders, indexedFolders } = this.loadFoldersAndMetadata(db);
|
|
1222
1258
|
const syncCheckpoints = this.loadCheckpointsSync(db);
|
|
1223
|
-
const personNeedle = input.person
|
|
1259
|
+
const personNeedle = input.person || undefined;
|
|
1224
1260
|
const domainNeedle = input.domain?.toLowerCase();
|
|
1225
1261
|
const limit = input.limit ?? 10;
|
|
1226
1262
|
const conditions = [];
|
|
1227
1263
|
const params = [];
|
|
1228
1264
|
if (personNeedle) {
|
|
1229
|
-
const
|
|
1230
|
-
conditions.push(
|
|
1231
|
-
params.push(
|
|
1265
|
+
const like = foldedLike(["from_json", "to_json", "cc_json"], personNeedle);
|
|
1266
|
+
conditions.push(like.sql);
|
|
1267
|
+
params.push(...like.params);
|
|
1232
1268
|
}
|
|
1233
1269
|
if (domainNeedle) {
|
|
1234
1270
|
const needle = `%@${escapeLike(domainNeedle)}%`;
|
|
@@ -1258,9 +1294,9 @@ export class LocalIndexService {
|
|
|
1258
1294
|
const snapshot = { ownerEmail, updatedAt, folders, indexedFolders, syncCheckpoints, messages };
|
|
1259
1295
|
const threads = this.buildThreads(snapshot, true)
|
|
1260
1296
|
.filter((thread) => thread.participants.some((participant) => {
|
|
1261
|
-
const participantText =
|
|
1297
|
+
const participantText = `${participant.name ?? ""} ${participant.address ?? ""}`;
|
|
1262
1298
|
const participantDomain = extractDomain(participant.address || "");
|
|
1263
|
-
if (personNeedle && participantText
|
|
1299
|
+
if (personNeedle && searchIncludes(participantText, personNeedle)) {
|
|
1264
1300
|
return true;
|
|
1265
1301
|
}
|
|
1266
1302
|
if (domainNeedle && participantDomain === domainNeedle) {
|
|
@@ -1324,7 +1360,7 @@ export class LocalIndexService {
|
|
|
1324
1360
|
this.chmodDbFiles();
|
|
1325
1361
|
// SQLite's LOWER() only folds ASCII and LIKE does not ignore accents, so "łódź" could not find
|
|
1326
1362
|
// "Łódź" and "pelcova" could not find "Pelcová". Compare folded text on both sides instead.
|
|
1327
|
-
db.function("FOLD", { deterministic: true }, (value) => (value == null ? null :
|
|
1363
|
+
db.function("FOLD", { deterministic: true }, (value) => (value == null ? null : searchKey(String(value))));
|
|
1328
1364
|
}
|
|
1329
1365
|
// auto_vacuum only takes effect on a brand-new/empty database (page_count 0) —
|
|
1330
1366
|
// setting the pragma alone does NOT retroactively enable incremental vacuuming
|
|
@@ -1352,6 +1388,7 @@ export class LocalIndexService {
|
|
|
1352
1388
|
db.exec("VACUUM");
|
|
1353
1389
|
}
|
|
1354
1390
|
this.runMigrations(db);
|
|
1391
|
+
this.rebuildFtsIfNeeded(db);
|
|
1355
1392
|
this.db = db;
|
|
1356
1393
|
await this.maybeImportLegacyIndex(db);
|
|
1357
1394
|
this.initialized = true;
|
|
@@ -1707,10 +1744,8 @@ export class LocalIndexService {
|
|
|
1707
1744
|
is_automated: email.isAutomated === undefined ? null : email.isAutomated ? 1 : 0,
|
|
1708
1745
|
delivered_to: email.deliveredTo ?? null,
|
|
1709
1746
|
});
|
|
1710
|
-
const mergedPreview = persisted.preview ?? "";
|
|
1711
|
-
const search = emailToSearchParts({ ...email, attachmentText: persisted.attachment_text ?? undefined });
|
|
1712
1747
|
deleteFts.run(email.id);
|
|
1713
|
-
insertFts.run(email.id, email
|
|
1748
|
+
insertFts.run(email.id, ...ftsColumns(email, persisted.preview ?? "", persisted.attachment_text ?? undefined));
|
|
1714
1749
|
}
|
|
1715
1750
|
if (cleanupExpunged) {
|
|
1716
1751
|
createSnapshotUidTable.run();
|
|
@@ -1777,6 +1812,37 @@ export class LocalIndexService {
|
|
|
1777
1812
|
db.pragma("incremental_vacuum");
|
|
1778
1813
|
}
|
|
1779
1814
|
}
|
|
1815
|
+
// Rebuilds messages_fts from the messages table when it was written by an older version (text stored
|
|
1816
|
+
// as-is, so "lodzi" could not find "Łodzi"). One transaction: either every row is converted or none.
|
|
1817
|
+
rebuildFtsIfNeeded(db) {
|
|
1818
|
+
const stored = db.prepare(`SELECT value FROM metadata WHERE key = 'ftsKeyVersion'`).get();
|
|
1819
|
+
if (stored?.value === FTS_KEY_VERSION)
|
|
1820
|
+
return;
|
|
1821
|
+
const total = Number(db.prepare(`SELECT COUNT(*) AS count FROM messages`).get().count);
|
|
1822
|
+
if (total > 0) {
|
|
1823
|
+
this.log.info(`Rebuilding the full-text index for ${total} messages (one-time)`, "LocalIndexService");
|
|
1824
|
+
}
|
|
1825
|
+
const insertFts = db.prepare(`
|
|
1826
|
+
INSERT INTO messages_fts (email_id, subject, preview, folder, labels, participants, attachment_names)
|
|
1827
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
1828
|
+
`);
|
|
1829
|
+
const nextBatch = db.prepare(`SELECT rowid AS _rowid, * FROM messages WHERE rowid > ? ORDER BY rowid LIMIT 500`);
|
|
1830
|
+
db.transaction(() => {
|
|
1831
|
+
db.exec(`DELETE FROM messages_fts`);
|
|
1832
|
+
let after = 0;
|
|
1833
|
+
for (;;) {
|
|
1834
|
+
const rows = nextBatch.all(after);
|
|
1835
|
+
if (rows.length === 0)
|
|
1836
|
+
break;
|
|
1837
|
+
for (const row of rows) {
|
|
1838
|
+
const email = this.rowToEmailSummary(row);
|
|
1839
|
+
insertFts.run(email.id, ...ftsColumns(email, row.preview ?? "", row.attachment_text ?? undefined));
|
|
1840
|
+
}
|
|
1841
|
+
after = rows[rows.length - 1]._rowid;
|
|
1842
|
+
}
|
|
1843
|
+
db.prepare(`INSERT INTO metadata (key, value) VALUES ('ftsKeyVersion', ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value`).run(FTS_KEY_VERSION);
|
|
1844
|
+
})();
|
|
1845
|
+
}
|
|
1780
1846
|
runMigrations(db) {
|
|
1781
1847
|
db.exec(`
|
|
1782
1848
|
CREATE TABLE IF NOT EXISTS metadata (
|
|
@@ -1961,8 +2027,9 @@ export class LocalIndexService {
|
|
|
1961
2027
|
params.push(filters.hasAttachment ? 1 : 0);
|
|
1962
2028
|
}
|
|
1963
2029
|
if (filters.subject) {
|
|
1964
|
-
|
|
1965
|
-
|
|
2030
|
+
const like = foldedLike(["subject"], filters.subject);
|
|
2031
|
+
conditions.push(like.sql);
|
|
2032
|
+
params.push(...like.params);
|
|
1966
2033
|
}
|
|
1967
2034
|
if (filters.senderDomain) {
|
|
1968
2035
|
conditions.push(`FOLD(from_json) LIKE ? ESCAPE '\\'`);
|
|
@@ -1976,13 +2043,14 @@ export class LocalIndexService {
|
|
|
1976
2043
|
// genuine match older than the window instead of finding it. Mirror the
|
|
1977
2044
|
// existing senderDomain LIKE pattern (a safe superset pre-filter).
|
|
1978
2045
|
if (filters.from) {
|
|
1979
|
-
|
|
1980
|
-
|
|
2046
|
+
const like = foldedLike(["from_json"], filters.from);
|
|
2047
|
+
conditions.push(like.sql);
|
|
2048
|
+
params.push(...like.params);
|
|
1981
2049
|
}
|
|
1982
2050
|
if (filters.to) {
|
|
1983
|
-
|
|
1984
|
-
|
|
1985
|
-
params.push(
|
|
2051
|
+
const like = foldedLike(["to_json", "cc_json", "bcc_json"], filters.to);
|
|
2052
|
+
conditions.push(like.sql);
|
|
2053
|
+
params.push(...like.params);
|
|
1986
2054
|
}
|
|
1987
2055
|
if (filters.messageId) {
|
|
1988
2056
|
conditions.push(`message_id = ?`);
|
|
@@ -2033,8 +2101,13 @@ export class LocalIndexService {
|
|
|
2033
2101
|
// NOT-prefix, so this searches for the term itself instead of silently dropping
|
|
2034
2102
|
// it (e.g. `AND gate schematics` now matches "AND", "gate" and "schematics" all
|
|
2035
2103
|
// required, rather than dropping "AND" and searching only the other two).
|
|
2104
|
+
// Each term is looked for in its folded form(s): the stored text is a searchKey (see
|
|
2105
|
+
// rebuildFtsIfNeeded), so Müller is stored as "muller" and "mueller" and either finds it.
|
|
2106
|
+
const quote = (form) => `"${form.replace(/"/g, '""')}"`;
|
|
2036
2107
|
const match = parsed.residualTerms
|
|
2037
|
-
.map((token) =>
|
|
2108
|
+
.map((token) => searchNeedles(token).filter(Boolean).map(quote))
|
|
2109
|
+
.filter((forms) => forms.length > 0)
|
|
2110
|
+
.map((forms) => (forms.length > 1 ? `(${forms.join(" OR ")})` : forms[0]))
|
|
2038
2111
|
.join(" AND ");
|
|
2039
2112
|
if (!match) {
|
|
2040
2113
|
// No terms survived parsing at all (e.g. an empty/whitespace-only query), so
|
|
@@ -2146,9 +2219,9 @@ export class LocalIndexService {
|
|
|
2146
2219
|
messageParams.push(options.folder);
|
|
2147
2220
|
}
|
|
2148
2221
|
if (options.label) {
|
|
2149
|
-
const
|
|
2150
|
-
messageConditions.push(
|
|
2151
|
-
messageParams.push(
|
|
2222
|
+
const like = foldedLike(["folder", "labels_json"], options.label);
|
|
2223
|
+
messageConditions.push(like.sql);
|
|
2224
|
+
messageParams.push(...like.params);
|
|
2152
2225
|
}
|
|
2153
2226
|
if (typeof options.isRead === "boolean") {
|
|
2154
2227
|
messageConditions.push(`is_read = ?`);
|
|
@@ -2188,14 +2261,14 @@ export class LocalIndexService {
|
|
|
2188
2261
|
params.push(input.folder);
|
|
2189
2262
|
}
|
|
2190
2263
|
if (input.label) {
|
|
2191
|
-
const
|
|
2192
|
-
conditions.push(
|
|
2193
|
-
params.push(
|
|
2264
|
+
const like = foldedLike(["folder", "labels_json"], input.label);
|
|
2265
|
+
conditions.push(like.sql);
|
|
2266
|
+
params.push(...like.params);
|
|
2194
2267
|
}
|
|
2195
2268
|
if (input.query) {
|
|
2196
|
-
const
|
|
2197
|
-
conditions.push(
|
|
2198
|
-
params.push(
|
|
2269
|
+
const like = foldedLike(["subject", "from_json", "to_json", "cc_json", "bcc_json", "labels_json"], input.query);
|
|
2270
|
+
conditions.push(like.sql);
|
|
2271
|
+
params.push(...like.params);
|
|
2199
2272
|
}
|
|
2200
2273
|
const sql = `SELECT * FROM messages${conditions.length > 0 ? ` WHERE ${conditions.join(" AND ")}` : ""}`;
|
|
2201
2274
|
const candidates = db
|
|
@@ -2371,16 +2444,14 @@ export class LocalIndexService {
|
|
|
2371
2444
|
const mailboxMessages = this.buildMailboxMessages(snapshot);
|
|
2372
2445
|
const threadCount = this.buildThreads(snapshot).length;
|
|
2373
2446
|
const labelCount = new Set(mailboxMessages.flatMap((message) => message.normalizedLabels.map((label) => label.toLowerCase()))).size;
|
|
2374
|
-
const ageMinutes = snapshot.updatedAt
|
|
2375
|
-
? Math.max(0, Math.round((Date.now() - new Date(snapshot.updatedAt).getTime()) / 60_000))
|
|
2376
|
-
: undefined;
|
|
2447
|
+
const ageMinutes = indexAgeMinutes(snapshot.updatedAt);
|
|
2377
2448
|
return {
|
|
2378
2449
|
path: this.dbPath,
|
|
2379
2450
|
ownerEmail: snapshot.ownerEmail,
|
|
2380
2451
|
updatedAt: snapshot.updatedAt,
|
|
2381
2452
|
ageMinutes,
|
|
2382
2453
|
staleThresholdMinutes: STALE_THRESHOLD_MINUTES,
|
|
2383
|
-
isStale:
|
|
2454
|
+
isStale: isIndexStale(ageMinutes),
|
|
2384
2455
|
lastSyncAt: snapshot.updatedAt,
|
|
2385
2456
|
indexFreshnessMinutes: ageMinutes,
|
|
2386
2457
|
// Selectable folders the index has never synced: mail arriving there is invisible to
|