proton-mail-bridge-client 1.11.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.
Files changed (72) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +445 -0
  3. package/dist/cli.d.ts +12 -0
  4. package/dist/cli.d.ts.map +1 -0
  5. package/dist/cli.js +1627 -0
  6. package/dist/cli.js.map +1 -0
  7. package/dist/index.d.ts +56 -0
  8. package/dist/index.d.ts.map +1 -0
  9. package/dist/index.js +3173 -0
  10. package/dist/index.js.map +1 -0
  11. package/dist/scripts/bridge-smoke.d.ts +2 -0
  12. package/dist/scripts/bridge-smoke.d.ts.map +1 -0
  13. package/dist/scripts/bridge-smoke.js +415 -0
  14. package/dist/scripts/bridge-smoke.js.map +1 -0
  15. package/dist/scripts/check-claude-desktop.d.ts +17 -0
  16. package/dist/scripts/check-claude-desktop.d.ts.map +1 -0
  17. package/dist/scripts/check-claude-desktop.js +138 -0
  18. package/dist/scripts/check-claude-desktop.js.map +1 -0
  19. package/dist/scripts/install-claude-desktop.d.ts +39 -0
  20. package/dist/scripts/install-claude-desktop.d.ts.map +1 -0
  21. package/dist/scripts/install-claude-desktop.js +257 -0
  22. package/dist/scripts/install-claude-desktop.js.map +1 -0
  23. package/dist/scripts/setup-claude-desktop.d.ts +15 -0
  24. package/dist/scripts/setup-claude-desktop.d.ts.map +1 -0
  25. package/dist/scripts/setup-claude-desktop.js +187 -0
  26. package/dist/scripts/setup-claude-desktop.js.map +1 -0
  27. package/dist/services/analytics-service.d.ts +45 -0
  28. package/dist/services/analytics-service.d.ts.map +1 -0
  29. package/dist/services/analytics-service.js +189 -0
  30. package/dist/services/analytics-service.js.map +1 -0
  31. package/dist/services/audit-service.d.ts +13 -0
  32. package/dist/services/audit-service.d.ts.map +1 -0
  33. package/dist/services/audit-service.js +67 -0
  34. package/dist/services/audit-service.js.map +1 -0
  35. package/dist/services/background-sync-service.d.ts +28 -0
  36. package/dist/services/background-sync-service.d.ts.map +1 -0
  37. package/dist/services/background-sync-service.js +216 -0
  38. package/dist/services/background-sync-service.js.map +1 -0
  39. package/dist/services/draft-store-service.d.ts +59 -0
  40. package/dist/services/draft-store-service.d.ts.map +1 -0
  41. package/dist/services/draft-store-service.js +251 -0
  42. package/dist/services/draft-store-service.js.map +1 -0
  43. package/dist/services/local-index-service.d.ts +90 -0
  44. package/dist/services/local-index-service.d.ts.map +1 -0
  45. package/dist/services/local-index-service.js +1462 -0
  46. package/dist/services/local-index-service.js.map +1 -0
  47. package/dist/services/simple-imap-service.d.ts +209 -0
  48. package/dist/services/simple-imap-service.d.ts.map +1 -0
  49. package/dist/services/simple-imap-service.js +1100 -0
  50. package/dist/services/simple-imap-service.js.map +1 -0
  51. package/dist/services/smtp-service.d.ts +15 -0
  52. package/dist/services/smtp-service.d.ts.map +1 -0
  53. package/dist/services/smtp-service.js +100 -0
  54. package/dist/services/smtp-service.js.map +1 -0
  55. package/dist/types/index.d.ts +355 -0
  56. package/dist/types/index.d.ts.map +1 -0
  57. package/dist/types/index.js +2 -0
  58. package/dist/types/index.js.map +1 -0
  59. package/dist/utils/helpers.d.ts +45 -0
  60. package/dist/utils/helpers.d.ts.map +1 -0
  61. package/dist/utils/helpers.js +393 -0
  62. package/dist/utils/helpers.js.map +1 -0
  63. package/dist/utils/logger.d.ts +22 -0
  64. package/dist/utils/logger.d.ts.map +1 -0
  65. package/dist/utils/logger.js +91 -0
  66. package/dist/utils/logger.js.map +1 -0
  67. package/dist/utils/runtime-policy.d.ts +12 -0
  68. package/dist/utils/runtime-policy.d.ts.map +1 -0
  69. package/dist/utils/runtime-policy.js +62 -0
  70. package/dist/utils/runtime-policy.js.map +1 -0
  71. package/glama.json +6 -0
  72. package/package.json +91 -0
@@ -0,0 +1,1462 @@
1
+ import Database from "better-sqlite3";
2
+ import { mkdir, readFile, rm } from "node:fs/promises";
3
+ import { dirname, join } from "node:path";
4
+ import { dedupeEmails, extractDomain, extractMessageIdList, lowerCaseAddress, normalizeMailboxLabel, normalizeMessageId, normalizeSubjectForThread, sortEmailsByNewest, } from "../utils/helpers.js";
5
+ import { logger } from "../utils/logger.js";
6
+ const DB_SCHEMA_VERSION = 3;
7
+ const STALE_THRESHOLD_MINUTES = 60;
8
+ function parseSearchQuery(query) {
9
+ const parsed = { residualTerms: [] };
10
+ for (const rawToken of query?.trim().split(/\s+/) ?? []) {
11
+ const separator = rawToken.indexOf(":");
12
+ if (separator <= 0) {
13
+ parsed.residualTerms.push(rawToken);
14
+ continue;
15
+ }
16
+ const key = rawToken.slice(0, separator).toLowerCase();
17
+ const value = rawToken.slice(separator + 1).trim();
18
+ if (!value) {
19
+ continue;
20
+ }
21
+ switch (key) {
22
+ case "domain":
23
+ case "fromdomain":
24
+ parsed.senderDomain = value.toLowerCase();
25
+ break;
26
+ case "label":
27
+ parsed.label = value;
28
+ break;
29
+ case "from":
30
+ parsed.from = value;
31
+ break;
32
+ case "to":
33
+ parsed.to = value;
34
+ break;
35
+ case "subject":
36
+ parsed.subject = value;
37
+ break;
38
+ default:
39
+ parsed.residualTerms.push(rawToken);
40
+ break;
41
+ }
42
+ }
43
+ return parsed;
44
+ }
45
+ function matchesIndexedSearch(email, filters) {
46
+ const parsedQuery = parseSearchQuery(filters.query);
47
+ const normalizedFilters = {
48
+ ...filters,
49
+ query: parsedQuery.residualTerms.join(" ") || undefined,
50
+ senderDomain: filters.senderDomain || parsedQuery.senderDomain,
51
+ label: filters.label || parsedQuery.label,
52
+ from: filters.from || parsedQuery.from,
53
+ to: filters.to || parsedQuery.to,
54
+ subject: filters.subject || parsedQuery.subject,
55
+ };
56
+ if (normalizedFilters.folder && email.folder !== normalizedFilters.folder) {
57
+ return false;
58
+ }
59
+ if (normalizedFilters.label) {
60
+ const labelNeedle = normalizedFilters.label.toLowerCase();
61
+ const folderMatch = normalizeMailboxLabel(email.folder)?.toLowerCase() === labelNeedle;
62
+ const labelMatch = email.labels.some((label) => normalizeMailboxLabel(label)?.toLowerCase() === labelNeedle);
63
+ if (!folderMatch && !labelMatch) {
64
+ return false;
65
+ }
66
+ }
67
+ if (normalizedFilters.threadId && email.threadId !== normalizedFilters.threadId) {
68
+ return false;
69
+ }
70
+ if (typeof normalizedFilters.hasAttachment === "boolean" &&
71
+ email.hasAttachments !== normalizedFilters.hasAttachment) {
72
+ return false;
73
+ }
74
+ if (normalizedFilters.attachmentName) {
75
+ const attachmentNeedle = normalizedFilters.attachmentName.toLowerCase();
76
+ const match = email.attachments.some((attachment) => (attachment.filename || "").toLowerCase().includes(attachmentNeedle));
77
+ if (!match) {
78
+ return false;
79
+ }
80
+ }
81
+ if (typeof normalizedFilters.isRead === "boolean" && email.isRead !== normalizedFilters.isRead) {
82
+ return false;
83
+ }
84
+ if (typeof normalizedFilters.isStarred === "boolean" && email.isStarred !== normalizedFilters.isStarred) {
85
+ return false;
86
+ }
87
+ const haystacks = [
88
+ email.subject,
89
+ email.preview ?? "",
90
+ email.attachmentText ?? "",
91
+ email.folder,
92
+ email.labels.join(" "),
93
+ ...email.from.map((value) => `${value.name ?? ""} ${value.address ?? ""}`),
94
+ ...email.to.map((value) => `${value.name ?? ""} ${value.address ?? ""}`),
95
+ ...email.cc.map((value) => `${value.name ?? ""} ${value.address ?? ""}`),
96
+ ]
97
+ .join("\n")
98
+ .toLowerCase();
99
+ if (normalizedFilters.query && !haystacks.includes(normalizedFilters.query.toLowerCase())) {
100
+ return false;
101
+ }
102
+ if (normalizedFilters.subject &&
103
+ !email.subject.toLowerCase().includes(normalizedFilters.subject.toLowerCase())) {
104
+ return false;
105
+ }
106
+ if (normalizedFilters.from) {
107
+ const fromNeedle = normalizedFilters.from.toLowerCase();
108
+ const match = email.from.some((value) => `${value.name ?? ""} ${value.address ?? ""}`.toLowerCase().includes(fromNeedle));
109
+ if (!match) {
110
+ return false;
111
+ }
112
+ }
113
+ if (normalizedFilters.to) {
114
+ const toNeedle = normalizedFilters.to.toLowerCase();
115
+ const recipients = [...email.to, ...email.cc, ...email.bcc];
116
+ const match = recipients.some((value) => `${value.name ?? ""} ${value.address ?? ""}`.toLowerCase().includes(toNeedle));
117
+ if (!match) {
118
+ return false;
119
+ }
120
+ }
121
+ if (normalizedFilters.senderDomain) {
122
+ const match = email.from.some((value) => extractDomain(value.address || "") === normalizedFilters.senderDomain);
123
+ if (!match) {
124
+ return false;
125
+ }
126
+ }
127
+ const emailDate = email.internalDate || email.date;
128
+ if (normalizedFilters.dateFrom && emailDate) {
129
+ if (new Date(emailDate).getTime() < new Date(normalizedFilters.dateFrom).getTime()) {
130
+ return false;
131
+ }
132
+ }
133
+ if (normalizedFilters.dateTo && emailDate) {
134
+ if (new Date(emailDate).getTime() > new Date(normalizedFilters.dateTo).getTime()) {
135
+ return false;
136
+ }
137
+ }
138
+ return true;
139
+ }
140
+ function canonicalMessageKey(email) {
141
+ const messageId = normalizeMessageId(email.messageId);
142
+ if (messageId) {
143
+ return messageId;
144
+ }
145
+ const fromAddress = lowerCaseAddress(email.from[0]?.address) || "unknown";
146
+ const dateBucket = email.internalDate || email.date || String(email.uid);
147
+ return `${normalizeSubjectForThread(email.subject).toLowerCase()}::${fromAddress}::${dateBucket}`;
148
+ }
149
+ function threadKeyForEmail(email) {
150
+ if (email.threadId?.trim()) {
151
+ return `imap:${email.threadId.trim()}`;
152
+ }
153
+ return fallbackThreadKey(email);
154
+ }
155
+ function specialUseToRole(specialUse, folderPath) {
156
+ switch (specialUse) {
157
+ case "\\Inbox":
158
+ return "inbox";
159
+ case "\\Sent":
160
+ return "sent";
161
+ case "\\Drafts":
162
+ return "drafts";
163
+ case "\\Trash":
164
+ return "trash";
165
+ case "\\Archive":
166
+ return "archive";
167
+ case "\\Junk":
168
+ return "spam";
169
+ default:
170
+ return normalizeMailboxLabel(folderPath)?.toLowerCase() || "folder";
171
+ }
172
+ }
173
+ function friendlySpecialUse(specialUse) {
174
+ return normalizeMailboxLabel(specialUse?.replace(/^\\/, ""));
175
+ }
176
+ function normalizedMailboxLabelsFor(email, folder) {
177
+ const labels = new Set();
178
+ const add = (value) => {
179
+ const normalized = normalizeMailboxLabel(value);
180
+ if (normalized) {
181
+ labels.add(normalized);
182
+ }
183
+ };
184
+ add(email.folder);
185
+ for (const label of email.labels) {
186
+ add(label);
187
+ }
188
+ add(folder?.specialUse?.replace(/^\\/, ""));
189
+ const pathParts = email.folder.split("/");
190
+ if (pathParts.length > 1) {
191
+ add(pathParts[pathParts.length - 1]);
192
+ }
193
+ return [...labels].sort((left, right) => left.localeCompare(right));
194
+ }
195
+ function locationScore(location) {
196
+ const specialUse = location.folder?.specialUse;
197
+ let score = 0;
198
+ switch (specialUse) {
199
+ case "\\Inbox":
200
+ score += 100;
201
+ break;
202
+ case "\\Sent":
203
+ score += 80;
204
+ break;
205
+ case "\\Drafts":
206
+ score += 70;
207
+ break;
208
+ case "\\Archive":
209
+ score += 50;
210
+ break;
211
+ case "\\Trash":
212
+ score += 10;
213
+ break;
214
+ default:
215
+ score += 40;
216
+ break;
217
+ }
218
+ if (!location.email.isRead) {
219
+ score += 3;
220
+ }
221
+ if (location.email.hasAttachments) {
222
+ score += 1;
223
+ }
224
+ if (location.email.isStarred) {
225
+ score += 2;
226
+ }
227
+ return score;
228
+ }
229
+ function uniqueParticipants(messages) {
230
+ const seen = new Set();
231
+ const participants = [];
232
+ for (const message of messages) {
233
+ for (const address of [...message.from, ...message.to, ...message.cc]) {
234
+ const key = lowerCaseAddress(address.address) || address.name?.trim().toLowerCase();
235
+ if (!key || seen.has(key)) {
236
+ continue;
237
+ }
238
+ seen.add(key);
239
+ participants.push(address);
240
+ }
241
+ }
242
+ return participants;
243
+ }
244
+ function isOutgoingMessage(message, ownerEmail) {
245
+ const owner = lowerCaseAddress(ownerEmail);
246
+ if (!owner) {
247
+ return false;
248
+ }
249
+ return message.from.some((address) => lowerCaseAddress(address.address) === owner);
250
+ }
251
+ function actionableThreadScore(thread, ownerEmail) {
252
+ const latestMessage = thread.messages[thread.messages.length - 1];
253
+ const latestIsOutgoing = latestMessage ? isOutgoingMessage(latestMessage, ownerEmail) : false;
254
+ const pendingOn = latestMessage
255
+ ? latestIsOutgoing
256
+ ? "them"
257
+ : "you"
258
+ : "unknown";
259
+ let score = 0;
260
+ score += thread.unreadCount * 10;
261
+ if (pendingOn === "you") {
262
+ score += 8;
263
+ }
264
+ if (latestMessage?.isStarred) {
265
+ score += 4;
266
+ }
267
+ if (latestMessage?.hasAttachments) {
268
+ score += 2;
269
+ }
270
+ const latestTime = new Date(thread.latestDate || 0).getTime();
271
+ if (latestTime > 0) {
272
+ const ageHours = (Date.now() - latestTime) / (60 * 60 * 1000);
273
+ if (ageHours > 24) {
274
+ score += 3;
275
+ }
276
+ else if (ageHours > 4) {
277
+ score += 1;
278
+ }
279
+ }
280
+ return { pendingOn, score };
281
+ }
282
+ function safeJsonParse(value, fallback) {
283
+ if (!value) {
284
+ return fallback;
285
+ }
286
+ try {
287
+ return JSON.parse(value);
288
+ }
289
+ catch {
290
+ return fallback;
291
+ }
292
+ }
293
+ function emailToSearchParts(email) {
294
+ return {
295
+ labels: [normalizeMailboxLabel(email.folder) || email.folder, ...email.labels.map((label) => normalizeMailboxLabel(label) || label)].join(" "),
296
+ participants: [...email.from, ...email.to, ...email.cc, ...email.bcc]
297
+ .map((value) => `${value.name ?? ""} ${value.address ?? ""} ${extractDomain(value.address || "") ?? ""}`.trim())
298
+ .join(" "),
299
+ attachmentNames: [
300
+ ...email.attachments.map((attachment) => attachment.filename || ""),
301
+ ...email.attachments.map((attachment) => attachment.kind || ""),
302
+ email.attachmentText || "",
303
+ ]
304
+ .join(" ")
305
+ .trim(),
306
+ };
307
+ }
308
+ function participantThreadSignature(message, ownerEmail) {
309
+ const owner = lowerCaseAddress(ownerEmail);
310
+ const addresses = [...message.from, ...message.to, ...message.cc]
311
+ .map((entry) => lowerCaseAddress(entry.address))
312
+ .filter((entry) => Boolean(entry));
313
+ const counterparties = owner ? addresses.filter((entry) => entry !== owner) : addresses;
314
+ const signatureSource = counterparties.length > 0 ? counterparties : addresses;
315
+ const signature = [...new Set(signatureSource)].sort().slice(0, 4).join("|");
316
+ return signature || "unknown";
317
+ }
318
+ function fallbackThreadKey(message, ownerEmail) {
319
+ return `subject:${normalizeSubjectForThread(message.subject).toLowerCase()}::${participantThreadSignature(message, ownerEmail)}`;
320
+ }
321
+ function searchRelevanceScore(email, filters) {
322
+ const parsedQuery = parseSearchQuery(filters.query);
323
+ const residual = parsedQuery.residualTerms.join(" ").toLowerCase();
324
+ const fullParticipants = [...email.from, ...email.to, ...email.cc, ...email.bcc]
325
+ .map((value) => `${value.name ?? ""} ${value.address ?? ""}`.trim().toLowerCase())
326
+ .join("\n");
327
+ let score = 0;
328
+ if (residual) {
329
+ if (email.subject.toLowerCase().includes(residual))
330
+ score += 12;
331
+ if ((email.preview || "").toLowerCase().includes(residual))
332
+ score += 8;
333
+ if ((email.attachmentText || "").toLowerCase().includes(residual))
334
+ score += 5;
335
+ if (fullParticipants.includes(residual))
336
+ score += 6;
337
+ }
338
+ if ((filters.senderDomain || parsedQuery.senderDomain) && email.from.some((entry) => extractDomain(entry.address || "") === (filters.senderDomain || parsedQuery.senderDomain))) {
339
+ score += 4;
340
+ }
341
+ if (email.hasAttachments)
342
+ score += 1;
343
+ if (!email.isRead)
344
+ score += 2;
345
+ if (email.isStarred)
346
+ score += 2;
347
+ const timestamp = new Date(email.internalDate || email.date || 0).getTime();
348
+ if (timestamp > 0) {
349
+ const ageHours = Math.max(0, (Date.now() - timestamp) / (60 * 60 * 1000));
350
+ score += Math.max(0, 3 - ageHours / 48);
351
+ }
352
+ return score;
353
+ }
354
+ export class LocalIndexService {
355
+ config;
356
+ log;
357
+ dbPath;
358
+ legacyIndexPath;
359
+ db;
360
+ initialized = false;
361
+ constructor(config, log = logger) {
362
+ this.config = config;
363
+ this.log = log;
364
+ this.dbPath = join(this.config.dataDir, "mail-index.sqlite");
365
+ this.legacyIndexPath = join(this.config.dataDir, "mail-index.json");
366
+ }
367
+ async recordSnapshot(input) {
368
+ const db = await this.ensureDb();
369
+ const ownerEmail = lowerCaseAddress(this.config.smtp.username);
370
+ this.applySnapshot(db, input, ownerEmail);
371
+ return this.getStatus();
372
+ }
373
+ async getStatus() {
374
+ const snapshot = await this.loadSnapshot();
375
+ return this.toStatus(snapshot);
376
+ }
377
+ async search(filters) {
378
+ const parsedQuery = parseSearchQuery(filters.query);
379
+ const normalizedFilters = {
380
+ ...filters,
381
+ query: parsedQuery.residualTerms.join(" ") || undefined,
382
+ senderDomain: filters.senderDomain || parsedQuery.senderDomain,
383
+ label: filters.label || parsedQuery.label,
384
+ from: filters.from || parsedQuery.from,
385
+ to: filters.to || parsedQuery.to,
386
+ subject: filters.subject || parsedQuery.subject,
387
+ };
388
+ if (normalizedFilters.threadId?.trim()) {
389
+ const snapshot = await this.loadSnapshot();
390
+ const thread = this.buildThreads(snapshot, true).find((entry) => entry.id === normalizedFilters.threadId);
391
+ if (thread) {
392
+ const threadMessages = sortEmailsByNewest(thread.messages).filter((email) => matchesIndexedSearch(email, { ...normalizedFilters, threadId: undefined })).sort((left, right) => searchRelevanceScore(right, normalizedFilters) - searchRelevanceScore(left, normalizedFilters));
393
+ return {
394
+ total: threadMessages.length,
395
+ emails: threadMessages.slice(0, normalizedFilters.limit ?? 100),
396
+ };
397
+ }
398
+ }
399
+ const db = await this.ensureDb();
400
+ const limit = normalizedFilters.limit ?? 100;
401
+ const emails = this.loadCandidateEmails(db, normalizedFilters, Math.max(limit * 10, 500));
402
+ const matches = dedupeEmails(emails)
403
+ .filter((email) => matchesIndexedSearch(email, normalizedFilters))
404
+ .sort((left, right) => {
405
+ const scoreDelta = searchRelevanceScore(right, normalizedFilters) - searchRelevanceScore(left, normalizedFilters);
406
+ if (scoreDelta !== 0) {
407
+ return scoreDelta;
408
+ }
409
+ return sortEmailsByNewest([left, right])[0] === left ? -1 : 1;
410
+ });
411
+ return {
412
+ total: matches.length,
413
+ emails: matches.slice(0, limit),
414
+ };
415
+ }
416
+ async clear() {
417
+ this.closeDb();
418
+ let removed = false;
419
+ for (const path of [this.dbPath, this.legacyIndexPath]) {
420
+ try {
421
+ await rm(path);
422
+ removed = true;
423
+ }
424
+ catch (error) {
425
+ if (!(error &&
426
+ typeof error === "object" &&
427
+ "code" in error &&
428
+ error.code === "ENOENT")) {
429
+ throw error;
430
+ }
431
+ }
432
+ }
433
+ return { path: this.dbPath, removed };
434
+ }
435
+ async listRecentMessages(limit = 50) {
436
+ const snapshot = await this.loadSnapshot();
437
+ return this.buildMailboxMessages(snapshot).slice(0, limit);
438
+ }
439
+ async getLabels(limit = 250) {
440
+ const snapshot = await this.loadSnapshot();
441
+ const messages = this.buildMailboxMessages(snapshot);
442
+ const counts = new Map();
443
+ for (const message of messages) {
444
+ const folderInfo = snapshot.folders.find((folder) => folder.path === message.folder);
445
+ const addCount = (id, name, type, specialUse) => {
446
+ const existing = counts.get(id) ?? {
447
+ label: {
448
+ id,
449
+ name,
450
+ type,
451
+ messageCount: 0,
452
+ unreadCount: 0,
453
+ threadCount: 0,
454
+ specialUse,
455
+ },
456
+ threadIds: new Set(),
457
+ };
458
+ existing.label.messageCount += 1;
459
+ if (!message.isRead) {
460
+ existing.label.unreadCount += 1;
461
+ }
462
+ existing.threadIds.add(message.threadKey);
463
+ existing.label.threadCount = existing.threadIds.size;
464
+ counts.set(id, existing);
465
+ };
466
+ const normalizedFolderName = normalizeMailboxLabel(message.folder) || message.folder;
467
+ addCount(`folder:${normalizedFolderName.toLowerCase()}`, normalizedFolderName, "folder", folderInfo?.specialUse);
468
+ if (folderInfo?.specialUse) {
469
+ const friendly = friendlySpecialUse(folderInfo.specialUse) || folderInfo.specialUse;
470
+ addCount(`special:${friendly.toLowerCase()}`, friendly, "special_use", folderInfo.specialUse);
471
+ }
472
+ for (const label of message.normalizedLabels) {
473
+ addCount(`label:${label.toLowerCase()}`, label, "label");
474
+ }
475
+ }
476
+ return [...counts.values()]
477
+ .map((entry) => entry.label)
478
+ .sort((left, right) => {
479
+ if (right.messageCount !== left.messageCount) {
480
+ return right.messageCount - left.messageCount;
481
+ }
482
+ return left.name.localeCompare(right.name);
483
+ })
484
+ .slice(0, limit);
485
+ }
486
+ async getThreads(input = {}) {
487
+ const snapshot = await this.loadSnapshot();
488
+ const threads = this.buildThreads(snapshot).filter((thread) => {
489
+ if (input.label) {
490
+ const labelNeedle = input.label.toLowerCase();
491
+ if (!thread.normalizedLabels.some((label) => label.toLowerCase() === labelNeedle)) {
492
+ return false;
493
+ }
494
+ }
495
+ if (input.query) {
496
+ const haystack = [
497
+ thread.subject,
498
+ ...thread.participants.map((participant) => `${participant.name ?? ""} ${participant.address ?? ""}`),
499
+ ...thread.normalizedLabels,
500
+ ]
501
+ .join("\n")
502
+ .toLowerCase();
503
+ if (!haystack.includes(input.query.toLowerCase())) {
504
+ return false;
505
+ }
506
+ }
507
+ return true;
508
+ });
509
+ const limit = input.limit ?? 100;
510
+ return {
511
+ total: threads.length,
512
+ threads: threads.slice(0, limit),
513
+ };
514
+ }
515
+ async getThreadById(threadId) {
516
+ const snapshot = await this.loadSnapshot();
517
+ const thread = this.buildThreads(snapshot, true).find((entry) => entry.id === threadId);
518
+ if (!thread) {
519
+ throw new Error(`Thread not found for id ${threadId}`);
520
+ }
521
+ return thread;
522
+ }
523
+ async getActionableThreads(input = {}) {
524
+ const snapshot = await this.loadSnapshot();
525
+ const pendingFilter = input.pendingOn || "any";
526
+ const limit = input.limit ?? 50;
527
+ const actionable = this.buildThreads(snapshot, true)
528
+ .map((thread) => {
529
+ const latestMessage = thread.messages[thread.messages.length - 1];
530
+ const { pendingOn, score } = actionableThreadScore(thread, snapshot.ownerEmail);
531
+ return {
532
+ ...thread,
533
+ latestEmailId: latestMessage?.primaryEmailId,
534
+ latestPreview: latestMessage?.preview,
535
+ latestFrom: latestMessage?.from ?? [],
536
+ latestIsRead: latestMessage?.isRead ?? true,
537
+ latestIsStarred: latestMessage?.isStarred ?? false,
538
+ latestHasAttachments: latestMessage?.hasAttachments ?? false,
539
+ pendingOn,
540
+ score,
541
+ };
542
+ })
543
+ .filter((thread) => {
544
+ if (input.unreadOnly !== false && thread.unreadCount === 0) {
545
+ return false;
546
+ }
547
+ if (pendingFilter !== "any" && thread.pendingOn !== pendingFilter) {
548
+ return false;
549
+ }
550
+ if (input.label) {
551
+ const labelNeedle = input.label.toLowerCase();
552
+ if (!thread.normalizedLabels.some((label) => label.toLowerCase() === labelNeedle)) {
553
+ return false;
554
+ }
555
+ }
556
+ if (input.query) {
557
+ const haystack = [
558
+ thread.subject,
559
+ thread.latestPreview || "",
560
+ ...thread.latestFrom.map((value) => `${value.name ?? ""} ${value.address ?? ""}`),
561
+ ...thread.normalizedLabels,
562
+ ]
563
+ .join("\n")
564
+ .toLowerCase();
565
+ if (!haystack.includes(input.query.toLowerCase())) {
566
+ return false;
567
+ }
568
+ }
569
+ return true;
570
+ })
571
+ .sort((left, right) => {
572
+ if (right.score !== left.score) {
573
+ return right.score - left.score;
574
+ }
575
+ const leftTime = new Date(left.latestDate || 0).getTime();
576
+ const rightTime = new Date(right.latestDate || 0).getTime();
577
+ return rightTime - leftTime;
578
+ });
579
+ return {
580
+ total: actionable.length,
581
+ threads: actionable.slice(0, limit),
582
+ };
583
+ }
584
+ async getSyncCheckpointMap() {
585
+ const snapshot = await this.loadSnapshot();
586
+ return Object.fromEntries(snapshot.syncCheckpoints.map((checkpoint) => [checkpoint.folder, checkpoint]));
587
+ }
588
+ async getInboxDigest(input = {}) {
589
+ const snapshot = await this.loadSnapshot();
590
+ const minAgeHours = input.minAgeHours ?? 24;
591
+ const allActionable = this.buildThreads(snapshot, true)
592
+ .map((thread) => {
593
+ const latestMessage = thread.messages[thread.messages.length - 1];
594
+ const { pendingOn, score } = actionableThreadScore(thread, snapshot.ownerEmail);
595
+ return {
596
+ ...thread,
597
+ latestEmailId: latestMessage?.primaryEmailId,
598
+ latestPreview: latestMessage?.preview,
599
+ latestFrom: latestMessage?.from ?? [],
600
+ latestIsRead: latestMessage?.isRead ?? true,
601
+ latestIsStarred: latestMessage?.isStarred ?? false,
602
+ latestHasAttachments: latestMessage?.hasAttachments ?? false,
603
+ pendingOn,
604
+ score,
605
+ };
606
+ })
607
+ .sort((left, right) => {
608
+ if (right.score !== left.score) {
609
+ return right.score - left.score;
610
+ }
611
+ return new Date(right.latestDate || 0).getTime() - new Date(left.latestDate || 0).getTime();
612
+ });
613
+ const now = Date.now();
614
+ const staleThresholdMs = minAgeHours * 60 * 60 * 1000;
615
+ const staleAwaitingYou = allActionable.filter((thread) => {
616
+ if (thread.pendingOn !== "you" || !thread.latestDate) {
617
+ return false;
618
+ }
619
+ return now - new Date(thread.latestDate).getTime() >= staleThresholdMs;
620
+ });
621
+ return {
622
+ generatedAt: new Date().toISOString(),
623
+ indexUpdatedAt: snapshot.updatedAt,
624
+ counts: {
625
+ totalThreads: allActionable.length,
626
+ unreadThreads: allActionable.filter((thread) => thread.unreadCount > 0).length,
627
+ pendingOnYou: allActionable.filter((thread) => thread.pendingOn === "you").length,
628
+ pendingOnThem: allActionable.filter((thread) => thread.pendingOn === "them").length,
629
+ starredThreads: allActionable.filter((thread) => thread.latestIsStarred).length,
630
+ attachmentThreads: allActionable.filter((thread) => thread.latestHasAttachments).length,
631
+ staleAwaitingYou: staleAwaitingYou.length,
632
+ },
633
+ topThreads: allActionable.slice(0, input.limit ?? 10),
634
+ staleAwaitingYou: staleAwaitingYou.slice(0, input.limit ?? 10),
635
+ };
636
+ }
637
+ async getFollowUpCandidates(input = {}) {
638
+ const snapshot = await this.loadSnapshot();
639
+ const minAgeHours = input.minAgeHours ?? 24;
640
+ const pendingOn = input.pendingOn ?? "you";
641
+ const thresholdMs = minAgeHours * 60 * 60 * 1000;
642
+ const now = Date.now();
643
+ const candidates = this.buildThreads(snapshot, true)
644
+ .map((thread) => {
645
+ const latestMessage = thread.messages[thread.messages.length - 1];
646
+ const { pendingOn: currentPendingOn, score } = actionableThreadScore(thread, snapshot.ownerEmail);
647
+ const ageHours = thread.latestDate
648
+ ? Math.max(0, Math.round((now - new Date(thread.latestDate).getTime()) / (60 * 60 * 1000)))
649
+ : undefined;
650
+ return {
651
+ ...thread,
652
+ latestEmailId: latestMessage?.primaryEmailId,
653
+ latestPreview: latestMessage?.preview,
654
+ latestFrom: latestMessage?.from ?? [],
655
+ latestIsRead: latestMessage?.isRead ?? true,
656
+ latestIsStarred: latestMessage?.isStarred ?? false,
657
+ latestHasAttachments: latestMessage?.hasAttachments ?? false,
658
+ pendingOn: currentPendingOn,
659
+ score,
660
+ ageHours,
661
+ suggestedAction: currentPendingOn === "you" ? "reply" : currentPendingOn === "them" ? "follow_up" : "review",
662
+ };
663
+ })
664
+ .filter((thread) => {
665
+ if (pendingOn !== "any" && thread.pendingOn !== pendingOn) {
666
+ return false;
667
+ }
668
+ if (thread.latestDate && now - new Date(thread.latestDate).getTime() < thresholdMs) {
669
+ return false;
670
+ }
671
+ return true;
672
+ })
673
+ .sort((left, right) => {
674
+ if ((right.ageHours ?? 0) !== (left.ageHours ?? 0)) {
675
+ return (right.ageHours ?? 0) - (left.ageHours ?? 0);
676
+ }
677
+ return right.score - left.score;
678
+ });
679
+ return {
680
+ generatedAt: new Date().toISOString(),
681
+ indexUpdatedAt: snapshot.updatedAt,
682
+ minAgeHours,
683
+ pendingOn,
684
+ total: candidates.length,
685
+ threads: candidates.slice(0, input.limit ?? 25),
686
+ };
687
+ }
688
+ async findDocumentThreads(input = {}) {
689
+ const snapshot = await this.loadSnapshot();
690
+ const category = input.category || "document";
691
+ const limit = input.limit ?? 25;
692
+ const keywordMap = {
693
+ document: ["pdf", "doc", "docx", "xls", "xlsx", "ppt", "pptx", "contract", "invoice", "receipt", "ticket"],
694
+ invoice: ["invoice", "receipt", "bill", "payment"],
695
+ contract: ["contract", "agreement", "msa", "dpa", "proposal"],
696
+ travel: ["itinerary", "ticket", "booking", "reservation", "boarding", "hotel", "flight"],
697
+ calendar: ["calendar", "invite", "meeting", "ics"],
698
+ };
699
+ const keywords = keywordMap[category] ?? keywordMap.document;
700
+ const matches = this.buildThreads(snapshot, true)
701
+ .map((thread) => {
702
+ const documents = thread.messages.flatMap((message) => message.attachments.filter((attachment) => {
703
+ const haystack = [
704
+ attachment.filename || "",
705
+ attachment.contentType || "",
706
+ attachment.kind || "",
707
+ message.subject,
708
+ message.preview || "",
709
+ message.attachmentText || "",
710
+ ]
711
+ .join(" ")
712
+ .toLowerCase();
713
+ return keywords.some((keyword) => haystack.includes(keyword));
714
+ }).map((attachment) => ({
715
+ emailId: message.primaryEmailId,
716
+ subject: message.subject,
717
+ filename: attachment.filename,
718
+ kind: attachment.kind,
719
+ contentType: attachment.contentType,
720
+ })));
721
+ return {
722
+ ...thread,
723
+ documents,
724
+ };
725
+ })
726
+ .filter((thread) => {
727
+ if (thread.documents.length === 0) {
728
+ return false;
729
+ }
730
+ if (!input.query) {
731
+ return true;
732
+ }
733
+ const haystack = [
734
+ thread.subject,
735
+ ...thread.documents.map((document) => `${document.filename || ""} ${document.subject}`),
736
+ ]
737
+ .join("\n")
738
+ .toLowerCase();
739
+ return haystack.includes(input.query.toLowerCase());
740
+ })
741
+ .sort((left, right) => {
742
+ if (right.documents.length !== left.documents.length) {
743
+ return right.documents.length - left.documents.length;
744
+ }
745
+ return new Date(right.latestDate || 0).getTime() - new Date(left.latestDate || 0).getTime();
746
+ });
747
+ return {
748
+ generatedAt: new Date().toISOString(),
749
+ indexUpdatedAt: snapshot.updatedAt,
750
+ category,
751
+ total: matches.length,
752
+ threads: matches.slice(0, limit),
753
+ };
754
+ }
755
+ async getMeetingPrep(input) {
756
+ const snapshot = await this.loadSnapshot();
757
+ const personNeedle = input.person?.toLowerCase();
758
+ const domainNeedle = input.domain?.toLowerCase();
759
+ const limit = input.limit ?? 10;
760
+ const threads = this.buildThreads(snapshot, true)
761
+ .filter((thread) => thread.participants.some((participant) => {
762
+ const participantText = `${participant.name ?? ""} ${participant.address ?? ""}`.toLowerCase();
763
+ const participantDomain = extractDomain(participant.address || "");
764
+ if (personNeedle && participantText.includes(personNeedle)) {
765
+ return true;
766
+ }
767
+ if (domainNeedle && participantDomain === domainNeedle) {
768
+ return true;
769
+ }
770
+ return false;
771
+ }))
772
+ .slice(0, limit);
773
+ const latestInbound = threads.flatMap((thread) => [...thread.messages]
774
+ .reverse()
775
+ .find((message) => !isOutgoingMessage(message, snapshot.ownerEmail))
776
+ ? [[...thread.messages].reverse().find((message) => !isOutgoingMessage(message, snapshot.ownerEmail))]
777
+ : []);
778
+ return {
779
+ generatedAt: new Date().toISOString(),
780
+ indexUpdatedAt: snapshot.updatedAt,
781
+ filters: {
782
+ person: input.person,
783
+ domain: input.domain,
784
+ },
785
+ totalThreads: threads.length,
786
+ threads,
787
+ latestInbound: latestInbound.slice(0, limit).map((message) => ({
788
+ emailId: message.primaryEmailId,
789
+ subject: message.subject,
790
+ from: message.from,
791
+ date: message.internalDate || message.date,
792
+ preview: message.preview,
793
+ })),
794
+ };
795
+ }
796
+ async runIntegrityCheck() {
797
+ const db = await this.ensureDb();
798
+ const integrity = String(db.prepare(`PRAGMA integrity_check`).get()?.integrity_check || "unknown");
799
+ const storedMessageCount = Number(db.prepare(`SELECT COUNT(*) AS count FROM messages`).get().count);
800
+ const ftsRowCount = Number(db.prepare(`SELECT COUNT(*) AS count FROM messages_fts`).get().count);
801
+ const syncCheckpointCount = Number(db.prepare(`SELECT COUNT(*) AS count FROM sync_state`).get().count);
802
+ return {
803
+ ok: integrity.toLowerCase() === "ok",
804
+ integrity,
805
+ storedMessageCount,
806
+ ftsRowCount,
807
+ syncCheckpointCount,
808
+ };
809
+ }
810
+ async ensureDb() {
811
+ if (this.db && this.initialized) {
812
+ return this.db;
813
+ }
814
+ await mkdir(dirname(this.dbPath), { recursive: true });
815
+ const db = this.db ?? new Database(this.dbPath);
816
+ db.pragma("journal_mode = WAL");
817
+ db.pragma("foreign_keys = ON");
818
+ db.pragma("synchronous = NORMAL");
819
+ this.runMigrations(db);
820
+ this.db = db;
821
+ await this.maybeImportLegacyIndex(db);
822
+ this.initialized = true;
823
+ return db;
824
+ }
825
+ applySnapshot(db, input, ownerEmail) {
826
+ const upsertFolder = db.prepare(`
827
+ INSERT INTO folders (
828
+ path, name, delimiter, special_use, listed, subscribed, flags_json,
829
+ messages, unseen, uid_next, last_indexed_at, last_indexed_count
830
+ ) VALUES (
831
+ @path, @name, @delimiter, @special_use, @listed, @subscribed, @flags_json,
832
+ @messages, @unseen, @uid_next, @last_indexed_at, @last_indexed_count
833
+ )
834
+ ON CONFLICT(path) DO UPDATE SET
835
+ name = excluded.name,
836
+ delimiter = excluded.delimiter,
837
+ special_use = excluded.special_use,
838
+ listed = excluded.listed,
839
+ subscribed = excluded.subscribed,
840
+ flags_json = excluded.flags_json,
841
+ messages = excluded.messages,
842
+ unseen = excluded.unseen,
843
+ uid_next = excluded.uid_next,
844
+ last_indexed_at = excluded.last_indexed_at,
845
+ last_indexed_count = excluded.last_indexed_count
846
+ `);
847
+ const upsertMessage = db.prepare(`
848
+ INSERT INTO messages (
849
+ email_id, folder, uid, seq, message_id, in_reply_to, references_json, thread_id, subject,
850
+ from_json, to_json, cc_json, bcc_json, reply_to_json, date, internal_date,
851
+ is_read, is_starred, flags_json, size, preview, has_attachments, attachments_json, attachment_text, labels_json
852
+ ) VALUES (
853
+ @email_id, @folder, @uid, @seq, @message_id, @in_reply_to, @references_json, @thread_id, @subject,
854
+ @from_json, @to_json, @cc_json, @bcc_json, @reply_to_json, @date, @internal_date,
855
+ @is_read, @is_starred, @flags_json, @size, @preview, @has_attachments, @attachments_json, @attachment_text, @labels_json
856
+ )
857
+ ON CONFLICT(email_id) DO UPDATE SET
858
+ folder = excluded.folder,
859
+ uid = excluded.uid,
860
+ seq = excluded.seq,
861
+ message_id = excluded.message_id,
862
+ in_reply_to = excluded.in_reply_to,
863
+ references_json = excluded.references_json,
864
+ thread_id = excluded.thread_id,
865
+ subject = excluded.subject,
866
+ from_json = excluded.from_json,
867
+ to_json = excluded.to_json,
868
+ cc_json = excluded.cc_json,
869
+ bcc_json = excluded.bcc_json,
870
+ reply_to_json = excluded.reply_to_json,
871
+ date = excluded.date,
872
+ internal_date = excluded.internal_date,
873
+ is_read = excluded.is_read,
874
+ is_starred = excluded.is_starred,
875
+ flags_json = excluded.flags_json,
876
+ size = excluded.size,
877
+ preview = excluded.preview,
878
+ has_attachments = excluded.has_attachments,
879
+ attachments_json = excluded.attachments_json,
880
+ attachment_text = excluded.attachment_text,
881
+ labels_json = excluded.labels_json
882
+ `);
883
+ const upsertSyncState = db.prepare(`
884
+ INSERT INTO sync_state (
885
+ folder, uid_validity, uid_next, highest_uid, last_sync_at, last_full_sync_at, strategy, changed, fetched, total
886
+ ) VALUES (
887
+ @folder, @uid_validity, @uid_next, @highest_uid, @last_sync_at, @last_full_sync_at, @strategy, @changed, @fetched, @total
888
+ )
889
+ ON CONFLICT(folder) DO UPDATE SET
890
+ uid_validity = excluded.uid_validity,
891
+ uid_next = excluded.uid_next,
892
+ highest_uid = excluded.highest_uid,
893
+ last_sync_at = excluded.last_sync_at,
894
+ last_full_sync_at = COALESCE(excluded.last_full_sync_at, sync_state.last_full_sync_at),
895
+ strategy = excluded.strategy,
896
+ changed = excluded.changed,
897
+ fetched = excluded.fetched,
898
+ total = excluded.total
899
+ `);
900
+ const deleteFts = db.prepare(`DELETE FROM messages_fts WHERE email_id = ?`);
901
+ const insertFts = db.prepare(`
902
+ INSERT INTO messages_fts (
903
+ email_id, subject, preview, folder, labels, participants, attachment_names
904
+ ) VALUES (?, ?, ?, ?, ?, ?, ?)
905
+ `);
906
+ const setMetadata = db.prepare(`
907
+ INSERT INTO metadata (key, value) VALUES (?, ?)
908
+ ON CONFLICT(key) DO UPDATE SET value = excluded.value
909
+ `);
910
+ const transaction = db.transaction(() => {
911
+ setMetadata.run("schemaVersion", String(DB_SCHEMA_VERSION));
912
+ setMetadata.run("ownerEmail", ownerEmail || "");
913
+ setMetadata.run("updatedAt", input.syncedAt);
914
+ for (const folder of input.folders) {
915
+ const folderStat = input.folderStats.find((entry) => entry.folder === folder.path);
916
+ upsertFolder.run({
917
+ path: folder.path,
918
+ name: folder.name,
919
+ delimiter: folder.delimiter,
920
+ special_use: folder.specialUse ?? null,
921
+ listed: folder.listed ? 1 : 0,
922
+ subscribed: folder.subscribed ? 1 : 0,
923
+ flags_json: JSON.stringify(folder.flags),
924
+ messages: folder.messages ?? null,
925
+ unseen: folder.unseen ?? null,
926
+ uid_next: folder.uidNext ?? null,
927
+ last_indexed_at: input.syncedAt,
928
+ last_indexed_count: folderStat?.fetched ?? null,
929
+ });
930
+ }
931
+ for (const folderStat of input.folderStats) {
932
+ upsertSyncState.run({
933
+ folder: folderStat.folder,
934
+ uid_validity: folderStat.uidValidity ?? null,
935
+ uid_next: folderStat.uidNext ?? null,
936
+ highest_uid: folderStat.highestUid ?? null,
937
+ last_sync_at: folderStat.lastSyncAt ?? input.syncedAt,
938
+ last_full_sync_at: folderStat.strategy === "full"
939
+ ? folderStat.lastFullSyncAt ?? folderStat.lastSyncAt ?? input.syncedAt
940
+ : folderStat.lastFullSyncAt ?? null,
941
+ strategy: folderStat.strategy ?? null,
942
+ changed: folderStat.changed ? 1 : 0,
943
+ fetched: folderStat.fetched ?? null,
944
+ total: folderStat.total ?? null,
945
+ });
946
+ }
947
+ for (const email of input.emails) {
948
+ upsertMessage.run({
949
+ email_id: email.id,
950
+ folder: email.folder,
951
+ uid: email.uid,
952
+ seq: email.seq,
953
+ message_id: email.messageId ?? null,
954
+ in_reply_to: email.inReplyTo ?? null,
955
+ references_json: JSON.stringify(email.references ?? []),
956
+ thread_id: email.threadId ?? null,
957
+ subject: email.subject,
958
+ from_json: JSON.stringify(email.from),
959
+ to_json: JSON.stringify(email.to),
960
+ cc_json: JSON.stringify(email.cc),
961
+ bcc_json: JSON.stringify(email.bcc),
962
+ reply_to_json: JSON.stringify(email.replyTo),
963
+ date: email.date ?? null,
964
+ internal_date: email.internalDate ?? null,
965
+ is_read: email.isRead ? 1 : 0,
966
+ is_starred: email.isStarred ? 1 : 0,
967
+ flags_json: JSON.stringify(email.flags),
968
+ size: email.size ?? null,
969
+ preview: email.preview ?? null,
970
+ has_attachments: email.hasAttachments ? 1 : 0,
971
+ attachments_json: JSON.stringify(email.attachments),
972
+ attachment_text: email.attachmentText ?? null,
973
+ labels_json: JSON.stringify(email.labels),
974
+ });
975
+ const search = emailToSearchParts(email);
976
+ deleteFts.run(email.id);
977
+ insertFts.run(email.id, email.subject, email.preview ?? "", email.folder, search.labels, search.participants, search.attachmentNames);
978
+ }
979
+ });
980
+ transaction();
981
+ }
982
+ runMigrations(db) {
983
+ db.exec(`
984
+ CREATE TABLE IF NOT EXISTS metadata (
985
+ key TEXT PRIMARY KEY,
986
+ value TEXT NOT NULL
987
+ );
988
+
989
+ CREATE TABLE IF NOT EXISTS folders (
990
+ path TEXT PRIMARY KEY,
991
+ name TEXT NOT NULL,
992
+ delimiter TEXT NOT NULL,
993
+ special_use TEXT,
994
+ listed INTEGER NOT NULL,
995
+ subscribed INTEGER NOT NULL,
996
+ flags_json TEXT NOT NULL,
997
+ messages INTEGER,
998
+ unseen INTEGER,
999
+ uid_next INTEGER,
1000
+ last_indexed_at TEXT,
1001
+ last_indexed_count INTEGER
1002
+ );
1003
+
1004
+ CREATE TABLE IF NOT EXISTS messages (
1005
+ email_id TEXT PRIMARY KEY,
1006
+ folder TEXT NOT NULL,
1007
+ uid INTEGER NOT NULL,
1008
+ seq INTEGER NOT NULL,
1009
+ message_id TEXT,
1010
+ in_reply_to TEXT,
1011
+ references_json TEXT NOT NULL DEFAULT '[]',
1012
+ thread_id TEXT,
1013
+ subject TEXT NOT NULL,
1014
+ from_json TEXT NOT NULL,
1015
+ to_json TEXT NOT NULL,
1016
+ cc_json TEXT NOT NULL,
1017
+ bcc_json TEXT NOT NULL,
1018
+ reply_to_json TEXT NOT NULL,
1019
+ date TEXT,
1020
+ internal_date TEXT,
1021
+ is_read INTEGER NOT NULL,
1022
+ is_starred INTEGER NOT NULL,
1023
+ flags_json TEXT NOT NULL,
1024
+ size INTEGER,
1025
+ preview TEXT,
1026
+ has_attachments INTEGER NOT NULL,
1027
+ attachments_json TEXT NOT NULL,
1028
+ attachment_text TEXT,
1029
+ labels_json TEXT NOT NULL
1030
+ );
1031
+
1032
+ CREATE TABLE IF NOT EXISTS sync_state (
1033
+ folder TEXT PRIMARY KEY,
1034
+ uid_validity TEXT,
1035
+ uid_next INTEGER,
1036
+ highest_uid INTEGER,
1037
+ last_sync_at TEXT,
1038
+ last_full_sync_at TEXT,
1039
+ strategy TEXT,
1040
+ changed INTEGER NOT NULL DEFAULT 0,
1041
+ fetched INTEGER,
1042
+ total INTEGER
1043
+ );
1044
+
1045
+ CREATE INDEX IF NOT EXISTS idx_messages_folder ON messages(folder);
1046
+ CREATE INDEX IF NOT EXISTS idx_messages_message_id ON messages(message_id);
1047
+ CREATE INDEX IF NOT EXISTS idx_messages_in_reply_to ON messages(in_reply_to);
1048
+ CREATE INDEX IF NOT EXISTS idx_messages_thread_id ON messages(thread_id);
1049
+ CREATE INDEX IF NOT EXISTS idx_messages_internal_date ON messages(internal_date);
1050
+
1051
+ CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5(
1052
+ email_id UNINDEXED,
1053
+ subject,
1054
+ preview,
1055
+ folder,
1056
+ labels,
1057
+ participants,
1058
+ attachment_names,
1059
+ tokenize = 'porter unicode61'
1060
+ );
1061
+ `);
1062
+ this.ensureMessagesColumns(db);
1063
+ }
1064
+ ensureMessagesColumns(db) {
1065
+ const columns = new Set(db.prepare(`PRAGMA table_info(messages)`).all().map((row) => row.name));
1066
+ if (!columns.has("references_json")) {
1067
+ db.exec(`ALTER TABLE messages ADD COLUMN references_json TEXT NOT NULL DEFAULT '[]'`);
1068
+ }
1069
+ if (!columns.has("attachment_text")) {
1070
+ db.exec(`ALTER TABLE messages ADD COLUMN attachment_text TEXT`);
1071
+ }
1072
+ }
1073
+ async maybeImportLegacyIndex(db) {
1074
+ const hasMetadata = db.prepare(`SELECT value FROM metadata WHERE key = 'schemaVersion'`).get();
1075
+ if (hasMetadata) {
1076
+ return;
1077
+ }
1078
+ try {
1079
+ const raw = await readFile(this.legacyIndexPath, "utf8");
1080
+ const legacy = JSON.parse(raw);
1081
+ this.log.info("Importing legacy JSON mailbox index into SQLite", "LocalIndexService", {
1082
+ legacyPath: this.legacyIndexPath,
1083
+ });
1084
+ this.applySnapshot(db, {
1085
+ folders: legacy.folders ?? [],
1086
+ emails: Object.values(legacy.messages ?? {}),
1087
+ syncedAt: legacy.updatedAt || new Date().toISOString(),
1088
+ folderStats: Object.values(legacy.indexedFolders ?? {}).map((entry) => ({
1089
+ folder: entry.path,
1090
+ fetched: entry.lastIndexedCount ?? 0,
1091
+ total: entry.messages ?? 0,
1092
+ })),
1093
+ }, lowerCaseAddress(this.config.smtp.username));
1094
+ }
1095
+ catch (error) {
1096
+ if (!(error &&
1097
+ typeof error === "object" &&
1098
+ "code" in error &&
1099
+ error.code === "ENOENT")) {
1100
+ this.log.warn("Failed to import legacy JSON mailbox index", "LocalIndexService", error);
1101
+ }
1102
+ const setMetadata = db.prepare(`
1103
+ INSERT INTO metadata (key, value) VALUES (?, ?)
1104
+ ON CONFLICT(key) DO UPDATE SET value = excluded.value
1105
+ `);
1106
+ setMetadata.run("schemaVersion", String(DB_SCHEMA_VERSION));
1107
+ setMetadata.run("ownerEmail", lowerCaseAddress(this.config.smtp.username) || "");
1108
+ setMetadata.run("updatedAt", "");
1109
+ }
1110
+ }
1111
+ loadCandidateEmails(db, filters, limitHint) {
1112
+ const sqlParts = [`SELECT * FROM messages`];
1113
+ const params = [];
1114
+ const conditions = [];
1115
+ if (filters.query) {
1116
+ const ftsIds = this.searchFtsIds(db, filters.query, limitHint);
1117
+ if (ftsIds.length === 0) {
1118
+ return [];
1119
+ }
1120
+ conditions.push(`email_id IN (${ftsIds.map(() => "?").join(", ")})`);
1121
+ params.push(...ftsIds);
1122
+ }
1123
+ if (filters.folder) {
1124
+ conditions.push(`folder = ?`);
1125
+ params.push(filters.folder);
1126
+ }
1127
+ if (typeof filters.isRead === "boolean") {
1128
+ conditions.push(`is_read = ?`);
1129
+ params.push(filters.isRead ? 1 : 0);
1130
+ }
1131
+ if (typeof filters.isStarred === "boolean") {
1132
+ conditions.push(`is_starred = ?`);
1133
+ params.push(filters.isStarred ? 1 : 0);
1134
+ }
1135
+ if (typeof filters.hasAttachment === "boolean") {
1136
+ conditions.push(`has_attachments = ?`);
1137
+ params.push(filters.hasAttachment ? 1 : 0);
1138
+ }
1139
+ if (filters.subject) {
1140
+ conditions.push(`LOWER(subject) LIKE ?`);
1141
+ params.push(`%${filters.subject.toLowerCase()}%`);
1142
+ }
1143
+ if (filters.senderDomain) {
1144
+ conditions.push(`LOWER(from_json) LIKE ?`);
1145
+ params.push(`%${filters.senderDomain.toLowerCase()}%`);
1146
+ }
1147
+ if (filters.threadId) {
1148
+ conditions.push(`thread_id = ?`);
1149
+ params.push(filters.threadId);
1150
+ }
1151
+ if (filters.dateFrom) {
1152
+ conditions.push(`COALESCE(internal_date, date) >= ?`);
1153
+ params.push(filters.dateFrom);
1154
+ }
1155
+ if (filters.dateTo) {
1156
+ conditions.push(`COALESCE(internal_date, date) <= ?`);
1157
+ params.push(filters.dateTo);
1158
+ }
1159
+ if (conditions.length > 0) {
1160
+ sqlParts.push(`WHERE ${conditions.join(" AND ")}`);
1161
+ }
1162
+ sqlParts.push(`ORDER BY COALESCE(internal_date, date) DESC, uid DESC LIMIT ?`);
1163
+ params.push(limitHint);
1164
+ return db
1165
+ .prepare(sqlParts.join(" "))
1166
+ .all(...params)
1167
+ .map((row) => this.rowToEmailSummary(row));
1168
+ }
1169
+ searchFtsIds(db, query, limit) {
1170
+ const parsed = parseSearchQuery(query);
1171
+ const match = parsed.residualTerms
1172
+ .map((token) => `"${token.replace(/"/g, '""')}"`)
1173
+ .join(" AND ");
1174
+ if (!match) {
1175
+ return [];
1176
+ }
1177
+ try {
1178
+ return db
1179
+ .prepare(`SELECT email_id FROM messages_fts WHERE messages_fts MATCH ? LIMIT ?`)
1180
+ .all(match, limit)
1181
+ .map((row) => String(row.email_id));
1182
+ }
1183
+ catch (error) {
1184
+ this.log.warn("FTS search failed, falling back to metadata scan", "LocalIndexService", {
1185
+ query,
1186
+ error,
1187
+ });
1188
+ return [];
1189
+ }
1190
+ }
1191
+ async loadSnapshot() {
1192
+ const db = await this.ensureDb();
1193
+ const metadataRows = db
1194
+ .prepare(`SELECT key, value FROM metadata`)
1195
+ .all();
1196
+ const metadata = Object.fromEntries(metadataRows.map((row) => [row.key, row.value]));
1197
+ const folders = db
1198
+ .prepare(`SELECT * FROM folders ORDER BY path ASC`)
1199
+ .all()
1200
+ .map((row) => this.rowToFolderInfo(row));
1201
+ const indexedFolders = db
1202
+ .prepare(`
1203
+ SELECT path, messages, unseen, special_use, last_indexed_at, last_indexed_count
1204
+ FROM folders
1205
+ ORDER BY path ASC
1206
+ `)
1207
+ .all()
1208
+ .map((row) => ({
1209
+ path: String(row.path),
1210
+ messages: row.messages,
1211
+ unseen: row.unseen,
1212
+ specialUse: row.special_use,
1213
+ lastIndexedAt: row.last_indexed_at,
1214
+ lastIndexedCount: row.last_indexed_count,
1215
+ }));
1216
+ const syncCheckpoints = db
1217
+ .prepare(`
1218
+ SELECT folder, uid_validity, uid_next, highest_uid, last_sync_at, last_full_sync_at, strategy, changed, fetched, total
1219
+ FROM sync_state
1220
+ ORDER BY folder ASC
1221
+ `)
1222
+ .all()
1223
+ .map((row) => ({
1224
+ folder: String(row.folder),
1225
+ uidValidity: row.uid_validity,
1226
+ uidNext: row.uid_next,
1227
+ highestUid: row.highest_uid,
1228
+ lastSyncAt: row.last_sync_at,
1229
+ lastFullSyncAt: row.last_full_sync_at,
1230
+ strategy: row.strategy,
1231
+ changed: Boolean(row.changed),
1232
+ fetched: row.fetched,
1233
+ total: row.total,
1234
+ }));
1235
+ const messages = db
1236
+ .prepare(`SELECT * FROM messages ORDER BY COALESCE(internal_date, date) DESC, uid DESC`)
1237
+ .all()
1238
+ .map((row) => this.rowToEmailSummary(row));
1239
+ return {
1240
+ ownerEmail: metadata.ownerEmail || undefined,
1241
+ updatedAt: metadata.updatedAt || undefined,
1242
+ folders,
1243
+ indexedFolders,
1244
+ syncCheckpoints,
1245
+ messages,
1246
+ };
1247
+ }
1248
+ rowToFolderInfo(row) {
1249
+ return {
1250
+ path: String(row.path),
1251
+ name: String(row.name),
1252
+ delimiter: String(row.delimiter),
1253
+ specialUse: typeof row.special_use === "string" ? row.special_use : undefined,
1254
+ listed: Boolean(row.listed),
1255
+ subscribed: Boolean(row.subscribed),
1256
+ flags: safeJsonParse(String(row.flags_json || "[]"), []),
1257
+ messages: typeof row.messages === "number" ? row.messages : undefined,
1258
+ unseen: typeof row.unseen === "number" ? row.unseen : undefined,
1259
+ uidNext: typeof row.uid_next === "number" ? row.uid_next : undefined,
1260
+ };
1261
+ }
1262
+ rowToEmailSummary(row) {
1263
+ return {
1264
+ id: row.email_id,
1265
+ folder: row.folder,
1266
+ uid: row.uid,
1267
+ seq: row.seq,
1268
+ messageId: row.message_id ?? undefined,
1269
+ inReplyTo: row.in_reply_to ?? undefined,
1270
+ references: safeJsonParse(row.references_json, []),
1271
+ threadId: row.thread_id ?? undefined,
1272
+ subject: row.subject,
1273
+ from: safeJsonParse(row.from_json, []),
1274
+ to: safeJsonParse(row.to_json, []),
1275
+ cc: safeJsonParse(row.cc_json, []),
1276
+ bcc: safeJsonParse(row.bcc_json, []),
1277
+ replyTo: safeJsonParse(row.reply_to_json, []),
1278
+ date: row.date ?? undefined,
1279
+ internalDate: row.internal_date ?? undefined,
1280
+ isRead: Boolean(row.is_read),
1281
+ isStarred: Boolean(row.is_starred),
1282
+ flags: safeJsonParse(row.flags_json, []),
1283
+ size: row.size ?? undefined,
1284
+ preview: row.preview ?? undefined,
1285
+ hasAttachments: Boolean(row.has_attachments),
1286
+ attachments: safeJsonParse(row.attachments_json, []),
1287
+ attachmentText: row.attachment_text ?? undefined,
1288
+ labels: safeJsonParse(row.labels_json, []),
1289
+ };
1290
+ }
1291
+ toStatus(snapshot) {
1292
+ const dedupedCount = dedupeEmails(snapshot.messages).length;
1293
+ const mailboxMessages = this.buildMailboxMessages(snapshot);
1294
+ const threadCount = this.buildThreads(snapshot).length;
1295
+ const labelCount = new Set(mailboxMessages.flatMap((message) => message.normalizedLabels.map((label) => label.toLowerCase()))).size;
1296
+ const ageMinutes = snapshot.updatedAt
1297
+ ? Math.max(0, Math.round((Date.now() - new Date(snapshot.updatedAt).getTime()) / 60_000))
1298
+ : undefined;
1299
+ return {
1300
+ path: this.dbPath,
1301
+ ownerEmail: snapshot.ownerEmail,
1302
+ updatedAt: snapshot.updatedAt,
1303
+ ageMinutes,
1304
+ staleThresholdMinutes: STALE_THRESHOLD_MINUTES,
1305
+ isStale: typeof ageMinutes === "number" ? ageMinutes > STALE_THRESHOLD_MINUTES : true,
1306
+ folderCount: snapshot.folders.length,
1307
+ labelCount,
1308
+ threadCount,
1309
+ storedMessageCount: snapshot.messages.length,
1310
+ dedupedMessageCount: dedupedCount,
1311
+ syncCheckpoints: snapshot.syncCheckpoints,
1312
+ folders: snapshot.indexedFolders,
1313
+ };
1314
+ }
1315
+ buildMailboxMessages(snapshot) {
1316
+ const foldersByPath = new Map(snapshot.folders.map((folder) => [folder.path, folder]));
1317
+ const groups = new Map();
1318
+ for (const email of snapshot.messages) {
1319
+ const key = canonicalMessageKey(email);
1320
+ const group = groups.get(key) ?? [];
1321
+ group.push({ email, folder: foldersByPath.get(email.folder) });
1322
+ groups.set(key, group);
1323
+ }
1324
+ return sortEmailsByNewest([...groups.entries()].map(([canonicalId, entries]) => {
1325
+ const primaryEntry = [...entries].sort((left, right) => locationScore(right) - locationScore(left))[0];
1326
+ const primary = primaryEntry.email;
1327
+ const primaryFolder = primaryEntry.folder;
1328
+ const locations = entries.map(({ email, folder }) => ({
1329
+ emailId: email.id,
1330
+ folder: email.folder,
1331
+ uid: email.uid,
1332
+ labels: [...email.labels],
1333
+ specialUse: folder?.specialUse,
1334
+ isRead: email.isRead,
1335
+ isStarred: email.isStarred,
1336
+ }));
1337
+ return {
1338
+ ...primary,
1339
+ canonicalId,
1340
+ primaryEmailId: primary.id,
1341
+ threadKey: threadKeyForEmail(primary),
1342
+ mailboxRole: specialUseToRole(primaryFolder?.specialUse, primary.folder),
1343
+ normalizedLabels: [...new Set(entries.flatMap((entry) => normalizedMailboxLabelsFor(entry.email, entry.folder)))]
1344
+ .sort((left, right) => left.localeCompare(right)),
1345
+ locations: locations.sort((left, right) => right.uid - left.uid),
1346
+ };
1347
+ }));
1348
+ }
1349
+ buildThreads(snapshot, includeMessages = false) {
1350
+ const messages = this.assignResolvedThreadKeys(this.buildMailboxMessages(snapshot), snapshot.ownerEmail);
1351
+ const groups = new Map();
1352
+ for (const message of messages) {
1353
+ const key = message.threadKey;
1354
+ const group = groups.get(key) ?? [];
1355
+ group.push(message);
1356
+ groups.set(key, group);
1357
+ }
1358
+ return [...groups.entries()]
1359
+ .map(([id, entries]) => {
1360
+ const sortedMessages = [...entries].sort((left, right) => {
1361
+ const leftTime = new Date(left.internalDate || left.date || 0).getTime();
1362
+ const rightTime = new Date(right.internalDate || right.date || 0).getTime();
1363
+ if (leftTime !== rightTime) {
1364
+ return leftTime - rightTime;
1365
+ }
1366
+ return left.uid - right.uid;
1367
+ });
1368
+ const latest = sortEmailsByNewest(sortedMessages)[0];
1369
+ const normalizedLabels = new Set();
1370
+ for (const message of sortedMessages) {
1371
+ for (const label of message.normalizedLabels) {
1372
+ normalizedLabels.add(label);
1373
+ }
1374
+ }
1375
+ const summary = {
1376
+ id,
1377
+ subject: latest ? normalizeSubjectForThread(latest.subject) : "(no subject)",
1378
+ messageCount: sortedMessages.length,
1379
+ unreadCount: sortedMessages.filter((message) => !message.isRead).length,
1380
+ latestDate: latest?.internalDate || latest?.date,
1381
+ participants: uniqueParticipants(sortedMessages),
1382
+ normalizedLabels: [...normalizedLabels].sort((left, right) => left.localeCompare(right)),
1383
+ messageIds: sortedMessages.map((message) => message.primaryEmailId),
1384
+ };
1385
+ if (!includeMessages) {
1386
+ return summary;
1387
+ }
1388
+ return {
1389
+ ...summary,
1390
+ messages: sortedMessages,
1391
+ };
1392
+ })
1393
+ .sort((left, right) => {
1394
+ const leftTime = new Date(left.latestDate || 0).getTime();
1395
+ const rightTime = new Date(right.latestDate || 0).getTime();
1396
+ if (rightTime !== leftTime) {
1397
+ return rightTime - leftTime;
1398
+ }
1399
+ return left.subject.localeCompare(right.subject);
1400
+ });
1401
+ }
1402
+ assignResolvedThreadKeys(messages, ownerEmail) {
1403
+ const byCanonicalId = new Map(messages.map((message) => [message.canonicalId, message]));
1404
+ const byMessageId = new Map(messages.flatMap((message) => {
1405
+ const normalized = normalizeMessageId(message.messageId);
1406
+ return normalized ? [[normalized, message]] : [];
1407
+ }));
1408
+ const resolvedKeys = new Map();
1409
+ const resolveThreadKey = (message, stack = new Set()) => {
1410
+ if (resolvedKeys.has(message.canonicalId)) {
1411
+ return resolvedKeys.get(message.canonicalId);
1412
+ }
1413
+ if (message.threadId?.trim()) {
1414
+ const direct = `imap:${message.threadId.trim()}`;
1415
+ resolvedKeys.set(message.canonicalId, direct);
1416
+ return direct;
1417
+ }
1418
+ if (stack.has(message.canonicalId)) {
1419
+ const fallback = fallbackThreadKey(message, ownerEmail);
1420
+ resolvedKeys.set(message.canonicalId, fallback);
1421
+ return fallback;
1422
+ }
1423
+ stack.add(message.canonicalId);
1424
+ const referenceCandidates = [
1425
+ normalizeMessageId(message.inReplyTo),
1426
+ ...[...extractMessageIdList(message.references)].reverse(),
1427
+ ].filter((value) => Boolean(value));
1428
+ for (const parentId of referenceCandidates) {
1429
+ const parent = byMessageId.get(parentId) ?? byCanonicalId.get(parentId);
1430
+ if (parent && parent.canonicalId !== message.canonicalId) {
1431
+ const resolved = resolveThreadKey(parent, stack);
1432
+ resolvedKeys.set(message.canonicalId, resolved);
1433
+ stack.delete(message.canonicalId);
1434
+ return resolved;
1435
+ }
1436
+ }
1437
+ const syntheticReferenceRoot = extractMessageIdList(message.references)[0] || normalizeMessageId(message.inReplyTo);
1438
+ if (syntheticReferenceRoot) {
1439
+ const synthetic = `ref:${syntheticReferenceRoot}`;
1440
+ resolvedKeys.set(message.canonicalId, synthetic);
1441
+ stack.delete(message.canonicalId);
1442
+ return synthetic;
1443
+ }
1444
+ const fallback = fallbackThreadKey(message, ownerEmail);
1445
+ resolvedKeys.set(message.canonicalId, fallback);
1446
+ stack.delete(message.canonicalId);
1447
+ return fallback;
1448
+ };
1449
+ return messages.map((message) => ({
1450
+ ...message,
1451
+ threadKey: resolveThreadKey(message),
1452
+ }));
1453
+ }
1454
+ closeDb() {
1455
+ if (this.db) {
1456
+ this.db.close();
1457
+ this.db = undefined;
1458
+ this.initialized = false;
1459
+ }
1460
+ }
1461
+ }
1462
+ //# sourceMappingURL=local-index-service.js.map