@bobfrankston/mailx-store 0.1.55 → 0.1.57
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 +46 -2
- package/db.js +188 -14
- package/package.json +5 -5
- package/store.d.ts +1 -1
- package/store.js +2 -2
package/db.d.ts
CHANGED
|
@@ -4,6 +4,18 @@
|
|
|
4
4
|
* Message bodies are NOT here -- they live in the MessageStore backend.
|
|
5
5
|
*/
|
|
6
6
|
import type { MessageEnvelope, Folder, EmailAddress, PagedResult, MessageQuery } from "@bobfrankston/mailx-types";
|
|
7
|
+
/** Sanitize a harvested display name. A real display name carries none of
|
|
8
|
+
* `@ < > |` — their presence means an address parser mis-split a header
|
|
9
|
+
* (e.g. a List-Id / "domain | Name@addr" fragment) and the "name" now
|
|
10
|
+
* embeds structural mail punctuation or a whole email address. The worst
|
|
11
|
+
* case is an embedded address that DIFFERS from this contact's own email:
|
|
12
|
+
* autocomplete then shows a TRUSTED name while sending ELSEWHERE. Live
|
|
13
|
+
* example (Bob 2026-06-22): a `discovered` row name `bob.ma | Bob@bob.ma`
|
|
14
|
+
* was wired to `miyakot@kt.rim.or.jp`, so test mail Bob addressed to
|
|
15
|
+
* himself silently went to a stranger in Japan ("MIA"). Drop the name;
|
|
16
|
+
* keep the contact (the email is still useful) — autocomplete falls back
|
|
17
|
+
* to showing the bare address, which the user won't mistake for someone. */
|
|
18
|
+
export declare function cleanContactName(name: string): string;
|
|
7
19
|
/** User-configured patterns from contacts.jsonc `denylistPatterns`. Compiled
|
|
8
20
|
* once on settings load; invalid regexes are skipped with a warning. */
|
|
9
21
|
export declare function setContactsDenyPatterns(patterns: string[]): void;
|
|
@@ -187,6 +199,26 @@ export declare class MailxDB {
|
|
|
187
199
|
}[];
|
|
188
200
|
updateLastSync(accountId: string, timestamp: number): void;
|
|
189
201
|
upsertFolder(accountId: string, folderPath: string, name: string, specialUse: string, delimiter: string): number;
|
|
202
|
+
/** Rename a folder row's path (+leaf name) and rewrite every descendant
|
|
203
|
+
* folder whose path lives under the old prefix. A single IMAP RENAME on the
|
|
204
|
+
* server renames child mailboxes too (RFC 3501), and Gmail/Outlook rename
|
|
205
|
+
* cascades the same way, so the local mirror must move the whole subtree in
|
|
206
|
+
* lockstep. All in one transaction so a crash can't leave half the subtree
|
|
207
|
+
* pointing at the old prefix.
|
|
208
|
+
*
|
|
209
|
+
* `newName` is the new LEAF name written to the renamed row's `name`
|
|
210
|
+
* column; descendant rows keep their own leaf names (only the prefix
|
|
211
|
+
* changes). The (account_id, path) UNIQUE constraint means the caller must
|
|
212
|
+
* have already verified newPath is free. */
|
|
213
|
+
renameFolderPath(accountId: string, oldPath: string, newPath: string, newName: string, delimiter: string): void;
|
|
214
|
+
/** Update only a folder's display `name`, leaving `path` untouched. For
|
|
215
|
+
* providers whose `path` is an opaque, rename-stable id (Outlook/Graph:
|
|
216
|
+
* path = folder id), a rename/reparent changes the display name but NOT
|
|
217
|
+
* the id — so synthesizing a new hierarchical path (as renameFolderPath
|
|
218
|
+
* does) would corrupt the id-as-path invariant every later Graph op relies
|
|
219
|
+
* on. This updates the name in place. No descendant rewrite: opaque-id
|
|
220
|
+
* children don't carry the parent in their path. */
|
|
221
|
+
renameFolderName(accountId: string, folderId: number, newName: string): void;
|
|
190
222
|
getFolders(accountId: string): Folder[];
|
|
191
223
|
/** Append a row to the audit_log table. Every destructive operation on
|
|
192
224
|
* the messages table writes here so "where did the rows go?" has an
|
|
@@ -252,6 +284,9 @@ export declare class MailxDB {
|
|
|
252
284
|
inReplyTo: string;
|
|
253
285
|
references: string[];
|
|
254
286
|
date: number;
|
|
287
|
+
/** Date: header (send time). Defaults to `date` when the caller has no
|
|
288
|
+
* header date, so the column is never null. */
|
|
289
|
+
sentDate?: number;
|
|
255
290
|
subject: string;
|
|
256
291
|
from: EmailAddress;
|
|
257
292
|
to: EmailAddress[];
|
|
@@ -290,7 +325,7 @@ export declare class MailxDB {
|
|
|
290
325
|
/** Short-TTL cache for the unified-inbox survivor COUNT (the GROUP BY that
|
|
291
326
|
* scans the whole inbox). Keyed by flaggedOnly+folder-set. */
|
|
292
327
|
private _unifiedTotalCache;
|
|
293
|
-
getUnifiedInbox(page?: number, pageSize?: number, flaggedOnly?: boolean): PagedResult<MessageEnvelope>;
|
|
328
|
+
getUnifiedInbox(page?: number, pageSize?: number, flaggedOnly?: boolean, dateBasis?: "sent" | "received"): PagedResult<MessageEnvelope>;
|
|
294
329
|
/** Map a `messages` row to a MessageEnvelope. Exposes `uuid` (stable local
|
|
295
330
|
* identity) and `bodyPath` (authoritative on-disk location) in addition
|
|
296
331
|
* to the server-binding metadata. */
|
|
@@ -464,6 +499,12 @@ export declare class MailxDB {
|
|
|
464
499
|
preview: string;
|
|
465
500
|
body_path: string;
|
|
466
501
|
}[];
|
|
502
|
+
/** Count local rows in a folder sharing one Message-ID. The Sent-sweep
|
|
503
|
+
* uses this as a sanity cross-check before re-APPENDing a "missing"
|
|
504
|
+
* message: >1 local copies mean earlier duplicates synced IN from the
|
|
505
|
+
* server, so the server-side header search that just said "missing"
|
|
506
|
+
* was lying (the 3,704-duplicate loop, 2026-07-02). */
|
|
507
|
+
countMessagesByMessageId(accountId: string, folderId: number, messageId: string): number;
|
|
467
508
|
/** Rebind a local row to a different server UID without re-upserting.
|
|
468
509
|
* Used by the Sent-sweep to repair optimistic-insert mispredictions
|
|
469
510
|
* (local row was inserted at the predicted UIDNEXT but the server
|
|
@@ -673,7 +714,10 @@ export declare class MailxDB {
|
|
|
673
714
|
deleteContactByGoogleId(googleId: string): number;
|
|
674
715
|
/** Full-text search across all messages. Supports qualifiers: from:, to:, subject: */
|
|
675
716
|
searchMessages(query: string, page?: number, pageSize?: number, accountId?: string, folderId?: number, includeTrashSpam?: boolean): PagedResult<MessageEnvelope>;
|
|
676
|
-
/** Rebuild FTS index from existing messages
|
|
717
|
+
/** Rebuild FTS index from existing messages.
|
|
718
|
+
* body_text is seeded from `preview` here (all db.ts can reach); the full
|
|
719
|
+
* message body is backfilled separately by mailx-imap's backfillFtsBodies()
|
|
720
|
+
* pass, which can read the cached .eml. */
|
|
677
721
|
rebuildSearchIndex(): number;
|
|
678
722
|
/** Queue a local action for later sync to IMAP */
|
|
679
723
|
queueSyncAction(accountId: string, action: string, uid: number, folderId: number, extra?: {
|
package/db.js
CHANGED
|
@@ -76,6 +76,25 @@ function isJunkContact(email, name) {
|
|
|
76
76
|
}
|
|
77
77
|
return false;
|
|
78
78
|
}
|
|
79
|
+
/** Sanitize a harvested display name. A real display name carries none of
|
|
80
|
+
* `@ < > |` — their presence means an address parser mis-split a header
|
|
81
|
+
* (e.g. a List-Id / "domain | Name@addr" fragment) and the "name" now
|
|
82
|
+
* embeds structural mail punctuation or a whole email address. The worst
|
|
83
|
+
* case is an embedded address that DIFFERS from this contact's own email:
|
|
84
|
+
* autocomplete then shows a TRUSTED name while sending ELSEWHERE. Live
|
|
85
|
+
* example (Bob 2026-06-22): a `discovered` row name `bob.ma | Bob@bob.ma`
|
|
86
|
+
* was wired to `miyakot@kt.rim.or.jp`, so test mail Bob addressed to
|
|
87
|
+
* himself silently went to a stranger in Japan ("MIA"). Drop the name;
|
|
88
|
+
* keep the contact (the email is still useful) — autocomplete falls back
|
|
89
|
+
* to showing the bare address, which the user won't mistake for someone. */
|
|
90
|
+
export function cleanContactName(name) {
|
|
91
|
+
const n = (name || "").trim().replace(/^"(.*)"$/, "$1").trim();
|
|
92
|
+
if (!n)
|
|
93
|
+
return "";
|
|
94
|
+
if (/[@<>|]/.test(n))
|
|
95
|
+
return "";
|
|
96
|
+
return n;
|
|
97
|
+
}
|
|
79
98
|
/** User-configured patterns from contacts.jsonc `denylistPatterns`. Compiled
|
|
80
99
|
* once on settings load; invalid regexes are skipped with a warning. */
|
|
81
100
|
export function setContactsDenyPatterns(patterns) {
|
|
@@ -537,6 +556,44 @@ export class MailxDB {
|
|
|
537
556
|
// parsed (existing IMAP-synced rows that predate the prefetch-reparse
|
|
538
557
|
// fix, where has_attachments stays 0 forever). NULL = never parsed.
|
|
539
558
|
this.addColumnIfMissing("messages", "body_parsed_at", "INTEGER");
|
|
559
|
+
// sent_date: the message's own Date: header (when it was SENT), distinct
|
|
560
|
+
// from `date` which is the per-account arrival time (IMAP INTERNALDATE /
|
|
561
|
+
// Gmail internalDate). Kept as its own column so the list can offer a
|
|
562
|
+
// Sent/Received date basis (Bob 2026-07-01) AND so identical messages
|
|
563
|
+
// delivered to multiple accounts cluster on send time. Backfilled to
|
|
564
|
+
// `date` for existing rows so it is ALWAYS non-null → a plain index sorts
|
|
565
|
+
// it as fast as the received-date path (no COALESCE, no hot-path
|
|
566
|
+
// regression of the getUnifiedInbox streaming walk). Real send times fill
|
|
567
|
+
// in as folders re-sync (or via `-repair`).
|
|
568
|
+
this.addColumnIfMissing("messages", "sent_date", "INTEGER");
|
|
569
|
+
// One-shot heal: strip header-fold whitespace from stored Message-IDs /
|
|
570
|
+
// In-Reply-To. A folded "Message-ID:\r\n\t<...>" header reached the DB
|
|
571
|
+
// as "\t<...>" (pre-trim iflow-direct envelope parser); the tab made
|
|
572
|
+
// every HEADER search for that id return 0 → the sent-sweep re-APPENDed
|
|
573
|
+
// "missing" messages forever (3,707 duplicates of one reply,
|
|
574
|
+
// 2026-07-02). Idempotent — after the first pass the WHERE matches
|
|
575
|
+
// nothing. char(9/10/13)+space covers all RFC 5322 fold whitespace.
|
|
576
|
+
try {
|
|
577
|
+
const ws = "char(9)||char(10)||char(13)||' '";
|
|
578
|
+
this.db.exec(`UPDATE messages SET message_id = TRIM(message_id, ${ws}) WHERE message_id <> TRIM(message_id, ${ws})`);
|
|
579
|
+
this.db.exec(`UPDATE messages SET in_reply_to = TRIM(in_reply_to, ${ws}) WHERE in_reply_to IS NOT NULL AND in_reply_to <> TRIM(in_reply_to, ${ws})`);
|
|
580
|
+
}
|
|
581
|
+
catch { /* non-fatal */ }
|
|
582
|
+
try {
|
|
583
|
+
this.db.exec("CREATE INDEX IF NOT EXISTS idx_messages_folder_sentdate ON messages(account_id, folder_id, sent_date DESC)");
|
|
584
|
+
}
|
|
585
|
+
catch { /* already exists */ }
|
|
586
|
+
try {
|
|
587
|
+
this.db.exec("CREATE INDEX IF NOT EXISTS idx_messages_sentdate ON messages(folder_id, sent_date DESC)");
|
|
588
|
+
}
|
|
589
|
+
catch { /* already exists */ }
|
|
590
|
+
try {
|
|
591
|
+
// One-shot backfill: seed sent_date from date wherever it's still
|
|
592
|
+
// null. Indexed IS NULL check keeps this near-instant after the first
|
|
593
|
+
// pass; new writes set sent_date directly so it converges to a no-op.
|
|
594
|
+
this.db.exec("UPDATE messages SET sent_date = date WHERE sent_date IS NULL");
|
|
595
|
+
}
|
|
596
|
+
catch { /* non-fatal */ }
|
|
540
597
|
// One-shot is_replied backfill on every boot. SQL-only, indexed
|
|
541
598
|
// both sides via idx_messages_message_id and idx_messages_in_reply_to,
|
|
542
599
|
// so a 100k-row mailbox finishes in milliseconds. Idempotent — after
|
|
@@ -815,18 +872,29 @@ export class MailxDB {
|
|
|
815
872
|
if (last === TARGET)
|
|
816
873
|
return;
|
|
817
874
|
let dropped = 0;
|
|
875
|
+
let renamed = 0;
|
|
818
876
|
try {
|
|
819
877
|
const rows = this.db.prepare("SELECT id, email, name FROM contacts WHERE source IN ('discovered','sent','received')").all();
|
|
820
878
|
const del = this.db.prepare("DELETE FROM contacts WHERE id = ?");
|
|
879
|
+
const blank = this.db.prepare("UPDATE contacts SET name = '' WHERE id = ?");
|
|
821
880
|
for (const r of rows) {
|
|
822
881
|
if (isJunkContact(r.email || "", r.name || "")) {
|
|
823
882
|
del.run(r.id);
|
|
824
883
|
dropped++;
|
|
884
|
+
continue;
|
|
885
|
+
}
|
|
886
|
+
// Blank mis-parsed display names that embed `@ < > |` — these
|
|
887
|
+
// can impersonate one identity while sending to another (the
|
|
888
|
+
// "bob.ma | Bob@bob.ma" → miyakot@kt.rim.or.jp cross-wire,
|
|
889
|
+
// Bob 2026-06-22). Keep the contact; drop only the unsafe name.
|
|
890
|
+
if (r.name && cleanContactName(r.name) !== r.name) {
|
|
891
|
+
blank.run(r.id);
|
|
892
|
+
renamed++;
|
|
825
893
|
}
|
|
826
894
|
}
|
|
827
895
|
this.setKv("contacts", "purge_v", TARGET);
|
|
828
|
-
if (dropped > 0)
|
|
829
|
-
console.log(` [contacts] one-shot purge: dropped ${dropped} junk rows (${TARGET})`);
|
|
896
|
+
if (dropped > 0 || renamed > 0)
|
|
897
|
+
console.log(` [contacts] one-shot purge: dropped ${dropped} junk rows, blanked ${renamed} unsafe names (${TARGET})`);
|
|
830
898
|
}
|
|
831
899
|
catch (e) {
|
|
832
900
|
console.warn(`[contacts] one-shot purge failed: ${e.message}`);
|
|
@@ -1340,6 +1408,48 @@ export class MailxDB {
|
|
|
1340
1408
|
const result = this.db.prepare("INSERT INTO folders (account_id, path, name, special_use, delimiter) VALUES (?, ?, ?, ?, ?)").run(accountId, folderPath, name, specialUse, delimiter);
|
|
1341
1409
|
return Number(result.lastInsertRowid);
|
|
1342
1410
|
}
|
|
1411
|
+
/** Rename a folder row's path (+leaf name) and rewrite every descendant
|
|
1412
|
+
* folder whose path lives under the old prefix. A single IMAP RENAME on the
|
|
1413
|
+
* server renames child mailboxes too (RFC 3501), and Gmail/Outlook rename
|
|
1414
|
+
* cascades the same way, so the local mirror must move the whole subtree in
|
|
1415
|
+
* lockstep. All in one transaction so a crash can't leave half the subtree
|
|
1416
|
+
* pointing at the old prefix.
|
|
1417
|
+
*
|
|
1418
|
+
* `newName` is the new LEAF name written to the renamed row's `name`
|
|
1419
|
+
* column; descendant rows keep their own leaf names (only the prefix
|
|
1420
|
+
* changes). The (account_id, path) UNIQUE constraint means the caller must
|
|
1421
|
+
* have already verified newPath is free. */
|
|
1422
|
+
renameFolderPath(accountId, oldPath, newPath, newName, delimiter) {
|
|
1423
|
+
const childPrefix = oldPath + delimiter;
|
|
1424
|
+
this.db.exec("BEGIN");
|
|
1425
|
+
try {
|
|
1426
|
+
// The folder itself — path + leaf name both change.
|
|
1427
|
+
this.db.prepare("UPDATE folders SET path = ?, name = ? WHERE account_id = ? AND path = ?").run(newPath, newName, accountId, oldPath);
|
|
1428
|
+
// Descendants — rewrite only the path prefix, keep their leaf names.
|
|
1429
|
+
// SUBSTR(path, len(childPrefix)+1) is the part after the old prefix.
|
|
1430
|
+
const descendants = this.db.prepare("SELECT id, path FROM folders WHERE account_id = ? AND path LIKE ? ESCAPE '\\'").all(accountId, childPrefix.replace(/[\\%_]/g, "\\$&") + "%");
|
|
1431
|
+
const upd = this.db.prepare("UPDATE folders SET path = ? WHERE id = ?");
|
|
1432
|
+
for (const d of descendants) {
|
|
1433
|
+
const rest = d.path.slice(oldPath.length); // includes the leading delimiter
|
|
1434
|
+
upd.run(newPath + rest, d.id);
|
|
1435
|
+
}
|
|
1436
|
+
this.db.exec("COMMIT");
|
|
1437
|
+
}
|
|
1438
|
+
catch (e) {
|
|
1439
|
+
this.db.exec("ROLLBACK");
|
|
1440
|
+
throw e;
|
|
1441
|
+
}
|
|
1442
|
+
}
|
|
1443
|
+
/** Update only a folder's display `name`, leaving `path` untouched. For
|
|
1444
|
+
* providers whose `path` is an opaque, rename-stable id (Outlook/Graph:
|
|
1445
|
+
* path = folder id), a rename/reparent changes the display name but NOT
|
|
1446
|
+
* the id — so synthesizing a new hierarchical path (as renameFolderPath
|
|
1447
|
+
* does) would corrupt the id-as-path invariant every later Graph op relies
|
|
1448
|
+
* on. This updates the name in place. No descendant rewrite: opaque-id
|
|
1449
|
+
* children don't carry the parent in their path. */
|
|
1450
|
+
renameFolderName(accountId, folderId, newName) {
|
|
1451
|
+
this.db.prepare("UPDATE folders SET name = ? WHERE account_id = ? AND id = ?").run(newName, accountId, folderId);
|
|
1452
|
+
}
|
|
1343
1453
|
getFolders(accountId) {
|
|
1344
1454
|
const rows = this.db.prepare("SELECT * FROM folders WHERE account_id = ? ORDER BY path").all(accountId);
|
|
1345
1455
|
const folders = rows.map(r => ({
|
|
@@ -1685,7 +1795,7 @@ export class MailxDB {
|
|
|
1685
1795
|
// subject HERE, but only when the existing mid is empty AND the
|
|
1686
1796
|
// incoming msg actually has one — never clobber a good value.
|
|
1687
1797
|
if (msg.messageId && (!existing.message_id || existing.message_id === "")) {
|
|
1688
|
-
this.db.prepare("UPDATE messages SET message_id = ?, date = ?, subject = ? WHERE id = ?").run(msg.messageId, msg.date || Date.now(), msg.subject || "", existing.id);
|
|
1798
|
+
this.db.prepare("UPDATE messages SET message_id = ?, date = ?, sent_date = ?, subject = ? WHERE id = ?").run(msg.messageId, msg.date || Date.now(), msg.sentDate ?? msg.date ?? Date.now(), msg.subject || "", existing.id);
|
|
1689
1799
|
// The row was indexed with an empty subject (it had no envelope),
|
|
1690
1800
|
// so search missed it. Re-index now that we have the real subject
|
|
1691
1801
|
// + from/to. Standalone FTS5 → delete + insert. body_text
|
|
@@ -1760,10 +1870,10 @@ export class MailxDB {
|
|
|
1760
1870
|
const result = this.db.prepare(`
|
|
1761
1871
|
INSERT INTO messages (
|
|
1762
1872
|
account_id, folder_id, uid, uuid, message_id, in_reply_to, refs, thread_id,
|
|
1763
|
-
date, subject, from_address, from_name, to_json, cc_json,
|
|
1873
|
+
date, sent_date, subject, from_address, from_name, to_json, cc_json,
|
|
1764
1874
|
flags_json, size, has_attachments, preview, body_path, body_parsed_at, cached_at, provider_id, is_replied
|
|
1765
|
-
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
1766
|
-
`).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);
|
|
1875
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
1876
|
+
`).run(msg.accountId, msg.folderId, msg.uid, uuid, msg.messageId, msg.inReplyTo, JSON.stringify(msg.references), threadId, msg.date, msg.sentDate ?? 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);
|
|
1767
1877
|
const rowId = Number(result.lastInsertRowid);
|
|
1768
1878
|
// Forward direction for ↩: if this message is a reply, mark the parent
|
|
1769
1879
|
// as replied-to. Cheap indexed UPDATE; no-op when the parent isn't in
|
|
@@ -1821,7 +1931,10 @@ export class MailxDB {
|
|
|
1821
1931
|
const offset = (page - 1) * pageSize;
|
|
1822
1932
|
const sort = query.sort || "date";
|
|
1823
1933
|
const sortDir = query.sortDir || "desc";
|
|
1824
|
-
|
|
1934
|
+
// Date basis: "sent" (Date: header, default — clusters identical mail
|
|
1935
|
+
// and matches other clients) or "received" (per-account arrival time).
|
|
1936
|
+
const dateCol = query.dateBasis === "received" ? "m.date" : "m.sent_date";
|
|
1937
|
+
const sortCol = sort === "from" ? "m.from_name" : sort === "subject" ? "m.subject" : dateCol;
|
|
1825
1938
|
let where = "m.account_id = ? AND mf.folder_id = ?";
|
|
1826
1939
|
const params = [query.accountId, query.folderId];
|
|
1827
1940
|
if (query.search) {
|
|
@@ -1886,6 +1999,7 @@ export class MailxDB {
|
|
|
1886
1999
|
references: JSON.parse(r.refs || "[]"),
|
|
1887
2000
|
threadId: r.thread_id || undefined,
|
|
1888
2001
|
date: r.date,
|
|
2002
|
+
sentDate: r.sent_date ?? r.date,
|
|
1889
2003
|
subject: r.subject,
|
|
1890
2004
|
from: { name: r.from_name, address: r.from_address },
|
|
1891
2005
|
to: JSON.parse(r.to_json),
|
|
@@ -1904,8 +2018,13 @@ export class MailxDB {
|
|
|
1904
2018
|
/** Short-TTL cache for the unified-inbox survivor COUNT (the GROUP BY that
|
|
1905
2019
|
* scans the whole inbox). Keyed by flaggedOnly+folder-set. */
|
|
1906
2020
|
_unifiedTotalCache = new Map();
|
|
1907
|
-
getUnifiedInbox(page = 1, pageSize = 50, flaggedOnly = false) {
|
|
2021
|
+
getUnifiedInbox(page = 1, pageSize = 50, flaggedOnly = false, dateBasis = "sent") {
|
|
1908
2022
|
const offset = (page - 1) * pageSize;
|
|
2023
|
+
// Date basis: sort/stream by the Date: header (sent, default) or the
|
|
2024
|
+
// per-account arrival time (received). sent_date is always non-null
|
|
2025
|
+
// (backfilled to date), and idx_messages_sentdate mirrors
|
|
2026
|
+
// idx_messages_folder_date, so the streaming walk stays O(page).
|
|
2027
|
+
const dateCol = dateBasis === "received" ? "m.date" : "m.sent_date";
|
|
1909
2028
|
// Find all inbox folder IDs
|
|
1910
2029
|
const inboxRows = this.db.prepare("SELECT id FROM folders WHERE special_use = 'inbox'").all();
|
|
1911
2030
|
if (inboxRows.length === 0)
|
|
@@ -1970,7 +2089,7 @@ export class MailxDB {
|
|
|
1970
2089
|
FROM messages m
|
|
1971
2090
|
JOIN message_folders mf ON mf.message_row_id = m.id
|
|
1972
2091
|
WHERE mf.folder_id IN (${placeholders})${flagFilter}
|
|
1973
|
-
ORDER BY
|
|
2092
|
+
ORDER BY ${dateCol} DESC, m.id DESC`);
|
|
1974
2093
|
// Per-survivor enrichment (runs only for the ≤pageSize kept rows, not the
|
|
1975
2094
|
// whole inbox). Same semantics as the old inline correlated subqueries.
|
|
1976
2095
|
const pendingStmt = this.db.prepare("SELECT 1 FROM sync_actions WHERE account_id = ? AND uid = ? LIMIT 1");
|
|
@@ -2011,6 +2130,7 @@ export class MailxDB {
|
|
|
2011
2130
|
references: JSON.parse(r.refs || "[]"),
|
|
2012
2131
|
threadId: r.thread_id || undefined,
|
|
2013
2132
|
date: r.date,
|
|
2133
|
+
sentDate: r.sent_date ?? r.date,
|
|
2014
2134
|
subject: r.subject,
|
|
2015
2135
|
from: { name: r.from_name, address: r.from_address },
|
|
2016
2136
|
to: JSON.parse(r.to_json),
|
|
@@ -2125,7 +2245,26 @@ export class MailxDB {
|
|
|
2125
2245
|
* for code paths that mean it; everyone else must pass folderId. */
|
|
2126
2246
|
updateMessageFlags(accountId, folderId, uid, flags) {
|
|
2127
2247
|
if (folderId != null) {
|
|
2128
|
-
|
|
2248
|
+
// Resolve the row via its folder MEMBERSHIP, not messages.folder_id.
|
|
2249
|
+
// The message-id collapse (upsertMessage move-detect) keeps ONE
|
|
2250
|
+
// `messages` row per message_id whose `folder_id` is an arbitrary
|
|
2251
|
+
// "primary" folder, while the message appears in OTHER folders via
|
|
2252
|
+
// `message_folders`. A `WHERE folder_id = ?` update therefore MISSED
|
|
2253
|
+
// any message viewed in a folder other than its stored primary —
|
|
2254
|
+
// 0 rows changed, so star/mark-read silently did nothing locally and
|
|
2255
|
+
// the change "came right back" on the next render (Bob 2026-06-27).
|
|
2256
|
+
// Match the membership (the same join getMessageByUid uses).
|
|
2257
|
+
const r = this.db.prepare(`
|
|
2258
|
+
UPDATE messages SET flags_json = ? WHERE id = (
|
|
2259
|
+
SELECT m.id FROM messages m
|
|
2260
|
+
JOIN message_folders mf ON mf.message_row_id = m.id
|
|
2261
|
+
WHERE m.account_id = ? AND mf.folder_id = ? AND mf.uid = ? LIMIT 1
|
|
2262
|
+
)
|
|
2263
|
+
`).run(JSON.stringify(flags), accountId, folderId, uid);
|
|
2264
|
+
// Fallback for any legacy row lacking a membership entry.
|
|
2265
|
+
if (r.changes === 0) {
|
|
2266
|
+
this.db.prepare("UPDATE messages SET flags_json = ? WHERE account_id = ? AND folder_id = ? AND uid = ?").run(JSON.stringify(flags), accountId, folderId, uid);
|
|
2267
|
+
}
|
|
2129
2268
|
}
|
|
2130
2269
|
else {
|
|
2131
2270
|
this.db.prepare("UPDATE messages SET flags_json = ? WHERE account_id = ? AND uid = ?").run(JSON.stringify(flags), accountId, uid);
|
|
@@ -2425,6 +2564,19 @@ export class MailxDB {
|
|
|
2425
2564
|
WHERE account_id = ? AND folder_id = ? AND cached_at >= ?
|
|
2426
2565
|
AND message_id IS NOT NULL AND message_id <> ''`).all(accountId, folderId, sinceMs);
|
|
2427
2566
|
}
|
|
2567
|
+
/** Count local rows in a folder sharing one Message-ID. The Sent-sweep
|
|
2568
|
+
* uses this as a sanity cross-check before re-APPENDing a "missing"
|
|
2569
|
+
* message: >1 local copies mean earlier duplicates synced IN from the
|
|
2570
|
+
* server, so the server-side header search that just said "missing"
|
|
2571
|
+
* was lying (the 3,704-duplicate loop, 2026-07-02). */
|
|
2572
|
+
countMessagesByMessageId(accountId, folderId, messageId) {
|
|
2573
|
+
if (!messageId)
|
|
2574
|
+
return 0;
|
|
2575
|
+
const r = this.db.prepare(`SELECT COUNT(*) AS c FROM messages m
|
|
2576
|
+
JOIN message_folders mf ON mf.message_row_id = m.id
|
|
2577
|
+
WHERE m.account_id = ? AND mf.folder_id = ? AND m.message_id = ?`).get(accountId, folderId, messageId);
|
|
2578
|
+
return r?.c | 0;
|
|
2579
|
+
}
|
|
2428
2580
|
/** Rebind a local row to a different server UID without re-upserting.
|
|
2429
2581
|
* Used by the Sent-sweep to repair optimistic-insert mispredictions
|
|
2430
2582
|
* (local row was inserted at the predicted UIDNEXT but the server
|
|
@@ -2579,6 +2731,7 @@ export class MailxDB {
|
|
|
2579
2731
|
return;
|
|
2580
2732
|
if (isJunkContact(lower, name))
|
|
2581
2733
|
return;
|
|
2734
|
+
name = cleanContactName(name);
|
|
2582
2735
|
const now = Date.now();
|
|
2583
2736
|
// discovered tier holds one row per email — bump if present, else
|
|
2584
2737
|
// insert. Doesn't touch preferred or google rows for the same email;
|
|
@@ -2690,6 +2843,7 @@ export class MailxDB {
|
|
|
2690
2843
|
return;
|
|
2691
2844
|
if (this.isAddressDenylisted(email))
|
|
2692
2845
|
return;
|
|
2846
|
+
name = cleanContactName(name);
|
|
2693
2847
|
const e = agg.get(email);
|
|
2694
2848
|
if (e) {
|
|
2695
2849
|
e.cnt++;
|
|
@@ -3216,6 +3370,7 @@ export class MailxDB {
|
|
|
3216
3370
|
for (const part of parts) {
|
|
3217
3371
|
const fromMatch = part.match(/^from:(.+)$/i);
|
|
3218
3372
|
const toMatch = part.match(/^to:(.+)$/i);
|
|
3373
|
+
const ccMatch = part.match(/^cc:(.+)$/i);
|
|
3219
3374
|
const subjectMatch = part.match(/^subject:(.+)$/i);
|
|
3220
3375
|
const dateMatch = part.match(/^date:([><]?=?)(.+)$/i);
|
|
3221
3376
|
const afterMatch = part.match(/^after:(.+)$/i);
|
|
@@ -3231,10 +3386,19 @@ export class MailxDB {
|
|
|
3231
3386
|
frags.push({ s: `{from_name from_address}:${term}*`, op: false });
|
|
3232
3387
|
}
|
|
3233
3388
|
else if (toMatch) {
|
|
3389
|
+
// `to:` deliberately spans To AND Cc — when someone searches
|
|
3390
|
+
// "to:bob" they mean "addressed to bob", and being Cc'd counts.
|
|
3391
|
+
// Use `cc:` for a Cc-only match. (Bcc isn't indexed: it's absent
|
|
3392
|
+
// on received mail by design, and only meaningful in Sent.)
|
|
3234
3393
|
const term = ftsClean(toMatch[1]);
|
|
3235
3394
|
if (term)
|
|
3236
3395
|
frags.push({ s: `{to_text cc_text}:${term}*`, op: false });
|
|
3237
3396
|
}
|
|
3397
|
+
else if (ccMatch) {
|
|
3398
|
+
const term = ftsClean(ccMatch[1]);
|
|
3399
|
+
if (term)
|
|
3400
|
+
frags.push({ s: `cc_text:${term}*`, op: false });
|
|
3401
|
+
}
|
|
3238
3402
|
else if (subjectMatch) {
|
|
3239
3403
|
const term = ftsClean(subjectMatch[1]);
|
|
3240
3404
|
if (term)
|
|
@@ -3334,6 +3498,9 @@ export class MailxDB {
|
|
|
3334
3498
|
ftsQuery += sep + frags[i].s;
|
|
3335
3499
|
}
|
|
3336
3500
|
ftsQuery = ftsQuery.trim();
|
|
3501
|
+
// (Header-scoped search is reachable via the `subject:`/`from:`/`to:`
|
|
3502
|
+
// qualifiers — no separate "headers only" mode needed, and body search
|
|
3503
|
+
// isn't slow enough to warrant one.)
|
|
3337
3504
|
// No real FTS term — either qualifier-only ("is:flagged after:1w") or
|
|
3338
3505
|
// every term sanitized away (user typed only punctuation). There is NO
|
|
3339
3506
|
// valid FTS5 "match everything" string — `MATCH '*'` is itself an FTS5
|
|
@@ -3419,16 +3586,23 @@ export class MailxDB {
|
|
|
3419
3586
|
return { items: [], total: 0, page, pageSize };
|
|
3420
3587
|
}
|
|
3421
3588
|
}
|
|
3422
|
-
/** Rebuild FTS index from existing messages
|
|
3589
|
+
/** Rebuild FTS index from existing messages.
|
|
3590
|
+
* body_text is seeded from `preview` here (all db.ts can reach); the full
|
|
3591
|
+
* message body is backfilled separately by mailx-imap's backfillFtsBodies()
|
|
3592
|
+
* pass, which can read the cached .eml. */
|
|
3423
3593
|
rebuildSearchIndex() {
|
|
3424
|
-
// Drop and recreate
|
|
3594
|
+
// Drop and recreate. MUST be the STANDALONE schema (no content=messages):
|
|
3595
|
+
// the external-content form indexes to_text/cc_text/body_text columns
|
|
3596
|
+
// that don't exist on `messages`, so content-dereferencing silently
|
|
3597
|
+
// drops those terms — the exact breakage migrateFtsSchema() repairs.
|
|
3598
|
+
// Recreating it here used to quietly reintroduce that bug on every
|
|
3599
|
+
// -reindex (Bob 2026-06-05 "search missed to/cc/body").
|
|
3425
3600
|
try {
|
|
3426
3601
|
this.db.exec("DROP TABLE IF EXISTS messages_fts");
|
|
3427
3602
|
}
|
|
3428
3603
|
catch { /* ignore */ }
|
|
3429
3604
|
this.db.exec(`CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5(
|
|
3430
|
-
subject, from_name, from_address, to_text, cc_text, body_text
|
|
3431
|
-
content=messages, content_rowid=id
|
|
3605
|
+
subject, from_name, from_address, to_text, cc_text, body_text
|
|
3432
3606
|
)`);
|
|
3433
3607
|
// Use a single transaction + prepared statement for speed (~50x faster than individual inserts)
|
|
3434
3608
|
const insert = this.db.prepare("INSERT INTO messages_fts (rowid, subject, from_name, from_address, to_text, cc_text, body_text) VALUES (?, ?, ?, ?, ?, ?, ?)");
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bobfrankston/mailx-store",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.57",
|
|
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.22",
|
|
13
|
+
"@bobfrankston/mailx-settings": "^0.1.33",
|
|
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.22",
|
|
33
|
+
"@bobfrankston/mailx-settings": "^0.1.33",
|
|
34
34
|
"@bobfrankston/mailx-bus": "^0.1.2",
|
|
35
35
|
"mailparser": "^3.7.2"
|
|
36
36
|
}
|
package/store.d.ts
CHANGED
|
@@ -130,7 +130,7 @@ export declare class Store {
|
|
|
130
130
|
/** Paginated message list for a (account, folder, ...) query. */
|
|
131
131
|
getMessages(query: MessageQuery): PagedResult<MessageEnvelope>;
|
|
132
132
|
/** All-Inboxes view: union of every account's INBOX, paginated. */
|
|
133
|
-
getUnifiedInbox(page?: number, pageSize?: number, flaggedOnly?: boolean): PagedResult<MessageEnvelope>;
|
|
133
|
+
getUnifiedInbox(page?: number, pageSize?: number, flaggedOnly?: boolean, dateBasis?: "sent" | "received"): PagedResult<MessageEnvelope>;
|
|
134
134
|
/** Local FTS5 search. Server-scope search is the reconciler's job. */
|
|
135
135
|
searchMessages(query: string, page?: number, pageSize?: number, accountId?: string, folderId?: number, includeTrashSpam?: boolean): PagedResult<MessageEnvelope>;
|
|
136
136
|
/** Read a fully-parsed message (envelope + body + attachments) entirely
|
package/store.js
CHANGED
|
@@ -182,8 +182,8 @@ export class Store {
|
|
|
182
182
|
return this.db.getMessages(query);
|
|
183
183
|
}
|
|
184
184
|
/** All-Inboxes view: union of every account's INBOX, paginated. */
|
|
185
|
-
getUnifiedInbox(page = 1, pageSize = 50, flaggedOnly = false) {
|
|
186
|
-
return this.db.getUnifiedInbox(page, pageSize, flaggedOnly);
|
|
185
|
+
getUnifiedInbox(page = 1, pageSize = 50, flaggedOnly = false, dateBasis = "sent") {
|
|
186
|
+
return this.db.getUnifiedInbox(page, pageSize, flaggedOnly, dateBasis);
|
|
187
187
|
}
|
|
188
188
|
/** Local FTS5 search. Server-scope search is the reconciler's job. */
|
|
189
189
|
searchMessages(query, page = 1, pageSize = 50, accountId, folderId, includeTrashSpam = false) {
|