@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.
- package/bin/mailx.js +18 -0
- package/client/app.js +107 -1
- package/client/components/calendar-sidebar.js +45 -79
- package/client/components/folder-tree.js +25 -0
- package/client/components/message-list.js +75 -1
- package/client/components/message-viewer.js +7 -0
- package/client/index.html +10 -5
- package/client/lib/api-client.js +31 -2
- package/client/lib/mailxapi.js +13 -0
- package/client/styles/components.css +124 -0
- package/client/styles/layout.css +34 -10
- package/package.json +3 -3
- package/packages/mailx-imap/index.js +31 -2
- package/packages/mailx-service/google-sync.d.ts +83 -0
- package/packages/mailx-service/google-sync.js +140 -0
- package/packages/mailx-service/index.d.ts +44 -2
- package/packages/mailx-service/index.js +218 -3
- package/packages/mailx-service/jsonrpc.js +25 -0
- package/packages/mailx-store/db.d.ts +49 -0
- package/packages/mailx-store/db.js +255 -6
- package/packages/mailx-store-web/db.js +54 -0
- package/packages/mailx-store-web/web-service.js +19 -10
- package/packages/mailx-types/index.d.ts +15 -3
- package/packages/mailx-types/index.js +90 -2
|
@@ -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.
|
|
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
|
-
|
|
789
|
-
|
|
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(
|
|
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 => ({
|
|
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
|
};
|