@bobfrankston/mailx 1.0.378 → 1.0.382

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.
@@ -8,6 +8,7 @@ import * as fs from "node:fs";
8
8
  import * as path from "node:path";
9
9
  import { fileURLToPath } from "node:url";
10
10
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
11
+ import * as gsync from "./google-sync.js";
11
12
  import { loadSettings, saveSettings, loadAccounts, loadAccountsAsync, saveAccounts, initCloudConfig, loadAllowlist, saveAllowlist, loadAutocomplete, saveAutocomplete, getStorageInfo, getConfigDir } from "@bobfrankston/mailx-settings";
12
13
  import { sanitizeHtml, encodeQuotedPrintable } from "@bobfrankston/mailx-types";
13
14
  import { simpleParser } from "mailparser";
@@ -445,6 +446,198 @@ export class MailxService {
445
446
  }
446
447
  return all.find((a) => a.primary) || all[0] || null;
447
448
  }
449
+ // ── Calendar / Tasks / Contacts: two-way cache (2026-04-23) ──
450
+ async primaryTokenProvider(feature) {
451
+ const acct = this.getPrimaryAccount(feature);
452
+ if (!acct)
453
+ throw new Error(`No primary account for ${feature}`);
454
+ return async () => {
455
+ const tok = await this.imapManager.getOAuthToken(acct.id);
456
+ if (!tok)
457
+ throw new Error(`No OAuth token for ${acct.id}`);
458
+ return tok;
459
+ };
460
+ }
461
+ /** Return cal events visible in [fromMs..toMs), refreshing from Google
462
+ * in the background if enough time has passed. Caller displays local
463
+ * results immediately; events updated from Google emit an event. */
464
+ async getCalendarEvents(fromMs, toMs) {
465
+ const acct = this.getPrimaryAccount("calendar");
466
+ if (!acct)
467
+ return [];
468
+ // Fire-and-forget refresh; don't block the read on it.
469
+ this.refreshCalendarEvents(acct.id, fromMs, toMs).catch(e => console.error(`[calendar] refresh failed: ${e.message}`));
470
+ return this.db.getCalendarEvents(acct.id, fromMs, toMs);
471
+ }
472
+ async refreshCalendarEvents(accountId, fromMs, toMs) {
473
+ const tp = await this.primaryTokenProvider("calendar");
474
+ const events = await gsync.listCalendarEvents(tp, fromMs, toMs);
475
+ for (const ev of events) {
476
+ const local = gsync.calendarEventToLocal(ev, accountId);
477
+ // Use provider_id as the uuid basis — existing local row with matching
478
+ // providerId stays; if we invent a new uuid every time we'd create
479
+ // duplicates on every poll. Look up by provider_id first.
480
+ const existing = this.db.getCalendarEvents(accountId, fromMs, toMs).find(e => e.providerId === ev.id);
481
+ this.db.upsertCalendarEvent({
482
+ uuid: existing?.uuid,
483
+ ...local,
484
+ });
485
+ }
486
+ }
487
+ async createCalendarEventLocal(ev) {
488
+ const acct = this.getPrimaryAccount("calendar");
489
+ if (!acct)
490
+ throw new Error("No primary calendar account");
491
+ const uuid = this.db.upsertCalendarEvent({
492
+ accountId: acct.id, ...ev, dirty: true,
493
+ });
494
+ this.db.enqueueStoreSync("calendar", "create", acct.id, uuid, ev);
495
+ this.drainStoreSync().catch(() => { });
496
+ return uuid;
497
+ }
498
+ async updateCalendarEventLocal(uuid, patch) {
499
+ // Merge with existing row before writing so partial patches don't
500
+ // null-out unspecified fields in upsert.
501
+ const existing = this.db.getCalendarEventByUuid(uuid);
502
+ if (!existing)
503
+ throw new Error(`No calendar event ${uuid}`);
504
+ this.db.upsertCalendarEvent({
505
+ uuid, accountId: existing.accountId, providerId: existing.providerId,
506
+ calendarId: existing.calendarId, dirty: true,
507
+ title: patch.title ?? existing.title,
508
+ startMs: patch.startMs ?? existing.startMs,
509
+ endMs: patch.endMs ?? existing.endMs,
510
+ allDay: patch.allDay ?? existing.allDay,
511
+ location: patch.location ?? existing.location,
512
+ notes: patch.notes ?? existing.notes,
513
+ });
514
+ this.db.enqueueStoreSync("calendar", "update", existing.accountId, uuid, { providerId: existing.providerId, patch });
515
+ this.drainStoreSync().catch(() => { });
516
+ }
517
+ async deleteCalendarEventLocal(uuid) {
518
+ const ev = this.db.getCalendarEventByUuid(uuid);
519
+ if (!ev)
520
+ return;
521
+ this.db.deleteCalendarEventLocal(uuid);
522
+ if (ev.providerId) {
523
+ this.db.enqueueStoreSync("calendar", "delete", ev.accountId, uuid, { providerId: ev.providerId });
524
+ this.drainStoreSync().catch(() => { });
525
+ }
526
+ else {
527
+ // Never made it to the server; just purge locally.
528
+ this.db.purgeCalendarEvent(uuid);
529
+ }
530
+ }
531
+ async getTasks(includeCompleted = false) {
532
+ const acct = this.getPrimaryAccount("tasks");
533
+ if (!acct)
534
+ return [];
535
+ this.refreshTasks(acct.id, includeCompleted).catch(e => console.error(`[tasks] refresh failed: ${e.message}`));
536
+ return this.db.getTasks(acct.id, includeCompleted);
537
+ }
538
+ async refreshTasks(accountId, includeCompleted) {
539
+ const tp = await this.primaryTokenProvider("tasks");
540
+ const tasks = await gsync.listTasks(tp, "@default", includeCompleted);
541
+ const existing = this.db.getTasks(accountId, true);
542
+ for (const t of tasks) {
543
+ const local = gsync.taskToLocal(t, accountId);
544
+ const prior = existing.find(e => e.providerId === t.id);
545
+ this.db.upsertTask({ uuid: prior?.uuid, ...local });
546
+ }
547
+ }
548
+ async createTaskLocal(t) {
549
+ const acct = this.getPrimaryAccount("tasks");
550
+ if (!acct)
551
+ throw new Error("No primary tasks account");
552
+ const uuid = this.db.upsertTask({ accountId: acct.id, ...t, dirty: true });
553
+ this.db.enqueueStoreSync("tasks", "create", acct.id, uuid, t);
554
+ this.drainStoreSync().catch(() => { });
555
+ return uuid;
556
+ }
557
+ async updateTaskLocal(uuid, patch) {
558
+ const existing = this.db.getTaskByUuid(uuid);
559
+ if (!existing)
560
+ throw new Error(`No task ${uuid}`);
561
+ this.db.upsertTask({
562
+ uuid, accountId: existing.accountId, providerId: existing.providerId,
563
+ listId: existing.listId, dirty: true,
564
+ title: patch.title ?? existing.title,
565
+ notes: patch.notes ?? existing.notes,
566
+ dueMs: patch.dueMs ?? existing.dueMs,
567
+ completedMs: patch.completedMs ?? existing.completedMs,
568
+ });
569
+ this.db.enqueueStoreSync("tasks", "update", existing.accountId, uuid, { providerId: existing.providerId, patch });
570
+ this.drainStoreSync().catch(() => { });
571
+ }
572
+ async deleteTaskLocal(uuid) {
573
+ const task = this.db.getTaskByUuid(uuid);
574
+ if (!task)
575
+ return;
576
+ this.db.deleteTaskLocal(uuid);
577
+ if (task.providerId) {
578
+ this.db.enqueueStoreSync("tasks", "delete", task.accountId, uuid, { providerId: task.providerId });
579
+ this.drainStoreSync().catch(() => { });
580
+ }
581
+ else {
582
+ this.db.purgeTask(uuid);
583
+ }
584
+ }
585
+ /** Drain the store_sync queue — calendar / tasks / contacts push-to-server.
586
+ * Called on every local edit, and on a periodic tick from the outbox worker. */
587
+ async drainStoreSync() {
588
+ const queue = this.db.getStoreSyncQueue();
589
+ for (const entry of queue) {
590
+ try {
591
+ if (entry.kind === "calendar") {
592
+ const tp = await this.primaryTokenProvider("calendar");
593
+ if (entry.op === "create") {
594
+ const created = await gsync.createCalendarEvent(tp, gsync.localToCalendarEvent(entry.payload));
595
+ this.db.markCalendarEventClean(entry.targetUuid, created.id, created.etag || "");
596
+ }
597
+ else if (entry.op === "update") {
598
+ const updated = await gsync.updateCalendarEvent(tp, entry.payload.providerId, gsync.localToCalendarEvent(entry.payload.patch));
599
+ this.db.markCalendarEventClean(entry.targetUuid, updated.id, updated.etag || "");
600
+ }
601
+ else if (entry.op === "delete") {
602
+ await gsync.deleteCalendarEvent(tp, entry.payload.providerId);
603
+ this.db.purgeCalendarEvent(entry.targetUuid);
604
+ }
605
+ }
606
+ else if (entry.kind === "tasks") {
607
+ const tp = await this.primaryTokenProvider("tasks");
608
+ if (entry.op === "create") {
609
+ const created = await gsync.createTask(tp, gsync.localToTask(entry.payload));
610
+ this.db.markTaskClean(entry.targetUuid, created.id, created.etag || "");
611
+ }
612
+ else if (entry.op === "update") {
613
+ const updated = await gsync.updateTask(tp, entry.payload.providerId, gsync.localToTask(entry.payload.patch));
614
+ this.db.markTaskClean(entry.targetUuid, updated.id, updated.etag || "");
615
+ }
616
+ else if (entry.op === "delete") {
617
+ await gsync.deleteTask(tp, entry.payload.providerId);
618
+ this.db.purgeTask(entry.targetUuid);
619
+ }
620
+ }
621
+ else if (entry.kind === "contacts") {
622
+ const tp = await this.primaryTokenProvider("contacts");
623
+ if (entry.op === "create") {
624
+ await gsync.createContact(tp, entry.payload);
625
+ }
626
+ else if (entry.op === "update") {
627
+ await gsync.updateContact(tp, entry.payload.resourceName, entry.payload.updatePersonFields || "names,emailAddresses", entry.payload.person);
628
+ }
629
+ else if (entry.op === "delete") {
630
+ await gsync.deleteContact(tp, entry.payload.resourceName);
631
+ }
632
+ }
633
+ this.db.completeStoreSync(entry.id);
634
+ }
635
+ catch (e) {
636
+ console.error(`[store_sync] ${entry.kind}/${entry.op}/${entry.targetUuid} failed: ${e.message}`);
637
+ this.db.failStoreSync(entry.id, e.message || String(e));
638
+ }
639
+ }
640
+ }
448
641
  /** List queued outgoing messages with parsed envelope headers so the UI
449
642
  * can render a pink-row "pending" view before IMAP APPEND succeeds. */
450
643
  listQueuedOutgoing() {
@@ -985,14 +1178,36 @@ export class MailxService {
985
1178
  listContacts(query, page = 1, pageSize = 100) {
986
1179
  return this.db.listContacts(query || "", page, pageSize);
987
1180
  }
988
- /** Upsert a contact from the address book UI (edit name). */
1181
+ /** Upsert a contact from the address book UI (edit name). Two-way cache:
1182
+ * commits locally, queues a Google People push. */
989
1183
  upsertContact(name, email) {
990
1184
  this.db.upsertContact(name || "", email);
1185
+ const acct = this.getPrimaryAccount("contacts");
1186
+ if (acct) {
1187
+ // Google People `createContact` — resourceName is assigned by the
1188
+ // server and stored back as google_id once the drainer gets an ACK.
1189
+ this.db.enqueueStoreSync("contacts", "create", acct.id, email, {
1190
+ names: [{ givenName: name || "" }],
1191
+ emailAddresses: [{ value: email }],
1192
+ });
1193
+ this.drainStoreSync().catch(() => { });
1194
+ }
991
1195
  return { ok: true };
992
1196
  }
993
- /** Delete a contact from the address book. */
1197
+ /** Delete a contact from the address book. Also pushes the deletion to
1198
+ * Google People if the contact had a resourceName (i.e. was synced). */
994
1199
  deleteContact(email) {
995
- this.db.deleteContact(email);
1200
+ const acct = this.getPrimaryAccount("contacts");
1201
+ // Look up the resourceName before deleting so we can push to Google.
1202
+ const contacts = this.db.listContacts("", 1, 10_000);
1203
+ const contact = (contacts.items || []).find((c) => c.email === email);
1204
+ this.db.deleteContactLocal(email);
1205
+ if (acct && contact?.googleId) {
1206
+ this.db.enqueueStoreSync("contacts", "delete", acct.id, email, {
1207
+ resourceName: contact.googleId,
1208
+ });
1209
+ this.drainStoreSync().catch(() => { });
1210
+ }
996
1211
  return { ok: true };
997
1212
  }
998
1213
  /** Open a configured local path in the OS file explorer. Whitelisted to
@@ -98,6 +98,31 @@ async function dispatchAction(svc, action, p) {
98
98
  return svc.getDiagnostics();
99
99
  case "getPrimaryAccount":
100
100
  return svc.getPrimaryAccount(p?.feature);
101
+ // Calendar / Tasks: two-way cache. Reads return local immediately
102
+ // and kick a background refresh that emits updates when done.
103
+ case "getCalendarEvents":
104
+ return await svc.getCalendarEvents(p.fromMs, p.toMs);
105
+ case "createCalendarEvent":
106
+ return { uuid: await svc.createCalendarEventLocal(p) };
107
+ case "updateCalendarEvent":
108
+ await svc.updateCalendarEventLocal(p.uuid, p.patch);
109
+ return { ok: true };
110
+ case "deleteCalendarEvent":
111
+ await svc.deleteCalendarEventLocal(p.uuid);
112
+ return { ok: true };
113
+ case "getTasks":
114
+ return await svc.getTasks(!!p.includeCompleted);
115
+ case "createTask":
116
+ return { uuid: await svc.createTaskLocal(p) };
117
+ case "updateTask":
118
+ await svc.updateTaskLocal(p.uuid, p.patch);
119
+ return { ok: true };
120
+ case "deleteTask":
121
+ await svc.deleteTaskLocal(p.uuid);
122
+ return { ok: true };
123
+ case "drainStoreSync":
124
+ await svc.drainStoreSync();
125
+ return { ok: true };
101
126
  case "getOutboxStatus":
102
127
  return svc.getOutboxStatus();
103
128
  case "listQueuedOutgoing":
@@ -33,6 +33,54 @@ export declare class MailxDB {
33
33
  * growing unboundedly. Default retention is 30 days; caller passes the
34
34
  * actual cutoff in ms since epoch. */
35
35
  pruneTombstones(olderThanMs: number): number;
36
+ upsertCalendarEvent(ev: {
37
+ uuid?: string;
38
+ accountId: string;
39
+ providerId?: string;
40
+ calendarId?: string;
41
+ title: string;
42
+ startMs: number;
43
+ endMs: number;
44
+ allDay?: boolean;
45
+ location?: string;
46
+ notes?: string;
47
+ etag?: string;
48
+ dirty?: boolean;
49
+ }): string;
50
+ getCalendarEvents(accountId: string, fromMs: number, toMs: number): any[];
51
+ /** Lookup by uuid only — used by patch/delete paths that don't have an
52
+ * accountId context. Returns the row even when it's soft-deleted. */
53
+ getCalendarEventByUuid(uuid: string): any | null;
54
+ getTaskByUuid(uuid: string): any | null;
55
+ getDirtyCalendarEvents(accountId: string): any[];
56
+ private calendarRowToObject;
57
+ markCalendarEventClean(uuid: string, providerId: string, etag: string): void;
58
+ deleteCalendarEventLocal(uuid: string): void;
59
+ purgeCalendarEvent(uuid: string): void;
60
+ upsertTask(t: {
61
+ uuid?: string;
62
+ accountId: string;
63
+ providerId?: string;
64
+ listId?: string;
65
+ title: string;
66
+ notes?: string;
67
+ dueMs?: number;
68
+ completedMs?: number;
69
+ etag?: string;
70
+ dirty?: boolean;
71
+ }): string;
72
+ getTasks(accountId: string, includeCompleted?: boolean): any[];
73
+ getDirtyTasks(accountId: string): any[];
74
+ private taskRowToObject;
75
+ markTaskClean(uuid: string, providerId: string, etag: string): void;
76
+ deleteTaskLocal(uuid: string): void;
77
+ purgeTask(uuid: string): void;
78
+ /** Local delete for the two-way cache drainer (symmetric with calendar/tasks). */
79
+ deleteContactLocal(email: string): void;
80
+ enqueueStoreSync(kind: string, op: string, accountId: string, targetUuid: string, payload: any): void;
81
+ getStoreSyncQueue(kind?: string, accountId?: string): any[];
82
+ completeStoreSync(id: number): void;
83
+ failStoreSync(id: number, error: string): void;
36
84
  /** Idempotently add a column to a table if it's missing. */
37
85
  private addColumnIfMissing;
38
86
  /** Compute a thread id for an incoming message. Strategy:
@@ -142,6 +190,7 @@ export declare class MailxDB {
142
190
  name: string;
143
191
  email: string;
144
192
  source: string;
193
+ googleId: string | null;
145
194
  useCount: number;
146
195
  lastUsed: number;
147
196
  }[];
@@ -142,6 +142,72 @@ const SCHEMA = `
142
142
  PRIMARY KEY (account_id, message_id)
143
143
  );
144
144
  CREATE INDEX IF NOT EXISTS idx_tombstones_deleted_at ON tombstones(deleted_at);
145
+
146
+ -- Calendar events: two-way cache of Google Calendar / local events.
147
+ -- uuid = local stable identity (survives provider_id rebinds).
148
+ -- provider_id = Google Calendar event id when known (null for local-only
149
+ -- events that haven't been pushed yet).
150
+ -- deleted = tombstone marker; drainer removes row from server then deletes
151
+ -- the row locally.
152
+ CREATE TABLE IF NOT EXISTS calendar_events (
153
+ uuid TEXT PRIMARY KEY,
154
+ account_id TEXT NOT NULL,
155
+ provider_id TEXT,
156
+ calendar_id TEXT DEFAULT 'primary',
157
+ title TEXT NOT NULL DEFAULT '',
158
+ start_ms INTEGER NOT NULL,
159
+ end_ms INTEGER NOT NULL,
160
+ all_day INTEGER DEFAULT 0,
161
+ location TEXT DEFAULT '',
162
+ notes TEXT DEFAULT '',
163
+ etag TEXT,
164
+ last_synced INTEGER DEFAULT 0,
165
+ dirty INTEGER DEFAULT 0,
166
+ deleted INTEGER DEFAULT 0,
167
+ updated_at INTEGER NOT NULL
168
+ );
169
+ CREATE INDEX IF NOT EXISTS idx_calendar_events_start ON calendar_events(account_id, start_ms);
170
+ CREATE INDEX IF NOT EXISTS idx_calendar_events_dirty ON calendar_events(dirty) WHERE dirty = 1;
171
+
172
+ -- Tasks: two-way cache of Google Tasks / local tasks. Same shape as
173
+ -- calendar_events minus the time range.
174
+ CREATE TABLE IF NOT EXISTS tasks (
175
+ uuid TEXT PRIMARY KEY,
176
+ account_id TEXT NOT NULL,
177
+ provider_id TEXT,
178
+ list_id TEXT DEFAULT '@default',
179
+ title TEXT NOT NULL DEFAULT '',
180
+ notes TEXT DEFAULT '',
181
+ due_ms INTEGER,
182
+ completed_ms INTEGER,
183
+ etag TEXT,
184
+ last_synced INTEGER DEFAULT 0,
185
+ dirty INTEGER DEFAULT 0,
186
+ deleted INTEGER DEFAULT 0,
187
+ updated_at INTEGER NOT NULL
188
+ );
189
+ CREATE INDEX IF NOT EXISTS idx_tasks_account ON tasks(account_id);
190
+ CREATE INDEX IF NOT EXISTS idx_tasks_dirty ON tasks(dirty) WHERE dirty = 1;
191
+
192
+ -- Generic store-sync queue for domains OTHER than messages. Messages
193
+ -- use sync_actions above. This table queues push-to-server actions
194
+ -- for calendar / tasks / contacts / allowlist. Kind identifies the
195
+ -- domain; op is "create" / "update" / "delete"; payload is JSON the
196
+ -- drainer posts to the provider. Target URL isn't stored — the
197
+ -- drainer knows the provider endpoint from kind + payload.
198
+ CREATE TABLE IF NOT EXISTS store_sync (
199
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
200
+ kind TEXT NOT NULL,
201
+ op TEXT NOT NULL,
202
+ account_id TEXT NOT NULL,
203
+ target_uuid TEXT NOT NULL,
204
+ payload TEXT,
205
+ attempts INTEGER DEFAULT 0,
206
+ last_error TEXT,
207
+ created_at INTEGER NOT NULL,
208
+ UNIQUE(kind, target_uuid, op)
209
+ );
210
+ CREATE INDEX IF NOT EXISTS idx_store_sync_account ON store_sync(account_id, kind);
145
211
  `;
146
212
  export class MailxDB {
147
213
  db;
@@ -303,6 +369,162 @@ export class MailxDB {
303
369
  return 0;
304
370
  }
305
371
  }
372
+ // ── Calendar events (two-way cache) ──
373
+ upsertCalendarEvent(ev) {
374
+ const uuid = ev.uuid || randomUUID().replace(/-/g, "");
375
+ this.db.prepare(`
376
+ INSERT INTO calendar_events
377
+ (uuid, account_id, provider_id, calendar_id, title, start_ms, end_ms,
378
+ all_day, location, notes, etag, last_synced, dirty, deleted, updated_at)
379
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?)
380
+ ON CONFLICT(uuid) DO UPDATE SET
381
+ account_id=excluded.account_id, provider_id=excluded.provider_id,
382
+ calendar_id=excluded.calendar_id, title=excluded.title,
383
+ start_ms=excluded.start_ms, end_ms=excluded.end_ms,
384
+ all_day=excluded.all_day, location=excluded.location,
385
+ notes=excluded.notes, etag=excluded.etag,
386
+ last_synced=excluded.last_synced, dirty=excluded.dirty,
387
+ updated_at=excluded.updated_at
388
+ `).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());
389
+ return uuid;
390
+ }
391
+ getCalendarEvents(accountId, fromMs, toMs) {
392
+ const rows = this.db.prepare(`
393
+ SELECT * FROM calendar_events
394
+ WHERE account_id = ? AND deleted = 0 AND start_ms >= ? AND start_ms < ?
395
+ ORDER BY start_ms ASC
396
+ `).all(accountId, fromMs, toMs);
397
+ return rows.map(this.calendarRowToObject);
398
+ }
399
+ /** Lookup by uuid only — used by patch/delete paths that don't have an
400
+ * accountId context. Returns the row even when it's soft-deleted. */
401
+ getCalendarEventByUuid(uuid) {
402
+ const r = this.db.prepare("SELECT * FROM calendar_events WHERE uuid = ?").get(uuid);
403
+ return r ? this.calendarRowToObject(r) : null;
404
+ }
405
+ getTaskByUuid(uuid) {
406
+ const r = this.db.prepare("SELECT * FROM tasks WHERE uuid = ?").get(uuid);
407
+ return r ? this.taskRowToObject(r) : null;
408
+ }
409
+ getDirtyCalendarEvents(accountId) {
410
+ const rows = this.db.prepare(`
411
+ SELECT * FROM calendar_events WHERE account_id = ? AND (dirty = 1 OR deleted = 1)
412
+ `).all(accountId);
413
+ return rows.map(this.calendarRowToObject);
414
+ }
415
+ calendarRowToObject(r) {
416
+ return {
417
+ uuid: r.uuid, accountId: r.account_id, providerId: r.provider_id,
418
+ calendarId: r.calendar_id, title: r.title, startMs: r.start_ms,
419
+ endMs: r.end_ms, allDay: !!r.all_day, location: r.location, notes: r.notes,
420
+ etag: r.etag, lastSynced: r.last_synced, dirty: !!r.dirty, deleted: !!r.deleted,
421
+ };
422
+ }
423
+ markCalendarEventClean(uuid, providerId, etag) {
424
+ this.db.prepare(`
425
+ UPDATE calendar_events SET dirty=0, provider_id=?, etag=?, last_synced=? WHERE uuid=?
426
+ `).run(providerId, etag, Date.now(), uuid);
427
+ }
428
+ deleteCalendarEventLocal(uuid) {
429
+ this.db.prepare("UPDATE calendar_events SET deleted=1, dirty=1, updated_at=? WHERE uuid=?").run(Date.now(), uuid);
430
+ }
431
+ purgeCalendarEvent(uuid) {
432
+ this.db.prepare("DELETE FROM calendar_events WHERE uuid=?").run(uuid);
433
+ }
434
+ // ── Tasks (two-way cache) ──
435
+ upsertTask(t) {
436
+ const uuid = t.uuid || randomUUID().replace(/-/g, "");
437
+ this.db.prepare(`
438
+ INSERT INTO tasks
439
+ (uuid, account_id, provider_id, list_id, title, notes, due_ms, completed_ms,
440
+ etag, last_synced, dirty, deleted, updated_at)
441
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?)
442
+ ON CONFLICT(uuid) DO UPDATE SET
443
+ account_id=excluded.account_id, provider_id=excluded.provider_id,
444
+ list_id=excluded.list_id, title=excluded.title, notes=excluded.notes,
445
+ due_ms=excluded.due_ms, completed_ms=excluded.completed_ms,
446
+ etag=excluded.etag, last_synced=excluded.last_synced,
447
+ dirty=excluded.dirty, updated_at=excluded.updated_at
448
+ `).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());
449
+ return uuid;
450
+ }
451
+ getTasks(accountId, includeCompleted = false) {
452
+ const where = includeCompleted
453
+ ? "account_id = ? AND deleted = 0"
454
+ : "account_id = ? AND deleted = 0 AND completed_ms IS NULL";
455
+ const rows = this.db.prepare(`SELECT * FROM tasks WHERE ${where} ORDER BY COALESCE(due_ms, updated_at) ASC`).all(accountId);
456
+ return rows.map(this.taskRowToObject);
457
+ }
458
+ getDirtyTasks(accountId) {
459
+ const rows = this.db.prepare(`SELECT * FROM tasks WHERE account_id = ? AND (dirty = 1 OR deleted = 1)`).all(accountId);
460
+ return rows.map(this.taskRowToObject);
461
+ }
462
+ taskRowToObject(r) {
463
+ return {
464
+ uuid: r.uuid, accountId: r.account_id, providerId: r.provider_id,
465
+ listId: r.list_id, title: r.title, notes: r.notes, dueMs: r.due_ms,
466
+ completedMs: r.completed_ms, etag: r.etag, lastSynced: r.last_synced,
467
+ dirty: !!r.dirty, deleted: !!r.deleted,
468
+ };
469
+ }
470
+ markTaskClean(uuid, providerId, etag) {
471
+ this.db.prepare(`UPDATE tasks SET dirty=0, provider_id=?, etag=?, last_synced=? WHERE uuid=?`)
472
+ .run(providerId, etag, Date.now(), uuid);
473
+ }
474
+ deleteTaskLocal(uuid) {
475
+ this.db.prepare("UPDATE tasks SET deleted=1, dirty=1, updated_at=? WHERE uuid=?").run(Date.now(), uuid);
476
+ }
477
+ purgeTask(uuid) {
478
+ this.db.prepare("DELETE FROM tasks WHERE uuid=?").run(uuid);
479
+ }
480
+ // ── Contacts two-way: existing upsertContact / deleteContact handle the
481
+ // local side; the service layer adds store_sync push-queue entries.
482
+ // (No extra methods needed here — upsertContact/deleteContact at the
483
+ // regular contact-management section are the two-way cache's local
484
+ // writers.)
485
+ /** Local delete for the two-way cache drainer (symmetric with calendar/tasks). */
486
+ deleteContactLocal(email) { this.deleteContact(email); }
487
+ // ── Store-sync queue (calendar / tasks / contacts / allowlist) ──
488
+ enqueueStoreSync(kind, op, accountId, targetUuid, payload) {
489
+ try {
490
+ this.db.prepare(`
491
+ INSERT OR REPLACE INTO store_sync (kind, op, account_id, target_uuid, payload, created_at)
492
+ VALUES (?, ?, ?, ?, ?, ?)
493
+ `).run(kind, op, accountId, targetUuid, JSON.stringify(payload), Date.now());
494
+ }
495
+ catch (e) {
496
+ console.error(` [store_sync] enqueue ${kind}/${op}/${targetUuid} failed: ${e.message}`);
497
+ }
498
+ }
499
+ getStoreSyncQueue(kind, accountId) {
500
+ let sql = "SELECT * FROM store_sync";
501
+ const params = [];
502
+ const wh = [];
503
+ if (kind) {
504
+ wh.push("kind = ?");
505
+ params.push(kind);
506
+ }
507
+ if (accountId) {
508
+ wh.push("account_id = ?");
509
+ params.push(accountId);
510
+ }
511
+ if (wh.length)
512
+ sql += " WHERE " + wh.join(" AND ");
513
+ sql += " ORDER BY created_at ASC";
514
+ const rows = this.db.prepare(sql).all(...params);
515
+ return rows.map(r => ({
516
+ id: r.id, kind: r.kind, op: r.op, accountId: r.account_id,
517
+ targetUuid: r.target_uuid,
518
+ payload: r.payload ? JSON.parse(r.payload) : null,
519
+ attempts: r.attempts, lastError: r.last_error,
520
+ }));
521
+ }
522
+ completeStoreSync(id) {
523
+ this.db.prepare("DELETE FROM store_sync WHERE id = ?").run(id);
524
+ }
525
+ failStoreSync(id, error) {
526
+ this.db.prepare("UPDATE store_sync SET attempts = attempts + 1, last_error = ? WHERE id = ?").run(error, id);
527
+ }
306
528
  /** Idempotently add a column to a table if it's missing. */
307
529
  addColumnIfMissing(table, column, sqlType) {
308
530
  try {
@@ -785,11 +1007,34 @@ export class MailxDB {
785
1007
  }
786
1008
  /** Search contacts by name or email prefix */
787
1009
  searchContacts(query, limit = 10) {
788
- const q = `%${query}%`;
789
- const rows = this.db.prepare(`SELECT name, email, source, use_count FROM contacts
1010
+ // Two-pass ranking so autocomplete feels responsive: rows whose name or
1011
+ // local-part starts with the query rank first (exact prefix match is
1012
+ // usually what the user wants), then substring matches fill out the
1013
+ // rest. Within each tier, sort by (recency-weighted use_count) so a
1014
+ // contact the user messaged today beats one from two years ago even
1015
+ // if the older one has more total sends. Recency bonus: +1 per send
1016
+ // decayed by ~half every 30 days.
1017
+ const prefixQ = `${query}%`;
1018
+ const substr = `%${query}%`;
1019
+ const rows = this.db.prepare(`SELECT name, email, source, use_count, last_used,
1020
+ (CASE
1021
+ WHEN lower(name) LIKE lower(?) THEN 3
1022
+ WHEN substr(email, 1, instr(email, '@') - 1) LIKE lower(?) THEN 2
1023
+ WHEN email LIKE ? OR name LIKE ? THEN 1
1024
+ ELSE 0
1025
+ END) AS match_rank
1026
+ FROM contacts
790
1027
  WHERE email LIKE ? OR name LIKE ?
791
- ORDER BY use_count DESC, last_used DESC
792
- LIMIT ?`).all(q, q, limit);
1028
+ ORDER BY match_rank DESC, use_count DESC, last_used DESC
1029
+ LIMIT ?`).all(prefixQ, prefixQ, substr, substr, substr, substr, limit);
1030
+ // Recency-weighted rescore, best-in-JS since SQLite lacks log/exp
1031
+ // natively. 30-day half-life — close enough to "recent contacts
1032
+ // float up" without being fussy about the exact decay curve.
1033
+ const now = Date.now();
1034
+ const HALF_LIFE_MS = 30 * 86400_000;
1035
+ const score = (r) => r.match_rank * 10_000
1036
+ + (r.use_count || 0) * Math.pow(0.5, Math.max(0, now - (r.last_used || 0)) / HALF_LIFE_MS);
1037
+ rows.sort((a, b) => score(b) - score(a));
793
1038
  return rows.map(r => ({ name: r.name, email: r.email, source: r.source, useCount: r.use_count }));
794
1039
  }
795
1040
  /** List all contacts (address-book view) with pagination + optional filter. */
@@ -800,12 +1045,16 @@ export class MailxDB {
800
1045
  const params = hasQuery ? [q, q] : [];
801
1046
  const totalRow = this.db.prepare(`SELECT COUNT(*) as c FROM contacts ${whereClause}`).get(...params);
802
1047
  const offset = (page - 1) * pageSize;
803
- const rows = this.db.prepare(`SELECT name, email, source, use_count, last_used FROM contacts
1048
+ const rows = this.db.prepare(`SELECT name, email, source, google_id, use_count, last_used FROM contacts
804
1049
  ${whereClause}
805
1050
  ORDER BY use_count DESC, last_used DESC
806
1051
  LIMIT ? OFFSET ?`).all(...params, pageSize, offset);
807
1052
  return {
808
- items: rows.map(r => ({ name: r.name, email: r.email, source: r.source, useCount: r.use_count, lastUsed: r.last_used })),
1053
+ items: rows.map(r => ({
1054
+ name: r.name, email: r.email, source: r.source,
1055
+ googleId: r.google_id || null,
1056
+ useCount: r.use_count, lastUsed: r.last_used,
1057
+ })),
809
1058
  total: totalRow?.c || 0,
810
1059
  page, pageSize,
811
1060
  };