read-email-mcp 1.0.0 → 2.0.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/store.js CHANGED
@@ -15,67 +15,67 @@ export class EmailStore {
15
15
  this.connection.pragma("busy_timeout = 5000");
16
16
  this.connection.pragma("temp_store = MEMORY");
17
17
  this.connection.pragma("foreign_keys = ON");
18
- this.connection.exec(`
19
- CREATE TABLE IF NOT EXISTS mailboxes (
20
- path TEXT PRIMARY KEY,
21
- delimiter TEXT NOT NULL,
22
- uid_validity TEXT,
23
- highest_uid INTEGER NOT NULL DEFAULT 0,
24
- selectable INTEGER NOT NULL DEFAULT 1,
25
- last_synced_at TEXT
26
- );
27
-
28
- CREATE TABLE IF NOT EXISTS messages (
29
- id INTEGER PRIMARY KEY AUTOINCREMENT,
30
- mailbox TEXT NOT NULL,
31
- uid INTEGER NOT NULL,
32
- uid_validity TEXT NOT NULL,
33
- message_id TEXT,
34
- in_reply_to TEXT,
35
- refs_json TEXT NOT NULL,
36
- subject TEXT,
37
- date TEXT NOT NULL,
38
- internal_date TEXT,
39
- from_json TEXT NOT NULL,
40
- to_json TEXT NOT NULL,
41
- cc_json TEXT NOT NULL,
42
- bcc_json TEXT NOT NULL,
43
- flags_json TEXT NOT NULL,
44
- size INTEGER,
45
- has_attachments INTEGER NOT NULL DEFAULT 0,
46
- attachment_count INTEGER NOT NULL DEFAULT 0,
47
- preview TEXT,
48
- text TEXT,
49
- html TEXT,
50
- headers_json TEXT NOT NULL,
51
- attachments_json TEXT NOT NULL,
52
- raw_path TEXT NOT NULL,
53
- parse_error TEXT,
54
- updated_at TEXT NOT NULL,
55
- UNIQUE(mailbox, uid)
56
- );
57
-
58
- CREATE INDEX IF NOT EXISTS idx_messages_date ON messages(date, id);
59
- CREATE INDEX IF NOT EXISTS idx_messages_mailbox_uid ON messages(mailbox, uid);
60
- CREATE INDEX IF NOT EXISTS idx_messages_message_id ON messages(message_id);
61
- CREATE INDEX IF NOT EXISTS idx_messages_in_reply_to ON messages(in_reply_to);
62
-
63
- CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5(
64
- subject,
65
- addresses,
66
- body,
67
- preview
68
- );
69
-
70
- CREATE TABLE IF NOT EXISTS sync_runs (
71
- id INTEGER PRIMARY KEY AUTOINCREMENT,
72
- ok INTEGER NOT NULL,
73
- mailbox TEXT,
74
- started_at TEXT NOT NULL,
75
- finished_at TEXT NOT NULL,
76
- message TEXT NOT NULL,
77
- synced_messages INTEGER NOT NULL
78
- );
18
+ this.connection.exec(`
19
+ CREATE TABLE IF NOT EXISTS mailboxes (
20
+ path TEXT PRIMARY KEY,
21
+ delimiter TEXT NOT NULL,
22
+ uid_validity TEXT,
23
+ highest_uid INTEGER NOT NULL DEFAULT 0,
24
+ selectable INTEGER NOT NULL DEFAULT 1,
25
+ last_synced_at TEXT
26
+ );
27
+
28
+ CREATE TABLE IF NOT EXISTS messages (
29
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
30
+ mailbox TEXT NOT NULL,
31
+ uid INTEGER NOT NULL,
32
+ uid_validity TEXT NOT NULL,
33
+ message_id TEXT,
34
+ in_reply_to TEXT,
35
+ refs_json TEXT NOT NULL,
36
+ subject TEXT,
37
+ date TEXT NOT NULL,
38
+ internal_date TEXT,
39
+ from_json TEXT NOT NULL,
40
+ to_json TEXT NOT NULL,
41
+ cc_json TEXT NOT NULL,
42
+ bcc_json TEXT NOT NULL,
43
+ flags_json TEXT NOT NULL,
44
+ size INTEGER,
45
+ has_attachments INTEGER NOT NULL DEFAULT 0,
46
+ attachment_count INTEGER NOT NULL DEFAULT 0,
47
+ preview TEXT,
48
+ text TEXT,
49
+ html TEXT,
50
+ headers_json TEXT NOT NULL,
51
+ attachments_json TEXT NOT NULL,
52
+ raw_path TEXT NOT NULL,
53
+ parse_error TEXT,
54
+ updated_at TEXT NOT NULL,
55
+ UNIQUE(mailbox, uid)
56
+ );
57
+
58
+ CREATE INDEX IF NOT EXISTS idx_messages_date ON messages(date, id);
59
+ CREATE INDEX IF NOT EXISTS idx_messages_mailbox_uid ON messages(mailbox, uid);
60
+ CREATE INDEX IF NOT EXISTS idx_messages_message_id ON messages(message_id);
61
+ CREATE INDEX IF NOT EXISTS idx_messages_in_reply_to ON messages(in_reply_to);
62
+
63
+ CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5(
64
+ subject,
65
+ addresses,
66
+ body,
67
+ preview
68
+ );
69
+
70
+ CREATE TABLE IF NOT EXISTS sync_runs (
71
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
72
+ ok INTEGER NOT NULL,
73
+ mailbox TEXT,
74
+ started_at TEXT NOT NULL,
75
+ finished_at TEXT NOT NULL,
76
+ message TEXT NOT NULL,
77
+ synced_messages INTEGER NOT NULL
78
+ );
79
79
  `);
80
80
  this.connection.pragma("optimize=0x10002");
81
81
  }
@@ -88,15 +88,15 @@ export class EmailStore {
88
88
  }
89
89
  upsertMailbox(mailbox) {
90
90
  this.connection
91
- .prepare(`
92
- INSERT INTO mailboxes(path, delimiter, uid_validity, highest_uid, selectable, last_synced_at)
93
- VALUES (@path, @delimiter, @uidValidity, @highestUid, @selectable, @lastSyncedAt)
94
- ON CONFLICT(path) DO UPDATE SET
95
- delimiter = excluded.delimiter,
96
- uid_validity = excluded.uid_validity,
97
- highest_uid = excluded.highest_uid,
98
- selectable = excluded.selectable,
99
- last_synced_at = excluded.last_synced_at
91
+ .prepare(`
92
+ INSERT INTO mailboxes(path, delimiter, uid_validity, highest_uid, selectable, last_synced_at)
93
+ VALUES (@path, @delimiter, @uidValidity, @highestUid, @selectable, @lastSyncedAt)
94
+ ON CONFLICT(path) DO UPDATE SET
95
+ delimiter = excluded.delimiter,
96
+ uid_validity = excluded.uid_validity,
97
+ highest_uid = excluded.highest_uid,
98
+ selectable = excluded.selectable,
99
+ last_synced_at = excluded.last_synced_at
100
100
  `)
101
101
  .run({
102
102
  path: mailbox.path,
@@ -109,19 +109,19 @@ export class EmailStore {
109
109
  }
110
110
  listMailboxes() {
111
111
  const rows = this.connection
112
- .prepare(`
113
- SELECT
114
- mailboxes.path,
115
- mailboxes.delimiter,
116
- mailboxes.uid_validity AS uidValidity,
117
- mailboxes.highest_uid AS highestUid,
118
- mailboxes.selectable,
119
- mailboxes.last_synced_at AS lastSyncedAt,
120
- COUNT(messages.id) AS messageCount
121
- FROM mailboxes
122
- LEFT JOIN messages ON messages.mailbox = mailboxes.path
123
- GROUP BY mailboxes.path
124
- ORDER BY mailboxes.path ASC
112
+ .prepare(`
113
+ SELECT
114
+ mailboxes.path,
115
+ mailboxes.delimiter,
116
+ mailboxes.uid_validity AS uidValidity,
117
+ mailboxes.highest_uid AS highestUid,
118
+ mailboxes.selectable,
119
+ mailboxes.last_synced_at AS lastSyncedAt,
120
+ COUNT(messages.id) AS messageCount
121
+ FROM mailboxes
122
+ LEFT JOIN messages ON messages.mailbox = mailboxes.path
123
+ GROUP BY mailboxes.path
124
+ ORDER BY mailboxes.path ASC
125
125
  `)
126
126
  .all();
127
127
  return rows.map((row) => ({
@@ -136,11 +136,11 @@ export class EmailStore {
136
136
  }
137
137
  getMailbox(mailboxPath) {
138
138
  const row = this.connection
139
- .prepare(`
140
- SELECT path, delimiter, uid_validity AS uidValidity, highest_uid AS highestUid,
141
- selectable, last_synced_at AS lastSyncedAt
142
- FROM mailboxes
143
- WHERE path = ?
139
+ .prepare(`
140
+ SELECT path, delimiter, uid_validity AS uidValidity, highest_uid AS highestUid,
141
+ selectable, last_synced_at AS lastSyncedAt
142
+ FROM mailboxes
143
+ WHERE path = ?
144
144
  `)
145
145
  .get(mailboxPath);
146
146
  if (!row) {
@@ -156,7 +156,9 @@ export class EmailStore {
156
156
  };
157
157
  }
158
158
  deleteMailboxMessages(mailboxPath) {
159
- const rows = this.connection.prepare("SELECT id FROM messages WHERE mailbox = ?").all(mailboxPath);
159
+ const rows = this.connection
160
+ .prepare("SELECT id, raw_path AS rawPath FROM messages WHERE mailbox = ?")
161
+ .all(mailboxPath);
160
162
  const transaction = this.connection.transaction(() => {
161
163
  for (const row of rows) {
162
164
  this.connection.prepare("DELETE FROM messages_fts WHERE rowid = ?").run(row.id);
@@ -164,6 +166,32 @@ export class EmailStore {
164
166
  this.connection.prepare("DELETE FROM messages WHERE mailbox = ?").run(mailboxPath);
165
167
  });
166
168
  transaction();
169
+ return rows.map((row) => row.rawPath);
170
+ }
171
+ updateMessageFlags(mailbox, uid, flags) {
172
+ const result = this.connection
173
+ .prepare("UPDATE messages SET flags_json = @flags, updated_at = @updatedAt WHERE mailbox = @mailbox AND uid = @uid")
174
+ .run({ flags: JSON.stringify(flags), updatedAt: new Date().toISOString(), mailbox, uid });
175
+ return result.changes > 0;
176
+ }
177
+ pruneMailboxMessagesExcept(mailboxPath, keepUids, maxUid) {
178
+ const rows = this.connection
179
+ .prepare("SELECT id, uid, raw_path AS rawPath FROM messages WHERE mailbox = ? AND uid <= ?")
180
+ .all(mailboxPath, maxUid);
181
+ const stale = rows.filter((row) => !keepUids.has(row.uid));
182
+ if (stale.length === 0) {
183
+ return [];
184
+ }
185
+ const transaction = this.connection.transaction(() => {
186
+ const deleteFts = this.connection.prepare("DELETE FROM messages_fts WHERE rowid = ?");
187
+ const deleteMessage = this.connection.prepare("DELETE FROM messages WHERE id = ?");
188
+ for (const row of stale) {
189
+ deleteFts.run(row.id);
190
+ deleteMessage.run(row.id);
191
+ }
192
+ });
193
+ transaction();
194
+ return stale.map((row) => row.rawPath);
167
195
  }
168
196
  upsertMessage(message) {
169
197
  const transaction = this.connection.transaction((input) => {
@@ -174,51 +202,51 @@ export class EmailStore {
174
202
  let id;
175
203
  if (existing) {
176
204
  this.connection
177
- .prepare(`
178
- UPDATE messages SET
179
- uid_validity = @uidValidity,
180
- message_id = @messageId,
181
- in_reply_to = @inReplyTo,
182
- refs_json = @refsJson,
183
- subject = @subject,
184
- date = @date,
185
- internal_date = @internalDate,
186
- from_json = @fromJson,
187
- to_json = @toJson,
188
- cc_json = @ccJson,
189
- bcc_json = @bccJson,
190
- flags_json = @flagsJson,
191
- size = @size,
192
- has_attachments = @hasAttachments,
193
- attachment_count = @attachmentCount,
194
- preview = @preview,
195
- text = @text,
196
- html = @html,
197
- headers_json = @headersJson,
198
- attachments_json = @attachmentsJson,
199
- raw_path = @rawPath,
200
- parse_error = @parseError,
201
- updated_at = @updatedAt
202
- WHERE id = @id
205
+ .prepare(`
206
+ UPDATE messages SET
207
+ uid_validity = @uidValidity,
208
+ message_id = @messageId,
209
+ in_reply_to = @inReplyTo,
210
+ refs_json = @refsJson,
211
+ subject = @subject,
212
+ date = @date,
213
+ internal_date = @internalDate,
214
+ from_json = @fromJson,
215
+ to_json = @toJson,
216
+ cc_json = @ccJson,
217
+ bcc_json = @bccJson,
218
+ flags_json = @flagsJson,
219
+ size = @size,
220
+ has_attachments = @hasAttachments,
221
+ attachment_count = @attachmentCount,
222
+ preview = @preview,
223
+ text = @text,
224
+ html = @html,
225
+ headers_json = @headersJson,
226
+ attachments_json = @attachmentsJson,
227
+ raw_path = @rawPath,
228
+ parse_error = @parseError,
229
+ updated_at = @updatedAt
230
+ WHERE id = @id
203
231
  `)
204
232
  .run({ ...payload, id: existing.id });
205
233
  id = existing.id;
206
234
  }
207
235
  else {
208
236
  const result = this.connection
209
- .prepare(`
210
- INSERT INTO messages(
211
- mailbox, uid, uid_validity, message_id, in_reply_to, refs_json, subject, date,
212
- internal_date, from_json, to_json, cc_json, bcc_json, flags_json, size,
213
- has_attachments, attachment_count, preview, text, html, headers_json,
214
- attachments_json, raw_path, parse_error, updated_at
215
- )
216
- VALUES (
217
- @mailbox, @uid, @uidValidity, @messageId, @inReplyTo, @refsJson, @subject, @date,
218
- @internalDate, @fromJson, @toJson, @ccJson, @bccJson, @flagsJson, @size,
219
- @hasAttachments, @attachmentCount, @preview, @text, @html, @headersJson,
220
- @attachmentsJson, @rawPath, @parseError, @updatedAt
221
- )
237
+ .prepare(`
238
+ INSERT INTO messages(
239
+ mailbox, uid, uid_validity, message_id, in_reply_to, refs_json, subject, date,
240
+ internal_date, from_json, to_json, cc_json, bcc_json, flags_json, size,
241
+ has_attachments, attachment_count, preview, text, html, headers_json,
242
+ attachments_json, raw_path, parse_error, updated_at
243
+ )
244
+ VALUES (
245
+ @mailbox, @uid, @uidValidity, @messageId, @inReplyTo, @refsJson, @subject, @date,
246
+ @internalDate, @fromJson, @toJson, @ccJson, @bccJson, @flagsJson, @size,
247
+ @hasAttachments, @attachmentCount, @preview, @text, @html, @headersJson,
248
+ @attachmentsJson, @rawPath, @parseError, @updatedAt
249
+ )
222
250
  `)
223
251
  .run(payload);
224
252
  id = Number(result.lastInsertRowid);
@@ -250,6 +278,16 @@ export class EmailStore {
250
278
  .get(messageId);
251
279
  return row ? rowToMessage(row) : null;
252
280
  }
281
+ getMessagesByIds(ids) {
282
+ if (ids.length === 0) {
283
+ return [];
284
+ }
285
+ const placeholders = ids.map(() => "?").join(", ");
286
+ const rows = this.connection
287
+ .prepare(`SELECT * FROM messages WHERE id IN (${placeholders}) ORDER BY date ASC, id ASC`)
288
+ .all(...ids);
289
+ return rows.map(rowToMessage);
290
+ }
253
291
  getThread(messageIdOrInternalId) {
254
292
  const root = typeof messageIdOrInternalId === "number"
255
293
  ? this.getMessageById(messageIdOrInternalId)
@@ -258,14 +296,48 @@ export class EmailStore {
258
296
  return [];
259
297
  }
260
298
  const identifiers = new Set([root.messageId, root.inReplyTo, ...root.references].filter((value) => Boolean(value)));
299
+ const found = new Map([[root.id, root]]);
261
300
  if (identifiers.size === 0) {
262
301
  return [root];
263
302
  }
264
- const all = this.connection.prepare("SELECT * FROM messages ORDER BY date ASC, id ASC").all().map(rowToMessage);
265
- return all.filter((message) => {
266
- const messageIdentifiers = [message.messageId, message.inReplyTo, ...message.references].filter((value) => Boolean(value));
267
- return messageIdentifiers.some((value) => identifiers.has(value));
268
- });
303
+ for (let round = 0; round < 10 && identifiers.size <= 500; round += 1) {
304
+ const batch = this.findMessagesReferencing([...identifiers]);
305
+ const fresh = batch.filter((message) => !found.has(message.id));
306
+ if (fresh.length === 0) {
307
+ break;
308
+ }
309
+ for (const message of fresh) {
310
+ found.set(message.id, message);
311
+ for (const identifier of [message.messageId, message.inReplyTo, ...message.references]) {
312
+ if (identifier) {
313
+ identifiers.add(identifier);
314
+ }
315
+ }
316
+ }
317
+ }
318
+ return [...found.values()].sort((a, b) => (a.date === b.date ? a.id - b.id : a.date.localeCompare(b.date)));
319
+ }
320
+ findMessagesReferencing(identifiers) {
321
+ if (identifiers.length === 0) {
322
+ return [];
323
+ }
324
+ const rows = [];
325
+ for (let index = 0; index < identifiers.length; index += 200) {
326
+ const chunk = identifiers.slice(index, index + 200);
327
+ const placeholders = chunk.map(() => "?").join(", ");
328
+ rows.push(...this.connection
329
+ .prepare(`
330
+ SELECT * FROM messages
331
+ WHERE message_id IN (${placeholders})
332
+ OR in_reply_to IN (${placeholders})
333
+ OR EXISTS (
334
+ SELECT 1 FROM json_each(messages.refs_json) ref
335
+ WHERE ref.value IN (${placeholders})
336
+ )
337
+ `)
338
+ .all(...chunk, ...chunk, ...chunk));
339
+ }
340
+ return rows.map(rowToMessage);
269
341
  }
270
342
  getDateBuckets(options) {
271
343
  const params = {};
@@ -282,9 +354,9 @@ export class EmailStore {
282
354
  }
283
355
  recordSyncResult(result) {
284
356
  this.connection
285
- .prepare(`
286
- INSERT INTO sync_runs(ok, mailbox, started_at, finished_at, message, synced_messages)
287
- VALUES (@ok, @mailbox, @startedAt, @finishedAt, @message, @syncedMessages)
357
+ .prepare(`
358
+ INSERT INTO sync_runs(ok, mailbox, started_at, finished_at, message, synced_messages)
359
+ VALUES (@ok, @mailbox, @startedAt, @finishedAt, @message, @syncedMessages)
288
360
  `)
289
361
  .run({
290
362
  ok: result.ok ? 1 : 0,
@@ -295,15 +367,79 @@ export class EmailStore {
295
367
  syncedMessages: result.syncedMessages
296
368
  });
297
369
  }
370
+ getMessageCounts() {
371
+ const row = this.connection
372
+ .prepare(`
373
+ SELECT
374
+ COUNT(*) AS total,
375
+ SUM(CASE WHEN NOT EXISTS (SELECT 1 FROM json_each(m.flags_json) f WHERE f.value = '\\Seen') THEN 1 ELSE 0 END) AS unread,
376
+ SUM(CASE WHEN EXISTS (SELECT 1 FROM json_each(m.flags_json) f WHERE f.value = '\\Flagged') THEN 1 ELSE 0 END) AS flagged
377
+ FROM messages m
378
+ `)
379
+ .get();
380
+ return { total: Number(row.total), unread: Number(row.unread ?? 0), flagged: Number(row.flagged ?? 0) };
381
+ }
382
+ getTopSenders(options = {}) {
383
+ const limit = normalizeLimit(options.limit);
384
+ const unreadClause = options.unreadOnly
385
+ ? "AND NOT EXISTS (SELECT 1 FROM json_each(m.flags_json) f WHERE f.value = '\\Seen')"
386
+ : "";
387
+ const rows = this.connection
388
+ .prepare(`
389
+ SELECT
390
+ LOWER(json_extract(sender.value, '$.address')) AS address,
391
+ MAX(NULLIF(json_extract(sender.value, '$.name'), '')) AS name,
392
+ COUNT(*) AS count,
393
+ SUM(CASE WHEN NOT EXISTS (SELECT 1 FROM json_each(m.flags_json) f WHERE f.value = '\\Seen') THEN 1 ELSE 0 END) AS unreadCount
394
+ FROM messages m, json_each(m.from_json) AS sender
395
+ WHERE json_extract(sender.value, '$.address') IS NOT NULL
396
+ ${unreadClause}
397
+ GROUP BY address
398
+ ORDER BY count DESC, address ASC
399
+ LIMIT @limit
400
+ `)
401
+ .all({ limit });
402
+ return rows.map((row) => ({
403
+ address: String(row.address),
404
+ name: nullableString(row.name),
405
+ count: Number(row.count),
406
+ unreadCount: Number(row.unreadCount ?? 0)
407
+ }));
408
+ }
409
+ searchRanked(filters, paging) {
410
+ const params = {
411
+ ftsQuery: toFtsQuery(filters.query ?? ""),
412
+ limit: paging.limit + 1,
413
+ offset: Math.max(Math.trunc(paging.offset), 0)
414
+ };
415
+ const where = this.filterClauses(filters, params);
416
+ where.push("messages_fts MATCH @ftsQuery");
417
+ const rows = this.connection
418
+ .prepare(`
419
+ SELECT m.*, bm25(messages_fts) AS rank
420
+ FROM messages m
421
+ JOIN messages_fts ON messages_fts.rowid = m.id
422
+ WHERE ${where.join(" AND ")}
423
+ ORDER BY rank ASC, m.id ASC
424
+ LIMIT @limit OFFSET @offset
425
+ `)
426
+ .all(params);
427
+ const hasMore = rows.length > paging.limit;
428
+ const pageRows = hasMore ? rows.slice(0, paging.limit) : rows;
429
+ return {
430
+ items: pageRows.map((row) => ({ ...messageToSummary(rowToMessage(row)), rank: Number(row.rank) })),
431
+ hasMore
432
+ };
433
+ }
298
434
  getSyncStatus() {
299
435
  const total = this.connection.prepare("SELECT COUNT(*) AS count FROM messages").get();
300
436
  const latest = this.connection.prepare("SELECT MAX(date) AS latest FROM messages").get();
301
437
  const last = this.connection
302
- .prepare(`
303
- SELECT ok, mailbox, started_at AS startedAt, finished_at AS finishedAt, message, synced_messages AS syncedMessages
304
- FROM sync_runs
305
- ORDER BY id DESC
306
- LIMIT 1
438
+ .prepare(`
439
+ SELECT ok, mailbox, started_at AS startedAt, finished_at AS finishedAt, message, synced_messages AS syncedMessages
440
+ FROM sync_runs
441
+ ORDER BY id DESC
442
+ LIMIT 1
307
443
  `)
308
444
  .get();
309
445
  return {
@@ -358,11 +494,11 @@ export class EmailStore {
358
494
  }
359
495
  const direction = sort === "date_asc" ? "ASC" : "DESC";
360
496
  const rows = this.connection
361
- .prepare(`
362
- SELECT m.* FROM messages m
363
- ${where.length ? `WHERE ${where.join(" AND ")}` : ""}
364
- ORDER BY m.date ${direction}, m.id ${direction}
365
- LIMIT @limit
497
+ .prepare(`
498
+ SELECT m.* FROM messages m
499
+ ${where.length ? `WHERE ${where.join(" AND ")}` : ""}
500
+ ORDER BY m.date ${direction}, m.id ${direction}
501
+ LIMIT @limit
366
502
  `)
367
503
  .all(params);
368
504
  const hasMore = rows.length > limit;
@@ -379,6 +515,13 @@ export class EmailStore {
379
515
  params.mailbox = filters.mailbox;
380
516
  clauses.push(`${prefix}mailbox = @mailbox`);
381
517
  }
518
+ if (filters.mailboxes?.length) {
519
+ const placeholders = filters.mailboxes.map((_, index) => `@mailboxes${index}`);
520
+ filters.mailboxes.forEach((mailbox, index) => {
521
+ params[`mailboxes${index}`] = mailbox;
522
+ });
523
+ clauses.push(`${prefix}mailbox IN (${placeholders.join(", ")})`);
524
+ }
382
525
  if (filters.from) {
383
526
  params.from = `%${filters.from.toLowerCase()}%`;
384
527
  clauses.push(`LOWER(${prefix}from_json) LIKE @from`);
@@ -396,11 +539,11 @@ export class EmailStore {
396
539
  clauses.push(`LOWER(${prefix}subject) LIKE @subject`);
397
540
  }
398
541
  if (filters.startDate) {
399
- params.startDate = filters.startDate;
542
+ params.startDate = normalizeFilterDate(filters.startDate);
400
543
  clauses.push(`${prefix}date >= @startDate`);
401
544
  }
402
545
  if (filters.endDate) {
403
- params.endDate = filters.endDate;
546
+ params.endDate = normalizeFilterDate(filters.endDate);
404
547
  clauses.push(`${prefix}date <= @endDate`);
405
548
  }
406
549
  if (filters.hasAttachments !== undefined) {
@@ -457,9 +600,9 @@ export class EmailStore {
457
600
  ].join(" ");
458
601
  this.connection.prepare("DELETE FROM messages_fts WHERE rowid = ?").run(id);
459
602
  this.connection
460
- .prepare(`
461
- INSERT INTO messages_fts(rowid, subject, addresses, body, preview)
462
- VALUES (?, ?, ?, ?, ?)
603
+ .prepare(`
604
+ INSERT INTO messages_fts(rowid, subject, addresses, body, preview)
605
+ VALUES (?, ?, ?, ?, ?)
463
606
  `)
464
607
  .run(id, message.subject ?? "", addresses, message.text ?? "", message.preview ?? "");
465
608
  }
@@ -539,10 +682,10 @@ function normalizeLimit(limit) {
539
682
  }
540
683
  return Math.min(Math.max(Math.trunc(limit ?? 25), 1), 100);
541
684
  }
542
- function encodeCursor(message, sort) {
685
+ export function encodeCursor(message, sort) {
543
686
  return Buffer.from(JSON.stringify({ date: message.date, id: message.id, sort }), "utf8").toString("base64url");
544
687
  }
545
- function decodeCursor(cursor) {
688
+ export function decodeCursor(cursor) {
546
689
  if (!cursor) {
547
690
  return null;
548
691
  }
@@ -567,6 +710,10 @@ function fileSize(filePath) {
567
710
  return 0;
568
711
  }
569
712
  }
713
+ function normalizeFilterDate(value) {
714
+ const parsed = new Date(value);
715
+ return Number.isNaN(parsed.getTime()) ? value : parsed.toISOString();
716
+ }
570
717
  function toFtsQuery(query) {
571
718
  const terms = query
572
719
  .trim()