@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.
- package/client/components/calendar-sidebar.js +40 -79
- package/client/components/message-list.js +37 -1
- package/client/components/message-viewer.js +17 -0
- package/client/index.html +5 -1
- package/client/lib/api-client.js +35 -4
- package/client/lib/mailxapi.js +14 -1
- package/client/styles/components.css +18 -0
- package/client/styles/layout.css +17 -10
- package/package.json +1 -1
- package/packages/mailx-imap/index.js +5 -1
- 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 +52 -7
- package/packages/mailx-service/index.js +240 -9
- package/packages/mailx-service/jsonrpc.js +26 -1
- package/packages/mailx-settings/index.js +3 -0
- package/packages/mailx-store/db.d.ts +48 -0
- package/packages/mailx-store/db.js +246 -2
- package/packages/mailx-store-web/android-bootstrap.js +19 -0
- package/packages/mailx-types/index.d.ts +4 -1
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Google Calendar / Tasks / People (Contacts) two-way sync helpers.
|
|
3
|
+
*
|
|
4
|
+
* Used by MailxService to push local edits to Google and pull server
|
|
5
|
+
* changes into the local cache. All functions take a `getToken` function
|
|
6
|
+
* and a fetch implementation so they stay platform-agnostic (Node uses
|
|
7
|
+
* global `fetch` on Node 18+; browsers use window.fetch).
|
|
8
|
+
*
|
|
9
|
+
* Error handling: throws on network / HTTP errors. Caller catches and
|
|
10
|
+
* either retries via the store_sync drainer or surfaces to the UI.
|
|
11
|
+
*/
|
|
12
|
+
async function googleFetch(tokenProvider, url, init = {}) {
|
|
13
|
+
const token = await tokenProvider();
|
|
14
|
+
const headers = new Headers(init.headers || {});
|
|
15
|
+
headers.set("Authorization", `Bearer ${token}`);
|
|
16
|
+
if (init.body && !headers.has("Content-Type"))
|
|
17
|
+
headers.set("Content-Type", "application/json");
|
|
18
|
+
const res = await fetch(url, { ...init, headers });
|
|
19
|
+
if (!res.ok) {
|
|
20
|
+
const body = await res.text().catch(() => "");
|
|
21
|
+
throw new Error(`Google API ${res.status} ${res.statusText}: ${body.slice(0, 300)}`);
|
|
22
|
+
}
|
|
23
|
+
return res;
|
|
24
|
+
}
|
|
25
|
+
export async function listCalendarEvents(tokenProvider, fromMs, toMs, calendarId = "primary") {
|
|
26
|
+
const from = new Date(fromMs).toISOString();
|
|
27
|
+
const to = new Date(toMs).toISOString();
|
|
28
|
+
const url = `https://www.googleapis.com/calendar/v3/calendars/${encodeURIComponent(calendarId)}/events`
|
|
29
|
+
+ `?timeMin=${encodeURIComponent(from)}&timeMax=${encodeURIComponent(to)}`
|
|
30
|
+
+ `&singleEvents=true&orderBy=startTime&maxResults=250`;
|
|
31
|
+
const all = [];
|
|
32
|
+
let pageToken;
|
|
33
|
+
do {
|
|
34
|
+
const pagedUrl = pageToken ? `${url}&pageToken=${encodeURIComponent(pageToken)}` : url;
|
|
35
|
+
const res = await googleFetch(tokenProvider, pagedUrl);
|
|
36
|
+
const data = await res.json();
|
|
37
|
+
for (const ev of (data.items || []))
|
|
38
|
+
all.push(ev);
|
|
39
|
+
pageToken = data.nextPageToken;
|
|
40
|
+
} while (pageToken);
|
|
41
|
+
return all;
|
|
42
|
+
}
|
|
43
|
+
export async function createCalendarEvent(tokenProvider, event, calendarId = "primary") {
|
|
44
|
+
const url = `https://www.googleapis.com/calendar/v3/calendars/${encodeURIComponent(calendarId)}/events`;
|
|
45
|
+
const res = await googleFetch(tokenProvider, url, { method: "POST", body: JSON.stringify(event) });
|
|
46
|
+
return res.json();
|
|
47
|
+
}
|
|
48
|
+
export async function updateCalendarEvent(tokenProvider, eventId, event, calendarId = "primary") {
|
|
49
|
+
const url = `https://www.googleapis.com/calendar/v3/calendars/${encodeURIComponent(calendarId)}/events/${encodeURIComponent(eventId)}`;
|
|
50
|
+
const res = await googleFetch(tokenProvider, url, { method: "PATCH", body: JSON.stringify(event) });
|
|
51
|
+
return res.json();
|
|
52
|
+
}
|
|
53
|
+
export async function deleteCalendarEvent(tokenProvider, eventId, calendarId = "primary") {
|
|
54
|
+
const url = `https://www.googleapis.com/calendar/v3/calendars/${encodeURIComponent(calendarId)}/events/${encodeURIComponent(eventId)}`;
|
|
55
|
+
await googleFetch(tokenProvider, url, { method: "DELETE" });
|
|
56
|
+
}
|
|
57
|
+
export async function listTasks(tokenProvider, listId = "@default", showCompleted = false) {
|
|
58
|
+
const url = `https://tasks.googleapis.com/tasks/v1/lists/${encodeURIComponent(listId)}/tasks`
|
|
59
|
+
+ `?showCompleted=${showCompleted}&maxResults=100`;
|
|
60
|
+
const res = await googleFetch(tokenProvider, url);
|
|
61
|
+
const data = await res.json();
|
|
62
|
+
return data.items || [];
|
|
63
|
+
}
|
|
64
|
+
export async function createTask(tokenProvider, task, listId = "@default") {
|
|
65
|
+
const url = `https://tasks.googleapis.com/tasks/v1/lists/${encodeURIComponent(listId)}/tasks`;
|
|
66
|
+
const res = await googleFetch(tokenProvider, url, { method: "POST", body: JSON.stringify(task) });
|
|
67
|
+
return res.json();
|
|
68
|
+
}
|
|
69
|
+
export async function updateTask(tokenProvider, taskId, task, listId = "@default") {
|
|
70
|
+
const url = `https://tasks.googleapis.com/tasks/v1/lists/${encodeURIComponent(listId)}/tasks/${encodeURIComponent(taskId)}`;
|
|
71
|
+
const res = await googleFetch(tokenProvider, url, { method: "PATCH", body: JSON.stringify(task) });
|
|
72
|
+
return res.json();
|
|
73
|
+
}
|
|
74
|
+
export async function deleteTask(tokenProvider, taskId, listId = "@default") {
|
|
75
|
+
const url = `https://tasks.googleapis.com/tasks/v1/lists/${encodeURIComponent(listId)}/tasks/${encodeURIComponent(taskId)}`;
|
|
76
|
+
await googleFetch(tokenProvider, url, { method: "DELETE" });
|
|
77
|
+
}
|
|
78
|
+
// ── Contacts (People API) ──
|
|
79
|
+
export async function createContact(tokenProvider, person) {
|
|
80
|
+
const url = `https://people.googleapis.com/v1/people:createContact`;
|
|
81
|
+
const res = await googleFetch(tokenProvider, url, { method: "POST", body: JSON.stringify(person) });
|
|
82
|
+
return res.json();
|
|
83
|
+
}
|
|
84
|
+
export async function updateContact(tokenProvider, resourceName, updatePersonFields, person) {
|
|
85
|
+
const url = `https://people.googleapis.com/v1/${resourceName}:updateContact`
|
|
86
|
+
+ `?updatePersonFields=${encodeURIComponent(updatePersonFields)}`;
|
|
87
|
+
const res = await googleFetch(tokenProvider, url, { method: "PATCH", body: JSON.stringify(person) });
|
|
88
|
+
return res.json();
|
|
89
|
+
}
|
|
90
|
+
export async function deleteContact(tokenProvider, resourceName) {
|
|
91
|
+
const url = `https://people.googleapis.com/v1/${resourceName}:deleteContact`;
|
|
92
|
+
await googleFetch(tokenProvider, url, { method: "DELETE" });
|
|
93
|
+
}
|
|
94
|
+
// ── Conversion helpers ──
|
|
95
|
+
export function calendarEventToLocal(ev, accountId) {
|
|
96
|
+
const allDay = !!ev.start.date && !ev.start.dateTime;
|
|
97
|
+
const startMs = allDay
|
|
98
|
+
? Date.parse(ev.start.date)
|
|
99
|
+
: Date.parse(ev.start.dateTime);
|
|
100
|
+
const endMs = allDay
|
|
101
|
+
? Date.parse(ev.end.date)
|
|
102
|
+
: Date.parse(ev.end.dateTime);
|
|
103
|
+
return {
|
|
104
|
+
providerId: ev.id, accountId,
|
|
105
|
+
title: ev.summary || "(no title)",
|
|
106
|
+
startMs, endMs, allDay,
|
|
107
|
+
location: ev.location || "", notes: ev.description || "",
|
|
108
|
+
etag: ev.etag || "",
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
export function localToCalendarEvent(local) {
|
|
112
|
+
const toIso = (ms) => new Date(ms).toISOString();
|
|
113
|
+
const toDate = (ms) => new Date(ms).toISOString().slice(0, 10);
|
|
114
|
+
return {
|
|
115
|
+
summary: local.title,
|
|
116
|
+
start: local.allDay ? { date: toDate(local.startMs) } : { dateTime: toIso(local.startMs) },
|
|
117
|
+
end: local.allDay ? { date: toDate(local.endMs) } : { dateTime: toIso(local.endMs) },
|
|
118
|
+
location: local.location || undefined,
|
|
119
|
+
description: local.notes || undefined,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
export function taskToLocal(t, accountId) {
|
|
123
|
+
return {
|
|
124
|
+
providerId: t.id, accountId, title: t.title || "",
|
|
125
|
+
notes: t.notes || "",
|
|
126
|
+
dueMs: t.due ? Date.parse(t.due) : undefined,
|
|
127
|
+
completedMs: t.completed ? Date.parse(t.completed) : undefined,
|
|
128
|
+
etag: t.etag || "",
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
export function localToTask(local) {
|
|
132
|
+
return {
|
|
133
|
+
title: local.title,
|
|
134
|
+
notes: local.notes || undefined,
|
|
135
|
+
due: local.dueMs ? new Date(local.dueMs).toISOString() : undefined,
|
|
136
|
+
completed: local.completedMs ? new Date(local.completedMs).toISOString() : undefined,
|
|
137
|
+
status: local.completedMs ? "completed" : "needsAction",
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
//# sourceMappingURL=google-sync.js.map
|
|
@@ -39,11 +39,54 @@ export declare class MailxService {
|
|
|
39
39
|
/** Per-account health snapshot: inactivity-timeout count, conn-cap hits,
|
|
40
40
|
* last failed IMAP command. Drives the diagnostics ⚠ badge in the UI. */
|
|
41
41
|
getDiagnostics(): any;
|
|
42
|
-
/** Return the account
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
|
|
42
|
+
/** Return the account that supplies `feature` data (calendar / tasks /
|
|
43
|
+
* contacts). Resolution order:
|
|
44
|
+
* 1. Any account with `primary<Feature>: true` (per-feature override)
|
|
45
|
+
* 2. Any account with `primary: true` (catch-all default)
|
|
46
|
+
* 3. First account (fallback)
|
|
47
|
+
* Called without `feature` it returns the catch-all primary — same
|
|
48
|
+
* semantics as the original single-flag version for back-compat. */
|
|
49
|
+
getPrimaryAccount(feature?: string): any;
|
|
50
|
+
private primaryTokenProvider;
|
|
51
|
+
/** Return cal events visible in [fromMs..toMs), refreshing from Google
|
|
52
|
+
* in the background if enough time has passed. Caller displays local
|
|
53
|
+
* results immediately; events updated from Google emit an event. */
|
|
54
|
+
getCalendarEvents(fromMs: number, toMs: number): Promise<any[]>;
|
|
55
|
+
private refreshCalendarEvents;
|
|
56
|
+
createCalendarEventLocal(ev: {
|
|
57
|
+
title: string;
|
|
58
|
+
startMs: number;
|
|
59
|
+
endMs: number;
|
|
60
|
+
allDay?: boolean;
|
|
61
|
+
location?: string;
|
|
62
|
+
notes?: string;
|
|
63
|
+
}): Promise<string>;
|
|
64
|
+
updateCalendarEventLocal(uuid: string, patch: {
|
|
65
|
+
title?: string;
|
|
66
|
+
startMs?: number;
|
|
67
|
+
endMs?: number;
|
|
68
|
+
allDay?: boolean;
|
|
69
|
+
location?: string;
|
|
70
|
+
notes?: string;
|
|
71
|
+
}): Promise<void>;
|
|
72
|
+
deleteCalendarEventLocal(uuid: string): Promise<void>;
|
|
73
|
+
getTasks(includeCompleted?: boolean): Promise<any[]>;
|
|
74
|
+
private refreshTasks;
|
|
75
|
+
createTaskLocal(t: {
|
|
76
|
+
title: string;
|
|
77
|
+
notes?: string;
|
|
78
|
+
dueMs?: number;
|
|
79
|
+
}): Promise<string>;
|
|
80
|
+
updateTaskLocal(uuid: string, patch: {
|
|
81
|
+
title?: string;
|
|
82
|
+
notes?: string;
|
|
83
|
+
dueMs?: number;
|
|
84
|
+
completedMs?: number;
|
|
85
|
+
}): Promise<void>;
|
|
86
|
+
deleteTaskLocal(uuid: string): Promise<void>;
|
|
87
|
+
/** Drain the store_sync queue — calendar / tasks / contacts push-to-server.
|
|
88
|
+
* Called on every local edit, and on a periodic tick from the outbox worker. */
|
|
89
|
+
drainStoreSync(): Promise<void>;
|
|
47
90
|
/** List queued outgoing messages with parsed envelope headers so the UI
|
|
48
91
|
* can render a pink-row "pending" view before IMAP APPEND succeeds. */
|
|
49
92
|
listQueuedOutgoing(): any[];
|
|
@@ -92,11 +135,13 @@ export declare class MailxService {
|
|
|
92
135
|
addContact(name: string, email: string): boolean;
|
|
93
136
|
/** Address-book listing — paginated, filterable. */
|
|
94
137
|
listContacts(query: string, page?: number, pageSize?: number): any;
|
|
95
|
-
/** Upsert a contact from the address book UI (edit name).
|
|
138
|
+
/** Upsert a contact from the address book UI (edit name). Two-way cache:
|
|
139
|
+
* commits locally, queues a Google People push. */
|
|
96
140
|
upsertContact(name: string, email: string): {
|
|
97
141
|
ok: true;
|
|
98
142
|
};
|
|
99
|
-
/** Delete a contact from the address book.
|
|
143
|
+
/** Delete a contact from the address book. Also pushes the deletion to
|
|
144
|
+
* Google People if the contact had a resourceName (i.e. was synced). */
|
|
100
145
|
deleteContact(email: string): {
|
|
101
146
|
ok: true;
|
|
102
147
|
};
|
|
@@ -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";
|
|
@@ -102,7 +103,14 @@ export class MailxService {
|
|
|
102
103
|
for (const cfg of cfgs) {
|
|
103
104
|
const a = dbAccounts.find(d => d.id === cfg.id);
|
|
104
105
|
if (a)
|
|
105
|
-
ordered.push({
|
|
106
|
+
ordered.push({
|
|
107
|
+
...a, label: cfg.label, defaultSend: cfg.defaultSend || false,
|
|
108
|
+
primary: !!cfg.primary,
|
|
109
|
+
primaryCalendar: !!cfg.primaryCalendar,
|
|
110
|
+
primaryTasks: !!cfg.primaryTasks,
|
|
111
|
+
primaryContacts: !!cfg.primaryContacts,
|
|
112
|
+
identityDomains: cfg.identityDomains || [],
|
|
113
|
+
});
|
|
106
114
|
}
|
|
107
115
|
// Append any DB accounts not in settings
|
|
108
116
|
for (const a of dbAccounts) {
|
|
@@ -421,14 +429,215 @@ export class MailxService {
|
|
|
421
429
|
getDiagnostics() {
|
|
422
430
|
return this.imapManager.getDiagnosticsSnapshot();
|
|
423
431
|
}
|
|
424
|
-
/** Return the account
|
|
425
|
-
*
|
|
426
|
-
*
|
|
427
|
-
*
|
|
428
|
-
|
|
432
|
+
/** Return the account that supplies `feature` data (calendar / tasks /
|
|
433
|
+
* contacts). Resolution order:
|
|
434
|
+
* 1. Any account with `primary<Feature>: true` (per-feature override)
|
|
435
|
+
* 2. Any account with `primary: true` (catch-all default)
|
|
436
|
+
* 3. First account (fallback)
|
|
437
|
+
* Called without `feature` it returns the catch-all primary — same
|
|
438
|
+
* semantics as the original single-flag version for back-compat. */
|
|
439
|
+
getPrimaryAccount(feature) {
|
|
429
440
|
const all = this.getAccounts();
|
|
441
|
+
if (feature) {
|
|
442
|
+
const perFeatureKey = "primary" + feature.charAt(0).toUpperCase() + feature.slice(1);
|
|
443
|
+
const perFeature = all.find((a) => a[perFeatureKey]);
|
|
444
|
+
if (perFeature)
|
|
445
|
+
return perFeature;
|
|
446
|
+
}
|
|
430
447
|
return all.find((a) => a.primary) || all[0] || null;
|
|
431
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.getCalendarEvents("", 0, Number.MAX_SAFE_INTEGER).find(e => e.uuid === 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.getCalendarEvents("", 0, Number.MAX_SAFE_INTEGER).find(e => e.uuid === 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.getTasks("", true).find(t => t.uuid === 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.getTasks("", true).find(t => t.uuid === 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
|
+
}
|
|
432
641
|
/** List queued outgoing messages with parsed envelope headers so the UI
|
|
433
642
|
* can render a pink-row "pending" view before IMAP APPEND succeeds. */
|
|
434
643
|
listQueuedOutgoing() {
|
|
@@ -969,14 +1178,36 @@ export class MailxService {
|
|
|
969
1178
|
listContacts(query, page = 1, pageSize = 100) {
|
|
970
1179
|
return this.db.listContacts(query || "", page, pageSize);
|
|
971
1180
|
}
|
|
972
|
-
/** 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. */
|
|
973
1183
|
upsertContact(name, email) {
|
|
974
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
|
+
}
|
|
975
1195
|
return { ok: true };
|
|
976
1196
|
}
|
|
977
|
-
/** 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). */
|
|
978
1199
|
deleteContact(email) {
|
|
979
|
-
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
|
+
}
|
|
980
1211
|
return { ok: true };
|
|
981
1212
|
}
|
|
982
1213
|
/** Open a configured local path in the OS file explorer. Whitelisted to
|
|
@@ -97,7 +97,32 @@ async function dispatchAction(svc, action, p) {
|
|
|
97
97
|
case "getDiagnostics":
|
|
98
98
|
return svc.getDiagnostics();
|
|
99
99
|
case "getPrimaryAccount":
|
|
100
|
-
return svc.getPrimaryAccount();
|
|
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":
|
|
@@ -377,6 +377,9 @@ function normalizeAccount(acct, globalName) {
|
|
|
377
377
|
},
|
|
378
378
|
enabled: acct.enabled ?? true,
|
|
379
379
|
primary: acct.primary,
|
|
380
|
+
primaryCalendar: acct.primaryCalendar,
|
|
381
|
+
primaryTasks: acct.primaryTasks,
|
|
382
|
+
primaryContacts: acct.primaryContacts,
|
|
380
383
|
defaultSend: acct.defaultSend,
|
|
381
384
|
syncContacts: acct.syncContacts ?? (provider?.imap.auth === "oauth2"),
|
|
382
385
|
relayDomains: acct.relayDomains,
|
|
@@ -7,6 +7,9 @@ import type { MessageEnvelope, Folder, EmailAddress, PagedResult, MessageQuery }
|
|
|
7
7
|
export declare class MailxDB {
|
|
8
8
|
private db;
|
|
9
9
|
constructor(dbDir: string);
|
|
10
|
+
/** Fail loud + early if expected columns are missing. Cheap (PRAGMA only
|
|
11
|
+
* runs at startup). The user-facing message names the recovery command. */
|
|
12
|
+
private verifySchema;
|
|
10
13
|
/** One-time: assign UUIDs to every `messages` row that's missing one.
|
|
11
14
|
* Runs on every startup but the WHERE clause makes it a no-op after the
|
|
12
15
|
* first pass. */
|
|
@@ -30,6 +33,50 @@ export declare class MailxDB {
|
|
|
30
33
|
* growing unboundedly. Default retention is 30 days; caller passes the
|
|
31
34
|
* actual cutoff in ms since epoch. */
|
|
32
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
|
+
getDirtyCalendarEvents(accountId: string): any[];
|
|
52
|
+
private calendarRowToObject;
|
|
53
|
+
markCalendarEventClean(uuid: string, providerId: string, etag: string): void;
|
|
54
|
+
deleteCalendarEventLocal(uuid: string): void;
|
|
55
|
+
purgeCalendarEvent(uuid: string): void;
|
|
56
|
+
upsertTask(t: {
|
|
57
|
+
uuid?: string;
|
|
58
|
+
accountId: string;
|
|
59
|
+
providerId?: string;
|
|
60
|
+
listId?: string;
|
|
61
|
+
title: string;
|
|
62
|
+
notes?: string;
|
|
63
|
+
dueMs?: number;
|
|
64
|
+
completedMs?: number;
|
|
65
|
+
etag?: string;
|
|
66
|
+
dirty?: boolean;
|
|
67
|
+
}): string;
|
|
68
|
+
getTasks(accountId: string, includeCompleted?: boolean): any[];
|
|
69
|
+
getDirtyTasks(accountId: string): any[];
|
|
70
|
+
private taskRowToObject;
|
|
71
|
+
markTaskClean(uuid: string, providerId: string, etag: string): void;
|
|
72
|
+
deleteTaskLocal(uuid: string): void;
|
|
73
|
+
purgeTask(uuid: string): void;
|
|
74
|
+
/** Local delete for the two-way cache drainer (symmetric with calendar/tasks). */
|
|
75
|
+
deleteContactLocal(email: string): void;
|
|
76
|
+
enqueueStoreSync(kind: string, op: string, accountId: string, targetUuid: string, payload: any): void;
|
|
77
|
+
getStoreSyncQueue(kind?: string, accountId?: string): any[];
|
|
78
|
+
completeStoreSync(id: number): void;
|
|
79
|
+
failStoreSync(id: number, error: string): void;
|
|
33
80
|
/** Idempotently add a column to a table if it's missing. */
|
|
34
81
|
private addColumnIfMissing;
|
|
35
82
|
/** Compute a thread id for an incoming message. Strategy:
|
|
@@ -139,6 +186,7 @@ export declare class MailxDB {
|
|
|
139
186
|
name: string;
|
|
140
187
|
email: string;
|
|
141
188
|
source: string;
|
|
189
|
+
googleId: string | null;
|
|
142
190
|
useCount: number;
|
|
143
191
|
lastUsed: number;
|
|
144
192
|
}[];
|