@bobfrankston/mailx 1.0.378 → 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.
@@ -10,68 +10,33 @@
10
10
  * Sidebar and the full-screen calendar modal (calendar.ts) read the SAME
11
11
  * underlying data — two views onto one source.
12
12
  *
13
- * Local-only events (LOCAL_STORE_KEY) are merged with Google events.
13
+ * All storage goes through the service-side two-way cache (calendar_events
14
+ * and tasks tables); this file does not use localStorage for data.
14
15
  */
15
- import { getPrimaryAccount } from "../lib/api-client.js";
16
- const LOCAL_STORE_KEY = "mailx-cal-events-v1";
17
- const TASK_STORE_KEY = "mailx-tasks-v1";
16
+ import { getCalendarEvents, createCalendarEvent, getTasks, createTask, updateTask, } from "../lib/api-client.js";
18
17
  const SIDEBAR_PREF = "mailx-calendar-sidebar-on";
19
18
  let viewYear = new Date().getFullYear();
20
19
  let viewMonth = new Date().getMonth();
21
20
  let viewDay = new Date().getDate();
22
21
  let lastEvents = [];
23
- function loadLocalEvents() {
24
- try {
25
- const raw = localStorage.getItem(LOCAL_STORE_KEY);
26
- if (!raw)
27
- return [];
28
- const arr = JSON.parse(raw);
29
- return Array.isArray(arr) ? arr : [];
30
- }
31
- catch {
32
- return [];
33
- }
34
- }
35
- function loadTasks() {
36
- try {
37
- const raw = localStorage.getItem(TASK_STORE_KEY);
38
- if (!raw)
39
- return [];
40
- const arr = JSON.parse(raw);
41
- return Array.isArray(arr) ? arr : [];
42
- }
43
- catch {
44
- return [];
45
- }
46
- }
47
- function saveTasks(tasks) {
48
- try {
49
- localStorage.setItem(TASK_STORE_KEY, JSON.stringify(tasks));
50
- }
51
- catch { /* */ }
52
- }
53
- /** Fetch upcoming events from Google Calendar via the primary account's
54
- * OAuth token. Returns merged Google + local events for the next 30 days
55
- * starting at `from`. Quietly returns local-only on any error so the
56
- * sidebar still works without network / without Google Calendar scope. */
22
+ /** Fetch events from the local two-way cache; service returns local rows
23
+ * immediately and kicks a background refresh from Google. Next render
24
+ * (view-nav or user action) picks up the refreshed rows. No localStorage
25
+ * — everything lives in the service-side DB so phone / desktop share
26
+ * the same events. */
57
27
  async function fetchUpcoming(from) {
58
- const local = loadLocalEvents();
59
- let google = [];
60
- try {
61
- const primary = await getPrimaryAccount("calendar");
62
- if (primary?.email) {
63
- // The OAuth token is held by the service-side OAuthTokenManager;
64
- // calendar fetch goes through a server-side proxy method (not
65
- // implemented yet — this is the seam). For now: local-only.
66
- // Wire-up: TODO — service.fetchGoogleCalendarEvents(accountId, from, days).
67
- // Sidebar already renders correctly with the merged result.
68
- }
69
- }
70
- catch { /* google unavailable, fall back to local */ }
71
28
  const horizon = from.getTime() + 30 * 86400_000;
72
- return [...local, ...google]
73
- .filter(e => e.start >= from.getTime() && e.start < horizon)
74
- .sort((a, b) => a.start - b.start);
29
+ const rows = await getCalendarEvents(from.getTime(), horizon);
30
+ return rows.map((r) => ({
31
+ id: r.uuid,
32
+ title: r.title,
33
+ start: r.startMs,
34
+ end: r.endMs,
35
+ allDay: !!r.allDay,
36
+ location: r.location,
37
+ notes: r.notes,
38
+ source: r.providerId ? "google" : "local",
39
+ }));
75
40
  }
76
41
  function formatDayHeader(d, today, tomorrow) {
77
42
  const sameDay = (a, b) => a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate();
@@ -124,9 +89,9 @@ function renderEvents(events) {
124
89
  }
125
90
  body.innerHTML = html;
126
91
  }
127
- function renderTasks() {
92
+ async function renderTasks() {
128
93
  const showDone = document.getElementById("cal-side-show-done")?.checked || false;
129
- const tasks = loadTasks().filter(t => showDone || !t.completed);
94
+ const tasks = await getTasks(showDone);
130
95
  const host = document.getElementById("cal-side-tasks");
131
96
  if (!host)
132
97
  return;
@@ -136,23 +101,19 @@ function renderTasks() {
136
101
  }
137
102
  let html = "<div class='cal-side-task-head'>Title</div>";
138
103
  for (const t of tasks) {
139
- html += `<div class="cal-side-task" data-id="${t.id}">
140
- <input type="checkbox" ${t.completed ? "checked" : ""} class="cal-side-task-check">
141
- <span class="cal-side-task-title${t.completed ? " done" : ""}">${escapeHtml(t.title)}</span>
104
+ const done = !!t.completedMs;
105
+ html += `<div class="cal-side-task" data-uuid="${t.uuid}">
106
+ <input type="checkbox" ${done ? "checked" : ""} class="cal-side-task-check">
107
+ <span class="cal-side-task-title${done ? " done" : ""}">${escapeHtml(t.title)}</span>
142
108
  </div>`;
143
109
  }
144
110
  host.innerHTML = html;
145
111
  host.querySelectorAll(".cal-side-task").forEach(row => {
146
- const id = row.dataset.id;
147
- row.querySelector(".cal-side-task-check")?.addEventListener("change", (e) => {
112
+ const uuid = row.dataset.uuid;
113
+ row.querySelector(".cal-side-task-check")?.addEventListener("change", async (e) => {
148
114
  const checked = e.target.checked;
149
- const all = loadTasks();
150
- const t = all.find(x => x.id === id);
151
- if (t) {
152
- t.completed = checked ? Date.now() : undefined;
153
- saveTasks(all);
154
- renderTasks();
155
- }
115
+ await updateTask(uuid, { completedMs: checked ? Date.now() : null });
116
+ renderTasks();
156
117
  });
157
118
  });
158
119
  }
@@ -225,19 +186,19 @@ export function initCalendarSidebar() {
225
186
  alert("Couldn't parse that date.");
226
187
  return;
227
188
  }
228
- const ev = {
229
- id: `cal-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
230
- title, start, end: start + (allDay ? 86400_000 : 3600_000),
231
- allDay, source: "local",
232
- };
233
- const all = loadLocalEvents();
234
- all.push(ev);
235
- try {
236
- localStorage.setItem(LOCAL_STORE_KEY, JSON.stringify(all));
237
- }
238
- catch { /* */ }
189
+ // Two-way cache: commit locally, service queues the push to Google.
190
+ await createCalendarEvent({
191
+ title, startMs: start, endMs: start + (allDay ? 86400_000 : 3600_000), allDay,
192
+ });
239
193
  await refresh();
240
194
  });
195
+ wireOnce("cal-side-new-task", async () => {
196
+ const title = prompt("Task title:");
197
+ if (!title)
198
+ return;
199
+ await createTask({ title });
200
+ renderTasks();
201
+ });
241
202
  const showDoneCb = document.getElementById("cal-side-show-done");
242
203
  if (showDoneCb && !showDoneCb.__wired) {
243
204
  showDoneCb.__wired = true;
package/client/index.html CHANGED
@@ -170,7 +170,10 @@
170
170
  <div class="cal-side-empty">Loading…</div>
171
171
  </div>
172
172
  <footer class="cal-side-foot">
173
- <label><input type="checkbox" id="cal-side-show-done"> Show completed Tasks</label>
173
+ <div class="cal-side-task-header-row">
174
+ <label><input type="checkbox" id="cal-side-show-done"> Show completed Tasks</label>
175
+ <button class="cal-side-new" id="cal-side-new-task" title="New task">+ Task</button>
176
+ </div>
174
177
  <div class="cal-side-tasks" id="cal-side-tasks"></div>
175
178
  </footer>
176
179
  </aside>
@@ -141,6 +141,35 @@ export function getDiagnostics() {
141
141
  export function getPrimaryAccount(feature) {
142
142
  return ipc().getPrimaryAccount?.(feature) ?? Promise.resolve(null);
143
143
  }
144
+ // Calendar / Tasks: two-way cache. Reads return local-cached rows; writes
145
+ // commit locally and queue a push to Google. Service layer handles drain.
146
+ export function getCalendarEvents(fromMs, toMs) {
147
+ return ipc().getCalendarEvents?.(fromMs, toMs) ?? Promise.resolve([]);
148
+ }
149
+ export function createCalendarEvent(ev) {
150
+ return ipc().createCalendarEvent?.(ev);
151
+ }
152
+ export function updateCalendarEvent(uuid, patch) {
153
+ return ipc().updateCalendarEvent?.(uuid, patch);
154
+ }
155
+ export function deleteCalendarEvent(uuid) {
156
+ return ipc().deleteCalendarEvent?.(uuid);
157
+ }
158
+ export function getTasks(includeCompleted = false) {
159
+ return ipc().getTasks?.(includeCompleted) ?? Promise.resolve([]);
160
+ }
161
+ export function createTask(t) {
162
+ return ipc().createTask?.(t);
163
+ }
164
+ export function updateTask(uuid, patch) {
165
+ return ipc().updateTask?.(uuid, patch);
166
+ }
167
+ export function deleteTask(uuid) {
168
+ return ipc().deleteTask?.(uuid);
169
+ }
170
+ export function drainStoreSync() {
171
+ return ipc().drainStoreSync?.();
172
+ }
144
173
  export function getOutboxStatus() {
145
174
  return ipc().getOutboxStatus();
146
175
  }
@@ -109,6 +109,19 @@
109
109
  getThreadMessages: function(accountId, threadId) {
110
110
  return callNode("getThreadMessages", { accountId: accountId, threadId: threadId });
111
111
  },
112
+ // Calendar / Tasks two-way cache. Reads return local DB immediately;
113
+ // writes commit locally and queue a push-to-Google action.
114
+ getCalendarEvents: function(fromMs, toMs) {
115
+ return callNode("getCalendarEvents", { fromMs: fromMs, toMs: toMs });
116
+ },
117
+ createCalendarEvent: function(ev) { return callNode("createCalendarEvent", ev); },
118
+ updateCalendarEvent: function(uuid, patch) { return callNode("updateCalendarEvent", { uuid: uuid, patch: patch }); },
119
+ deleteCalendarEvent: function(uuid) { return callNode("deleteCalendarEvent", { uuid: uuid }); },
120
+ getTasks: function(includeCompleted) { return callNode("getTasks", { includeCompleted: !!includeCompleted }); },
121
+ createTask: function(t) { return callNode("createTask", t); },
122
+ updateTask: function(uuid, patch) { return callNode("updateTask", { uuid: uuid, patch: patch }); },
123
+ deleteTask: function(uuid) { return callNode("deleteTask", { uuid: uuid }); },
124
+ drainStoreSync: function() { return callNode("drainStoreSync"); },
112
125
  readJsoncFile: function(name) {
113
126
  return callNode("readJsoncFile", { name: name });
114
127
  },
@@ -44,15 +44,17 @@ body.calendar-sidebar-on {
44
44
  .main-area { grid-area: main; }
45
45
  .status-bar { grid-area: status; }
46
46
 
47
- /* Vertical icon rail (Dovecot/Thunderbird-style). Always visible on
48
- wide+medium tiers; collapses on narrow (icons move into the hamburger
49
- menu TBD; for now hidden on narrow). */
47
+ /* Vertical icon rail Thunderbird Supernova style: dark background with
48
+ light icons so the rail reads as "chrome" and contrasts visibly against
49
+ the content area (whether the app's in light or dark theme). Always
50
+ visible on wide+medium tiers; hidden on narrow (icons fold into
51
+ hamburger — TBD). */
50
52
  .icon-rail {
51
53
  display: flex;
52
54
  flex-direction: column;
53
55
  justify-content: space-between;
54
- background: var(--color-bg-alt, #f4f4f5);
55
- border-right: 1px solid var(--color-border);
56
+ background: oklch(0.25 0.01 250);
57
+ border-right: 1px solid oklch(0.18 0.01 250);
56
58
  padding: 6px 0;
57
59
  overflow: hidden;
58
60
  }
@@ -71,16 +73,21 @@ body.calendar-sidebar-on {
71
73
  background: transparent;
72
74
  cursor: pointer;
73
75
  font-size: 16px;
74
- color: var(--color-text);
76
+ color: oklch(0.88 0.01 250);
75
77
  border-left: 3px solid transparent;
76
- transition: background 0.12s, border-color 0.12s;
78
+ transition: background 0.12s, border-color 0.12s, color 0.12s;
77
79
  }
78
80
  .rail-btn:hover:not([disabled]) {
79
- background: var(--color-hover, rgba(0,0,0,0.06));
81
+ background: oklch(0.32 0.02 250);
82
+ color: oklch(0.96 0.01 250);
80
83
  }
81
84
  .rail-btn[data-active="true"] {
82
- background: var(--color-hover, rgba(0,0,0,0.06));
83
- border-left-color: var(--color-accent, #1a6dd4);
85
+ background: oklch(0.35 0.03 250);
86
+ color: oklch(0.98 0.01 250);
87
+ border-left-color: var(--color-accent, #4a9cff);
88
+ }
89
+ .rail-btn[disabled] {
90
+ color: oklch(0.55 0.01 250);
84
91
  }
85
92
  .rail-btn[disabled] {
86
93
  opacity: 0.35;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx",
3
- "version": "1.0.378",
3
+ "version": "1.0.379",
4
4
  "description": "Local-first email client with IMAP sync and standalone native app",
5
5
  "type": "module",
6
6
  "main": "bin/mailx.js",
@@ -564,7 +564,11 @@ export class ImapManager extends EventEmitter {
564
564
  const hasToken = fs.existsSync(path.join(tokenDir, "oauth-token.json"));
565
565
  const TOKEN_FETCH_TIMEOUT_MS = hasToken ? 30_000 : 120_000;
566
566
  const authPromise = authenticateOAuth(credPath, {
567
- scope: "https://mail.google.com/ https://www.googleapis.com/auth/contacts.readonly https://www.googleapis.com/auth/calendar https://www.googleapis.com/auth/drive",
567
+ // Scope set covers two-way sync of all mailx-managed local
568
+ // stores: mail (mail.google.com), contacts (full, not
569
+ // readonly — we write edits back), calendar (full), tasks
570
+ // (full), drive (for shared accounts.jsonc).
571
+ scope: "https://mail.google.com/ https://www.googleapis.com/auth/contacts https://www.googleapis.com/auth/calendar https://www.googleapis.com/auth/tasks https://www.googleapis.com/auth/drive",
568
572
  tokenDirectory: tokenDir,
569
573
  credentialsKey: "installed",
570
574
  loginHint: account.imap.user,
@@ -0,0 +1,83 @@
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
+ type TokenProvider = () => Promise<string>;
13
+ export interface GCalEvent {
14
+ id: string;
15
+ summary: string;
16
+ start: {
17
+ dateTime?: string;
18
+ date?: string;
19
+ };
20
+ end: {
21
+ dateTime?: string;
22
+ date?: string;
23
+ };
24
+ location?: string;
25
+ description?: string;
26
+ etag?: string;
27
+ }
28
+ export declare function listCalendarEvents(tokenProvider: TokenProvider, fromMs: number, toMs: number, calendarId?: string): Promise<GCalEvent[]>;
29
+ export declare function createCalendarEvent(tokenProvider: TokenProvider, event: any, calendarId?: string): Promise<GCalEvent>;
30
+ export declare function updateCalendarEvent(tokenProvider: TokenProvider, eventId: string, event: any, calendarId?: string): Promise<GCalEvent>;
31
+ export declare function deleteCalendarEvent(tokenProvider: TokenProvider, eventId: string, calendarId?: string): Promise<void>;
32
+ export interface GTask {
33
+ id: string;
34
+ title: string;
35
+ notes?: string;
36
+ due?: string;
37
+ completed?: string;
38
+ status?: "needsAction" | "completed";
39
+ etag?: string;
40
+ }
41
+ export declare function listTasks(tokenProvider: TokenProvider, listId?: string, showCompleted?: boolean): Promise<GTask[]>;
42
+ export declare function createTask(tokenProvider: TokenProvider, task: any, listId?: string): Promise<GTask>;
43
+ export declare function updateTask(tokenProvider: TokenProvider, taskId: string, task: any, listId?: string): Promise<GTask>;
44
+ export declare function deleteTask(tokenProvider: TokenProvider, taskId: string, listId?: string): Promise<void>;
45
+ export declare function createContact(tokenProvider: TokenProvider, person: any): Promise<any>;
46
+ export declare function updateContact(tokenProvider: TokenProvider, resourceName: string, updatePersonFields: string, person: any): Promise<any>;
47
+ export declare function deleteContact(tokenProvider: TokenProvider, resourceName: string): Promise<void>;
48
+ export declare function calendarEventToLocal(ev: GCalEvent, accountId: string): {
49
+ providerId: string;
50
+ accountId: string;
51
+ title: string;
52
+ startMs: number;
53
+ endMs: number;
54
+ allDay: boolean;
55
+ location: string;
56
+ notes: string;
57
+ etag: string;
58
+ };
59
+ export declare function localToCalendarEvent(local: {
60
+ title: string;
61
+ startMs: number;
62
+ endMs: number;
63
+ allDay?: boolean;
64
+ location?: string;
65
+ notes?: string;
66
+ }): any;
67
+ export declare function taskToLocal(t: GTask, accountId: string): {
68
+ providerId: string;
69
+ accountId: string;
70
+ title: string;
71
+ notes: string;
72
+ dueMs: number | undefined;
73
+ completedMs: number | undefined;
74
+ etag: string;
75
+ };
76
+ export declare function localToTask(local: {
77
+ title: string;
78
+ notes?: string;
79
+ dueMs?: number;
80
+ completedMs?: number;
81
+ }): any;
82
+ export {};
83
+ //# sourceMappingURL=google-sync.d.ts.map
@@ -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
@@ -47,6 +47,46 @@ export declare class MailxService {
47
47
  * Called without `feature` it returns the catch-all primary — same
48
48
  * semantics as the original single-flag version for back-compat. */
49
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>;
50
90
  /** List queued outgoing messages with parsed envelope headers so the UI
51
91
  * can render a pink-row "pending" view before IMAP APPEND succeeds. */
52
92
  listQueuedOutgoing(): any[];
@@ -95,11 +135,13 @@ export declare class MailxService {
95
135
  addContact(name: string, email: string): boolean;
96
136
  /** Address-book listing — paginated, filterable. */
97
137
  listContacts(query: string, page?: number, pageSize?: number): any;
98
- /** 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. */
99
140
  upsertContact(name: string, email: string): {
100
141
  ok: true;
101
142
  };
102
- /** 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). */
103
145
  deleteContact(email: string): {
104
146
  ok: true;
105
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";
@@ -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.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
+ }
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,50 @@ 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
+ 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;
36
80
  /** Idempotently add a column to a table if it's missing. */
37
81
  private addColumnIfMissing;
38
82
  /** Compute a thread id for an incoming message. Strategy:
@@ -142,6 +186,7 @@ export declare class MailxDB {
142
186
  name: string;
143
187
  email: string;
144
188
  source: string;
189
+ googleId: string | null;
145
190
  useCount: number;
146
191
  lastUsed: number;
147
192
  }[];
@@ -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,152 @@ 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
+ 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
+ }
306
518
  /** Idempotently add a column to a table if it's missing. */
307
519
  addColumnIfMissing(table, column, sqlType) {
308
520
  try {
@@ -800,12 +1012,16 @@ export class MailxDB {
800
1012
  const params = hasQuery ? [q, q] : [];
801
1013
  const totalRow = this.db.prepare(`SELECT COUNT(*) as c FROM contacts ${whereClause}`).get(...params);
802
1014
  const offset = (page - 1) * pageSize;
803
- 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
804
1016
  ${whereClause}
805
1017
  ORDER BY use_count DESC, last_used DESC
806
1018
  LIMIT ? OFFSET ?`).all(...params, pageSize, offset);
807
1019
  return {
808
- 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
+ })),
809
1025
  total: totalRow?.c || 0,
810
1026
  page, pageSize,
811
1027
  };