@bobfrankston/mailx-store 0.1.3 → 0.1.7

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 (5) hide show
  1. package/db.d.ts +282 -4
  2. package/db.js +1513 -57
  3. package/file-store.d.ts +45 -13
  4. package/file-store.js +108 -43
  5. package/package.json +13 -9
package/db.js CHANGED
@@ -3,9 +3,65 @@
3
3
  * Stores message headers, folder structure, sync state.
4
4
  * Message bodies are NOT here -- they live in the MessageStore backend.
5
5
  */
6
- import Database from "better-sqlite3";
6
+ import { DatabaseSync } from "node:sqlite";
7
+ import { randomUUID } from "node:crypto";
7
8
  import * as path from "node:path";
8
9
  import * as fs from "node:fs";
10
+ import { CONTACT_RULES } from "@bobfrankston/mailx-types";
11
+ /** Addresses that have no business in autocomplete. Patterns load from
12
+ * contact-rules.jsonc in mailx-types — a single source of truth shipped
13
+ * with every release. To change the rules edit the JSONC and rebuild. */
14
+ const JUNK_LOCAL_RE = new RegExp(CONTACT_RULES.junk.localExact, "i");
15
+ const JUNK_LOCAL_SUFFIX_RE = new RegExp(CONTACT_RULES.junk.localSuffix, "i");
16
+ const JUNK_LOCAL_PREFIX_RE = new RegExp(CONTACT_RULES.junk.localPrefix, "i");
17
+ const JUNK_DOMAIN_RE = new RegExp(CONTACT_RULES.junk.domain, "i");
18
+ const JUNK_LOCAL_ONEOFF_RE = new RegExp(CONTACT_RULES.junk.localOneoff, "i");
19
+ /** Optional user-configurable pattern list, refreshed when contacts.jsonc
20
+ * reloads. Each entry is a regex compiled lazily in setContactsDenyPatterns. */
21
+ let _denyPatterns = [];
22
+ function isJunkContact(email, name) {
23
+ const lower = (email || "").toLowerCase();
24
+ const at = lower.indexOf("@");
25
+ const local = at >= 0 ? lower.slice(0, at) : lower;
26
+ const domain = at >= 0 ? lower.slice(at + 1) : "";
27
+ if (JUNK_LOCAL_RE.test(local))
28
+ return true;
29
+ if (JUNK_LOCAL_SUFFIX_RE.test(local))
30
+ return true;
31
+ if (JUNK_LOCAL_PREFIX_RE.test(local))
32
+ return true;
33
+ if (JUNK_LOCAL_ONEOFF_RE.test(local))
34
+ return true;
35
+ if (domain && JUNK_DOMAIN_RE.test(domain))
36
+ return true;
37
+ // Bare numeric / hex addresses (rotating IDs from automated systems)
38
+ // — three or fewer chars is too short to be useful regardless.
39
+ if (local.length < 2)
40
+ return true;
41
+ const lname = (name || "").trim().toLowerCase();
42
+ if (lname.includes("mailer-daemon") || lname.includes("postmaster"))
43
+ return true;
44
+ for (const re of _denyPatterns) {
45
+ if (re.test(lower))
46
+ return true;
47
+ }
48
+ return false;
49
+ }
50
+ /** User-configured patterns from contacts.jsonc `denylistPatterns`. Compiled
51
+ * once on settings load; invalid regexes are skipped with a warning. */
52
+ export function setContactsDenyPatterns(patterns) {
53
+ _denyPatterns = [];
54
+ for (const p of patterns) {
55
+ if (!p)
56
+ continue;
57
+ try {
58
+ _denyPatterns.push(new RegExp(p, "i"));
59
+ }
60
+ catch (e) {
61
+ console.warn(`[contacts] invalid denylistPattern "${p}": ${e.message}`);
62
+ }
63
+ }
64
+ }
9
65
  const SCHEMA = `
10
66
  CREATE TABLE IF NOT EXISTS accounts (
11
67
  id TEXT PRIMARY KEY,
@@ -37,6 +93,7 @@ const SCHEMA = `
37
93
  message_id TEXT,
38
94
  in_reply_to TEXT,
39
95
  refs TEXT,
96
+ thread_id TEXT,
40
97
  date INTEGER NOT NULL,
41
98
  subject TEXT DEFAULT '',
42
99
  from_address TEXT DEFAULT '',
@@ -58,6 +115,19 @@ const SCHEMA = `
58
115
  CREATE INDEX IF NOT EXISTS idx_messages_message_id
59
116
  ON messages(message_id);
60
117
 
118
+ -- Note: idx_messages_thread_id is created by the addColumnIfMissing migration
119
+ -- in the constructor, AFTER thread_id is guaranteed to exist. Including it
120
+ -- here would crash startup on any pre-thread_id DB because exec(SCHEMA) runs
121
+ -- before the column-add migration.
122
+
123
+ CREATE TABLE IF NOT EXISTS sent_log (
124
+ message_id TEXT PRIMARY KEY,
125
+ account_id TEXT NOT NULL,
126
+ subject TEXT DEFAULT '',
127
+ recipients TEXT DEFAULT '',
128
+ sent_at INTEGER NOT NULL
129
+ );
130
+
61
131
  CREATE TABLE IF NOT EXISTS queue (
62
132
  id INTEGER PRIMARY KEY AUTOINCREMENT,
63
133
  status TEXT NOT NULL DEFAULT 'pending',
@@ -79,7 +149,7 @@ const SCHEMA = `
79
149
 
80
150
  CREATE TABLE IF NOT EXISTS contacts (
81
151
  id INTEGER PRIMARY KEY AUTOINCREMENT,
82
- source TEXT NOT NULL DEFAULT 'sent',
152
+ source TEXT NOT NULL DEFAULT 'discovered',
83
153
  google_id TEXT,
84
154
  name TEXT DEFAULT '',
85
155
  email TEXT NOT NULL,
@@ -87,7 +157,7 @@ const SCHEMA = `
87
157
  last_used INTEGER DEFAULT 0,
88
158
  use_count INTEGER DEFAULT 0,
89
159
  updated_at INTEGER NOT NULL,
90
- UNIQUE(email)
160
+ UNIQUE(source, email, name)
91
161
  );
92
162
 
93
163
  CREATE INDEX IF NOT EXISTS idx_contacts_email ON contacts(email);
@@ -112,16 +182,661 @@ const SCHEMA = `
112
182
  last_error TEXT,
113
183
  UNIQUE(account_id, action, uid, folder_id)
114
184
  );
185
+
186
+ -- Tombstones: messages the user deleted locally. Sync checks this table
187
+ -- before inserting a new row so a server-side delete that hasn't yet
188
+ -- propagated (or a stale server listing during the EXPUNGE race) can't
189
+ -- resurrect a message the user already removed. Keyed by Message-ID
190
+ -- because that's the only identifier stable across UID renumbers,
191
+ -- UIDVALIDITY bumps, and cross-folder moves.
192
+ CREATE TABLE IF NOT EXISTS tombstones (
193
+ account_id TEXT NOT NULL,
194
+ message_id TEXT NOT NULL,
195
+ deleted_at INTEGER NOT NULL,
196
+ subject TEXT DEFAULT '',
197
+ PRIMARY KEY (account_id, message_id)
198
+ );
199
+ CREATE INDEX IF NOT EXISTS idx_tombstones_deleted_at ON tombstones(deleted_at);
200
+
201
+ -- Calendar events: two-way cache of Google Calendar / local events.
202
+ -- uuid = local stable identity (survives provider_id rebinds).
203
+ -- provider_id = Google Calendar event id when known (null for local-only
204
+ -- events that haven't been pushed yet).
205
+ -- deleted = tombstone marker; drainer removes row from server then deletes
206
+ -- the row locally.
207
+ CREATE TABLE IF NOT EXISTS calendar_events (
208
+ uuid TEXT PRIMARY KEY,
209
+ account_id TEXT NOT NULL,
210
+ provider_id TEXT,
211
+ calendar_id TEXT DEFAULT 'primary',
212
+ title TEXT NOT NULL DEFAULT '',
213
+ start_ms INTEGER NOT NULL,
214
+ end_ms INTEGER NOT NULL,
215
+ all_day INTEGER DEFAULT 0,
216
+ location TEXT DEFAULT '',
217
+ notes TEXT DEFAULT '',
218
+ etag TEXT,
219
+ last_synced INTEGER DEFAULT 0,
220
+ dirty INTEGER DEFAULT 0,
221
+ deleted INTEGER DEFAULT 0,
222
+ updated_at INTEGER NOT NULL
223
+ );
224
+ CREATE INDEX IF NOT EXISTS idx_calendar_events_start ON calendar_events(account_id, start_ms);
225
+ CREATE INDEX IF NOT EXISTS idx_calendar_events_dirty ON calendar_events(dirty) WHERE dirty = 1;
226
+ -- getCalendarEventByProviderId runs once per event on every Google refresh;
227
+ -- without this index each lookup is a full table scan over calendar_events.
228
+ CREATE INDEX IF NOT EXISTS idx_calendar_events_provider ON calendar_events(account_id, provider_id);
229
+
230
+ -- Tasks: two-way cache of Google Tasks / local tasks. Same shape as
231
+ -- calendar_events minus the time range.
232
+ CREATE TABLE IF NOT EXISTS tasks (
233
+ uuid TEXT PRIMARY KEY,
234
+ account_id TEXT NOT NULL,
235
+ provider_id TEXT,
236
+ list_id TEXT DEFAULT '@default',
237
+ title TEXT NOT NULL DEFAULT '',
238
+ notes TEXT DEFAULT '',
239
+ due_ms INTEGER,
240
+ completed_ms INTEGER,
241
+ etag TEXT,
242
+ last_synced INTEGER DEFAULT 0,
243
+ dirty INTEGER DEFAULT 0,
244
+ deleted INTEGER DEFAULT 0,
245
+ updated_at INTEGER NOT NULL
246
+ );
247
+ CREATE INDEX IF NOT EXISTS idx_tasks_account ON tasks(account_id);
248
+ CREATE INDEX IF NOT EXISTS idx_tasks_dirty ON tasks(dirty) WHERE dirty = 1;
249
+ -- Mirror calendar_events: any provider_id lookup path needs a proper index,
250
+ -- even if today's reconcile does the dedup in memory — prevents a future
251
+ -- refactor from accidentally introducing an O(N) scan.
252
+ CREATE INDEX IF NOT EXISTS idx_tasks_provider ON tasks(account_id, provider_id);
253
+
254
+ -- Generic store-sync queue for domains OTHER than messages. Messages
255
+ -- use sync_actions above. This table queues push-to-server actions
256
+ -- for calendar / tasks / contacts / allowlist. Kind identifies the
257
+ -- domain; op is "create" / "update" / "delete"; payload is JSON the
258
+ -- drainer posts to the provider. Target URL isn't stored — the
259
+ -- drainer knows the provider endpoint from kind + payload.
260
+ CREATE TABLE IF NOT EXISTS store_sync (
261
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
262
+ kind TEXT NOT NULL,
263
+ op TEXT NOT NULL,
264
+ account_id TEXT NOT NULL,
265
+ target_uuid TEXT NOT NULL,
266
+ payload TEXT,
267
+ attempts INTEGER DEFAULT 0,
268
+ last_error TEXT,
269
+ created_at INTEGER NOT NULL,
270
+ UNIQUE(kind, target_uuid, op)
271
+ );
272
+ CREATE INDEX IF NOT EXISTS idx_store_sync_account ON store_sync(account_id, kind);
273
+ -- UNIQUE(kind, target_uuid, op) covers queries that start with kind; lookups
274
+ -- by target_uuid alone ("is this uuid queued for any op?") would otherwise
275
+ -- table-scan. Cheap; store_sync is tiny and write-heavy.
276
+ CREATE INDEX IF NOT EXISTS idx_store_sync_target_uuid ON store_sync(target_uuid);
277
+ -- Generic per-scope/per-key string store. Used for sync tokens (Google
278
+ -- People nextSyncToken per account, Gmail history-id, calendar sync token,
279
+ -- etc.) and any other small bits of state that need to outlive a process
280
+ -- restart but don't deserve their own table. Keyed by (scope, key).
281
+ CREATE TABLE IF NOT EXISTS kv (
282
+ scope TEXT NOT NULL,
283
+ key TEXT NOT NULL,
284
+ value TEXT,
285
+ updated_at INTEGER NOT NULL,
286
+ PRIMARY KEY(scope, key)
287
+ );
115
288
  `;
116
289
  export class MailxDB {
117
290
  db;
118
291
  constructor(dbDir) {
119
292
  fs.mkdirSync(dbDir, { recursive: true });
120
293
  const dbPath = path.join(dbDir, "mailx.db");
121
- this.db = new Database(dbPath);
122
- this.db.pragma("journal_mode = WAL");
123
- this.db.pragma("foreign_keys = ON");
294
+ this.db = new DatabaseSync(dbPath);
295
+ this.db.exec("PRAGMA journal_mode = WAL");
296
+ this.db.exec("PRAGMA foreign_keys = ON");
124
297
  this.db.exec(SCHEMA);
298
+ // Idempotent migrations for older databases that predate new columns.
299
+ // SQLite doesn't support "ADD COLUMN IF NOT EXISTS", so we just try the
300
+ // ALTER and catch the "duplicate column" error. Simpler and more robust
301
+ // than probing via PRAGMA table_info (which can behave differently
302
+ // across sqlite drivers).
303
+ this.addColumnIfMissing("messages", "thread_id", "TEXT");
304
+ try {
305
+ this.db.exec("CREATE INDEX IF NOT EXISTS idx_messages_thread_id ON messages(account_id, thread_id)");
306
+ }
307
+ catch { /* already exists */ }
308
+ // provider_id: native server-side id for API-backed providers (Gmail
309
+ // hex id, Outlook Graph id, etc.). Lets fetchOne look up the message
310
+ // directly instead of paginating listMessageIds for every body fetch
311
+ // — a UID-only path costs 2-3 rate-limited API calls per message.
312
+ this.addColumnIfMissing("messages", "provider_id", "TEXT");
313
+ // uuid: stable per-message local identity. Assigned the first time
314
+ // mailx sees the message and never changes — survives server UID
315
+ // renumbers, UIDVALIDITY bumps, and cross-folder moves (the sync
316
+ // rebinds the (folder_id, uid) tuple but keeps the UUID). All UI
317
+ // references SHOULD flow through uuid; (account_id, folder_id, uid)
318
+ // remains the server-binding metadata used only by sync.
319
+ this.addColumnIfMissing("messages", "uuid", "TEXT");
320
+ try {
321
+ this.db.exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_messages_uuid ON messages(uuid)");
322
+ }
323
+ catch { /* already exists */ }
324
+ // bcc_json: pre-existing DBs predate this column. Without the migration
325
+ // every contacts seed pass throws "no such column: m.bcc_json" and the
326
+ // local autocomplete corpus stays empty.
327
+ this.addColumnIfMissing("messages", "bcc_json", "TEXT DEFAULT '[]'");
328
+ // calendar_events: recurring_event_id carries the Google Calendar
329
+ // series id when the event is an expanded instance of a recurrence.
330
+ // Filters like "hide recurring events" check this column.
331
+ this.addColumnIfMissing("calendar_events", "recurring_event_id", "TEXT");
332
+ this.addColumnIfMissing("calendar_events", "html_link", "TEXT");
333
+ // Backfill UUIDs for any pre-existing rows that were inserted before
334
+ // this column landed. One UPDATE + an id roundtrip per row — cheap
335
+ // at our row counts, runs once per DB upgrade.
336
+ this.backfillUuids();
337
+ // One-shot contacts table reset: the contacts schema's UNIQUE constraint
338
+ // was widened from `(email)` to `(source, email, name)` so the same
339
+ // address can carry multiple distinct (name, source) entries — Bob's
340
+ // wife at bob@example.com and a separate `Bob Smith <bob@example.com>`
341
+ // for work, both legitimate. Old rows with the email-only unique key
342
+ // would block the new inserts. Per user "don't migrate, start fresh":
343
+ // drop the old table, recreate it via SCHEMA, reseed from messages on
344
+ // next sync. Gated by a kv flag so we run exactly once per machine.
345
+ const contactsResetFlag = this.getKv("schema", "contacts_v2");
346
+ if (!contactsResetFlag) {
347
+ try {
348
+ this.db.exec("DROP TABLE IF EXISTS contacts");
349
+ this.db.exec(`
350
+ CREATE TABLE contacts (
351
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
352
+ source TEXT NOT NULL DEFAULT 'discovered',
353
+ google_id TEXT,
354
+ name TEXT DEFAULT '',
355
+ email TEXT NOT NULL,
356
+ organization TEXT DEFAULT '',
357
+ last_used INTEGER DEFAULT 0,
358
+ use_count INTEGER DEFAULT 0,
359
+ updated_at INTEGER NOT NULL,
360
+ UNIQUE(source, email, name)
361
+ );
362
+ CREATE INDEX IF NOT EXISTS idx_contacts_email ON contacts(email);
363
+ CREATE INDEX IF NOT EXISTS idx_contacts_name ON contacts(name);
364
+ `);
365
+ this.setKv("schema", "contacts_v2", String(Date.now()));
366
+ console.log(" [db] contacts table reset to v2 schema (multi-name-per-email)");
367
+ }
368
+ catch (e) {
369
+ console.error(` [db] contacts v2 reset failed: ${e.message}`);
370
+ }
371
+ }
372
+ // Post-migration sanity check: verify the columns we actually read in
373
+ // SELECTs exist. If any migration silently failed (stale driver, DB
374
+ // file locked, permission error), later code would throw cryptic
375
+ // "no such column" errors buried deep in a sync run. Fail loud here
376
+ // with a clear "run mailx -rebuild" message. C32 on Linux was exactly
377
+ // this — old mailx-store that predated thread_id/uuid migrations.
378
+ this.verifySchema();
379
+ }
380
+ /** Fail loud + early if expected columns are missing. Cheap (PRAGMA only
381
+ * runs at startup). The user-facing message names the recovery command. */
382
+ verifySchema() {
383
+ const required = {
384
+ messages: ["thread_id", "provider_id", "uuid", "bcc_json"],
385
+ calendar_events: ["recurring_event_id", "html_link"],
386
+ };
387
+ for (const [table, cols] of Object.entries(required)) {
388
+ let actual;
389
+ try {
390
+ actual = this.db.prepare(`PRAGMA table_info(${table})`).all();
391
+ }
392
+ catch (e) {
393
+ throw new Error(`[mailx-store] schema check failed for "${table}": ${e.message}. Run 'mailx -rebuild' to rebuild the local store.`);
394
+ }
395
+ const names = new Set(actual.map(r => r.name));
396
+ const missing = cols.filter(c => !names.has(c));
397
+ if (missing.length > 0) {
398
+ throw new Error(`[mailx-store] table "${table}" is missing columns [${missing.join(", ")}] — schema migration did not complete. Run 'mailx -rebuild' to rebuild the local store.`);
399
+ }
400
+ }
401
+ this.runOneShotJunkContactPurge();
402
+ }
403
+ /** One-shot: rewrite absolute body_path entries to relative-to-store
404
+ * paths. Idempotent; flag stored in kv. Caller passes a FileMessageStore
405
+ * whose `rewriteAbsoluteToRelative` does the actual mapping (mailx-store
406
+ * doesn't depend on file-store directly to avoid a cycle). */
407
+ runOneShotBodyPathMigration(rewriteFn) {
408
+ const TARGET = "v1-relative";
409
+ const last = this.getKv("body_paths", "migrate_v");
410
+ if (last === TARGET)
411
+ return;
412
+ try {
413
+ const rows = this.db.prepare("SELECT id, body_path FROM messages WHERE body_path IS NOT NULL AND body_path != ''").all();
414
+ const upd = this.db.prepare("UPDATE messages SET body_path = ? WHERE id = ?");
415
+ const update = (id, newPath) => upd.run(newPath, id);
416
+ const n = rewriteFn(rows, update);
417
+ this.setKv("body_paths", "migrate_v", TARGET);
418
+ if (n > 0)
419
+ console.log(` [body-paths] one-shot: rewrote ${n} of ${rows.length} body_path entries to relative`);
420
+ }
421
+ catch (e) {
422
+ console.warn(`[body-paths] one-shot migration failed: ${e.message}`);
423
+ }
424
+ }
425
+ /** One-shot purge: when the in-DB filter rules tighten (new prefix or
426
+ * domain matchers), historical contacts harvested under the old rules
427
+ * remain in the table and keep being written back to contacts.jsonc on
428
+ * every cloud save. Bumping the version below re-runs the purge once
429
+ * per device. The kv flag (`contacts:purge_v`) records the last version
430
+ * that ran so we don't keep doing the work on every startup. */
431
+ runOneShotJunkContactPurge() {
432
+ const TARGET = CONTACT_RULES.rulesVersion; // bump in contact-rules.jsonc to re-run
433
+ const last = this.getKv("contacts", "purge_v");
434
+ if (last === TARGET)
435
+ return;
436
+ let dropped = 0;
437
+ try {
438
+ const rows = this.db.prepare("SELECT id, email, name FROM contacts WHERE source IN ('discovered','sent','received')").all();
439
+ const del = this.db.prepare("DELETE FROM contacts WHERE id = ?");
440
+ for (const r of rows) {
441
+ if (isJunkContact(r.email || "", r.name || "")) {
442
+ del.run(r.id);
443
+ dropped++;
444
+ }
445
+ }
446
+ this.setKv("contacts", "purge_v", TARGET);
447
+ if (dropped > 0)
448
+ console.log(` [contacts] one-shot purge: dropped ${dropped} junk rows (${TARGET})`);
449
+ }
450
+ catch (e) {
451
+ console.warn(`[contacts] one-shot purge failed: ${e.message}`);
452
+ }
453
+ }
454
+ /** Fetch a string from the kv table. Returns null when not set. */
455
+ getKv(scope, key) {
456
+ const r = this.db.prepare("SELECT value FROM kv WHERE scope = ? AND key = ?").get(scope, key);
457
+ return r?.value ?? null;
458
+ }
459
+ /** Upsert a kv row. Pass `null` to delete. */
460
+ setKv(scope, key, value) {
461
+ if (value === null) {
462
+ this.db.prepare("DELETE FROM kv WHERE scope = ? AND key = ?").run(scope, key);
463
+ return;
464
+ }
465
+ this.db.prepare("INSERT INTO kv (scope, key, value, updated_at) VALUES (?, ?, ?, ?) "
466
+ + "ON CONFLICT(scope, key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at").run(scope, key, value, Date.now());
467
+ }
468
+ /** One-time: assign UUIDs to every `messages` row that's missing one.
469
+ * Runs on every startup but the WHERE clause makes it a no-op after the
470
+ * first pass. */
471
+ backfillUuids() {
472
+ try {
473
+ const rows = this.db.prepare("SELECT id FROM messages WHERE uuid IS NULL OR uuid = ''").all();
474
+ if (rows.length === 0)
475
+ return;
476
+ console.log(` [db] backfilling ${rows.length} message UUIDs`);
477
+ const upd = this.db.prepare("UPDATE messages SET uuid = ? WHERE id = ?");
478
+ this.db.exec("BEGIN");
479
+ try {
480
+ for (const r of rows)
481
+ upd.run(randomUUID().replace(/-/g, ""), r.id);
482
+ this.db.exec("COMMIT");
483
+ }
484
+ catch (e) {
485
+ this.db.exec("ROLLBACK");
486
+ throw e;
487
+ }
488
+ }
489
+ catch (e) {
490
+ console.error(` [db] backfillUuids failed: ${e.message}`);
491
+ }
492
+ }
493
+ // ── Sent-log (dedup) ──
494
+ /** Has this Message-ID already been sent? Used to prevent the outbox from
495
+ * re-sending the same raw file across crash/restart cycles. */
496
+ hasSentMessage(messageId) {
497
+ if (!messageId)
498
+ return false;
499
+ const row = this.db.prepare("SELECT 1 FROM sent_log WHERE message_id = ? LIMIT 1").get(messageId);
500
+ return !!row;
501
+ }
502
+ /** Record a successfully sent message so future attempts are skipped. */
503
+ recordSent(messageId, accountId, subject, recipients) {
504
+ if (!messageId)
505
+ return;
506
+ try {
507
+ this.db.prepare("INSERT INTO sent_log (message_id, account_id, subject, recipients, sent_at) VALUES (?, ?, ?, ?, ?) ON CONFLICT(message_id) DO NOTHING").run(messageId, accountId, subject || "", recipients.join(", "), Date.now());
508
+ }
509
+ catch (e) {
510
+ console.error(` [sent_log] failed to record ${messageId}: ${e.message}`);
511
+ }
512
+ }
513
+ /** Q49 heuristic: has the user ever sent a message to `recipientEmail`
514
+ * that had a non-empty Cc field? Used by compose to auto-expand the Cc
515
+ * input when replying to someone who customarily gets Cc'd with others.
516
+ * Query scans only Sent folders (special_use='sent') and matches the
517
+ * recipient's address inside `to_json` via LIKE. No special index — the
518
+ * Sent folder's row count is typically a few thousand at most; acceptable
519
+ * on the compose-open path. */
520
+ hasCcHistoryTo(recipientEmail) {
521
+ const email = (recipientEmail || "").trim().toLowerCase();
522
+ if (!email)
523
+ return false;
524
+ try {
525
+ const row = this.db.prepare(`
526
+ SELECT 1 FROM messages m
527
+ JOIN folders f ON m.folder_id = f.id
528
+ WHERE f.special_use = 'sent'
529
+ AND lower(m.to_json) LIKE ?
530
+ AND m.cc_json IS NOT NULL AND m.cc_json != '[]' AND m.cc_json != ''
531
+ LIMIT 1
532
+ `).get(`%"${email}"%`);
533
+ return !!row;
534
+ }
535
+ catch {
536
+ return false;
537
+ }
538
+ }
539
+ /** Same shape as hasCcHistoryTo for the Bcc field. Bcc only appears in the
540
+ * user's own Sent copy, so it's still a reliable signal that this user
541
+ * habitually Bccs when writing to this recipient. */
542
+ hasBccHistoryTo(recipientEmail) {
543
+ const email = (recipientEmail || "").trim().toLowerCase();
544
+ if (!email)
545
+ return false;
546
+ try {
547
+ const row = this.db.prepare(`
548
+ SELECT 1 FROM messages m
549
+ JOIN folders f ON m.folder_id = f.id
550
+ WHERE f.special_use = 'sent'
551
+ AND lower(m.to_json) LIKE ?
552
+ AND m.bcc_json IS NOT NULL AND m.bcc_json != '[]' AND m.bcc_json != ''
553
+ LIMIT 1
554
+ `).get(`%"${email}"%`);
555
+ return !!row;
556
+ }
557
+ catch {
558
+ return false;
559
+ }
560
+ }
561
+ // ── Tombstones (local-delete record so server echo can't resurrect) ──
562
+ /** Mark a Message-ID as locally-deleted for an account. No-op if messageId
563
+ * is empty (e.g. provider stripped the header) — without a stable id we
564
+ * can't check against future sync results anyway. */
565
+ addTombstone(accountId, messageId, subject = "") {
566
+ if (!messageId)
567
+ return;
568
+ try {
569
+ this.db.prepare("INSERT INTO tombstones (account_id, message_id, deleted_at, subject) VALUES (?, ?, ?, ?) ON CONFLICT(account_id, message_id) DO UPDATE SET deleted_at = excluded.deleted_at").run(accountId, messageId, Date.now(), subject || "");
570
+ }
571
+ catch (e) {
572
+ console.error(` [tombstones] failed to record ${messageId}: ${e.message}`);
573
+ }
574
+ }
575
+ /** Is this Message-ID tombstoned for this account? */
576
+ hasTombstone(accountId, messageId) {
577
+ if (!messageId)
578
+ return false;
579
+ const row = this.db.prepare("SELECT 1 FROM tombstones WHERE account_id = ? AND message_id = ? LIMIT 1").get(accountId, messageId);
580
+ return !!row;
581
+ }
582
+ /** Remove a tombstone — used by "undelete" (Ctrl-Z) so a subsequent sync
583
+ * re-imports the message as normal. Also lets the user recover from a
584
+ * mistaken local delete. */
585
+ removeTombstone(accountId, messageId) {
586
+ if (!messageId)
587
+ return;
588
+ try {
589
+ this.db.prepare("DELETE FROM tombstones WHERE account_id = ? AND message_id = ?").run(accountId, messageId);
590
+ }
591
+ catch (e) {
592
+ console.error(` [tombstones] failed to remove ${messageId}: ${e.message}`);
593
+ }
594
+ }
595
+ /** Age-out tombstones older than the given cutoff. Keeps the table from
596
+ * growing unboundedly. Default retention is 30 days; caller passes the
597
+ * actual cutoff in ms since epoch. */
598
+ pruneTombstones(olderThanMs) {
599
+ try {
600
+ const res = this.db.prepare("DELETE FROM tombstones WHERE deleted_at < ?").run(olderThanMs);
601
+ return Number(res.changes || 0);
602
+ }
603
+ catch (e) {
604
+ console.error(` [tombstones] prune failed: ${e.message}`);
605
+ return 0;
606
+ }
607
+ }
608
+ // ── Calendar events (two-way cache) ──
609
+ upsertCalendarEvent(ev) {
610
+ const uuid = ev.uuid || randomUUID().replace(/-/g, "");
611
+ this.db.prepare(`
612
+ INSERT INTO calendar_events
613
+ (uuid, account_id, provider_id, calendar_id, title, start_ms, end_ms,
614
+ all_day, location, notes, etag, last_synced, dirty, deleted, updated_at,
615
+ recurring_event_id, html_link)
616
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?)
617
+ ON CONFLICT(uuid) DO UPDATE SET
618
+ account_id=excluded.account_id, provider_id=excluded.provider_id,
619
+ calendar_id=excluded.calendar_id, title=excluded.title,
620
+ start_ms=excluded.start_ms, end_ms=excluded.end_ms,
621
+ all_day=excluded.all_day, location=excluded.location,
622
+ notes=excluded.notes, etag=excluded.etag,
623
+ last_synced=excluded.last_synced, dirty=excluded.dirty,
624
+ updated_at=excluded.updated_at,
625
+ recurring_event_id=excluded.recurring_event_id,
626
+ html_link=excluded.html_link
627
+ `).run(uuid, ev.accountId, ev.providerId || null, ev.calendarId || "primary", ev.title, ev.startMs, ev.endMs, ev.allDay ? 1 : 0, ev.location || "", ev.notes || "", ev.etag || null, ev.dirty ? 0 : Date.now(), ev.dirty ? 1 : 0, Date.now(), ev.recurringEventId || null, ev.htmlLink || null);
628
+ return uuid;
629
+ }
630
+ getCalendarEvents(accountId, fromMs, toMs) {
631
+ const rows = this.db.prepare(`
632
+ SELECT * FROM calendar_events
633
+ WHERE account_id = ? AND deleted = 0 AND start_ms >= ? AND start_ms < ?
634
+ ORDER BY start_ms ASC
635
+ `).all(accountId, fromMs, toMs);
636
+ return rows.map(this.calendarRowToObject);
637
+ }
638
+ /** Lookup by uuid only — used by patch/delete paths that don't have an
639
+ * accountId context. Returns the row even when it's soft-deleted. */
640
+ getCalendarEventByUuid(uuid) {
641
+ const r = this.db.prepare("SELECT * FROM calendar_events WHERE uuid = ?").get(uuid);
642
+ return r ? this.calendarRowToObject(r) : null;
643
+ }
644
+ getTaskByUuid(uuid) {
645
+ const r = this.db.prepare("SELECT * FROM tasks WHERE uuid = ?").get(uuid);
646
+ return r ? this.taskRowToObject(r) : null;
647
+ }
648
+ getDirtyCalendarEvents(accountId) {
649
+ const rows = this.db.prepare(`
650
+ SELECT * FROM calendar_events WHERE account_id = ? AND (dirty = 1 OR deleted = 1)
651
+ `).all(accountId);
652
+ return rows.map(this.calendarRowToObject);
653
+ }
654
+ calendarRowToObject(r) {
655
+ return {
656
+ uuid: r.uuid, accountId: r.account_id, providerId: r.provider_id,
657
+ calendarId: r.calendar_id, title: r.title, startMs: r.start_ms,
658
+ endMs: r.end_ms, allDay: !!r.all_day, location: r.location, notes: r.notes,
659
+ etag: r.etag, lastSynced: r.last_synced, dirty: !!r.dirty, deleted: !!r.deleted,
660
+ recurringEventId: r.recurring_event_id || null,
661
+ htmlLink: r.html_link || null,
662
+ };
663
+ }
664
+ /** Find a calendar event by its Google Calendar event id (provider_id).
665
+ * Global lookup — not window-scoped — so repeat pulls dedup cleanly. */
666
+ getCalendarEventByProviderId(accountId, providerId) {
667
+ const r = this.db.prepare("SELECT * FROM calendar_events WHERE account_id = ? AND provider_id = ?").get(accountId, providerId);
668
+ return r ? this.calendarRowToObject(r) : null;
669
+ }
670
+ markCalendarEventClean(uuid, providerId, etag) {
671
+ this.db.prepare(`
672
+ UPDATE calendar_events SET dirty=0, provider_id=?, etag=?, last_synced=? WHERE uuid=?
673
+ `).run(providerId, etag, Date.now(), uuid);
674
+ }
675
+ deleteCalendarEventLocal(uuid) {
676
+ this.db.prepare("UPDATE calendar_events SET deleted=1, dirty=1, updated_at=? WHERE uuid=?").run(Date.now(), uuid);
677
+ }
678
+ purgeCalendarEvent(uuid) {
679
+ this.db.prepare("DELETE FROM calendar_events WHERE uuid=?").run(uuid);
680
+ }
681
+ // ── Tasks (two-way cache) ──
682
+ upsertTask(t) {
683
+ const uuid = t.uuid || randomUUID().replace(/-/g, "");
684
+ this.db.prepare(`
685
+ INSERT INTO tasks
686
+ (uuid, account_id, provider_id, list_id, title, notes, due_ms, completed_ms,
687
+ etag, last_synced, dirty, deleted, updated_at)
688
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?)
689
+ ON CONFLICT(uuid) DO UPDATE SET
690
+ account_id=excluded.account_id, provider_id=excluded.provider_id,
691
+ list_id=excluded.list_id, title=excluded.title, notes=excluded.notes,
692
+ due_ms=excluded.due_ms, completed_ms=excluded.completed_ms,
693
+ etag=excluded.etag, last_synced=excluded.last_synced,
694
+ dirty=excluded.dirty, updated_at=excluded.updated_at
695
+ `).run(uuid, t.accountId, t.providerId || null, t.listId || "@default", t.title, t.notes || "", t.dueMs || null, t.completedMs || null, t.etag || null, t.dirty ? 0 : Date.now(), t.dirty ? 1 : 0, Date.now());
696
+ return uuid;
697
+ }
698
+ getTasks(accountId, includeCompleted = false) {
699
+ const where = includeCompleted
700
+ ? "account_id = ? AND deleted = 0"
701
+ : "account_id = ? AND deleted = 0 AND completed_ms IS NULL";
702
+ const rows = this.db.prepare(`SELECT * FROM tasks WHERE ${where} ORDER BY COALESCE(due_ms, updated_at) ASC`).all(accountId);
703
+ return rows.map(this.taskRowToObject);
704
+ }
705
+ getDirtyTasks(accountId) {
706
+ const rows = this.db.prepare(`SELECT * FROM tasks WHERE account_id = ? AND (dirty = 1 OR deleted = 1)`).all(accountId);
707
+ return rows.map(this.taskRowToObject);
708
+ }
709
+ taskRowToObject(r) {
710
+ return {
711
+ uuid: r.uuid, accountId: r.account_id, providerId: r.provider_id,
712
+ listId: r.list_id, title: r.title, notes: r.notes, dueMs: r.due_ms,
713
+ completedMs: r.completed_ms, etag: r.etag, lastSynced: r.last_synced,
714
+ dirty: !!r.dirty, deleted: !!r.deleted,
715
+ };
716
+ }
717
+ markTaskClean(uuid, providerId, etag) {
718
+ this.db.prepare(`UPDATE tasks SET dirty=0, provider_id=?, etag=?, last_synced=? WHERE uuid=?`)
719
+ .run(providerId, etag, Date.now(), uuid);
720
+ }
721
+ deleteTaskLocal(uuid) {
722
+ this.db.prepare("UPDATE tasks SET deleted=1, dirty=1, updated_at=? WHERE uuid=?").run(Date.now(), uuid);
723
+ }
724
+ purgeTask(uuid) {
725
+ this.db.prepare("DELETE FROM tasks WHERE uuid=?").run(uuid);
726
+ }
727
+ // ── Contacts two-way: existing upsertContact / deleteContact handle the
728
+ // local side; the service layer adds store_sync push-queue entries.
729
+ // (No extra methods needed here — upsertContact/deleteContact at the
730
+ // regular contact-management section are the two-way cache's local
731
+ // writers.)
732
+ /** Local delete for the two-way cache drainer (symmetric with calendar/tasks). */
733
+ deleteContactLocal(email) { this.deleteContact(email); }
734
+ // ── Store-sync queue (calendar / tasks / contacts / allowlist) ──
735
+ enqueueStoreSync(kind, op, accountId, targetUuid, payload) {
736
+ try {
737
+ this.db.prepare(`
738
+ INSERT OR REPLACE INTO store_sync (kind, op, account_id, target_uuid, payload, created_at)
739
+ VALUES (?, ?, ?, ?, ?, ?)
740
+ `).run(kind, op, accountId, targetUuid, JSON.stringify(payload), Date.now());
741
+ }
742
+ catch (e) {
743
+ console.error(` [store_sync] enqueue ${kind}/${op}/${targetUuid} failed: ${e.message}`);
744
+ }
745
+ }
746
+ getStoreSyncQueue(kind, accountId) {
747
+ let sql = "SELECT * FROM store_sync";
748
+ const params = [];
749
+ const wh = [];
750
+ if (kind) {
751
+ wh.push("kind = ?");
752
+ params.push(kind);
753
+ }
754
+ if (accountId) {
755
+ wh.push("account_id = ?");
756
+ params.push(accountId);
757
+ }
758
+ if (wh.length)
759
+ sql += " WHERE " + wh.join(" AND ");
760
+ sql += " ORDER BY created_at ASC";
761
+ const rows = this.db.prepare(sql).all(...params);
762
+ return rows.map(r => ({
763
+ id: r.id, kind: r.kind, op: r.op, accountId: r.account_id,
764
+ targetUuid: r.target_uuid,
765
+ payload: r.payload ? JSON.parse(r.payload) : null,
766
+ attempts: r.attempts, lastError: r.last_error,
767
+ }));
768
+ }
769
+ completeStoreSync(id) {
770
+ this.db.prepare("DELETE FROM store_sync WHERE id = ?").run(id);
771
+ }
772
+ failStoreSync(id, error) {
773
+ this.db.prepare("UPDATE store_sync SET attempts = attempts + 1, last_error = ? WHERE id = ?").run(error, id);
774
+ }
775
+ /** Idempotently add a column to a table if it's missing. */
776
+ addColumnIfMissing(table, column, sqlType) {
777
+ try {
778
+ this.db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${sqlType}`);
779
+ console.log(` [db] added column ${table}.${column}`);
780
+ }
781
+ catch (e) {
782
+ const msg = String(e?.message || e);
783
+ // "duplicate column name" is the expected case when column already exists
784
+ if (!/duplicate column/i.test(msg)) {
785
+ console.error(` [db] migration ${table}.${column} failed: ${msg}`);
786
+ }
787
+ }
788
+ }
789
+ /** Compute a thread id for an incoming message. Strategy:
790
+ * 1. If any ancestor (in_reply_to or references) is already present in
791
+ * messages with a thread_id, reuse it — this handles the case where
792
+ * replies arrive before / after the root.
793
+ * 2. Otherwise use the oldest ref (first entry in References), or
794
+ * in_reply_to, or the message's own messageId as the thread root. */
795
+ computeThreadId(accountId, messageId, inReplyTo, references) {
796
+ const candidates = [];
797
+ if (references && references.length)
798
+ candidates.push(...references);
799
+ if (inReplyTo && !candidates.includes(inReplyTo))
800
+ candidates.push(inReplyTo);
801
+ if (messageId && !candidates.includes(messageId))
802
+ candidates.push(messageId);
803
+ // Look for an existing thread anchored on any of the ancestors
804
+ for (const mid of candidates) {
805
+ if (!mid)
806
+ continue;
807
+ const row = this.db.prepare("SELECT thread_id FROM messages WHERE account_id = ? AND message_id = ? AND thread_id IS NOT NULL LIMIT 1").get(accountId, mid);
808
+ if (row?.thread_id)
809
+ return row.thread_id;
810
+ }
811
+ // No existing thread — seed from the oldest ref, falling back to
812
+ // in_reply_to, then messageId
813
+ return (references && references[0]) || inReplyTo || messageId || `orphan-${Date.now()}-${Math.random().toString(36).slice(2)}`;
814
+ }
815
+ /** Get all messages in a thread (across folders) for a given account. */
816
+ getThreadMessages(accountId, threadId) {
817
+ if (!threadId)
818
+ return [];
819
+ const rows = this.db.prepare(`SELECT * FROM messages WHERE account_id = ? AND thread_id = ? ORDER BY date ASC`).all(accountId, threadId);
820
+ return rows.map(r => ({
821
+ id: r.id,
822
+ accountId: r.account_id,
823
+ folderId: r.folder_id,
824
+ uid: r.uid,
825
+ messageId: r.message_id || "",
826
+ inReplyTo: r.in_reply_to || "",
827
+ references: JSON.parse(r.refs || "[]"),
828
+ threadId: r.thread_id || undefined,
829
+ date: r.date,
830
+ subject: r.subject,
831
+ from: { name: r.from_name, address: r.from_address },
832
+ to: JSON.parse(r.to_json),
833
+ cc: JSON.parse(r.cc_json),
834
+ flags: JSON.parse(r.flags_json),
835
+ size: r.size,
836
+ hasAttachments: !!r.has_attachments,
837
+ preview: r.preview,
838
+ bodyPath: r.body_path || undefined,
839
+ }));
125
840
  }
126
841
  close() {
127
842
  this.db.close();
@@ -137,6 +852,9 @@ export class MailxDB {
137
852
  getAccounts() {
138
853
  return this.db.prepare("SELECT id, name, email, last_sync as lastSync FROM accounts").all();
139
854
  }
855
+ getAccountConfigs() {
856
+ return this.db.prepare("SELECT id, name, email, config_json as configJson FROM accounts").all();
857
+ }
140
858
  updateLastSync(accountId, timestamp) {
141
859
  this.db.prepare("UPDATE accounts SET last_sync = ? WHERE id = ?").run(timestamp, accountId);
142
860
  }
@@ -152,7 +870,7 @@ export class MailxDB {
152
870
  }
153
871
  getFolders(accountId) {
154
872
  const rows = this.db.prepare("SELECT * FROM folders WHERE account_id = ? ORDER BY path").all(accountId);
155
- return rows.map(r => ({
873
+ const folders = rows.map(r => ({
156
874
  id: r.id,
157
875
  accountId: r.account_id,
158
876
  path: r.path,
@@ -163,11 +881,48 @@ export class MailxDB {
163
881
  unreadCount: r.unread_count,
164
882
  children: []
165
883
  }));
884
+ // Sub-folder inheritance: a folder under Drafts/Sent/Trash/Junk/Archive
885
+ // inherits the parent's special role for UI purposes (column layout,
886
+ // open-in-compose, etc.). INBOX is intentionally excluded — its sub-
887
+ // folders are typically filtered mail and inheriting "inbox" would
888
+ // inflate All Inboxes. findFolder() still resolves to the canonical
889
+ // folder because rows are sorted by path and the parent sorts before
890
+ // its children.
891
+ const INHERITABLE = new Set(["sent", "drafts", "trash", "junk", "archive"]);
892
+ const roleByPath = new Map();
893
+ for (const f of folders) {
894
+ if (f.specialUse && INHERITABLE.has(f.specialUse)) {
895
+ roleByPath.set(f.path, f.specialUse);
896
+ }
897
+ }
898
+ for (const f of folders) {
899
+ if (f.specialUse)
900
+ continue;
901
+ const delim = f.delimiter || "/";
902
+ const parts = f.path.split(delim);
903
+ while (parts.length > 1) {
904
+ parts.pop();
905
+ const role = roleByPath.get(parts.join(delim));
906
+ if (role) {
907
+ f.specialUse = role;
908
+ break;
909
+ }
910
+ }
911
+ }
912
+ return folders;
166
913
  }
167
914
  deleteFolder(folderId) {
168
915
  this.db.prepare("DELETE FROM messages WHERE folder_id = ?").run(folderId);
169
916
  this.db.prepare("DELETE FROM folders WHERE id = ?").run(folderId);
170
917
  }
918
+ markFolderRead(folderId) {
919
+ this.db.prepare(`UPDATE messages SET flags_json = REPLACE(flags_json, '[]', '["\\\\Seen"]') WHERE folder_id = ? AND flags_json NOT LIKE '%\\\\Seen%'`).run(folderId);
920
+ this.recalcFolderCounts(folderId);
921
+ }
922
+ deleteAllMessages(accountId, folderId) {
923
+ this.db.prepare("DELETE FROM messages WHERE account_id = ? AND folder_id = ?").run(accountId, folderId);
924
+ this.recalcFolderCounts(folderId);
925
+ }
171
926
  updateFolderCounts(folderId, total, unread) {
172
927
  this.db.prepare("UPDATE folders SET total_count = ?, unread_count = ? WHERE id = ?").run(total, unread, folderId);
173
928
  }
@@ -180,23 +935,68 @@ export class MailxDB {
180
935
  }
181
936
  // ── Messages ──
182
937
  upsertMessage(msg) {
183
- const existing = this.db.prepare("SELECT id FROM messages WHERE account_id = ? AND folder_id = ? AND uid = ?").get(msg.accountId, msg.folderId, msg.uid);
938
+ const existing = this.db.prepare("SELECT id, provider_id FROM messages WHERE account_id = ? AND folder_id = ? AND uid = ?").get(msg.accountId, msg.folderId, msg.uid);
184
939
  if (existing) {
185
- this.db.prepare(`
186
- UPDATE messages SET flags_json = ?, preview = ?, body_path = ?, cached_at = ?
187
- WHERE id = ?
188
- `).run(JSON.stringify(msg.flags), msg.preview, msg.bodyPath, Date.now(), existing.id);
940
+ // Backfill provider_id on existing rows that predate this column —
941
+ // critical for body fetch to bypass listMessageIds pagination.
942
+ if (msg.providerId && !existing.provider_id) {
943
+ this.db.prepare("UPDATE messages SET provider_id = ? WHERE id = ?").run(msg.providerId, existing.id);
944
+ }
945
+ // Only overwrite body_path / preview when the caller actually has a
946
+ // body. Metadata-only syncs (Gmail API storeApiMessages, IMAP
947
+ // header-only fetches) pass bodyPath: "" and would otherwise wipe
948
+ // the path that prefetch just wrote, causing prefetch to re-download
949
+ // every message every cycle.
950
+ if (msg.bodyPath) {
951
+ this.db.prepare(`
952
+ UPDATE messages SET flags_json = ?, preview = ?, body_path = ?, cached_at = ?
953
+ WHERE id = ?
954
+ `).run(JSON.stringify(msg.flags), msg.preview, msg.bodyPath, Date.now(), existing.id);
955
+ }
956
+ else {
957
+ this.db.prepare(`
958
+ UPDATE messages SET flags_json = ?, cached_at = ?
959
+ WHERE id = ?
960
+ `).run(JSON.stringify(msg.flags), Date.now(), existing.id);
961
+ }
189
962
  return existing.id;
190
963
  }
964
+ // Move-detection: if this Message-ID already exists for this account
965
+ // in a DIFFERENT folder, treat it as a server-side move rather than a
966
+ // new arrival. Rebind that row to (folder_id, uid) — keep the UUID,
967
+ // body_path, flags, all the local state. Saves a body re-fetch and
968
+ // preserves any local references (in_reply_to, dally entries, undo
969
+ // stacks) that point at the UUID. Only kicks in when messageId is
970
+ // present (servers usually include it; if not we fall through to a
971
+ // fresh insert which mints a new UUID).
972
+ if (msg.messageId) {
973
+ const moved = this.db.prepare("SELECT id, folder_id, uid FROM messages WHERE account_id = ? AND message_id = ? LIMIT 1").get(msg.accountId, msg.messageId);
974
+ if (moved) {
975
+ console.log(` [move-detect] ${msg.accountId} ${msg.messageId}: rebinding row ${moved.id} (folder ${moved.folder_id}/uid ${moved.uid} → folder ${msg.folderId}/uid ${msg.uid})`);
976
+ // Update folder_id + uid; preserve uuid, body_path, flags
977
+ // (server flags will catch up on the next full sync).
978
+ this.db.prepare("UPDATE messages SET folder_id = ?, uid = ?, cached_at = ? WHERE id = ?").run(msg.folderId, msg.uid, Date.now(), moved.id);
979
+ return moved.id;
980
+ }
981
+ }
191
982
  const toText = msg.to.map(a => `${a.name} ${a.address}`).join(" ");
192
983
  const ccText = msg.cc.map(a => `${a.name} ${a.address}`).join(" ");
984
+ // Thread id = oldest ancestor in the reference chain, or the in-reply-to
985
+ // parent, or the message's own Message-ID as a fallback. We also check
986
+ // whether an existing row already has a thread_id for any of the refs,
987
+ // so late-arriving replies latch onto the same thread.
988
+ const threadId = this.computeThreadId(msg.accountId, msg.messageId, msg.inReplyTo, msg.references);
989
+ // Mint a per-message local identity UUID at first-sight. Stable for
990
+ // the life of the row — survives server UID renumbers, folder moves
991
+ // (sync rebinds folder_id/uid but keeps uuid), UIDVALIDITY bumps.
992
+ const uuid = randomUUID().replace(/-/g, "");
193
993
  const result = this.db.prepare(`
194
994
  INSERT INTO messages (
195
- account_id, folder_id, uid, message_id, in_reply_to, refs,
995
+ account_id, folder_id, uid, uuid, message_id, in_reply_to, refs, thread_id,
196
996
  date, subject, from_address, from_name, to_json, cc_json,
197
- flags_json, size, has_attachments, preview, body_path, cached_at
198
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
199
- `).run(msg.accountId, msg.folderId, msg.uid, msg.messageId, msg.inReplyTo, JSON.stringify(msg.references), msg.date, msg.subject, msg.from.address, msg.from.name, JSON.stringify(msg.to), JSON.stringify(msg.cc), JSON.stringify(msg.flags), msg.size, msg.hasAttachments ? 1 : 0, msg.preview, msg.bodyPath, Date.now());
997
+ flags_json, size, has_attachments, preview, body_path, cached_at, provider_id
998
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
999
+ `).run(msg.accountId, msg.folderId, msg.uid, uuid, msg.messageId, msg.inReplyTo, JSON.stringify(msg.references), threadId, msg.date, msg.subject, msg.from.address, msg.from.name, JSON.stringify(msg.to), JSON.stringify(msg.cc), JSON.stringify(msg.flags), msg.size, msg.hasAttachments ? 1 : 0, msg.preview, msg.bodyPath, Date.now(), msg.providerId || null);
200
1000
  const rowId = Number(result.lastInsertRowid);
201
1001
  // Index for full-text search
202
1002
  try {
@@ -219,16 +1019,38 @@ export class MailxDB {
219
1019
  const term = `%${query.search}%`;
220
1020
  params.push(term, term, term);
221
1021
  }
1022
+ if (query.flaggedOnly) {
1023
+ // flags_json is a JSON array like ["\\Seen","\\Flagged"]. A plain
1024
+ // LIKE on the serialized form is sufficient to find rows with the
1025
+ // \Flagged flag without decoding every row.
1026
+ where += " AND flags_json LIKE '%\\\\Flagged%'";
1027
+ }
222
1028
  const total = this.db.prepare(`SELECT COUNT(*) as cnt FROM messages WHERE ${where}`).get(...params).cnt;
223
- const rows = this.db.prepare(`SELECT * FROM messages WHERE ${where} ORDER BY ${sortCol} ${sortDir} LIMIT ? OFFSET ?`).all(...params, pageSize, offset);
1029
+ // LEFT JOIN sync_actions so each row carries a `pending` flag
1030
+ // true when the user has a queued local action (move/flag/delete)
1031
+ // not yet acknowledged by the server. UI renders these in pink so
1032
+ // local-only state is visible (Slice C of S1). Negative UIDs also
1033
+ // count as pending: that's the convention for optimistic local
1034
+ // inserts (e.g. Sent rows written the moment the user hits Send,
1035
+ // before the real APPENDUID comes back from the server).
1036
+ const rows = this.db.prepare(`SELECT m.*, (
1037
+ EXISTS(
1038
+ SELECT 1 FROM sync_actions sa
1039
+ WHERE sa.account_id = m.account_id AND sa.uid = m.uid
1040
+ ) OR m.uid < 0
1041
+ ) AS pending
1042
+ FROM messages m WHERE ${where.replace(/\b(account_id|folder_id|uid|date|subject|from_name|from_address|flags_json)\b/g, "m.$1")}
1043
+ ORDER BY m.${sortCol} ${sortDir} LIMIT ? OFFSET ?`).all(...params, pageSize, offset);
224
1044
  const items = rows.map(r => ({
225
1045
  id: r.id,
226
1046
  accountId: r.account_id,
227
1047
  folderId: r.folder_id,
228
1048
  uid: r.uid,
1049
+ uuid: r.uuid || "",
229
1050
  messageId: r.message_id || "",
230
1051
  inReplyTo: r.in_reply_to || "",
231
1052
  references: JSON.parse(r.refs || "[]"),
1053
+ threadId: r.thread_id || undefined,
232
1054
  date: r.date,
233
1055
  subject: r.subject,
234
1056
  from: { name: r.from_name, address: r.from_address },
@@ -237,22 +1059,72 @@ export class MailxDB {
237
1059
  flags: JSON.parse(r.flags_json),
238
1060
  size: r.size,
239
1061
  hasAttachments: !!r.has_attachments,
240
- preview: r.preview
1062
+ preview: r.preview,
1063
+ bodyPath: r.body_path || "",
1064
+ pending: !!r.pending,
241
1065
  }));
242
1066
  return { items, total, page, pageSize };
243
1067
  }
244
- getMessageByUid(accountId, uid) {
245
- const r = this.db.prepare("SELECT * FROM messages WHERE account_id = ? AND uid = ?").get(accountId, uid);
246
- if (!r)
247
- return null;
1068
+ /** Unified inbox: all inbox folders across accounts, sorted by date, paginated in SQL */
1069
+ getUnifiedInbox(page = 1, pageSize = 50) {
1070
+ const offset = (page - 1) * pageSize;
1071
+ // Find all inbox folder IDs
1072
+ const inboxRows = this.db.prepare("SELECT id FROM folders WHERE special_use = 'inbox'").all();
1073
+ if (inboxRows.length === 0)
1074
+ return { items: [], total: 0, page, pageSize };
1075
+ const placeholders = inboxRows.map(() => "?").join(",");
1076
+ const folderIds = inboxRows.map((r) => r.id);
1077
+ const total = this.db.prepare(`SELECT COUNT(*) as cnt FROM messages WHERE folder_id IN (${placeholders})`).get(...folderIds).cnt;
1078
+ const rows = this.db.prepare(`SELECT m.*, EXISTS(
1079
+ SELECT 1 FROM sync_actions sa
1080
+ WHERE sa.account_id = m.account_id AND sa.uid = m.uid
1081
+ ) AS pending,
1082
+ (SELECT COUNT(DISTINCT account_id) FROM messages m2
1083
+ WHERE m2.message_id = m.message_id AND m.message_id != '') AS dupeCount
1084
+ FROM messages m WHERE m.folder_id IN (${placeholders})
1085
+ ORDER BY m.date DESC LIMIT ? OFFSET ?`).all(...folderIds, pageSize, offset);
1086
+ const items = rows.map(r => ({
1087
+ id: r.id,
1088
+ accountId: r.account_id,
1089
+ folderId: r.folder_id,
1090
+ uid: r.uid,
1091
+ messageId: r.message_id || "",
1092
+ inReplyTo: r.in_reply_to || "",
1093
+ references: JSON.parse(r.refs || "[]"),
1094
+ threadId: r.thread_id || undefined,
1095
+ date: r.date,
1096
+ subject: r.subject,
1097
+ from: { name: r.from_name, address: r.from_address },
1098
+ to: JSON.parse(r.to_json),
1099
+ cc: JSON.parse(r.cc_json),
1100
+ flags: JSON.parse(r.flags_json),
1101
+ size: r.size,
1102
+ hasAttachments: !!r.has_attachments,
1103
+ preview: r.preview,
1104
+ bodyPath: r.body_path || "",
1105
+ pending: !!r.pending,
1106
+ // >=2 means the same message-id exists under another account in
1107
+ // the local DB (delivered to both accounts, or a mailing-list
1108
+ // Bcc). The unified-inbox UI shows a small ⇆ badge on these
1109
+ // rows so the user knows "this is a copy of the same message".
1110
+ dupeCount: r.dupeCount | 0,
1111
+ }));
1112
+ return { items, total, page, pageSize };
1113
+ }
1114
+ /** Map a `messages` row to a MessageEnvelope. Exposes `uuid` (stable local
1115
+ * identity) and `bodyPath` (authoritative on-disk location) in addition
1116
+ * to the server-binding metadata. */
1117
+ rowToEnvelope(r) {
248
1118
  return {
249
1119
  id: r.id,
250
1120
  accountId: r.account_id,
251
1121
  folderId: r.folder_id,
252
1122
  uid: r.uid,
1123
+ uuid: r.uuid || "",
253
1124
  messageId: r.message_id || "",
254
1125
  inReplyTo: r.in_reply_to || "",
255
1126
  references: JSON.parse(r.refs || "[]"),
1127
+ threadId: r.thread_id || undefined,
256
1128
  date: r.date,
257
1129
  subject: r.subject,
258
1130
  from: { name: r.from_name, address: r.from_address },
@@ -261,9 +1133,34 @@ export class MailxDB {
261
1133
  flags: JSON.parse(r.flags_json),
262
1134
  size: r.size,
263
1135
  hasAttachments: !!r.has_attachments,
264
- preview: r.preview
1136
+ preview: r.preview,
1137
+ bodyPath: r.body_path || "",
1138
+ providerId: r.provider_id || undefined,
265
1139
  };
266
1140
  }
1141
+ getMessageByUid(accountId, uid, folderId) {
1142
+ const sql = folderId != null
1143
+ ? "SELECT * FROM messages WHERE account_id = ? AND uid = ? AND folder_id = ?"
1144
+ : "SELECT * FROM messages WHERE account_id = ? AND uid = ?";
1145
+ const params = folderId != null ? [accountId, uid, folderId] : [accountId, uid];
1146
+ const r = this.db.prepare(sql).get(...params);
1147
+ if (!r)
1148
+ return null;
1149
+ return this.rowToEnvelope(r);
1150
+ }
1151
+ /** Look up a message by its stable local UUID. Returned envelope includes
1152
+ * the current (folder_id, uid) — these may have changed since the UUID
1153
+ * was minted (folder move or server UID renumber) but the UUID itself
1154
+ * is stable. Use this as the identity in any long-lived reference
1155
+ * (compose in-reply-to, dally, undo stacks). */
1156
+ getMessageByUuid(uuid) {
1157
+ if (!uuid)
1158
+ return null;
1159
+ const r = this.db.prepare("SELECT * FROM messages WHERE uuid = ?").get(uuid);
1160
+ if (!r)
1161
+ return null;
1162
+ return this.rowToEnvelope(r);
1163
+ }
267
1164
  getMessageBodyPath(accountId, uid) {
268
1165
  const r = this.db.prepare("SELECT body_path FROM messages WHERE account_id = ? AND uid = ?").get(accountId, uid);
269
1166
  return r?.body_path || "";
@@ -271,10 +1168,40 @@ export class MailxDB {
271
1168
  updateMessageFlags(accountId, uid, flags) {
272
1169
  this.db.prepare("UPDATE messages SET flags_json = ? WHERE account_id = ? AND uid = ?").run(JSON.stringify(flags), accountId, uid);
273
1170
  }
1171
+ updateMessageFolder(accountId, uid, targetFolderId) {
1172
+ // Idempotency: if a row already exists at (account, target_folder, uid)
1173
+ // — common with Gmail's hash-synthesized UIDs across labels, or any
1174
+ // case where the move was already partially applied — the UPDATE
1175
+ // would fail the (acct, folder, uid) unique constraint. Treat it as
1176
+ // a no-op: drop the source row, the message is already where the
1177
+ // user wants it. Previously surfaced as "Mark-as-spam failed: UNIQUE
1178
+ // constraint failed" — bad UX for what is logically already done.
1179
+ const existingTarget = this.db.prepare("SELECT id FROM messages WHERE account_id = ? AND folder_id = ? AND uid = ?").get(accountId, targetFolderId, uid);
1180
+ if (existingTarget) {
1181
+ this.db.prepare("DELETE FROM messages WHERE account_id = ? AND uid = ? AND folder_id != ?").run(accountId, uid, targetFolderId);
1182
+ return;
1183
+ }
1184
+ this.db.prepare("UPDATE messages SET folder_id = ? WHERE account_id = ? AND uid = ?").run(targetFolderId, accountId, uid);
1185
+ }
1186
+ updateBodyPath(accountId, uid, bodyPath) {
1187
+ this.db.prepare("UPDATE messages SET body_path = ? WHERE account_id = ? AND uid = ?").run(bodyPath, accountId, uid);
1188
+ }
1189
+ /** Get messages without cached bodies (for background prefetch) */
1190
+ getMessagesWithoutBody(accountId, limit = 50) {
1191
+ return this.db.prepare("SELECT uid, folder_id as folderId FROM messages WHERE account_id = ? AND (body_path IS NULL OR body_path = '') ORDER BY date DESC LIMIT ?").all(accountId, limit);
1192
+ }
274
1193
  getHighestUid(accountId, folderId) {
275
1194
  const r = this.db.prepare("SELECT MAX(uid) as maxUid FROM messages WHERE account_id = ? AND folder_id = ?").get(accountId, folderId);
276
1195
  return r?.maxUid || 0;
277
1196
  }
1197
+ getOldestDate(accountId, folderId) {
1198
+ const r = this.db.prepare("SELECT MIN(date) as minDate FROM messages WHERE account_id = ? AND folder_id = ?").get(accountId, folderId);
1199
+ return r?.minDate || 0;
1200
+ }
1201
+ getMessageCount(accountId, folderId) {
1202
+ const r = this.db.prepare("SELECT count(*) as cnt FROM messages WHERE account_id = ? AND folder_id = ?").get(accountId, folderId);
1203
+ return r?.cnt || 0;
1204
+ }
278
1205
  /** Get all UIDs for a folder */
279
1206
  getUidsForFolder(accountId, folderId) {
280
1207
  const rows = this.db.prepare("SELECT uid FROM messages WHERE account_id = ? AND folder_id = ?").all(accountId, folderId);
@@ -282,7 +1209,19 @@ export class MailxDB {
282
1209
  }
283
1210
  /** Delete a message by account + UID */
284
1211
  deleteMessage(accountId, uid) {
1212
+ // Get folderId before deleting so we can update counts
1213
+ const msg = this.db.prepare("SELECT folder_id FROM messages WHERE account_id = ? AND uid = ?").get(accountId, uid);
285
1214
  this.db.prepare("DELETE FROM messages WHERE account_id = ? AND uid = ?").run(accountId, uid);
1215
+ // Refresh folder counts
1216
+ if (msg)
1217
+ this.recalcFolderCounts(msg.folder_id);
1218
+ }
1219
+ /** Recalculate folder total/unread counts from actual messages */
1220
+ recalcFolderCounts(folderId) {
1221
+ const counts = this.db.prepare(`SELECT COUNT(*) as total,
1222
+ SUM(CASE WHEN flags_json NOT LIKE '%\\\\Seen%' THEN 1 ELSE 0 END) as unread
1223
+ FROM messages WHERE folder_id = ?`).get(folderId);
1224
+ this.updateFolderCounts(folderId, counts?.total || 0, counts?.unread || 0);
286
1225
  }
287
1226
  /** Bulk insert within a transaction for sync performance */
288
1227
  beginTransaction() { this.db.exec("BEGIN"); }
@@ -291,51 +1230,490 @@ export class MailxDB {
291
1230
  // ── Contacts ──
292
1231
  /** Record an address used in sent mail */
293
1232
  recordSentAddress(name, email) {
1233
+ // Don't pollute the contacts table with non-addresses.
1234
+ if (!email || !/^[^\s<>@]+@[^\s<>@]+\.[^\s<>@]+$/.test(email))
1235
+ return;
1236
+ const lower = email.toLowerCase();
1237
+ if (this.isAddressDenylisted(lower))
1238
+ return;
1239
+ if (isJunkContact(lower, name))
1240
+ return;
294
1241
  const now = Date.now();
295
- const existing = this.db.prepare("SELECT id FROM contacts WHERE email = ?").get(email);
1242
+ // discovered tier holds one row per email bump if present, else
1243
+ // insert. Doesn't touch preferred or google rows for the same email;
1244
+ // those are independent address-book entries the user/Google curates.
1245
+ const existing = this.db.prepare("SELECT id, name FROM contacts WHERE source = 'discovered' AND lower(email) = ?").get(lower);
296
1246
  if (existing) {
297
- this.db.prepare("UPDATE contacts SET name = CASE WHEN ? != '' THEN ? ELSE name END, last_used = ?, use_count = use_count + 1, updated_at = ? WHERE email = ?").run(name, name, now, now, email);
1247
+ this.db.prepare("UPDATE contacts SET name = CASE WHEN name = '' AND ? != '' THEN ? ELSE name END, last_used = ?, use_count = use_count + 1, updated_at = ? WHERE id = ?").run(name, name, now, now, existing.id);
298
1248
  }
299
1249
  else {
300
- this.db.prepare("INSERT INTO contacts (source, name, email, last_used, use_count, updated_at) VALUES ('sent', ?, ?, ?, 1, ?)").run(name, email, now, now);
1250
+ this.db.prepare("INSERT INTO contacts (source, name, email, last_used, use_count, updated_at) VALUES ('discovered', ?, ?, ?, 1, ?)").run(name || "", email, now, now);
301
1251
  }
1252
+ this.notifyContactsChanged();
302
1253
  }
303
- /** Seed contacts from all message senders in the DB */
1254
+ /** True if `email` (lowercased) appears in the active denylist. Cached
1255
+ * in-memory; refreshed on contacts.jsonc reload via setContactsDenylist. */
1256
+ _denylist = new Set();
1257
+ isAddressDenylisted(emailLower) {
1258
+ return this._denylist.has(emailLower);
1259
+ }
1260
+ setContactsDenylist(emails) {
1261
+ this._denylist = new Set(emails.map(e => (e || "").trim().toLowerCase()).filter(Boolean));
1262
+ }
1263
+ /** Priority sender / domain index — populated from contacts.jsonc on reload.
1264
+ * Used by the client to flag list rows. Source of truth is the JSONC; this
1265
+ * is just a fast in-memory lookup. */
1266
+ _prioritySenders = new Set();
1267
+ _priorityDomains = new Set();
1268
+ setPriorityIndex(senders, domains) {
1269
+ this._prioritySenders = new Set((senders || []).map(s => (s || "").trim().toLowerCase()).filter(Boolean));
1270
+ this._priorityDomains = new Set((domains || []).map(d => (d || "").trim().toLowerCase()).filter(Boolean));
1271
+ }
1272
+ getPriorityIndex() {
1273
+ return {
1274
+ senders: Array.from(this._prioritySenders).sort(),
1275
+ domains: Array.from(this._priorityDomains).sort(),
1276
+ };
1277
+ }
1278
+ isPrioritySender(addr) {
1279
+ const lower = (addr || "").trim().toLowerCase();
1280
+ if (this._prioritySenders.has(lower))
1281
+ return true;
1282
+ const at = lower.indexOf("@");
1283
+ const domain = at >= 0 ? lower.slice(at + 1) : "";
1284
+ return !!domain && this._priorityDomains.has(domain);
1285
+ }
1286
+ /** Callback fired when local-DB contacts mutations happen (sends adding
1287
+ * to discovered, corpus seeder finding new addresses). The service
1288
+ * registers a debounced cloud flush here so the GDrive copy stays in
1289
+ * sync. NOT fired from applyContactsConfig — that's the inbound path
1290
+ * and would create a write loop. */
1291
+ _onContactsChanged;
1292
+ setOnContactsChanged(cb) {
1293
+ this._onContactsChanged = cb;
1294
+ }
1295
+ notifyContactsChanged() {
1296
+ try {
1297
+ this._onContactsChanged?.();
1298
+ }
1299
+ catch { /* ignore */ }
1300
+ }
1301
+ /** Seed `discovered`-tier contacts from every address that appears in
1302
+ * any cached message — From / To / Cc / Bcc across all folders. One row
1303
+ * per email; first non-empty name observed wins. Sent-folder rows skip
1304
+ * the From (it's us). Junk addresses (noreply, mailer-daemon, *-bounces)
1305
+ * and denylisted addresses are dropped at seed time so they never enter
1306
+ * autocomplete.
1307
+ *
1308
+ * Discovered is a single tier; sub-distinctions like sent-vs-received
1309
+ * collapse here because the user-facing UI shows them as one "discovered"
1310
+ * source. Recency-weighted use_count differentiates within the tier. */
304
1311
  seedContactsFromMessages() {
1312
+ const VALID = /^[^\s<>@]+@[^\s<>@]+\.[^\s<>@]+$/;
305
1313
  const now = Date.now();
306
- const rows = this.db.prepare(`SELECT from_name, from_address, COUNT(*) as cnt, MAX(date) as last
307
- FROM messages
308
- WHERE from_address != ''
309
- GROUP BY from_address`).all();
1314
+ const agg = new Map();
1315
+ const bump = (name, address, date) => {
1316
+ const email = (address || "").trim().toLowerCase();
1317
+ if (!email || !VALID.test(email))
1318
+ return;
1319
+ if (isJunkContact(email, name))
1320
+ return;
1321
+ if (this.isAddressDenylisted(email))
1322
+ return;
1323
+ const e = agg.get(email);
1324
+ if (e) {
1325
+ e.cnt++;
1326
+ if (date > e.last)
1327
+ e.last = date;
1328
+ if (!e.name && name)
1329
+ e.name = name;
1330
+ }
1331
+ else {
1332
+ agg.set(email, { name: name || "", cnt: 1, last: date || 0 });
1333
+ }
1334
+ };
1335
+ // Sent folder: recipients only (skip the user's own From address).
1336
+ const sentRows = this.db.prepare(`SELECT m.to_json, m.cc_json, m.bcc_json, m.date
1337
+ FROM messages m
1338
+ JOIN folders f ON m.folder_id = f.id
1339
+ WHERE f.special_use = 'sent'`).all();
1340
+ for (const r of sentRows) {
1341
+ const date = r.date || 0;
1342
+ for (const field of [r.to_json, r.cc_json, r.bcc_json]) {
1343
+ if (!field)
1344
+ continue;
1345
+ let parsed;
1346
+ try {
1347
+ parsed = JSON.parse(field);
1348
+ }
1349
+ catch {
1350
+ continue;
1351
+ }
1352
+ if (!Array.isArray(parsed))
1353
+ continue;
1354
+ for (const a of parsed) {
1355
+ if (!a)
1356
+ continue;
1357
+ bump(a.name || "", a.address || a.email || "", date);
1358
+ }
1359
+ }
1360
+ }
1361
+ // Other folders: From + recipients.
1362
+ const recvRows = this.db.prepare(`SELECT m.from_name, m.from_address, m.to_json, m.cc_json, m.bcc_json, m.date
1363
+ FROM messages m
1364
+ LEFT JOIN folders f ON m.folder_id = f.id
1365
+ WHERE f.special_use IS NULL OR f.special_use != 'sent'`).all();
1366
+ for (const r of recvRows) {
1367
+ const date = r.date || 0;
1368
+ bump(r.from_name, r.from_address, date);
1369
+ for (const field of [r.to_json, r.cc_json, r.bcc_json]) {
1370
+ if (!field)
1371
+ continue;
1372
+ let parsed;
1373
+ try {
1374
+ parsed = JSON.parse(field);
1375
+ }
1376
+ catch {
1377
+ continue;
1378
+ }
1379
+ if (!Array.isArray(parsed))
1380
+ continue;
1381
+ for (const a of parsed) {
1382
+ if (!a)
1383
+ continue;
1384
+ bump(a.name || "", a.address || a.email || "", date);
1385
+ }
1386
+ }
1387
+ }
310
1388
  let added = 0;
311
- for (const r of rows) {
312
- const existing = this.db.prepare("SELECT id FROM contacts WHERE email = ?").get(r.from_address);
1389
+ let bumped = 0;
1390
+ const insStmt = this.db.prepare("INSERT INTO contacts (source, name, email, last_used, use_count, updated_at) VALUES ('discovered', ?, ?, ?, ?, ?)");
1391
+ const updStmt = this.db.prepare(`UPDATE contacts SET use_count = ?,
1392
+ last_used = max(last_used, ?),
1393
+ name = CASE WHEN name = '' AND ? != '' THEN ? ELSE name END,
1394
+ updated_at = ?
1395
+ WHERE id = ?`);
1396
+ for (const [email, info] of agg) {
1397
+ const existing = this.db.prepare("SELECT id FROM contacts WHERE source = 'discovered' AND lower(email) = ?").get(email);
313
1398
  if (!existing) {
314
- this.db.prepare("INSERT INTO contacts (source, name, email, last_used, use_count, updated_at) VALUES ('received', ?, ?, ?, ?, ?)").run(r.from_name || "", r.from_address, r.last, r.cnt, now);
1399
+ insStmt.run(info.name, email, info.last, info.cnt, now);
315
1400
  added++;
316
1401
  }
1402
+ else {
1403
+ updStmt.run(info.cnt, info.last, info.name, info.name, now, existing.id);
1404
+ bumped++;
1405
+ }
1406
+ }
1407
+ if (added > 0 || bumped > 0) {
1408
+ console.log(` [contacts] seed: ${added} new + ${bumped} refreshed (discovered)`);
1409
+ this.notifyContactsChanged();
317
1410
  }
318
1411
  return added;
319
1412
  }
320
- /** Search contacts by name or email prefix */
1413
+ /** Apply the contents of contacts.jsonc replaces all preferred-tier rows
1414
+ * with the entries in `preferred[]`, merges `discovered[]` into the local
1415
+ * cache, sets the in-memory denylist, and purges any discovered rows
1416
+ * whose email is now denylisted. Preferred rows are *not* auto-purged on
1417
+ * denylist hit — if the user explicitly added them they win that
1418
+ * conflict; we just log a warning.
1419
+ *
1420
+ * Discovered rows from the file are MERGED with whatever the local
1421
+ * message-corpus seeder has produced. Each device contributes its
1422
+ * observed addresses; over time GDrive accumulates the union. */
1423
+ applyContactsConfig(cfg) {
1424
+ const preferred = Array.isArray(cfg.preferred) ? cfg.preferred : [];
1425
+ const denylist = Array.isArray(cfg.denylist) ? cfg.denylist : [];
1426
+ const denylistPatterns = Array.isArray(cfg.denylistPatterns) ? cfg.denylistPatterns : [];
1427
+ const priorityDomains = Array.isArray(cfg.priorityDomains) ? cfg.priorityDomains : [];
1428
+ const discovered = Array.isArray(cfg.discovered) ? cfg.discovered : [];
1429
+ this.setContactsDenylist(denylist);
1430
+ setContactsDenyPatterns(denylistPatterns);
1431
+ // Priority index — preferred[] entries with priority:true plus the
1432
+ // top-level priorityDomains[]. Address book and visual highlight
1433
+ // are independent: not every preferred contact is priority.
1434
+ const prioritySenders = preferred
1435
+ .filter(e => e && e.priority === true && e.email)
1436
+ .map(e => e.email);
1437
+ this.setPriorityIndex(prioritySenders, priorityDomains);
1438
+ // Wipe and rewrite preferred-tier rows owned by contacts.jsonc.
1439
+ // The address-book UI's legacy `upsertContact` still writes
1440
+ // source='manual' rows; those are owned by the address-book code
1441
+ // path, not contacts.jsonc, so we leave them alone here.
1442
+ this.db.exec("DELETE FROM contacts WHERE source NOT IN ('google', 'discovered', 'manual')");
1443
+ const VALID = /^[^\s<>@]+@[^\s<>@]+\.[^\s<>@]+$/;
1444
+ const now = Date.now();
1445
+ const ins = this.db.prepare(`INSERT OR IGNORE INTO contacts (source, name, email, organization, last_used, use_count, updated_at)
1446
+ VALUES (?, ?, ?, ?, 0, 0, ?)`);
1447
+ const denySet = new Set(denylist.map(e => (e || "").trim().toLowerCase()).filter(Boolean));
1448
+ const conflicts = [];
1449
+ let inserted = 0;
1450
+ for (const entry of preferred) {
1451
+ if (!entry)
1452
+ continue;
1453
+ const email = (entry.email || "").trim();
1454
+ if (!email || !VALID.test(email))
1455
+ continue;
1456
+ if (denySet.has(email.toLowerCase())) {
1457
+ conflicts.push(email);
1458
+ continue;
1459
+ }
1460
+ const source = (entry.source || "preferred").trim() || "preferred";
1461
+ const name = (entry.name || "").trim();
1462
+ const org = (entry.organization || entry.org || "").trim();
1463
+ try {
1464
+ const r = ins.run(source, name, email, org, now);
1465
+ if (r.changes)
1466
+ inserted++;
1467
+ }
1468
+ catch { /* dup row, skip */ }
1469
+ }
1470
+ // Merge discovered[] from cloud into local cache. For each entry:
1471
+ // existing row wins on use_count (max), name fills if empty, lastUsed
1472
+ // is max. Missing rows are inserted. Denylisted entries skipped.
1473
+ const insDiscovered = this.db.prepare("INSERT INTO contacts (source, name, email, last_used, use_count, updated_at) VALUES ('discovered', ?, ?, ?, ?, ?)");
1474
+ const updDiscovered = this.db.prepare(`UPDATE contacts SET use_count = max(use_count, ?),
1475
+ last_used = max(last_used, ?),
1476
+ name = CASE WHEN name = '' AND ? != '' THEN ? ELSE name END,
1477
+ updated_at = ?
1478
+ WHERE id = ?`);
1479
+ let discoveredAdded = 0;
1480
+ for (const entry of discovered) {
1481
+ if (!entry)
1482
+ continue;
1483
+ const email = (entry.email || "").trim();
1484
+ if (!email || !VALID.test(email))
1485
+ continue;
1486
+ const lower = email.toLowerCase();
1487
+ if (denySet.has(lower))
1488
+ continue;
1489
+ if (isJunkContact(lower, entry.name || ""))
1490
+ continue;
1491
+ const name = (entry.name || "").trim();
1492
+ const useCount = Math.max(0, entry.useCount || 0);
1493
+ const lastUsed = Math.max(0, entry.lastUsed || 0);
1494
+ const existing = this.db.prepare("SELECT id FROM contacts WHERE source = 'discovered' AND lower(email) = ?").get(lower);
1495
+ if (!existing) {
1496
+ insDiscovered.run(name, email, lastUsed, useCount, now);
1497
+ discoveredAdded++;
1498
+ }
1499
+ else {
1500
+ updDiscovered.run(useCount, lastUsed, name, name, now, existing.id);
1501
+ }
1502
+ }
1503
+ // Purge discovered rows for any denylisted email.
1504
+ const purge = this.db.prepare("DELETE FROM contacts WHERE source = 'discovered' AND lower(email) = ?");
1505
+ let purged = 0;
1506
+ for (const e of denySet) {
1507
+ const r = purge.run(e);
1508
+ purged += Number(r.changes || 0);
1509
+ }
1510
+ if (conflicts.length > 0) {
1511
+ console.warn(` [contacts] config: ${conflicts.length} preferred entries also appear in denylist — denylist wins, entries skipped: ${conflicts.join(", ")}`);
1512
+ }
1513
+ console.log(` [contacts] config applied: ${inserted} preferred + ${discoveredAdded} discovered row(s), ${denySet.size} denylisted, ${purged} discovered row(s) purged`);
1514
+ return { preferred: inserted, discovered: discoveredAdded, purged, conflicts };
1515
+ }
1516
+ /** Build the contacts.jsonc shape from current DB state — for round-trip
1517
+ * to GDrive. Preferred-tier rows come from anything not in the reserved
1518
+ * system sources; discovered comes from `source='discovered'` rows;
1519
+ * denylist comes from the in-memory set (set by applyContactsConfig).
1520
+ * Caller is responsible for actually writing the cloud copy. */
1521
+ exportContactsConfig() {
1522
+ const preferredRows = this.db.prepare(`SELECT name, email, source, organization
1523
+ FROM contacts
1524
+ WHERE source NOT IN ('google', 'discovered', 'manual')
1525
+ ORDER BY source, lower(email), lower(name)`).all();
1526
+ const discoveredRows = this.db.prepare(`SELECT name, email, use_count, last_used
1527
+ FROM contacts
1528
+ WHERE source = 'discovered'
1529
+ ORDER BY use_count DESC, last_used DESC, lower(email)`).all();
1530
+ return {
1531
+ preferred: preferredRows.map(r => {
1532
+ const out = { name: r.name || "", email: r.email, source: r.source };
1533
+ if (r.organization)
1534
+ out.organization = r.organization;
1535
+ return out;
1536
+ }),
1537
+ denylist: Array.from(this._denylist),
1538
+ discovered: discoveredRows.map(r => ({
1539
+ name: r.name || "",
1540
+ email: r.email,
1541
+ useCount: r.use_count,
1542
+ lastUsed: r.last_used,
1543
+ })),
1544
+ };
1545
+ }
1546
+ /** Search contacts by name or email prefix.
1547
+ *
1548
+ * Source-tier bonus is what makes the curated address book win against
1549
+ * passive corpus harvest. Anything in `contacts.jsonc#preferred[]` (any
1550
+ * source value other than the two reserved system sources) gets the
1551
+ * highest tier — that's the user's explicit address book and overrides
1552
+ * Google. Google sits in the middle (the auto-synced address book).
1553
+ * `discovered` is the corpus-harvested floor.
1554
+ *
1555
+ * Multi-name-per-email is supported: the same email can carry distinct
1556
+ * (source, name) rows — Bob's wife and Bob Smith both at bob@example.com
1557
+ * surface as two rows, each typing-completable by their own name. */
321
1558
  searchContacts(query, limit = 10) {
1559
+ query = (query || "").trim();
1560
+ if (!query)
1561
+ return [];
1562
+ // Split into whitespace-separated tokens. Each token must appear in
1563
+ // name or email — order- and adjacency-independent. So "eleanor elkin"
1564
+ // matches "Eleanor Elkin", "Elkin, Eleanor", "Eleanor M Elkin", and
1565
+ // "elkin@eleanor.example". The first token gets the prefix bonus for
1566
+ // ranking; remaining tokens just have to be present.
1567
+ const tokens = query.split(/\s+/).filter(Boolean);
1568
+ const firstSubstr = `%${tokens[0]}%`;
1569
+ const firstPrefix = `${tokens[0]}%`;
1570
+ const tokenWhere = tokens.map(() => "(name LIKE ? OR email LIKE ?)").join(" AND ");
1571
+ const tokenParams = [];
1572
+ for (const t of tokens) {
1573
+ tokenParams.push(`%${t}%`, `%${t}%`);
1574
+ }
1575
+ let rows;
1576
+ try {
1577
+ // Source tier: anything not in the two reserved system sources
1578
+ // ('google', 'discovered') is preferred-tier — i.e. came out of
1579
+ // contacts.jsonc#preferred[]. The user's `source: "work"` /
1580
+ // `source: "family"` tags all rank +40 alongside the default
1581
+ // `preferred` label.
1582
+ rows = this.db.prepare(`SELECT name, email, source, use_count, last_used,
1583
+ (CASE
1584
+ WHEN lower(name) LIKE lower(?) THEN 3
1585
+ WHEN substr(email, 1, instr(email, '@') - 1) LIKE lower(?) THEN 2
1586
+ WHEN email LIKE ? OR name LIKE ? THEN 1
1587
+ ELSE 0
1588
+ END) +
1589
+ (CASE
1590
+ WHEN source = 'google' THEN 30
1591
+ WHEN source = 'discovered' THEN 0
1592
+ ELSE 40
1593
+ END) AS match_rank
1594
+ FROM contacts
1595
+ WHERE ${tokenWhere}
1596
+ ORDER BY match_rank DESC, use_count DESC, last_used DESC
1597
+ LIMIT ?`).all(firstPrefix, firstPrefix, firstSubstr, firstSubstr, ...tokenParams, limit * 2);
1598
+ }
1599
+ catch (e) {
1600
+ console.error(` [searchContacts] ranked query failed (${e?.message}) — falling back to simple LIKE`);
1601
+ rows = this.db.prepare(`SELECT name, email, source, use_count, last_used, 0 AS match_rank
1602
+ FROM contacts
1603
+ WHERE ${tokenWhere}
1604
+ ORDER BY use_count DESC, last_used DESC
1605
+ LIMIT ?`).all(...tokenParams, limit * 2);
1606
+ }
1607
+ // Filter out denylisted emails as a defense-in-depth — applyContactsConfig
1608
+ // already purges discovered rows on denylist, but a Google sync that
1609
+ // reintroduced a denylisted address would otherwise leak through.
1610
+ rows = rows.filter(r => !this.isAddressDenylisted((r.email || "").toLowerCase()));
1611
+ const now = Date.now();
1612
+ const HALF_LIFE_MS = 30 * 86400_000;
1613
+ const score = (r) => (r.match_rank || 0) * 10_000
1614
+ + (r.use_count || 0) * Math.pow(0.5, Math.max(0, now - (r.last_used || 0)) / HALF_LIFE_MS);
1615
+ rows.sort((a, b) => score(b) - score(a));
1616
+ rows = rows.slice(0, limit);
1617
+ return rows.map(r => ({ name: r.name, email: r.email, source: r.source, useCount: r.use_count }));
1618
+ }
1619
+ /** List all contacts (address-book view) with pagination + optional filter. */
1620
+ listContacts(query, page = 1, pageSize = 100) {
1621
+ query = (query || "").trim();
1622
+ const hasQuery = !!query;
322
1623
  const q = `%${query}%`;
323
- const rows = this.db.prepare(`SELECT name, email, source, use_count FROM contacts
324
- WHERE email LIKE ? OR name LIKE ?
1624
+ const whereClause = hasQuery ? "WHERE email LIKE ? OR name LIKE ?" : "";
1625
+ const params = hasQuery ? [q, q] : [];
1626
+ const totalRow = this.db.prepare(`SELECT COUNT(*) as c FROM contacts ${whereClause}`).get(...params);
1627
+ const offset = (page - 1) * pageSize;
1628
+ const rows = this.db.prepare(`SELECT name, email, source, google_id, use_count, last_used FROM contacts
1629
+ ${whereClause}
325
1630
  ORDER BY use_count DESC, last_used DESC
326
- LIMIT ?`).all(q, q, limit);
327
- return rows.map(r => ({ name: r.name, email: r.email, source: r.source, useCount: r.use_count }));
1631
+ LIMIT ? OFFSET ?`).all(...params, pageSize, offset);
1632
+ return {
1633
+ items: rows.map(r => ({
1634
+ name: r.name, email: r.email, source: r.source,
1635
+ googleId: r.google_id || null,
1636
+ useCount: r.use_count, lastUsed: r.last_used,
1637
+ })),
1638
+ total: totalRow?.c || 0,
1639
+ page, pageSize,
1640
+ };
1641
+ }
1642
+ /** Update or insert a contact manually (from the address book UI). */
1643
+ upsertContact(name, email) {
1644
+ if (!email || !/^[^\s<>@]+@[^\s<>@]+\.[^\s<>@]+$/.test(email)) {
1645
+ throw new Error(`Invalid email: ${email}`);
1646
+ }
1647
+ const now = Date.now();
1648
+ const existing = this.db.prepare("SELECT id FROM contacts WHERE email = ?").get(email);
1649
+ if (existing) {
1650
+ this.db.prepare("UPDATE contacts SET name = ?, updated_at = ? WHERE email = ?").run(name, now, email);
1651
+ }
1652
+ else {
1653
+ this.db.prepare("INSERT INTO contacts (source, name, email, last_used, use_count, updated_at) VALUES ('manual', ?, ?, ?, 0, ?)").run(name, email, now, now);
1654
+ }
1655
+ }
1656
+ /** Delete a contact by email (address book UI). */
1657
+ deleteContact(email) {
1658
+ this.db.prepare("DELETE FROM contacts WHERE email = ?").run(email);
1659
+ }
1660
+ /** Delete contact rows by Google People resourceName. Used by the
1661
+ * incremental People sync when a person comes back with `metadata.deleted = true`
1662
+ * — the email may have already changed/disappeared, but the resourceName
1663
+ * is stable. Removes all rows tied to that Google identity (a single
1664
+ * contact can have multiple email addresses, each is its own row). */
1665
+ deleteContactByGoogleId(googleId) {
1666
+ if (!googleId)
1667
+ return 0;
1668
+ const r = this.db.prepare("DELETE FROM contacts WHERE google_id = ?").run(googleId);
1669
+ return Number(r.changes || 0);
328
1670
  }
329
1671
  // ── Search ──
330
1672
  /** Full-text search across all messages. Supports qualifiers: from:, to:, subject: */
331
- searchMessages(query, page = 1, pageSize = 50) {
332
- // Parse qualifiers
1673
+ searchMessages(query, page = 1, pageSize = 50, accountId, folderId) {
1674
+ query = (query || "").trim();
1675
+ // Parse qualifiers (C45: extended set — date:, has:, is:, folder:).
333
1676
  let ftsQuery = "";
334
1677
  const parts = query.match(/(?:[^\s"]+|"[^"]*")+/g) || [];
1678
+ // Extra SQL where-clauses for qualifiers that don't map to FTS columns.
1679
+ const extraWhere = [];
1680
+ const extraParams = [];
1681
+ // Parse a "1d", "1w", "2024-01-15", "yesterday", "today" etc. into ms epoch.
1682
+ const parseRel = (s) => {
1683
+ const lower = s.toLowerCase().trim();
1684
+ if (lower === "today") {
1685
+ const d = new Date();
1686
+ d.setHours(0, 0, 0, 0);
1687
+ return d.getTime();
1688
+ }
1689
+ if (lower === "yesterday") {
1690
+ const d = new Date();
1691
+ d.setHours(0, 0, 0, 0);
1692
+ return d.getTime() - 86400_000;
1693
+ }
1694
+ const rel = lower.match(/^(\d+)([dwmy])$/);
1695
+ if (rel) {
1696
+ const n = parseInt(rel[1]);
1697
+ const unit = rel[2];
1698
+ const ms = unit === "d" ? n * 86400_000
1699
+ : unit === "w" ? n * 7 * 86400_000
1700
+ : unit === "m" ? n * 30 * 86400_000
1701
+ : n * 365 * 86400_000;
1702
+ return Date.now() - ms;
1703
+ }
1704
+ const ts = Date.parse(s);
1705
+ return isNaN(ts) ? null : ts;
1706
+ };
335
1707
  for (const part of parts) {
336
1708
  const fromMatch = part.match(/^from:(.+)$/i);
337
1709
  const toMatch = part.match(/^to:(.+)$/i);
338
1710
  const subjectMatch = part.match(/^subject:(.+)$/i);
1711
+ const dateMatch = part.match(/^date:([><]?=?)(.+)$/i);
1712
+ const afterMatch = part.match(/^after:(.+)$/i);
1713
+ const beforeMatch = part.match(/^before:(.+)$/i);
1714
+ const hasMatch = part.match(/^has:(.+)$/i);
1715
+ const isMatch = part.match(/^is:(.+)$/i);
1716
+ const folderMatch = part.match(/^folder:(.+)$/i);
339
1717
  if (fromMatch) {
340
1718
  const term = fromMatch[1].replace(/"/g, "");
341
1719
  ftsQuery += `(from_name:${term} OR from_address:${term}) `;
@@ -348,27 +1726,95 @@ export class MailxDB {
348
1726
  const term = subjectMatch[1].replace(/"/g, "");
349
1727
  ftsQuery += `subject:${term} `;
350
1728
  }
1729
+ else if (dateMatch || afterMatch || beforeMatch) {
1730
+ const op = dateMatch ? (dateMatch[1] || "=") : (afterMatch ? ">" : "<");
1731
+ const valStr = dateMatch ? dateMatch[2] : (afterMatch ? afterMatch[1] : beforeMatch[1]);
1732
+ const ts = parseRel(valStr.replace(/"/g, ""));
1733
+ if (ts !== null) {
1734
+ if (op === ">" || op === ">=") {
1735
+ extraWhere.push("m.date >= ?");
1736
+ extraParams.push(ts);
1737
+ }
1738
+ else if (op === "<" || op === "<=") {
1739
+ extraWhere.push("m.date <= ?");
1740
+ extraParams.push(ts);
1741
+ }
1742
+ else {
1743
+ extraWhere.push("m.date >= ? AND m.date < ?");
1744
+ extraParams.push(ts, ts + 86400_000);
1745
+ }
1746
+ }
1747
+ }
1748
+ else if (hasMatch) {
1749
+ const v = hasMatch[1].toLowerCase().replace(/"/g, "");
1750
+ if (v === "attachment" || v === "attachments") {
1751
+ extraWhere.push("m.has_attachments = 1");
1752
+ }
1753
+ }
1754
+ else if (isMatch) {
1755
+ const v = isMatch[1].toLowerCase().replace(/"/g, "");
1756
+ if (v === "flagged" || v === "starred")
1757
+ extraWhere.push("m.flags_json LIKE '%\\\\Flagged%'");
1758
+ else if (v === "unread")
1759
+ extraWhere.push("m.flags_json NOT LIKE '%\\\\Seen%'");
1760
+ else if (v === "read" || v === "seen")
1761
+ extraWhere.push("m.flags_json LIKE '%\\\\Seen%'");
1762
+ else if (v === "answered")
1763
+ extraWhere.push("m.flags_json LIKE '%\\\\Answered%'");
1764
+ else if (v === "draft")
1765
+ extraWhere.push("m.flags_json LIKE '%\\\\Draft%'");
1766
+ }
1767
+ else if (folderMatch) {
1768
+ const v = folderMatch[1].replace(/"/g, "");
1769
+ extraWhere.push("LOWER(f.name) LIKE ?");
1770
+ extraParams.push(`%${v.toLowerCase()}%`);
1771
+ }
351
1772
  else {
352
- // Unqualified — search everything
353
- ftsQuery += `${part}* `;
1773
+ // Unqualified — search everything.
1774
+ let term = part.replace(/^\/|\/$/g, "");
1775
+ if (term.includes("|")) {
1776
+ const alts = term.split("|").filter(Boolean).map(t => `${t}*`).join(" OR ");
1777
+ ftsQuery += `(${alts}) `;
1778
+ }
1779
+ else {
1780
+ ftsQuery += `${term}* `;
1781
+ }
354
1782
  }
355
1783
  }
356
1784
  ftsQuery = ftsQuery.trim();
1785
+ // If the user typed only qualifier-only terms (e.g. "is:flagged after:1w"),
1786
+ // FTS query is empty — match-all surrogate.
357
1787
  if (!ftsQuery)
358
- return { items: [], total: 0, page, pageSize };
1788
+ ftsQuery = "*";
359
1789
  const offset = (page - 1) * pageSize;
360
1790
  try {
361
- const countRow = this.db.prepare("SELECT COUNT(*) as cnt FROM messages_fts WHERE messages_fts MATCH ?").get(ftsQuery);
1791
+ let scopeWhere = "";
1792
+ const scopeParams = [];
1793
+ if (accountId && folderId) {
1794
+ scopeWhere = " AND m.account_id = ? AND m.folder_id = ?";
1795
+ scopeParams.push(accountId, folderId);
1796
+ }
1797
+ else if (accountId) {
1798
+ scopeWhere = " AND m.account_id = ?";
1799
+ scopeParams.push(accountId);
1800
+ }
1801
+ if (extraWhere.length > 0) {
1802
+ scopeWhere += " AND " + extraWhere.join(" AND ");
1803
+ scopeParams.push(...extraParams);
1804
+ }
1805
+ const countRow = this.db.prepare(`SELECT COUNT(*) as cnt FROM messages m JOIN messages_fts fts ON m.id = fts.rowid WHERE messages_fts MATCH ?${scopeWhere}`).get(ftsQuery, ...scopeParams);
362
1806
  const total = countRow?.cnt || 0;
363
- const rows = this.db.prepare(`SELECT m.* FROM messages m
1807
+ const rows = this.db.prepare(`SELECT m.*, f.name AS folder_name FROM messages m
364
1808
  JOIN messages_fts fts ON m.id = fts.rowid
365
- WHERE messages_fts MATCH ?
1809
+ LEFT JOIN folders f ON f.id = m.folder_id AND f.account_id = m.account_id
1810
+ WHERE messages_fts MATCH ?${scopeWhere}
366
1811
  ORDER BY m.date DESC
367
- LIMIT ? OFFSET ?`).all(ftsQuery, pageSize, offset);
1812
+ LIMIT ? OFFSET ?`).all(ftsQuery, ...scopeParams, pageSize, offset);
368
1813
  const items = rows.map(r => ({
369
1814
  id: r.id,
370
1815
  accountId: r.account_id,
371
1816
  folderId: r.folder_id,
1817
+ folderName: r.folder_name || "",
372
1818
  uid: r.uid,
373
1819
  messageId: r.message_id || "",
374
1820
  inReplyTo: r.in_reply_to || "",
@@ -401,18 +1847,28 @@ export class MailxDB {
401
1847
  subject, from_name, from_address, to_text, cc_text, body_text,
402
1848
  content=messages, content_rowid=id
403
1849
  )`);
1850
+ // Use a single transaction + prepared statement for speed (~50x faster than individual inserts)
1851
+ const insert = this.db.prepare("INSERT INTO messages_fts (rowid, subject, from_name, from_address, to_text, cc_text, body_text) VALUES (?, ?, ?, ?, ?, ?, ?)");
404
1852
  const rows = this.db.prepare("SELECT id, subject, from_name, from_address, to_json, cc_json, preview FROM messages").all();
405
1853
  let count = 0;
406
- for (const r of rows) {
407
- const to = JSON.parse(r.to_json || "[]");
408
- const cc = JSON.parse(r.cc_json || "[]");
409
- const toText = to.map((a) => `${a.name} ${a.address}`).join(" ");
410
- const ccText = cc.map((a) => `${a.name} ${a.address}`).join(" ");
411
- try {
412
- this.db.prepare("INSERT INTO messages_fts (rowid, subject, from_name, from_address, to_text, cc_text, body_text) VALUES (?, ?, ?, ?, ?, ?, ?)").run(r.id, r.subject, r.from_name, r.from_address, toText, ccText, r.preview);
413
- count++;
1854
+ this.db.exec("BEGIN");
1855
+ try {
1856
+ for (const r of rows) {
1857
+ const to = JSON.parse(r.to_json || "[]");
1858
+ const cc = JSON.parse(r.cc_json || "[]");
1859
+ const toText = to.map((a) => `${a.name} ${a.address}`).join(" ");
1860
+ const ccText = cc.map((a) => `${a.name} ${a.address}`).join(" ");
1861
+ try {
1862
+ insert.run(r.id, r.subject, r.from_name, r.from_address, toText, ccText, r.preview);
1863
+ count++;
1864
+ }
1865
+ catch { /* skip duplicates */ }
414
1866
  }
415
- catch { /* skip duplicates */ }
1867
+ this.db.exec("COMMIT");
1868
+ }
1869
+ catch (e) {
1870
+ this.db.exec("ROLLBACK");
1871
+ throw e;
416
1872
  }
417
1873
  return count;
418
1874
  }