@bobfrankston/mailx-store 0.1.29 → 0.1.31
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 +36 -3
- package/db.js +111 -12
- package/package.json +5 -5
- package/store.js +2 -2
package/db.d.ts
CHANGED
|
@@ -259,9 +259,15 @@ export declare class MailxDB {
|
|
|
259
259
|
/** Backfill the FTS5 `body_text` column for a message after its body
|
|
260
260
|
* has been parsed. Capped at ~64 KB of text per row — FTS5 stores the
|
|
261
261
|
* raw text and indexes tokens; we don't need every byte of a 1 MB
|
|
262
|
-
* marketing email to find a word in the first paragraph.
|
|
263
|
-
*
|
|
264
|
-
|
|
262
|
+
* marketing email to find a word in the first paragraph.
|
|
263
|
+
*
|
|
264
|
+
* Keyed on the FTS rowid (= messages.id) directly. The earlier
|
|
265
|
+
* (account, folder_id, uid) lookup keyed on the LEGACY messages.folder_id
|
|
266
|
+
* column — a message can live in several folders via message_folders,
|
|
267
|
+
* so that lookup could miss the row entirely and silently leave the
|
|
268
|
+
* body un-indexed (Bob 2026-05-18: "mcdade" in an opened message's
|
|
269
|
+
* body, search found nothing). */
|
|
270
|
+
updateFtsBody(rowId: number, bodyText: string): void;
|
|
265
271
|
/** List view: messages currently in (account, folder).
|
|
266
272
|
* Joins through `message_folders` so the UID + folder location come
|
|
267
273
|
* from membership rows, not from the legacy messages.folder_id /
|
|
@@ -314,6 +320,33 @@ export declare class MailxDB {
|
|
|
314
320
|
id: number;
|
|
315
321
|
} | null;
|
|
316
322
|
updateBodyPath(accountId: string, uid: number, bodyPath: string): void;
|
|
323
|
+
/** Write the post-download metadata in one go: body file path, parsed
|
|
324
|
+
* attachment flag, preview text, and the parse timestamp. Called from
|
|
325
|
+
* prefetch after the body has been written to disk and parsed — without
|
|
326
|
+
* this, IMAP metadata-only sync leaves has_attachments=0 and preview=""
|
|
327
|
+
* forever (the paperclip 📎 never renders for non-Gmail accounts).
|
|
328
|
+
* Pass bodyPath = "" to update only the parsed fields (used by the
|
|
329
|
+
* backfill worker re-parsing existing .eml files on disk). */
|
|
330
|
+
updateBodyMeta(accountId: string, uid: number, bodyPath: string, hasAttachments: boolean, preview: string): void;
|
|
331
|
+
/** Find rows whose body is on disk but was never run through extractPreview
|
|
332
|
+
* (has_attachments/preview reflect "no body source at sync time", typical
|
|
333
|
+
* of the IMAP metadata-only path). Used by the backfill worker to walk
|
|
334
|
+
* every existing message once after the prefetch-reparse fix landed.
|
|
335
|
+
* Returns id + body_path so the worker doesn't need a second lookup. */
|
|
336
|
+
getMessagesNeedingBodyReparse(accountId: string, limit?: number): {
|
|
337
|
+
id: number;
|
|
338
|
+
uid: number;
|
|
339
|
+
bodyPath: string;
|
|
340
|
+
}[];
|
|
341
|
+
/** Backfill variant that writes by row id (avoids re-resolving uid). */
|
|
342
|
+
updateBodyMetaById(rowId: number, hasAttachments: boolean, preview: string): void;
|
|
343
|
+
/** Mark a message as "replied to" by its RFC-5322 Message-ID. Source of
|
|
344
|
+
* truth for the ↩ indicator: the local DB knows who replied to whom via
|
|
345
|
+
* In-Reply-To headers, independent of whether the IMAP server propagates
|
|
346
|
+
* \Answered (Gmail labels, cross-account replies, etc.). Called from the
|
|
347
|
+
* send path when msg.inReplyTo is set, and from upsertMessage when a
|
|
348
|
+
* reply arrives. Idempotent — UPDATE on an already-1 row is a no-op. */
|
|
349
|
+
markRepliedByMessageId(accountId: string, messageId: string): boolean;
|
|
317
350
|
/** Get messages without cached bodies (for background prefetch) */
|
|
318
351
|
getMessagesWithoutBody(accountId: string, limit?: number): {
|
|
319
352
|
uid: number;
|
package/db.js
CHANGED
|
@@ -389,6 +389,44 @@ export class MailxDB {
|
|
|
389
389
|
this.db.exec("CREATE INDEX IF NOT EXISTS idx_messages_thread_id ON messages(account_id, thread_id)");
|
|
390
390
|
}
|
|
391
391
|
catch { /* already exists */ }
|
|
392
|
+
// is_replied: set when ANY other message in this account has in_reply_to
|
|
393
|
+
// pointing at this row's message_id. Primary source of truth for the ↩
|
|
394
|
+
// marker — \Answered is plan B (some servers strip it, Gmail labels
|
|
395
|
+
// don't propagate it, cross-account replies don't update the source).
|
|
396
|
+
// Backward-compatible default 0; rebuilt on the next sync as upsertMessage
|
|
397
|
+
// walks references both forward (mark parent) and backward (set self).
|
|
398
|
+
this.addColumnIfMissing("messages", "is_replied", "INTEGER DEFAULT 0");
|
|
399
|
+
try {
|
|
400
|
+
this.db.exec("CREATE INDEX IF NOT EXISTS idx_messages_in_reply_to ON messages(account_id, in_reply_to)");
|
|
401
|
+
}
|
|
402
|
+
catch { /* already exists */ }
|
|
403
|
+
// body_parsed_at: timestamp of the last extractPreview run. Backfills
|
|
404
|
+
// use this to find rows whose body_path is populated but were never
|
|
405
|
+
// parsed (existing IMAP-synced rows that predate the prefetch-reparse
|
|
406
|
+
// fix, where has_attachments stays 0 forever). NULL = never parsed.
|
|
407
|
+
this.addColumnIfMissing("messages", "body_parsed_at", "INTEGER");
|
|
408
|
+
// One-shot is_replied backfill on every boot. SQL-only, indexed
|
|
409
|
+
// both sides via idx_messages_message_id and idx_messages_in_reply_to,
|
|
410
|
+
// so a 100k-row mailbox finishes in milliseconds. Idempotent — after
|
|
411
|
+
// the first run the WHERE-is_replied=0 filter prunes work down to any
|
|
412
|
+
// new linkages that arrived during the previous session.
|
|
413
|
+
try {
|
|
414
|
+
const r = this.db.prepare(`
|
|
415
|
+
UPDATE messages SET is_replied = 1
|
|
416
|
+
WHERE is_replied = 0
|
|
417
|
+
AND message_id IS NOT NULL AND message_id != ''
|
|
418
|
+
AND EXISTS (
|
|
419
|
+
SELECT 1 FROM messages m2
|
|
420
|
+
WHERE m2.account_id = messages.account_id
|
|
421
|
+
AND m2.in_reply_to = messages.message_id
|
|
422
|
+
)
|
|
423
|
+
`).run();
|
|
424
|
+
if (r.changes > 0)
|
|
425
|
+
console.log(` [db] backfilled is_replied on ${r.changes} rows`);
|
|
426
|
+
}
|
|
427
|
+
catch (e) {
|
|
428
|
+
console.error(` [db] is_replied backfill failed: ${e?.message || e}`);
|
|
429
|
+
}
|
|
392
430
|
// provider_id: native server-side id for API-backed providers (Gmail
|
|
393
431
|
// hex id, Outlook Graph id, etc.). Lets fetchOne look up the message
|
|
394
432
|
// directly instead of paginating listMessageIds for every body fetch
|
|
@@ -1068,6 +1106,7 @@ export class MailxDB {
|
|
|
1068
1106
|
flags: JSON.parse(r.flags_json),
|
|
1069
1107
|
size: r.size,
|
|
1070
1108
|
hasAttachments: !!r.has_attachments,
|
|
1109
|
+
isReplied: !!r.is_replied,
|
|
1071
1110
|
preview: r.preview,
|
|
1072
1111
|
bodyPath: r.body_path || undefined,
|
|
1073
1112
|
}));
|
|
@@ -1398,9 +1437,9 @@ export class MailxDB {
|
|
|
1398
1437
|
// every message every cycle.
|
|
1399
1438
|
if (msg.bodyPath) {
|
|
1400
1439
|
this.db.prepare(`
|
|
1401
|
-
UPDATE messages SET flags_json = ?, preview = ?, body_path = ?, cached_at = ?
|
|
1440
|
+
UPDATE messages SET flags_json = ?, preview = ?, has_attachments = ?, body_path = ?, body_parsed_at = ?, cached_at = ?
|
|
1402
1441
|
WHERE id = ?
|
|
1403
|
-
`).run(flagsToWrite, msg.preview, msg.bodyPath, Date.now(), existing.id);
|
|
1442
|
+
`).run(flagsToWrite, msg.preview, msg.hasAttachments ? 1 : 0, msg.bodyPath, Date.now(), Date.now(), existing.id);
|
|
1404
1443
|
}
|
|
1405
1444
|
else {
|
|
1406
1445
|
this.db.prepare(`
|
|
@@ -1471,14 +1510,26 @@ export class MailxDB {
|
|
|
1471
1510
|
// the life of the row — survives server UID renumbers, folder moves
|
|
1472
1511
|
// (sync rebinds folder_id/uid but keeps uuid), UIDVALIDITY bumps.
|
|
1473
1512
|
const uuid = randomUUID().replace(/-/g, "");
|
|
1513
|
+
// Backward direction for ↩: a reply may have arrived BEFORE its parent
|
|
1514
|
+
// in sync order (very common — Gmail/IMAP fetch is date-DESC). If any
|
|
1515
|
+
// existing row already references this new message, the new message is
|
|
1516
|
+
// already-replied at insert time.
|
|
1517
|
+
const alreadyReplied = msg.messageId
|
|
1518
|
+
? !!this.db.prepare("SELECT 1 FROM messages WHERE account_id = ? AND in_reply_to = ? LIMIT 1").get(msg.accountId, msg.messageId)
|
|
1519
|
+
: false;
|
|
1474
1520
|
const result = this.db.prepare(`
|
|
1475
1521
|
INSERT INTO messages (
|
|
1476
1522
|
account_id, folder_id, uid, uuid, message_id, in_reply_to, refs, thread_id,
|
|
1477
1523
|
date, subject, from_address, from_name, to_json, cc_json,
|
|
1478
|
-
flags_json, size, has_attachments, preview, body_path, cached_at, provider_id
|
|
1479
|
-
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
1480
|
-
`).run(msg.accountId, msg.folderId, msg.uid, uuid, msg.messageId, msg.inReplyTo, JSON.stringify(msg.references), threadId, msg.date, msg.subject, msg.from.address, msg.from.name, JSON.stringify(msg.to), JSON.stringify(msg.cc), JSON.stringify(msg.flags), msg.size, msg.hasAttachments ? 1 : 0, msg.preview, msg.bodyPath, Date.now(), msg.providerId || null);
|
|
1524
|
+
flags_json, size, has_attachments, preview, body_path, body_parsed_at, cached_at, provider_id, is_replied
|
|
1525
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
1526
|
+
`).run(msg.accountId, msg.folderId, msg.uid, uuid, msg.messageId, msg.inReplyTo, JSON.stringify(msg.references), threadId, msg.date, msg.subject, msg.from.address, msg.from.name, JSON.stringify(msg.to), JSON.stringify(msg.cc), JSON.stringify(msg.flags), msg.size, msg.hasAttachments ? 1 : 0, msg.preview, msg.bodyPath, msg.bodyPath ? Date.now() : null, Date.now(), msg.providerId || null, alreadyReplied ? 1 : 0);
|
|
1481
1527
|
const rowId = Number(result.lastInsertRowid);
|
|
1528
|
+
// Forward direction for ↩: if this message is a reply, mark the parent
|
|
1529
|
+
// as replied-to. Cheap indexed UPDATE; no-op when the parent isn't in
|
|
1530
|
+
// mailx's DB (cross-account, archived, never synced).
|
|
1531
|
+
if (msg.inReplyTo)
|
|
1532
|
+
this.markRepliedByMessageId(msg.accountId, msg.inReplyTo);
|
|
1482
1533
|
// Record the (folder, uid) membership for the new row. New schema
|
|
1483
1534
|
// source of truth — old folder_id/uid columns above are kept in
|
|
1484
1535
|
// sync during the additive migration but reads will move to JOIN
|
|
@@ -1501,15 +1552,20 @@ export class MailxDB {
|
|
|
1501
1552
|
/** Backfill the FTS5 `body_text` column for a message after its body
|
|
1502
1553
|
* has been parsed. Capped at ~64 KB of text per row — FTS5 stores the
|
|
1503
1554
|
* raw text and indexes tokens; we don't need every byte of a 1 MB
|
|
1504
|
-
* marketing email to find a word in the first paragraph.
|
|
1505
|
-
*
|
|
1506
|
-
|
|
1555
|
+
* marketing email to find a word in the first paragraph.
|
|
1556
|
+
*
|
|
1557
|
+
* Keyed on the FTS rowid (= messages.id) directly. The earlier
|
|
1558
|
+
* (account, folder_id, uid) lookup keyed on the LEGACY messages.folder_id
|
|
1559
|
+
* column — a message can live in several folders via message_folders,
|
|
1560
|
+
* so that lookup could miss the row entirely and silently leave the
|
|
1561
|
+
* body un-indexed (Bob 2026-05-18: "mcdade" in an opened message's
|
|
1562
|
+
* body, search found nothing). */
|
|
1563
|
+
updateFtsBody(rowId, bodyText) {
|
|
1564
|
+
if (!rowId)
|
|
1565
|
+
return;
|
|
1507
1566
|
try {
|
|
1508
|
-
const row = this.db.prepare("SELECT id FROM messages WHERE account_id = ? AND folder_id = ? AND uid = ?").get(accountId, folderId, uid);
|
|
1509
|
-
if (!row)
|
|
1510
|
-
return;
|
|
1511
1567
|
const capped = bodyText.length > 64_000 ? bodyText.slice(0, 64_000) : bodyText;
|
|
1512
|
-
this.db.prepare("UPDATE messages_fts SET body_text = ? WHERE rowid = ?").run(capped,
|
|
1568
|
+
this.db.prepare("UPDATE messages_fts SET body_text = ? WHERE rowid = ?").run(capped, rowId);
|
|
1513
1569
|
}
|
|
1514
1570
|
catch { /* FTS update is best-effort */ }
|
|
1515
1571
|
}
|
|
@@ -1592,6 +1648,7 @@ export class MailxDB {
|
|
|
1592
1648
|
flags: JSON.parse(r.flags_json),
|
|
1593
1649
|
size: r.size,
|
|
1594
1650
|
hasAttachments: !!r.has_attachments,
|
|
1651
|
+
isReplied: !!r.is_replied,
|
|
1595
1652
|
preview: r.preview,
|
|
1596
1653
|
bodyPath: r.body_path || "",
|
|
1597
1654
|
pending: !!r.pending,
|
|
@@ -1661,6 +1718,7 @@ export class MailxDB {
|
|
|
1661
1718
|
flags: JSON.parse(r.flags_json),
|
|
1662
1719
|
size: r.size,
|
|
1663
1720
|
hasAttachments: !!r.has_attachments,
|
|
1721
|
+
isReplied: !!r.is_replied,
|
|
1664
1722
|
preview: r.preview,
|
|
1665
1723
|
bodyPath: r.body_path || "",
|
|
1666
1724
|
pending: !!r.pending,
|
|
@@ -1694,6 +1752,7 @@ export class MailxDB {
|
|
|
1694
1752
|
flags: JSON.parse(r.flags_json),
|
|
1695
1753
|
size: r.size,
|
|
1696
1754
|
hasAttachments: !!r.has_attachments,
|
|
1755
|
+
isReplied: !!r.is_replied,
|
|
1697
1756
|
preview: r.preview,
|
|
1698
1757
|
bodyPath: r.body_path || "",
|
|
1699
1758
|
providerId: r.provider_id || undefined,
|
|
@@ -1828,6 +1887,45 @@ export class MailxDB {
|
|
|
1828
1887
|
updateBodyPath(accountId, uid, bodyPath) {
|
|
1829
1888
|
this.db.prepare("UPDATE messages SET body_path = ? WHERE account_id = ? AND uid = ?").run(bodyPath, accountId, uid);
|
|
1830
1889
|
}
|
|
1890
|
+
/** Write the post-download metadata in one go: body file path, parsed
|
|
1891
|
+
* attachment flag, preview text, and the parse timestamp. Called from
|
|
1892
|
+
* prefetch after the body has been written to disk and parsed — without
|
|
1893
|
+
* this, IMAP metadata-only sync leaves has_attachments=0 and preview=""
|
|
1894
|
+
* forever (the paperclip 📎 never renders for non-Gmail accounts).
|
|
1895
|
+
* Pass bodyPath = "" to update only the parsed fields (used by the
|
|
1896
|
+
* backfill worker re-parsing existing .eml files on disk). */
|
|
1897
|
+
updateBodyMeta(accountId, uid, bodyPath, hasAttachments, preview) {
|
|
1898
|
+
if (bodyPath) {
|
|
1899
|
+
this.db.prepare("UPDATE messages SET body_path = ?, has_attachments = ?, preview = ?, body_parsed_at = ? WHERE account_id = ? AND uid = ?").run(bodyPath, hasAttachments ? 1 : 0, preview, Date.now(), accountId, uid);
|
|
1900
|
+
}
|
|
1901
|
+
else {
|
|
1902
|
+
this.db.prepare("UPDATE messages SET has_attachments = ?, preview = ?, body_parsed_at = ? WHERE account_id = ? AND uid = ?").run(hasAttachments ? 1 : 0, preview, Date.now(), accountId, uid);
|
|
1903
|
+
}
|
|
1904
|
+
}
|
|
1905
|
+
/** Find rows whose body is on disk but was never run through extractPreview
|
|
1906
|
+
* (has_attachments/preview reflect "no body source at sync time", typical
|
|
1907
|
+
* of the IMAP metadata-only path). Used by the backfill worker to walk
|
|
1908
|
+
* every existing message once after the prefetch-reparse fix landed.
|
|
1909
|
+
* Returns id + body_path so the worker doesn't need a second lookup. */
|
|
1910
|
+
getMessagesNeedingBodyReparse(accountId, limit = 50) {
|
|
1911
|
+
return this.db.prepare("SELECT id, uid, body_path AS bodyPath FROM messages WHERE account_id = ? AND body_path IS NOT NULL AND body_path != '' AND body_parsed_at IS NULL LIMIT ?").all(accountId, limit);
|
|
1912
|
+
}
|
|
1913
|
+
/** Backfill variant that writes by row id (avoids re-resolving uid). */
|
|
1914
|
+
updateBodyMetaById(rowId, hasAttachments, preview) {
|
|
1915
|
+
this.db.prepare("UPDATE messages SET has_attachments = ?, preview = ?, body_parsed_at = ? WHERE id = ?").run(hasAttachments ? 1 : 0, preview, Date.now(), rowId);
|
|
1916
|
+
}
|
|
1917
|
+
/** Mark a message as "replied to" by its RFC-5322 Message-ID. Source of
|
|
1918
|
+
* truth for the ↩ indicator: the local DB knows who replied to whom via
|
|
1919
|
+
* In-Reply-To headers, independent of whether the IMAP server propagates
|
|
1920
|
+
* \Answered (Gmail labels, cross-account replies, etc.). Called from the
|
|
1921
|
+
* send path when msg.inReplyTo is set, and from upsertMessage when a
|
|
1922
|
+
* reply arrives. Idempotent — UPDATE on an already-1 row is a no-op. */
|
|
1923
|
+
markRepliedByMessageId(accountId, messageId) {
|
|
1924
|
+
if (!messageId)
|
|
1925
|
+
return false;
|
|
1926
|
+
const r = this.db.prepare("UPDATE messages SET is_replied = 1 WHERE account_id = ? AND message_id = ? AND (is_replied IS NULL OR is_replied = 0)").run(accountId, messageId);
|
|
1927
|
+
return r.changes > 0;
|
|
1928
|
+
}
|
|
1831
1929
|
/** Get messages without cached bodies (for background prefetch) */
|
|
1832
1930
|
getMessagesWithoutBody(accountId, limit = 50) {
|
|
1833
1931
|
// Prefetch order: smallest first, NULLs last, recent within tiebreak.
|
|
@@ -2650,6 +2748,7 @@ export class MailxDB {
|
|
|
2650
2748
|
flags: JSON.parse(r.flags_json),
|
|
2651
2749
|
size: r.size,
|
|
2652
2750
|
hasAttachments: !!r.has_attachments,
|
|
2751
|
+
isReplied: !!r.is_replied,
|
|
2653
2752
|
preview: r.preview
|
|
2654
2753
|
}));
|
|
2655
2754
|
return { items, total, page, pageSize };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bobfrankston/mailx-store",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.31",
|
|
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.18",
|
|
13
|
+
"@bobfrankston/mailx-settings": "^0.1.21",
|
|
14
14
|
"@bobfrankston/mailx-bus": "^0.1.2",
|
|
15
15
|
"mailparser": "^3.7.2"
|
|
16
16
|
},
|
|
@@ -29,8 +29,8 @@
|
|
|
29
29
|
},
|
|
30
30
|
".transformedSnapshot": {
|
|
31
31
|
"dependencies": {
|
|
32
|
-
"@bobfrankston/mailx-types": "^0.1.
|
|
33
|
-
"@bobfrankston/mailx-settings": "^0.1.
|
|
32
|
+
"@bobfrankston/mailx-types": "^0.1.18",
|
|
33
|
+
"@bobfrankston/mailx-settings": "^0.1.21",
|
|
34
34
|
"@bobfrankston/mailx-bus": "^0.1.2",
|
|
35
35
|
"mailparser": "^3.7.2"
|
|
36
36
|
}
|
package/store.js
CHANGED
|
@@ -305,9 +305,9 @@ export class Store {
|
|
|
305
305
|
// backfill, searches miss any word that only appears deeper in the
|
|
306
306
|
// body. Fire-and-forget — failures are non-fatal, never block the
|
|
307
307
|
// user's preview render.
|
|
308
|
-
if (bodyText) {
|
|
308
|
+
if (bodyText && envelope.id) {
|
|
309
309
|
try {
|
|
310
|
-
this.db.
|
|
310
|
+
this.db.updateFtsBody(envelope.id, bodyText);
|
|
311
311
|
}
|
|
312
312
|
catch { /* */ }
|
|
313
313
|
}
|