proton-mail-bridge-client 2.2.4 → 2.3.0
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 +1 -1
- package/dist/index.js.map +1 -1
- package/dist/services/local-index-service.d.ts +1 -0
- package/dist/services/local-index-service.d.ts.map +1 -1
- package/dist/services/local-index-service.js +117 -59
- 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
|
@@ -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,9 +3,13 @@ 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;
|
|
10
14
|
const DEFAULT_SNAPSHOT_LIMIT = 5000;
|
|
11
15
|
// Results older than this carry `stale:true` plus a warning. Deliberately much lower than
|
|
@@ -48,6 +52,19 @@ export function shapeThreadForList(thread) {
|
|
|
48
52
|
function escapeLike(value) {
|
|
49
53
|
return value.replace(/\\/g, "\\\\").replace(/%/g, "\\%").replace(/_/g, "\\_");
|
|
50
54
|
}
|
|
55
|
+
// WHERE fragment: any of `columns` contains `needle`, ignoring case, accents and spelling variants
|
|
56
|
+
// (Müller/Mueller, see searchKey). FOLD() is the SQL function registered in ensureDb.
|
|
57
|
+
function foldedLike(columns, needle) {
|
|
58
|
+
const parts = [];
|
|
59
|
+
const params = [];
|
|
60
|
+
for (const form of searchNeedles(needle)) {
|
|
61
|
+
for (const column of columns) {
|
|
62
|
+
parts.push(`FOLD(${column}) LIKE ? ESCAPE '\\'`);
|
|
63
|
+
params.push(`%${escapeLike(form)}%`);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return { sql: `(${parts.join(" OR ")})`, params };
|
|
67
|
+
}
|
|
51
68
|
function parseSearchQuery(query) {
|
|
52
69
|
const parsed = { residualTerms: [] };
|
|
53
70
|
for (const rawToken of query?.trim().split(/\s+/) ?? []) {
|
|
@@ -100,9 +117,9 @@ function matchesIndexedSearch(email, filters) {
|
|
|
100
117
|
return false;
|
|
101
118
|
}
|
|
102
119
|
if (normalizedFilters.label) {
|
|
103
|
-
const
|
|
104
|
-
const folderMatch =
|
|
105
|
-
const labelMatch = email.labels.some((label) =>
|
|
120
|
+
const wanted = normalizedFilters.label;
|
|
121
|
+
const folderMatch = searchEquals(normalizeMailboxLabel(email.folder) ?? "", wanted);
|
|
122
|
+
const labelMatch = email.labels.some((label) => searchEquals(normalizeMailboxLabel(label) ?? "", wanted));
|
|
106
123
|
if (!folderMatch && !labelMatch) {
|
|
107
124
|
return false;
|
|
108
125
|
}
|
|
@@ -131,8 +148,8 @@ function matchesIndexedSearch(email, filters) {
|
|
|
131
148
|
return false;
|
|
132
149
|
}
|
|
133
150
|
if (normalizedFilters.attachmentName) {
|
|
134
|
-
const attachmentNeedle =
|
|
135
|
-
const match = email.attachments.some((attachment) =>
|
|
151
|
+
const attachmentNeedle = normalizedFilters.attachmentName;
|
|
152
|
+
const match = email.attachments.some((attachment) => searchIncludes(attachment.filename || "", attachmentNeedle));
|
|
136
153
|
if (!match) {
|
|
137
154
|
return false;
|
|
138
155
|
}
|
|
@@ -153,33 +170,33 @@ function matchesIndexedSearch(email, filters) {
|
|
|
153
170
|
...email.to.map((value) => `${value.name ?? ""} ${value.address ?? ""}`),
|
|
154
171
|
...email.cc.map((value) => `${value.name ?? ""} ${value.address ?? ""}`),
|
|
155
172
|
].join("\n");
|
|
156
|
-
const
|
|
173
|
+
const haystackKey = searchKey(haystacks);
|
|
157
174
|
// searchFtsIds() ANDs every whitespace-separated query token as its own quoted FTS5 term
|
|
158
175
|
// (so "invoice payment" matches a preview like "payment for the invoice is overdue"), but
|
|
159
176
|
// this post-filter used to require the ENTIRE joined query as one literal substring —
|
|
160
177
|
// rejecting anything FTS already matched whose words were merely out of order or separated
|
|
161
178
|
// by other words. Match FTS's own AND-of-terms semantics instead of a single-phrase substring.
|
|
162
179
|
if (normalizedFilters.query) {
|
|
163
|
-
const terms =
|
|
164
|
-
if (terms.length > 0 && !terms.every((term) =>
|
|
180
|
+
const terms = normalizedFilters.query.split(/\s+/).filter(Boolean);
|
|
181
|
+
if (terms.length > 0 && !terms.every((term) => searchNeedles(term).some((form) => haystackKey.includes(form)))) {
|
|
165
182
|
return false;
|
|
166
183
|
}
|
|
167
184
|
}
|
|
168
185
|
if (normalizedFilters.subject &&
|
|
169
|
-
!
|
|
186
|
+
!searchIncludes(email.subject, normalizedFilters.subject)) {
|
|
170
187
|
return false;
|
|
171
188
|
}
|
|
172
189
|
if (normalizedFilters.from) {
|
|
173
|
-
const fromNeedle =
|
|
174
|
-
const match = email.from.some((value) =>
|
|
190
|
+
const fromNeedle = normalizedFilters.from;
|
|
191
|
+
const match = email.from.some((value) => searchIncludes(`${value.name ?? ""} ${value.address ?? ""}`, fromNeedle));
|
|
175
192
|
if (!match) {
|
|
176
193
|
return false;
|
|
177
194
|
}
|
|
178
195
|
}
|
|
179
196
|
if (normalizedFilters.to) {
|
|
180
|
-
const toNeedle =
|
|
197
|
+
const toNeedle = normalizedFilters.to;
|
|
181
198
|
const recipients = [...email.to, ...email.cc, ...email.bcc];
|
|
182
|
-
const match = recipients.some((value) =>
|
|
199
|
+
const match = recipients.some((value) => searchIncludes(`${value.name ?? ""} ${value.address ?? ""}`, toNeedle));
|
|
183
200
|
if (!match) {
|
|
184
201
|
return false;
|
|
185
202
|
}
|
|
@@ -381,6 +398,11 @@ function safeJsonParse(value, fallback) {
|
|
|
381
398
|
return fallback;
|
|
382
399
|
}
|
|
383
400
|
}
|
|
401
|
+
// The six searchable columns of messages_fts, in table order after email_id, as their searchKey.
|
|
402
|
+
function ftsColumns(email, preview, attachmentText) {
|
|
403
|
+
const parts = emailToSearchParts({ ...email, attachmentText });
|
|
404
|
+
return [email.subject, preview, email.folder, parts.labels, parts.participants, parts.attachmentNames].map(searchKey);
|
|
405
|
+
}
|
|
384
406
|
function emailToSearchParts(email) {
|
|
385
407
|
return {
|
|
386
408
|
labels: [normalizeMailboxLabel(email.folder) || email.folder, ...email.labels.map((label) => normalizeMailboxLabel(label) || label)].join(" "),
|
|
@@ -762,19 +784,18 @@ export class LocalIndexService {
|
|
|
762
784
|
// get plain ThreadSummary shapes, not the full per-message detail.
|
|
763
785
|
const threads = this.buildThreads(snapshot, true).filter((thread) => {
|
|
764
786
|
if (input.label) {
|
|
765
|
-
const
|
|
766
|
-
if (!thread.normalizedLabels.some((label) =>
|
|
787
|
+
const wanted = input.label;
|
|
788
|
+
if (!thread.normalizedLabels.some((label) => searchEquals(label, wanted))) {
|
|
767
789
|
return false;
|
|
768
790
|
}
|
|
769
791
|
}
|
|
770
792
|
if (input.query) {
|
|
771
|
-
const
|
|
772
|
-
const threadLevelHaystack = foldSearchText([
|
|
793
|
+
const threadLevelHaystack = [
|
|
773
794
|
...thread.participants.map((participant) => `${participant.name ?? ""} ${participant.address ?? ""}`),
|
|
774
795
|
...thread.normalizedLabels,
|
|
775
|
-
].join("\n")
|
|
776
|
-
const matchesAnyMessage = thread.messages.some((message) =>
|
|
777
|
-
if (!matchesAnyMessage && !threadLevelHaystack.
|
|
796
|
+
].join("\n");
|
|
797
|
+
const matchesAnyMessage = thread.messages.some((message) => searchIncludes(message.subject, input.query));
|
|
798
|
+
if (!matchesAnyMessage && !searchIncludes(threadLevelHaystack, input.query)) {
|
|
778
799
|
return false;
|
|
779
800
|
}
|
|
780
801
|
}
|
|
@@ -865,14 +886,14 @@ export class LocalIndexService {
|
|
|
865
886
|
conditions.push(`is_read = 0`);
|
|
866
887
|
}
|
|
867
888
|
if (input.label) {
|
|
868
|
-
const
|
|
869
|
-
conditions.push(
|
|
870
|
-
params.push(
|
|
889
|
+
const like = foldedLike(["folder", "labels_json"], input.label);
|
|
890
|
+
conditions.push(like.sql);
|
|
891
|
+
params.push(...like.params);
|
|
871
892
|
}
|
|
872
893
|
if (input.query) {
|
|
873
|
-
const
|
|
874
|
-
conditions.push(
|
|
875
|
-
params.push(
|
|
894
|
+
const like = foldedLike(["subject", "preview", "from_json", "labels_json"], input.query);
|
|
895
|
+
conditions.push(like.sql);
|
|
896
|
+
params.push(...like.params);
|
|
876
897
|
}
|
|
877
898
|
const sql = `SELECT * FROM messages${conditions.length > 0 ? ` WHERE ${conditions.join(" AND ")}` : ""}`;
|
|
878
899
|
const candidateMessages = db
|
|
@@ -913,19 +934,19 @@ export class LocalIndexService {
|
|
|
913
934
|
return false;
|
|
914
935
|
}
|
|
915
936
|
if (input.label) {
|
|
916
|
-
const
|
|
917
|
-
if (!thread.normalizedLabels.some((label) =>
|
|
937
|
+
const wanted = input.label;
|
|
938
|
+
if (!thread.normalizedLabels.some((label) => searchEquals(label, wanted))) {
|
|
918
939
|
return false;
|
|
919
940
|
}
|
|
920
941
|
}
|
|
921
942
|
if (input.query) {
|
|
922
|
-
const haystack =
|
|
943
|
+
const haystack = [
|
|
923
944
|
thread.subject,
|
|
924
945
|
thread.latestPreview || "",
|
|
925
946
|
...thread.latestFrom.map((value) => `${value.name ?? ""} ${value.address ?? ""}`),
|
|
926
947
|
...thread.normalizedLabels,
|
|
927
|
-
].join("\n")
|
|
928
|
-
if (!haystack
|
|
948
|
+
].join("\n");
|
|
949
|
+
if (!searchIncludes(haystack, input.query)) {
|
|
929
950
|
return false;
|
|
930
951
|
}
|
|
931
952
|
}
|
|
@@ -1220,15 +1241,15 @@ export class LocalIndexService {
|
|
|
1220
1241
|
const db = await this.ensureDb();
|
|
1221
1242
|
const { ownerEmail, updatedAt, folders, indexedFolders } = this.loadFoldersAndMetadata(db);
|
|
1222
1243
|
const syncCheckpoints = this.loadCheckpointsSync(db);
|
|
1223
|
-
const personNeedle = input.person
|
|
1244
|
+
const personNeedle = input.person || undefined;
|
|
1224
1245
|
const domainNeedle = input.domain?.toLowerCase();
|
|
1225
1246
|
const limit = input.limit ?? 10;
|
|
1226
1247
|
const conditions = [];
|
|
1227
1248
|
const params = [];
|
|
1228
1249
|
if (personNeedle) {
|
|
1229
|
-
const
|
|
1230
|
-
conditions.push(
|
|
1231
|
-
params.push(
|
|
1250
|
+
const like = foldedLike(["from_json", "to_json", "cc_json"], personNeedle);
|
|
1251
|
+
conditions.push(like.sql);
|
|
1252
|
+
params.push(...like.params);
|
|
1232
1253
|
}
|
|
1233
1254
|
if (domainNeedle) {
|
|
1234
1255
|
const needle = `%@${escapeLike(domainNeedle)}%`;
|
|
@@ -1258,9 +1279,9 @@ export class LocalIndexService {
|
|
|
1258
1279
|
const snapshot = { ownerEmail, updatedAt, folders, indexedFolders, syncCheckpoints, messages };
|
|
1259
1280
|
const threads = this.buildThreads(snapshot, true)
|
|
1260
1281
|
.filter((thread) => thread.participants.some((participant) => {
|
|
1261
|
-
const participantText =
|
|
1282
|
+
const participantText = `${participant.name ?? ""} ${participant.address ?? ""}`;
|
|
1262
1283
|
const participantDomain = extractDomain(participant.address || "");
|
|
1263
|
-
if (personNeedle && participantText
|
|
1284
|
+
if (personNeedle && searchIncludes(participantText, personNeedle)) {
|
|
1264
1285
|
return true;
|
|
1265
1286
|
}
|
|
1266
1287
|
if (domainNeedle && participantDomain === domainNeedle) {
|
|
@@ -1324,7 +1345,7 @@ export class LocalIndexService {
|
|
|
1324
1345
|
this.chmodDbFiles();
|
|
1325
1346
|
// SQLite's LOWER() only folds ASCII and LIKE does not ignore accents, so "łódź" could not find
|
|
1326
1347
|
// "Łódź" and "pelcova" could not find "Pelcová". Compare folded text on both sides instead.
|
|
1327
|
-
db.function("FOLD", { deterministic: true }, (value) => (value == null ? null :
|
|
1348
|
+
db.function("FOLD", { deterministic: true }, (value) => (value == null ? null : searchKey(String(value))));
|
|
1328
1349
|
}
|
|
1329
1350
|
// auto_vacuum only takes effect on a brand-new/empty database (page_count 0) —
|
|
1330
1351
|
// setting the pragma alone does NOT retroactively enable incremental vacuuming
|
|
@@ -1352,6 +1373,7 @@ export class LocalIndexService {
|
|
|
1352
1373
|
db.exec("VACUUM");
|
|
1353
1374
|
}
|
|
1354
1375
|
this.runMigrations(db);
|
|
1376
|
+
this.rebuildFtsIfNeeded(db);
|
|
1355
1377
|
this.db = db;
|
|
1356
1378
|
await this.maybeImportLegacyIndex(db);
|
|
1357
1379
|
this.initialized = true;
|
|
@@ -1707,10 +1729,8 @@ export class LocalIndexService {
|
|
|
1707
1729
|
is_automated: email.isAutomated === undefined ? null : email.isAutomated ? 1 : 0,
|
|
1708
1730
|
delivered_to: email.deliveredTo ?? null,
|
|
1709
1731
|
});
|
|
1710
|
-
const mergedPreview = persisted.preview ?? "";
|
|
1711
|
-
const search = emailToSearchParts({ ...email, attachmentText: persisted.attachment_text ?? undefined });
|
|
1712
1732
|
deleteFts.run(email.id);
|
|
1713
|
-
insertFts.run(email.id, email
|
|
1733
|
+
insertFts.run(email.id, ...ftsColumns(email, persisted.preview ?? "", persisted.attachment_text ?? undefined));
|
|
1714
1734
|
}
|
|
1715
1735
|
if (cleanupExpunged) {
|
|
1716
1736
|
createSnapshotUidTable.run();
|
|
@@ -1777,6 +1797,37 @@ export class LocalIndexService {
|
|
|
1777
1797
|
db.pragma("incremental_vacuum");
|
|
1778
1798
|
}
|
|
1779
1799
|
}
|
|
1800
|
+
// Rebuilds messages_fts from the messages table when it was written by an older version (text stored
|
|
1801
|
+
// as-is, so "lodzi" could not find "Łodzi"). One transaction: either every row is converted or none.
|
|
1802
|
+
rebuildFtsIfNeeded(db) {
|
|
1803
|
+
const stored = db.prepare(`SELECT value FROM metadata WHERE key = 'ftsKeyVersion'`).get();
|
|
1804
|
+
if (stored?.value === FTS_KEY_VERSION)
|
|
1805
|
+
return;
|
|
1806
|
+
const total = Number(db.prepare(`SELECT COUNT(*) AS count FROM messages`).get().count);
|
|
1807
|
+
if (total > 0) {
|
|
1808
|
+
this.log.info(`Rebuilding the full-text index for ${total} messages (one-time)`, "LocalIndexService");
|
|
1809
|
+
}
|
|
1810
|
+
const insertFts = db.prepare(`
|
|
1811
|
+
INSERT INTO messages_fts (email_id, subject, preview, folder, labels, participants, attachment_names)
|
|
1812
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
1813
|
+
`);
|
|
1814
|
+
const nextBatch = db.prepare(`SELECT rowid AS _rowid, * FROM messages WHERE rowid > ? ORDER BY rowid LIMIT 500`);
|
|
1815
|
+
db.transaction(() => {
|
|
1816
|
+
db.exec(`DELETE FROM messages_fts`);
|
|
1817
|
+
let after = 0;
|
|
1818
|
+
for (;;) {
|
|
1819
|
+
const rows = nextBatch.all(after);
|
|
1820
|
+
if (rows.length === 0)
|
|
1821
|
+
break;
|
|
1822
|
+
for (const row of rows) {
|
|
1823
|
+
const email = this.rowToEmailSummary(row);
|
|
1824
|
+
insertFts.run(email.id, ...ftsColumns(email, row.preview ?? "", row.attachment_text ?? undefined));
|
|
1825
|
+
}
|
|
1826
|
+
after = rows[rows.length - 1]._rowid;
|
|
1827
|
+
}
|
|
1828
|
+
db.prepare(`INSERT INTO metadata (key, value) VALUES ('ftsKeyVersion', ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value`).run(FTS_KEY_VERSION);
|
|
1829
|
+
})();
|
|
1830
|
+
}
|
|
1780
1831
|
runMigrations(db) {
|
|
1781
1832
|
db.exec(`
|
|
1782
1833
|
CREATE TABLE IF NOT EXISTS metadata (
|
|
@@ -1961,8 +2012,9 @@ export class LocalIndexService {
|
|
|
1961
2012
|
params.push(filters.hasAttachment ? 1 : 0);
|
|
1962
2013
|
}
|
|
1963
2014
|
if (filters.subject) {
|
|
1964
|
-
|
|
1965
|
-
|
|
2015
|
+
const like = foldedLike(["subject"], filters.subject);
|
|
2016
|
+
conditions.push(like.sql);
|
|
2017
|
+
params.push(...like.params);
|
|
1966
2018
|
}
|
|
1967
2019
|
if (filters.senderDomain) {
|
|
1968
2020
|
conditions.push(`FOLD(from_json) LIKE ? ESCAPE '\\'`);
|
|
@@ -1976,13 +2028,14 @@ export class LocalIndexService {
|
|
|
1976
2028
|
// genuine match older than the window instead of finding it. Mirror the
|
|
1977
2029
|
// existing senderDomain LIKE pattern (a safe superset pre-filter).
|
|
1978
2030
|
if (filters.from) {
|
|
1979
|
-
|
|
1980
|
-
|
|
2031
|
+
const like = foldedLike(["from_json"], filters.from);
|
|
2032
|
+
conditions.push(like.sql);
|
|
2033
|
+
params.push(...like.params);
|
|
1981
2034
|
}
|
|
1982
2035
|
if (filters.to) {
|
|
1983
|
-
|
|
1984
|
-
|
|
1985
|
-
params.push(
|
|
2036
|
+
const like = foldedLike(["to_json", "cc_json", "bcc_json"], filters.to);
|
|
2037
|
+
conditions.push(like.sql);
|
|
2038
|
+
params.push(...like.params);
|
|
1986
2039
|
}
|
|
1987
2040
|
if (filters.messageId) {
|
|
1988
2041
|
conditions.push(`message_id = ?`);
|
|
@@ -2033,8 +2086,13 @@ export class LocalIndexService {
|
|
|
2033
2086
|
// NOT-prefix, so this searches for the term itself instead of silently dropping
|
|
2034
2087
|
// it (e.g. `AND gate schematics` now matches "AND", "gate" and "schematics" all
|
|
2035
2088
|
// required, rather than dropping "AND" and searching only the other two).
|
|
2089
|
+
// Each term is looked for in its folded form(s): the stored text is a searchKey (see
|
|
2090
|
+
// rebuildFtsIfNeeded), so Müller is stored as "muller" and "mueller" and either finds it.
|
|
2091
|
+
const quote = (form) => `"${form.replace(/"/g, '""')}"`;
|
|
2036
2092
|
const match = parsed.residualTerms
|
|
2037
|
-
.map((token) =>
|
|
2093
|
+
.map((token) => searchNeedles(token).filter(Boolean).map(quote))
|
|
2094
|
+
.filter((forms) => forms.length > 0)
|
|
2095
|
+
.map((forms) => (forms.length > 1 ? `(${forms.join(" OR ")})` : forms[0]))
|
|
2038
2096
|
.join(" AND ");
|
|
2039
2097
|
if (!match) {
|
|
2040
2098
|
// No terms survived parsing at all (e.g. an empty/whitespace-only query), so
|
|
@@ -2146,9 +2204,9 @@ export class LocalIndexService {
|
|
|
2146
2204
|
messageParams.push(options.folder);
|
|
2147
2205
|
}
|
|
2148
2206
|
if (options.label) {
|
|
2149
|
-
const
|
|
2150
|
-
messageConditions.push(
|
|
2151
|
-
messageParams.push(
|
|
2207
|
+
const like = foldedLike(["folder", "labels_json"], options.label);
|
|
2208
|
+
messageConditions.push(like.sql);
|
|
2209
|
+
messageParams.push(...like.params);
|
|
2152
2210
|
}
|
|
2153
2211
|
if (typeof options.isRead === "boolean") {
|
|
2154
2212
|
messageConditions.push(`is_read = ?`);
|
|
@@ -2188,14 +2246,14 @@ export class LocalIndexService {
|
|
|
2188
2246
|
params.push(input.folder);
|
|
2189
2247
|
}
|
|
2190
2248
|
if (input.label) {
|
|
2191
|
-
const
|
|
2192
|
-
conditions.push(
|
|
2193
|
-
params.push(
|
|
2249
|
+
const like = foldedLike(["folder", "labels_json"], input.label);
|
|
2250
|
+
conditions.push(like.sql);
|
|
2251
|
+
params.push(...like.params);
|
|
2194
2252
|
}
|
|
2195
2253
|
if (input.query) {
|
|
2196
|
-
const
|
|
2197
|
-
conditions.push(
|
|
2198
|
-
params.push(
|
|
2254
|
+
const like = foldedLike(["subject", "from_json", "to_json", "cc_json", "bcc_json", "labels_json"], input.query);
|
|
2255
|
+
conditions.push(like.sql);
|
|
2256
|
+
params.push(...like.params);
|
|
2199
2257
|
}
|
|
2200
2258
|
const sql = `SELECT * FROM messages${conditions.length > 0 ? ` WHERE ${conditions.join(" AND ")}` : ""}`;
|
|
2201
2259
|
const candidates = db
|