@bobfrankston/mailx 1.0.377 → 1.0.379

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.
@@ -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;
@@ -182,6 +248,34 @@ export class MailxDB {
182
248
  // this column landed. One UPDATE + an id roundtrip per row — cheap
183
249
  // at our row counts, runs once per DB upgrade.
184
250
  this.backfillUuids();
251
+ // Post-migration sanity check: verify the columns we actually read in
252
+ // SELECTs exist. If any migration silently failed (stale driver, DB
253
+ // file locked, permission error), later code would throw cryptic
254
+ // "no such column" errors buried deep in a sync run. Fail loud here
255
+ // with a clear "run mailx -rebuild" message. C32 on Linux was exactly
256
+ // this — old mailx-store that predated thread_id/uuid migrations.
257
+ this.verifySchema();
258
+ }
259
+ /** Fail loud + early if expected columns are missing. Cheap (PRAGMA only
260
+ * runs at startup). The user-facing message names the recovery command. */
261
+ verifySchema() {
262
+ const required = {
263
+ messages: ["thread_id", "provider_id", "uuid"],
264
+ };
265
+ for (const [table, cols] of Object.entries(required)) {
266
+ let actual;
267
+ try {
268
+ actual = this.db.prepare(`PRAGMA table_info(${table})`).all();
269
+ }
270
+ catch (e) {
271
+ throw new Error(`[mailx-store] schema check failed for "${table}": ${e.message}. Run 'mailx -rebuild' to rebuild the local store.`);
272
+ }
273
+ const names = new Set(actual.map(r => r.name));
274
+ const missing = cols.filter(c => !names.has(c));
275
+ if (missing.length > 0) {
276
+ 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.`);
277
+ }
278
+ }
185
279
  }
186
280
  /** One-time: assign UUIDs to every `messages` row that's missing one.
187
281
  * Runs on every startup but the WHERE clause makes it a no-op after the
@@ -275,6 +369,152 @@ export class MailxDB {
275
369
  return 0;
276
370
  }
277
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
+ getDirtyCalendarEvents(accountId) {
400
+ const rows = this.db.prepare(`
401
+ SELECT * FROM calendar_events WHERE account_id = ? AND (dirty = 1 OR deleted = 1)
402
+ `).all(accountId);
403
+ return rows.map(this.calendarRowToObject);
404
+ }
405
+ calendarRowToObject(r) {
406
+ return {
407
+ uuid: r.uuid, accountId: r.account_id, providerId: r.provider_id,
408
+ calendarId: r.calendar_id, title: r.title, startMs: r.start_ms,
409
+ endMs: r.end_ms, allDay: !!r.all_day, location: r.location, notes: r.notes,
410
+ etag: r.etag, lastSynced: r.last_synced, dirty: !!r.dirty, deleted: !!r.deleted,
411
+ };
412
+ }
413
+ markCalendarEventClean(uuid, providerId, etag) {
414
+ this.db.prepare(`
415
+ UPDATE calendar_events SET dirty=0, provider_id=?, etag=?, last_synced=? WHERE uuid=?
416
+ `).run(providerId, etag, Date.now(), uuid);
417
+ }
418
+ deleteCalendarEventLocal(uuid) {
419
+ this.db.prepare("UPDATE calendar_events SET deleted=1, dirty=1, updated_at=? WHERE uuid=?").run(Date.now(), uuid);
420
+ }
421
+ purgeCalendarEvent(uuid) {
422
+ this.db.prepare("DELETE FROM calendar_events WHERE uuid=?").run(uuid);
423
+ }
424
+ // ── Tasks (two-way cache) ──
425
+ upsertTask(t) {
426
+ const uuid = t.uuid || randomUUID().replace(/-/g, "");
427
+ this.db.prepare(`
428
+ INSERT INTO tasks
429
+ (uuid, account_id, provider_id, list_id, title, notes, due_ms, completed_ms,
430
+ etag, last_synced, dirty, deleted, updated_at)
431
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?)
432
+ ON CONFLICT(uuid) DO UPDATE SET
433
+ account_id=excluded.account_id, provider_id=excluded.provider_id,
434
+ list_id=excluded.list_id, title=excluded.title, notes=excluded.notes,
435
+ due_ms=excluded.due_ms, completed_ms=excluded.completed_ms,
436
+ etag=excluded.etag, last_synced=excluded.last_synced,
437
+ dirty=excluded.dirty, updated_at=excluded.updated_at
438
+ `).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());
439
+ return uuid;
440
+ }
441
+ getTasks(accountId, includeCompleted = false) {
442
+ const where = includeCompleted
443
+ ? "account_id = ? AND deleted = 0"
444
+ : "account_id = ? AND deleted = 0 AND completed_ms IS NULL";
445
+ const rows = this.db.prepare(`SELECT * FROM tasks WHERE ${where} ORDER BY COALESCE(due_ms, updated_at) ASC`).all(accountId);
446
+ return rows.map(this.taskRowToObject);
447
+ }
448
+ getDirtyTasks(accountId) {
449
+ const rows = this.db.prepare(`SELECT * FROM tasks WHERE account_id = ? AND (dirty = 1 OR deleted = 1)`).all(accountId);
450
+ return rows.map(this.taskRowToObject);
451
+ }
452
+ taskRowToObject(r) {
453
+ return {
454
+ uuid: r.uuid, accountId: r.account_id, providerId: r.provider_id,
455
+ listId: r.list_id, title: r.title, notes: r.notes, dueMs: r.due_ms,
456
+ completedMs: r.completed_ms, etag: r.etag, lastSynced: r.last_synced,
457
+ dirty: !!r.dirty, deleted: !!r.deleted,
458
+ };
459
+ }
460
+ markTaskClean(uuid, providerId, etag) {
461
+ this.db.prepare(`UPDATE tasks SET dirty=0, provider_id=?, etag=?, last_synced=? WHERE uuid=?`)
462
+ .run(providerId, etag, Date.now(), uuid);
463
+ }
464
+ deleteTaskLocal(uuid) {
465
+ this.db.prepare("UPDATE tasks SET deleted=1, dirty=1, updated_at=? WHERE uuid=?").run(Date.now(), uuid);
466
+ }
467
+ purgeTask(uuid) {
468
+ this.db.prepare("DELETE FROM tasks WHERE uuid=?").run(uuid);
469
+ }
470
+ // ── Contacts two-way: existing upsertContact / deleteContact handle the
471
+ // local side; the service layer adds store_sync push-queue entries.
472
+ // (No extra methods needed here — upsertContact/deleteContact at the
473
+ // regular contact-management section are the two-way cache's local
474
+ // writers.)
475
+ /** Local delete for the two-way cache drainer (symmetric with calendar/tasks). */
476
+ deleteContactLocal(email) { this.deleteContact(email); }
477
+ // ── Store-sync queue (calendar / tasks / contacts / allowlist) ──
478
+ enqueueStoreSync(kind, op, accountId, targetUuid, payload) {
479
+ try {
480
+ this.db.prepare(`
481
+ INSERT OR REPLACE INTO store_sync (kind, op, account_id, target_uuid, payload, created_at)
482
+ VALUES (?, ?, ?, ?, ?, ?)
483
+ `).run(kind, op, accountId, targetUuid, JSON.stringify(payload), Date.now());
484
+ }
485
+ catch (e) {
486
+ console.error(` [store_sync] enqueue ${kind}/${op}/${targetUuid} failed: ${e.message}`);
487
+ }
488
+ }
489
+ getStoreSyncQueue(kind, accountId) {
490
+ let sql = "SELECT * FROM store_sync";
491
+ const params = [];
492
+ const wh = [];
493
+ if (kind) {
494
+ wh.push("kind = ?");
495
+ params.push(kind);
496
+ }
497
+ if (accountId) {
498
+ wh.push("account_id = ?");
499
+ params.push(accountId);
500
+ }
501
+ if (wh.length)
502
+ sql += " WHERE " + wh.join(" AND ");
503
+ sql += " ORDER BY created_at ASC";
504
+ const rows = this.db.prepare(sql).all(...params);
505
+ return rows.map(r => ({
506
+ id: r.id, kind: r.kind, op: r.op, accountId: r.account_id,
507
+ targetUuid: r.target_uuid,
508
+ payload: r.payload ? JSON.parse(r.payload) : null,
509
+ attempts: r.attempts, lastError: r.last_error,
510
+ }));
511
+ }
512
+ completeStoreSync(id) {
513
+ this.db.prepare("DELETE FROM store_sync WHERE id = ?").run(id);
514
+ }
515
+ failStoreSync(id, error) {
516
+ this.db.prepare("UPDATE store_sync SET attempts = attempts + 1, last_error = ? WHERE id = ?").run(error, id);
517
+ }
278
518
  /** Idempotently add a column to a table if it's missing. */
279
519
  addColumnIfMissing(table, column, sqlType) {
280
520
  try {
@@ -772,12 +1012,16 @@ export class MailxDB {
772
1012
  const params = hasQuery ? [q, q] : [];
773
1013
  const totalRow = this.db.prepare(`SELECT COUNT(*) as c FROM contacts ${whereClause}`).get(...params);
774
1014
  const offset = (page - 1) * pageSize;
775
- const rows = this.db.prepare(`SELECT name, email, source, use_count, last_used FROM contacts
1015
+ const rows = this.db.prepare(`SELECT name, email, source, google_id, use_count, last_used FROM contacts
776
1016
  ${whereClause}
777
1017
  ORDER BY use_count DESC, last_used DESC
778
1018
  LIMIT ? OFFSET ?`).all(...params, pageSize, offset);
779
1019
  return {
780
- items: rows.map(r => ({ name: r.name, email: r.email, source: r.source, useCount: r.use_count, lastUsed: r.last_used })),
1020
+ items: rows.map(r => ({
1021
+ name: r.name, email: r.email, source: r.source,
1022
+ googleId: r.google_id || null,
1023
+ useCount: r.use_count, lastUsed: r.last_used,
1024
+ })),
781
1025
  total: totalRow?.c || 0,
782
1026
  page, pageSize,
783
1027
  };
@@ -983,6 +983,25 @@ function installBridge() {
983
983
  syncAll: async () => { await service.syncAll(); return { ok: true }; },
984
984
  syncAccount: async (accountId) => { await service.syncAccount(accountId); return { ok: true }; },
985
985
  getSyncPending: () => service.getSyncPending(),
986
+ getPrimaryAccount: (feature) => {
987
+ // Resolve primary account for a feature (calendar/tasks/contacts):
988
+ // per-feature flag → catch-all `primary` → first account.
989
+ const all = db.getAccountConfigs().map(r => {
990
+ try {
991
+ return { id: r.id, name: r.name, email: r.email, ...JSON.parse(r.configJson) };
992
+ }
993
+ catch {
994
+ return { id: r.id, name: r.name, email: r.email };
995
+ }
996
+ });
997
+ if (feature) {
998
+ const key = "primary" + feature.charAt(0).toUpperCase() + feature.slice(1);
999
+ const perFeature = all.find((a) => a[key]);
1000
+ if (perFeature)
1001
+ return perFeature;
1002
+ }
1003
+ return all.find((a) => a.primary) || all[0] || null;
1004
+ },
986
1005
  reauthenticate: async (accountId) => ({ ok: await service.reauthenticate(accountId) }),
987
1006
  markFolderRead: (_accountId, folderId) => { service.markFolderRead(folderId); return { ok: true }; },
988
1007
  createFolder: async () => ({ ok: false, error: "Not supported on mobile" }),
@@ -29,7 +29,10 @@ export interface AccountConfig {
29
29
  password?: string;
30
30
  };
31
31
  enabled: boolean;
32
- primary?: boolean; /** Designates the account that supplies Calendar / Tasks / Contacts data. At most one per user; first one wins if multiple set. */
32
+ primary?: boolean; /** Catch-all "this is my main account" default source for Calendar / Tasks / Contacts when no per-feature override set. */
33
+ primaryCalendar?: boolean; /** Per-feature override: use this account's Google Calendar. Falls back to `primary` if unset. */
34
+ primaryTasks?: boolean; /** Per-feature override: use this account's Google Tasks. Falls back to `primary` if unset. */
35
+ primaryContacts?: boolean; /** Per-feature override: use this account's Google Contacts. Falls back to `primary` if unset. */
33
36
  defaultSend?: boolean; /** Use this account's SMTP when From doesn't match any account */
34
37
  syncContacts?: boolean; /** Sync contacts even when account is disabled (contacts-only Gmail) */
35
38
  relayDomains?: string[]; /** Domains to skip in Delivered-To chain (e.g., ["m.connectivity.xyz"]) */