@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
|
@@ -142,6 +142,72 @@ const SCHEMA = `
|
|
|
142
142
|
PRIMARY KEY (account_id, message_id)
|
|
143
143
|
);
|
|
144
144
|
CREATE INDEX IF NOT EXISTS idx_tombstones_deleted_at ON tombstones(deleted_at);
|
|
145
|
+
|
|
146
|
+
-- Calendar events: two-way cache of Google Calendar / local events.
|
|
147
|
+
-- uuid = local stable identity (survives provider_id rebinds).
|
|
148
|
+
-- provider_id = Google Calendar event id when known (null for local-only
|
|
149
|
+
-- events that haven't been pushed yet).
|
|
150
|
+
-- deleted = tombstone marker; drainer removes row from server then deletes
|
|
151
|
+
-- the row locally.
|
|
152
|
+
CREATE TABLE IF NOT EXISTS calendar_events (
|
|
153
|
+
uuid TEXT PRIMARY KEY,
|
|
154
|
+
account_id TEXT NOT NULL,
|
|
155
|
+
provider_id TEXT,
|
|
156
|
+
calendar_id TEXT DEFAULT 'primary',
|
|
157
|
+
title TEXT NOT NULL DEFAULT '',
|
|
158
|
+
start_ms INTEGER NOT NULL,
|
|
159
|
+
end_ms INTEGER NOT NULL,
|
|
160
|
+
all_day INTEGER DEFAULT 0,
|
|
161
|
+
location TEXT DEFAULT '',
|
|
162
|
+
notes TEXT DEFAULT '',
|
|
163
|
+
etag TEXT,
|
|
164
|
+
last_synced INTEGER DEFAULT 0,
|
|
165
|
+
dirty INTEGER DEFAULT 0,
|
|
166
|
+
deleted INTEGER DEFAULT 0,
|
|
167
|
+
updated_at INTEGER NOT NULL
|
|
168
|
+
);
|
|
169
|
+
CREATE INDEX IF NOT EXISTS idx_calendar_events_start ON calendar_events(account_id, start_ms);
|
|
170
|
+
CREATE INDEX IF NOT EXISTS idx_calendar_events_dirty ON calendar_events(dirty) WHERE dirty = 1;
|
|
171
|
+
|
|
172
|
+
-- Tasks: two-way cache of Google Tasks / local tasks. Same shape as
|
|
173
|
+
-- calendar_events minus the time range.
|
|
174
|
+
CREATE TABLE IF NOT EXISTS tasks (
|
|
175
|
+
uuid TEXT PRIMARY KEY,
|
|
176
|
+
account_id TEXT NOT NULL,
|
|
177
|
+
provider_id TEXT,
|
|
178
|
+
list_id TEXT DEFAULT '@default',
|
|
179
|
+
title TEXT NOT NULL DEFAULT '',
|
|
180
|
+
notes TEXT DEFAULT '',
|
|
181
|
+
due_ms INTEGER,
|
|
182
|
+
completed_ms INTEGER,
|
|
183
|
+
etag TEXT,
|
|
184
|
+
last_synced INTEGER DEFAULT 0,
|
|
185
|
+
dirty INTEGER DEFAULT 0,
|
|
186
|
+
deleted INTEGER DEFAULT 0,
|
|
187
|
+
updated_at INTEGER NOT NULL
|
|
188
|
+
);
|
|
189
|
+
CREATE INDEX IF NOT EXISTS idx_tasks_account ON tasks(account_id);
|
|
190
|
+
CREATE INDEX IF NOT EXISTS idx_tasks_dirty ON tasks(dirty) WHERE dirty = 1;
|
|
191
|
+
|
|
192
|
+
-- Generic store-sync queue for domains OTHER than messages. Messages
|
|
193
|
+
-- use sync_actions above. This table queues push-to-server actions
|
|
194
|
+
-- for calendar / tasks / contacts / allowlist. Kind identifies the
|
|
195
|
+
-- domain; op is "create" / "update" / "delete"; payload is JSON the
|
|
196
|
+
-- drainer posts to the provider. Target URL isn't stored — the
|
|
197
|
+
-- drainer knows the provider endpoint from kind + payload.
|
|
198
|
+
CREATE TABLE IF NOT EXISTS store_sync (
|
|
199
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
200
|
+
kind TEXT NOT NULL,
|
|
201
|
+
op TEXT NOT NULL,
|
|
202
|
+
account_id TEXT NOT NULL,
|
|
203
|
+
target_uuid TEXT NOT NULL,
|
|
204
|
+
payload TEXT,
|
|
205
|
+
attempts INTEGER DEFAULT 0,
|
|
206
|
+
last_error TEXT,
|
|
207
|
+
created_at INTEGER NOT NULL,
|
|
208
|
+
UNIQUE(kind, target_uuid, op)
|
|
209
|
+
);
|
|
210
|
+
CREATE INDEX IF NOT EXISTS idx_store_sync_account ON store_sync(account_id, kind);
|
|
145
211
|
`;
|
|
146
212
|
export class MailxDB {
|
|
147
213
|
db;
|
|
@@ -182,6 +248,34 @@ export class MailxDB {
|
|
|
182
248
|
// this column landed. One UPDATE + an id roundtrip per row — cheap
|
|
183
249
|
// at our row counts, runs once per DB upgrade.
|
|
184
250
|
this.backfillUuids();
|
|
251
|
+
// Post-migration sanity check: verify the columns we actually read in
|
|
252
|
+
// SELECTs exist. If any migration silently failed (stale driver, DB
|
|
253
|
+
// file locked, permission error), later code would throw cryptic
|
|
254
|
+
// "no such column" errors buried deep in a sync run. Fail loud here
|
|
255
|
+
// with a clear "run mailx -rebuild" message. C32 on Linux was exactly
|
|
256
|
+
// this — old mailx-store that predated thread_id/uuid migrations.
|
|
257
|
+
this.verifySchema();
|
|
258
|
+
}
|
|
259
|
+
/** Fail loud + early if expected columns are missing. Cheap (PRAGMA only
|
|
260
|
+
* runs at startup). The user-facing message names the recovery command. */
|
|
261
|
+
verifySchema() {
|
|
262
|
+
const required = {
|
|
263
|
+
messages: ["thread_id", "provider_id", "uuid"],
|
|
264
|
+
};
|
|
265
|
+
for (const [table, cols] of Object.entries(required)) {
|
|
266
|
+
let actual;
|
|
267
|
+
try {
|
|
268
|
+
actual = this.db.prepare(`PRAGMA table_info(${table})`).all();
|
|
269
|
+
}
|
|
270
|
+
catch (e) {
|
|
271
|
+
throw new Error(`[mailx-store] schema check failed for "${table}": ${e.message}. Run 'mailx -rebuild' to rebuild the local store.`);
|
|
272
|
+
}
|
|
273
|
+
const names = new Set(actual.map(r => r.name));
|
|
274
|
+
const missing = cols.filter(c => !names.has(c));
|
|
275
|
+
if (missing.length > 0) {
|
|
276
|
+
throw new Error(`[mailx-store] table "${table}" is missing columns [${missing.join(", ")}] — schema migration did not complete. Run 'mailx -rebuild' to rebuild the local store.`);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
185
279
|
}
|
|
186
280
|
/** One-time: assign UUIDs to every `messages` row that's missing one.
|
|
187
281
|
* Runs on every startup but the WHERE clause makes it a no-op after the
|
|
@@ -275,6 +369,152 @@ export class MailxDB {
|
|
|
275
369
|
return 0;
|
|
276
370
|
}
|
|
277
371
|
}
|
|
372
|
+
// ── Calendar events (two-way cache) ──
|
|
373
|
+
upsertCalendarEvent(ev) {
|
|
374
|
+
const uuid = ev.uuid || randomUUID().replace(/-/g, "");
|
|
375
|
+
this.db.prepare(`
|
|
376
|
+
INSERT INTO calendar_events
|
|
377
|
+
(uuid, account_id, provider_id, calendar_id, title, start_ms, end_ms,
|
|
378
|
+
all_day, location, notes, etag, last_synced, dirty, deleted, updated_at)
|
|
379
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?)
|
|
380
|
+
ON CONFLICT(uuid) DO UPDATE SET
|
|
381
|
+
account_id=excluded.account_id, provider_id=excluded.provider_id,
|
|
382
|
+
calendar_id=excluded.calendar_id, title=excluded.title,
|
|
383
|
+
start_ms=excluded.start_ms, end_ms=excluded.end_ms,
|
|
384
|
+
all_day=excluded.all_day, location=excluded.location,
|
|
385
|
+
notes=excluded.notes, etag=excluded.etag,
|
|
386
|
+
last_synced=excluded.last_synced, dirty=excluded.dirty,
|
|
387
|
+
updated_at=excluded.updated_at
|
|
388
|
+
`).run(uuid, ev.accountId, ev.providerId || null, ev.calendarId || "primary", ev.title, ev.startMs, ev.endMs, ev.allDay ? 1 : 0, ev.location || "", ev.notes || "", ev.etag || null, ev.dirty ? 0 : Date.now(), ev.dirty ? 1 : 0, Date.now());
|
|
389
|
+
return uuid;
|
|
390
|
+
}
|
|
391
|
+
getCalendarEvents(accountId, fromMs, toMs) {
|
|
392
|
+
const rows = this.db.prepare(`
|
|
393
|
+
SELECT * FROM calendar_events
|
|
394
|
+
WHERE account_id = ? AND deleted = 0 AND start_ms >= ? AND start_ms < ?
|
|
395
|
+
ORDER BY start_ms ASC
|
|
396
|
+
`).all(accountId, fromMs, toMs);
|
|
397
|
+
return rows.map(this.calendarRowToObject);
|
|
398
|
+
}
|
|
399
|
+
getDirtyCalendarEvents(accountId) {
|
|
400
|
+
const rows = this.db.prepare(`
|
|
401
|
+
SELECT * FROM calendar_events WHERE account_id = ? AND (dirty = 1 OR deleted = 1)
|
|
402
|
+
`).all(accountId);
|
|
403
|
+
return rows.map(this.calendarRowToObject);
|
|
404
|
+
}
|
|
405
|
+
calendarRowToObject(r) {
|
|
406
|
+
return {
|
|
407
|
+
uuid: r.uuid, accountId: r.account_id, providerId: r.provider_id,
|
|
408
|
+
calendarId: r.calendar_id, title: r.title, startMs: r.start_ms,
|
|
409
|
+
endMs: r.end_ms, allDay: !!r.all_day, location: r.location, notes: r.notes,
|
|
410
|
+
etag: r.etag, lastSynced: r.last_synced, dirty: !!r.dirty, deleted: !!r.deleted,
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
markCalendarEventClean(uuid, providerId, etag) {
|
|
414
|
+
this.db.prepare(`
|
|
415
|
+
UPDATE calendar_events SET dirty=0, provider_id=?, etag=?, last_synced=? WHERE uuid=?
|
|
416
|
+
`).run(providerId, etag, Date.now(), uuid);
|
|
417
|
+
}
|
|
418
|
+
deleteCalendarEventLocal(uuid) {
|
|
419
|
+
this.db.prepare("UPDATE calendar_events SET deleted=1, dirty=1, updated_at=? WHERE uuid=?").run(Date.now(), uuid);
|
|
420
|
+
}
|
|
421
|
+
purgeCalendarEvent(uuid) {
|
|
422
|
+
this.db.prepare("DELETE FROM calendar_events WHERE uuid=?").run(uuid);
|
|
423
|
+
}
|
|
424
|
+
// ── Tasks (two-way cache) ──
|
|
425
|
+
upsertTask(t) {
|
|
426
|
+
const uuid = t.uuid || randomUUID().replace(/-/g, "");
|
|
427
|
+
this.db.prepare(`
|
|
428
|
+
INSERT INTO tasks
|
|
429
|
+
(uuid, account_id, provider_id, list_id, title, notes, due_ms, completed_ms,
|
|
430
|
+
etag, last_synced, dirty, deleted, updated_at)
|
|
431
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?)
|
|
432
|
+
ON CONFLICT(uuid) DO UPDATE SET
|
|
433
|
+
account_id=excluded.account_id, provider_id=excluded.provider_id,
|
|
434
|
+
list_id=excluded.list_id, title=excluded.title, notes=excluded.notes,
|
|
435
|
+
due_ms=excluded.due_ms, completed_ms=excluded.completed_ms,
|
|
436
|
+
etag=excluded.etag, last_synced=excluded.last_synced,
|
|
437
|
+
dirty=excluded.dirty, updated_at=excluded.updated_at
|
|
438
|
+
`).run(uuid, t.accountId, t.providerId || null, t.listId || "@default", t.title, t.notes || "", t.dueMs || null, t.completedMs || null, t.etag || null, t.dirty ? 0 : Date.now(), t.dirty ? 1 : 0, Date.now());
|
|
439
|
+
return uuid;
|
|
440
|
+
}
|
|
441
|
+
getTasks(accountId, includeCompleted = false) {
|
|
442
|
+
const where = includeCompleted
|
|
443
|
+
? "account_id = ? AND deleted = 0"
|
|
444
|
+
: "account_id = ? AND deleted = 0 AND completed_ms IS NULL";
|
|
445
|
+
const rows = this.db.prepare(`SELECT * FROM tasks WHERE ${where} ORDER BY COALESCE(due_ms, updated_at) ASC`).all(accountId);
|
|
446
|
+
return rows.map(this.taskRowToObject);
|
|
447
|
+
}
|
|
448
|
+
getDirtyTasks(accountId) {
|
|
449
|
+
const rows = this.db.prepare(`SELECT * FROM tasks WHERE account_id = ? AND (dirty = 1 OR deleted = 1)`).all(accountId);
|
|
450
|
+
return rows.map(this.taskRowToObject);
|
|
451
|
+
}
|
|
452
|
+
taskRowToObject(r) {
|
|
453
|
+
return {
|
|
454
|
+
uuid: r.uuid, accountId: r.account_id, providerId: r.provider_id,
|
|
455
|
+
listId: r.list_id, title: r.title, notes: r.notes, dueMs: r.due_ms,
|
|
456
|
+
completedMs: r.completed_ms, etag: r.etag, lastSynced: r.last_synced,
|
|
457
|
+
dirty: !!r.dirty, deleted: !!r.deleted,
|
|
458
|
+
};
|
|
459
|
+
}
|
|
460
|
+
markTaskClean(uuid, providerId, etag) {
|
|
461
|
+
this.db.prepare(`UPDATE tasks SET dirty=0, provider_id=?, etag=?, last_synced=? WHERE uuid=?`)
|
|
462
|
+
.run(providerId, etag, Date.now(), uuid);
|
|
463
|
+
}
|
|
464
|
+
deleteTaskLocal(uuid) {
|
|
465
|
+
this.db.prepare("UPDATE tasks SET deleted=1, dirty=1, updated_at=? WHERE uuid=?").run(Date.now(), uuid);
|
|
466
|
+
}
|
|
467
|
+
purgeTask(uuid) {
|
|
468
|
+
this.db.prepare("DELETE FROM tasks WHERE uuid=?").run(uuid);
|
|
469
|
+
}
|
|
470
|
+
// ── Contacts two-way: existing upsertContact / deleteContact handle the
|
|
471
|
+
// local side; the service layer adds store_sync push-queue entries.
|
|
472
|
+
// (No extra methods needed here — upsertContact/deleteContact at the
|
|
473
|
+
// regular contact-management section are the two-way cache's local
|
|
474
|
+
// writers.)
|
|
475
|
+
/** Local delete for the two-way cache drainer (symmetric with calendar/tasks). */
|
|
476
|
+
deleteContactLocal(email) { this.deleteContact(email); }
|
|
477
|
+
// ── Store-sync queue (calendar / tasks / contacts / allowlist) ──
|
|
478
|
+
enqueueStoreSync(kind, op, accountId, targetUuid, payload) {
|
|
479
|
+
try {
|
|
480
|
+
this.db.prepare(`
|
|
481
|
+
INSERT OR REPLACE INTO store_sync (kind, op, account_id, target_uuid, payload, created_at)
|
|
482
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
483
|
+
`).run(kind, op, accountId, targetUuid, JSON.stringify(payload), Date.now());
|
|
484
|
+
}
|
|
485
|
+
catch (e) {
|
|
486
|
+
console.error(` [store_sync] enqueue ${kind}/${op}/${targetUuid} failed: ${e.message}`);
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
getStoreSyncQueue(kind, accountId) {
|
|
490
|
+
let sql = "SELECT * FROM store_sync";
|
|
491
|
+
const params = [];
|
|
492
|
+
const wh = [];
|
|
493
|
+
if (kind) {
|
|
494
|
+
wh.push("kind = ?");
|
|
495
|
+
params.push(kind);
|
|
496
|
+
}
|
|
497
|
+
if (accountId) {
|
|
498
|
+
wh.push("account_id = ?");
|
|
499
|
+
params.push(accountId);
|
|
500
|
+
}
|
|
501
|
+
if (wh.length)
|
|
502
|
+
sql += " WHERE " + wh.join(" AND ");
|
|
503
|
+
sql += " ORDER BY created_at ASC";
|
|
504
|
+
const rows = this.db.prepare(sql).all(...params);
|
|
505
|
+
return rows.map(r => ({
|
|
506
|
+
id: r.id, kind: r.kind, op: r.op, accountId: r.account_id,
|
|
507
|
+
targetUuid: r.target_uuid,
|
|
508
|
+
payload: r.payload ? JSON.parse(r.payload) : null,
|
|
509
|
+
attempts: r.attempts, lastError: r.last_error,
|
|
510
|
+
}));
|
|
511
|
+
}
|
|
512
|
+
completeStoreSync(id) {
|
|
513
|
+
this.db.prepare("DELETE FROM store_sync WHERE id = ?").run(id);
|
|
514
|
+
}
|
|
515
|
+
failStoreSync(id, error) {
|
|
516
|
+
this.db.prepare("UPDATE store_sync SET attempts = attempts + 1, last_error = ? WHERE id = ?").run(error, id);
|
|
517
|
+
}
|
|
278
518
|
/** Idempotently add a column to a table if it's missing. */
|
|
279
519
|
addColumnIfMissing(table, column, sqlType) {
|
|
280
520
|
try {
|
|
@@ -772,12 +1012,16 @@ export class MailxDB {
|
|
|
772
1012
|
const params = hasQuery ? [q, q] : [];
|
|
773
1013
|
const totalRow = this.db.prepare(`SELECT COUNT(*) as c FROM contacts ${whereClause}`).get(...params);
|
|
774
1014
|
const offset = (page - 1) * pageSize;
|
|
775
|
-
const rows = this.db.prepare(`SELECT name, email, source, use_count, last_used FROM contacts
|
|
1015
|
+
const rows = this.db.prepare(`SELECT name, email, source, google_id, use_count, last_used FROM contacts
|
|
776
1016
|
${whereClause}
|
|
777
1017
|
ORDER BY use_count DESC, last_used DESC
|
|
778
1018
|
LIMIT ? OFFSET ?`).all(...params, pageSize, offset);
|
|
779
1019
|
return {
|
|
780
|
-
items: rows.map(r => ({
|
|
1020
|
+
items: rows.map(r => ({
|
|
1021
|
+
name: r.name, email: r.email, source: r.source,
|
|
1022
|
+
googleId: r.google_id || null,
|
|
1023
|
+
useCount: r.use_count, lastUsed: r.last_used,
|
|
1024
|
+
})),
|
|
781
1025
|
total: totalRow?.c || 0,
|
|
782
1026
|
page, pageSize,
|
|
783
1027
|
};
|
|
@@ -983,6 +983,25 @@ function installBridge() {
|
|
|
983
983
|
syncAll: async () => { await service.syncAll(); return { ok: true }; },
|
|
984
984
|
syncAccount: async (accountId) => { await service.syncAccount(accountId); return { ok: true }; },
|
|
985
985
|
getSyncPending: () => service.getSyncPending(),
|
|
986
|
+
getPrimaryAccount: (feature) => {
|
|
987
|
+
// Resolve primary account for a feature (calendar/tasks/contacts):
|
|
988
|
+
// per-feature flag → catch-all `primary` → first account.
|
|
989
|
+
const all = db.getAccountConfigs().map(r => {
|
|
990
|
+
try {
|
|
991
|
+
return { id: r.id, name: r.name, email: r.email, ...JSON.parse(r.configJson) };
|
|
992
|
+
}
|
|
993
|
+
catch {
|
|
994
|
+
return { id: r.id, name: r.name, email: r.email };
|
|
995
|
+
}
|
|
996
|
+
});
|
|
997
|
+
if (feature) {
|
|
998
|
+
const key = "primary" + feature.charAt(0).toUpperCase() + feature.slice(1);
|
|
999
|
+
const perFeature = all.find((a) => a[key]);
|
|
1000
|
+
if (perFeature)
|
|
1001
|
+
return perFeature;
|
|
1002
|
+
}
|
|
1003
|
+
return all.find((a) => a.primary) || all[0] || null;
|
|
1004
|
+
},
|
|
986
1005
|
reauthenticate: async (accountId) => ({ ok: await service.reauthenticate(accountId) }),
|
|
987
1006
|
markFolderRead: (_accountId, folderId) => { service.markFolderRead(folderId); return { ok: true }; },
|
|
988
1007
|
createFolder: async () => ({ ok: false, error: "Not supported on mobile" }),
|
|
@@ -29,7 +29,10 @@ export interface AccountConfig {
|
|
|
29
29
|
password?: string;
|
|
30
30
|
};
|
|
31
31
|
enabled: boolean;
|
|
32
|
-
primary?: boolean; /**
|
|
32
|
+
primary?: boolean; /** Catch-all "this is my main account" — default source for Calendar / Tasks / Contacts when no per-feature override set. */
|
|
33
|
+
primaryCalendar?: boolean; /** Per-feature override: use this account's Google Calendar. Falls back to `primary` if unset. */
|
|
34
|
+
primaryTasks?: boolean; /** Per-feature override: use this account's Google Tasks. Falls back to `primary` if unset. */
|
|
35
|
+
primaryContacts?: boolean; /** Per-feature override: use this account's Google Contacts. Falls back to `primary` if unset. */
|
|
33
36
|
defaultSend?: boolean; /** Use this account's SMTP when From doesn't match any account */
|
|
34
37
|
syncContacts?: boolean; /** Sync contacts even when account is disabled (contacts-only Gmail) */
|
|
35
38
|
relayDomains?: string[]; /** Domains to skip in Delivered-To chain (e.g., ["m.connectivity.xyz"]) */
|