@bobfrankston/mailx-store 0.1.18 → 0.1.20
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/db.d.ts +7 -0
- package/db.js +78 -11
- package/package.json +5 -5
package/db.d.ts
CHANGED
|
@@ -82,6 +82,7 @@ export declare class MailxDB {
|
|
|
82
82
|
dirty?: boolean;
|
|
83
83
|
recurringEventId?: string;
|
|
84
84
|
htmlLink?: string;
|
|
85
|
+
isHoliday?: boolean;
|
|
85
86
|
}): string;
|
|
86
87
|
getCalendarEvents(accountId: string, fromMs: number, toMs: number): any[];
|
|
87
88
|
/** Lookup by uuid only — used by patch/delete paths that don't have an
|
|
@@ -223,6 +224,12 @@ export declare class MailxDB {
|
|
|
223
224
|
bodyPath: string;
|
|
224
225
|
providerId?: string;
|
|
225
226
|
}): number;
|
|
227
|
+
/** Backfill the FTS5 `body_text` column for a message after its body
|
|
228
|
+
* has been parsed. Capped at ~64 KB of text per row — FTS5 stores the
|
|
229
|
+
* raw text and indexes tokens; we don't need every byte of a 1 MB
|
|
230
|
+
* marketing email to find a word in the first paragraph. Called from
|
|
231
|
+
* LocalStore.getMessage right after simpleParser succeeds. */
|
|
232
|
+
updateFtsBodyByUid(accountId: string, folderId: number, uid: number, bodyText: string): void;
|
|
226
233
|
/** List view: messages currently in (account, folder).
|
|
227
234
|
* Joins through `message_folders` so the UID + folder location come
|
|
228
235
|
* from membership rows, not from the legacy messages.folder_id /
|
package/db.js
CHANGED
|
@@ -406,6 +406,11 @@ export class MailxDB {
|
|
|
406
406
|
// Filters like "hide recurring events" check this column.
|
|
407
407
|
this.addColumnIfMissing("calendar_events", "recurring_event_id", "TEXT");
|
|
408
408
|
this.addColumnIfMissing("calendar_events", "html_link", "TEXT");
|
|
409
|
+
// Holidays come from a separate Google calendar
|
|
410
|
+
// (en.usa#holiday@group.v.calendar.google.com) but share the
|
|
411
|
+
// calendar_events table; the flag distinguishes them so the UI can
|
|
412
|
+
// style them differently and the alarm scheduler can skip them.
|
|
413
|
+
this.addColumnIfMissing("calendar_events", "is_holiday", "INTEGER NOT NULL DEFAULT 0");
|
|
409
414
|
// Backfill UUIDs for any pre-existing rows that were inserted before
|
|
410
415
|
// this column landed. One UPDATE + an id roundtrip per row — cheap
|
|
411
416
|
// at our row counts, runs once per DB upgrade.
|
|
@@ -732,8 +737,8 @@ export class MailxDB {
|
|
|
732
737
|
INSERT INTO calendar_events
|
|
733
738
|
(uuid, account_id, provider_id, calendar_id, title, start_ms, end_ms,
|
|
734
739
|
all_day, location, notes, etag, last_synced, dirty, deleted, updated_at,
|
|
735
|
-
recurring_event_id, html_link)
|
|
736
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?)
|
|
740
|
+
recurring_event_id, html_link, is_holiday)
|
|
741
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?)
|
|
737
742
|
ON CONFLICT(uuid) DO UPDATE SET
|
|
738
743
|
account_id=excluded.account_id, provider_id=excluded.provider_id,
|
|
739
744
|
calendar_id=excluded.calendar_id, title=excluded.title,
|
|
@@ -743,8 +748,9 @@ export class MailxDB {
|
|
|
743
748
|
last_synced=excluded.last_synced, dirty=excluded.dirty,
|
|
744
749
|
updated_at=excluded.updated_at,
|
|
745
750
|
recurring_event_id=excluded.recurring_event_id,
|
|
746
|
-
html_link=excluded.html_link
|
|
747
|
-
|
|
751
|
+
html_link=excluded.html_link,
|
|
752
|
+
is_holiday=excluded.is_holiday
|
|
753
|
+
`).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(), ev.recurringEventId || null, ev.htmlLink || null, ev.isHoliday ? 1 : 0);
|
|
748
754
|
return uuid;
|
|
749
755
|
}
|
|
750
756
|
getCalendarEvents(accountId, fromMs, toMs) {
|
|
@@ -779,6 +785,7 @@ export class MailxDB {
|
|
|
779
785
|
etag: r.etag, lastSynced: r.last_synced, dirty: !!r.dirty, deleted: !!r.deleted,
|
|
780
786
|
recurringEventId: r.recurring_event_id || null,
|
|
781
787
|
htmlLink: r.html_link || null,
|
|
788
|
+
isHoliday: !!r.is_holiday,
|
|
782
789
|
};
|
|
783
790
|
}
|
|
784
791
|
/** Find a calendar event by its Google Calendar event id (provider_id).
|
|
@@ -1045,10 +1052,31 @@ export class MailxDB {
|
|
|
1045
1052
|
}
|
|
1046
1053
|
}
|
|
1047
1054
|
deleteFolder(folderId) {
|
|
1048
|
-
//
|
|
1049
|
-
//
|
|
1050
|
-
//
|
|
1051
|
-
//
|
|
1055
|
+
// Repoint the legacy `messages.folder_id` for any message that
|
|
1056
|
+
// lives in this folder AND at least one other. Without this the
|
|
1057
|
+
// final `DELETE FROM folders` fails with a FOREIGN KEY constraint:
|
|
1058
|
+
// the message survives the GC (still belongs to its other folder)
|
|
1059
|
+
// but its primary folder_id still points at the row we're about
|
|
1060
|
+
// to delete. Pick the smallest remaining membership as the new
|
|
1061
|
+
// primary; arbitrary but stable.
|
|
1062
|
+
this.db.prepare(`
|
|
1063
|
+
UPDATE messages
|
|
1064
|
+
SET folder_id = (
|
|
1065
|
+
SELECT MIN(folder_id) FROM message_folders
|
|
1066
|
+
WHERE message_row_id = messages.id
|
|
1067
|
+
AND folder_id != ?
|
|
1068
|
+
)
|
|
1069
|
+
WHERE folder_id = ?
|
|
1070
|
+
AND EXISTS (
|
|
1071
|
+
SELECT 1 FROM message_folders
|
|
1072
|
+
WHERE message_row_id = messages.id
|
|
1073
|
+
AND folder_id != ?
|
|
1074
|
+
)
|
|
1075
|
+
`).run(folderId, folderId, folderId);
|
|
1076
|
+
// Drop memberships in this folder, then GC any messages rows that
|
|
1077
|
+
// are now orphaned (no remaining memberships). A multi-folder
|
|
1078
|
+
// message (e.g. Gmail label = INBOX + Important) keeps its body
|
|
1079
|
+
// intact when only one of its folders is deleted.
|
|
1052
1080
|
const orphans = this.gcMembershipsAndCollectOrphans(folderId);
|
|
1053
1081
|
if (orphans.count > 0)
|
|
1054
1082
|
this.audit({ kind: "delete-bulk", folderId, count: orphans.count, reason: "folder deleted", source: "deleteFolder" });
|
|
@@ -1234,6 +1262,23 @@ export class MailxDB {
|
|
|
1234
1262
|
if (msg.providerId && !existing.provider_id) {
|
|
1235
1263
|
this.db.prepare("UPDATE messages SET provider_id = ? WHERE id = ?").run(msg.providerId, existing.id);
|
|
1236
1264
|
}
|
|
1265
|
+
// Local-first flag preservation: a row whose flags the user
|
|
1266
|
+
// changed (mark read, flag/star, etc.) gets a pending
|
|
1267
|
+
// `sync_action` queued. Until that action drains to the server,
|
|
1268
|
+
// the server's snapshot is STALE for flags — pulling its
|
|
1269
|
+
// "unread" answer back and overwriting the local "\Seen" would
|
|
1270
|
+
// bounce the read row back to unread, which is what Bob 2026-05-12
|
|
1271
|
+
// reported: "you keep reverting messages to show up as unread
|
|
1272
|
+
// even after it is marked read." Keep the local flags whenever a
|
|
1273
|
+
// pending flag-push exists; the regular processSyncActions cycle
|
|
1274
|
+
// will reconcile in the user's direction.
|
|
1275
|
+
const pendingFlags = this.db.prepare(`SELECT 1 FROM sync_actions
|
|
1276
|
+
WHERE account_id = ? AND folder_id = ? AND uid = ?
|
|
1277
|
+
AND action = 'flags'
|
|
1278
|
+
LIMIT 1`).get(msg.accountId, msg.folderId, msg.uid);
|
|
1279
|
+
const flagsToWrite = pendingFlags
|
|
1280
|
+
? (this.db.prepare("SELECT flags_json FROM messages WHERE id = ?").get(existing.id)?.flags_json ?? JSON.stringify(msg.flags))
|
|
1281
|
+
: JSON.stringify(msg.flags);
|
|
1237
1282
|
// Only overwrite body_path / preview when the caller actually has a
|
|
1238
1283
|
// body. Metadata-only syncs (Gmail API storeApiMessages, IMAP
|
|
1239
1284
|
// header-only fetches) pass bodyPath: "" and would otherwise wipe
|
|
@@ -1243,13 +1288,13 @@ export class MailxDB {
|
|
|
1243
1288
|
this.db.prepare(`
|
|
1244
1289
|
UPDATE messages SET flags_json = ?, preview = ?, body_path = ?, cached_at = ?
|
|
1245
1290
|
WHERE id = ?
|
|
1246
|
-
`).run(
|
|
1291
|
+
`).run(flagsToWrite, msg.preview, msg.bodyPath, Date.now(), existing.id);
|
|
1247
1292
|
}
|
|
1248
1293
|
else {
|
|
1249
1294
|
this.db.prepare(`
|
|
1250
1295
|
UPDATE messages SET flags_json = ?, cached_at = ?
|
|
1251
1296
|
WHERE id = ?
|
|
1252
|
-
`).run(
|
|
1297
|
+
`).run(flagsToWrite, Date.now(), existing.id);
|
|
1253
1298
|
}
|
|
1254
1299
|
// Refresh membership last_seen_at — server confirmed this UID
|
|
1255
1300
|
// is still in this folder. No-op if migration already populated
|
|
@@ -1327,13 +1372,35 @@ export class MailxDB {
|
|
|
1327
1372
|
// sync during the additive migration but reads will move to JOIN
|
|
1328
1373
|
// with message_folders.
|
|
1329
1374
|
this.upsertMessageFolder(rowId, msg.folderId, msg.uid);
|
|
1330
|
-
// Index for full-text search
|
|
1375
|
+
// Index for full-text search. body_text seeded from `msg.preview`
|
|
1376
|
+
// here — the full parsed body isn't available at upsert time (we
|
|
1377
|
+
// store .eml on disk; parsing is on-demand). LocalStore.getMessage
|
|
1378
|
+
// calls `updateFtsBodyByUid` after simpleParser runs to swap the
|
|
1379
|
+
// preview-only seed for the full body text, so subsequent searches
|
|
1380
|
+
// hit body content too. Without that backfill, words that only
|
|
1381
|
+
// appear deep in the body (Bob 2026-05-12: "I searched for ksink
|
|
1382
|
+
// which is in <...>.eml but it was not found") never match.
|
|
1331
1383
|
try {
|
|
1332
1384
|
this.db.prepare("INSERT INTO messages_fts (rowid, subject, from_name, from_address, to_text, cc_text, body_text) VALUES (?, ?, ?, ?, ?, ?, ?)").run(rowId, msg.subject, msg.from.name, msg.from.address, toText, ccText, msg.preview);
|
|
1333
1385
|
}
|
|
1334
1386
|
catch { /* FTS insert may fail on rebuild, non-fatal */ }
|
|
1335
1387
|
return rowId;
|
|
1336
1388
|
}
|
|
1389
|
+
/** Backfill the FTS5 `body_text` column for a message after its body
|
|
1390
|
+
* has been parsed. Capped at ~64 KB of text per row — FTS5 stores the
|
|
1391
|
+
* raw text and indexes tokens; we don't need every byte of a 1 MB
|
|
1392
|
+
* marketing email to find a word in the first paragraph. Called from
|
|
1393
|
+
* LocalStore.getMessage right after simpleParser succeeds. */
|
|
1394
|
+
updateFtsBodyByUid(accountId, folderId, uid, bodyText) {
|
|
1395
|
+
try {
|
|
1396
|
+
const row = this.db.prepare("SELECT id FROM messages WHERE account_id = ? AND folder_id = ? AND uid = ?").get(accountId, folderId, uid);
|
|
1397
|
+
if (!row)
|
|
1398
|
+
return;
|
|
1399
|
+
const capped = bodyText.length > 64_000 ? bodyText.slice(0, 64_000) : bodyText;
|
|
1400
|
+
this.db.prepare("UPDATE messages_fts SET body_text = ? WHERE rowid = ?").run(capped, row.id);
|
|
1401
|
+
}
|
|
1402
|
+
catch { /* FTS update is best-effort */ }
|
|
1403
|
+
}
|
|
1337
1404
|
/** List view: messages currently in (account, folder).
|
|
1338
1405
|
* Joins through `message_folders` so the UID + folder location come
|
|
1339
1406
|
* from membership rows, not from the legacy messages.folder_id /
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bobfrankston/mailx-store",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.20",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"types": "index.d.ts",
|
|
@@ -9,8 +9,8 @@
|
|
|
9
9
|
},
|
|
10
10
|
"license": "ISC",
|
|
11
11
|
"dependencies": {
|
|
12
|
-
"@bobfrankston/mailx-types": "^0.1.
|
|
13
|
-
"@bobfrankston/mailx-settings": "^0.1.
|
|
12
|
+
"@bobfrankston/mailx-types": "^0.1.11",
|
|
13
|
+
"@bobfrankston/mailx-settings": "^0.1.16"
|
|
14
14
|
},
|
|
15
15
|
"repository": {
|
|
16
16
|
"type": "git",
|
|
@@ -25,8 +25,8 @@
|
|
|
25
25
|
},
|
|
26
26
|
".transformedSnapshot": {
|
|
27
27
|
"dependencies": {
|
|
28
|
-
"@bobfrankston/mailx-types": "^0.1.
|
|
29
|
-
"@bobfrankston/mailx-settings": "^0.1.
|
|
28
|
+
"@bobfrankston/mailx-types": "^0.1.11",
|
|
29
|
+
"@bobfrankston/mailx-settings": "^0.1.16"
|
|
30
30
|
}
|
|
31
31
|
}
|
|
32
32
|
}
|