@bobfrankston/mailx-store 0.1.25 → 0.1.27

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/charset.d.ts ADDED
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Charset normalization for incoming email bodies.
3
+ *
4
+ * Many senders (esp. PHPMailer-driven marketing) declare
5
+ * `charset=iso-8859-1` but emit UTF-8 bytes. simpleParser honors the
6
+ * declared charset and produces "â??" garbage for every non-ASCII
7
+ * codepoint (em-dash, smart quotes, …). When the raw body bytes are
8
+ * valid UTF-8, rewrite the charset header before parsing. We only
9
+ * override the obviously-wrong legacy declarations; explicit utf-8 /
10
+ * koi8 / etc. pass through.
11
+ */
12
+ /** Returns either the original buffer (no change needed) or a copy with
13
+ * the leading charset declaration rewritten to utf-8. */
14
+ export declare function sniffAndFixCharset(raw: Buffer): Buffer;
15
+ //# sourceMappingURL=charset.d.ts.map
package/charset.js ADDED
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Charset normalization for incoming email bodies.
3
+ *
4
+ * Many senders (esp. PHPMailer-driven marketing) declare
5
+ * `charset=iso-8859-1` but emit UTF-8 bytes. simpleParser honors the
6
+ * declared charset and produces "â??" garbage for every non-ASCII
7
+ * codepoint (em-dash, smart quotes, …). When the raw body bytes are
8
+ * valid UTF-8, rewrite the charset header before parsing. We only
9
+ * override the obviously-wrong legacy declarations; explicit utf-8 /
10
+ * koi8 / etc. pass through.
11
+ */
12
+ /** Returns either the original buffer (no change needed) or a copy with
13
+ * the leading charset declaration rewritten to utf-8. */
14
+ export function sniffAndFixCharset(raw) {
15
+ const HEAD_LIMIT = 16384;
16
+ const head = raw.subarray(0, Math.min(HEAD_LIMIT, raw.length)).toString("latin1");
17
+ const re = /charset\s*=\s*"?(iso-8859-1|us-ascii|windows-1252|latin1)"?/gi;
18
+ if (!re.test(head))
19
+ return raw;
20
+ if (!isValidUtf8(raw))
21
+ return raw;
22
+ const fixed = head.replace(/charset\s*=\s*"?(iso-8859-1|us-ascii|windows-1252|latin1)"?/gi, "charset=utf-8");
23
+ return Buffer.concat([Buffer.from(fixed, "latin1"), raw.subarray(head.length)]);
24
+ }
25
+ /** Strict UTF-8 validity check: rejects overlong forms, invalid start
26
+ * bytes, and dangling continuations. Used to confirm the body is really
27
+ * UTF-8 before overriding a Latin-1 declaration. */
28
+ function isValidUtf8(buf) {
29
+ let i = 0;
30
+ while (i < buf.length) {
31
+ const b = buf[i];
32
+ if (b < 0x80) {
33
+ i++;
34
+ continue;
35
+ }
36
+ let need;
37
+ if ((b & 0xE0) === 0xC0) {
38
+ if (b < 0xC2)
39
+ return false;
40
+ need = 1;
41
+ }
42
+ else if ((b & 0xF0) === 0xE0)
43
+ need = 2;
44
+ else if ((b & 0xF8) === 0xF0) {
45
+ if (b > 0xF4)
46
+ return false;
47
+ need = 3;
48
+ }
49
+ else
50
+ return false;
51
+ if (i + need >= buf.length)
52
+ return false;
53
+ for (let k = 1; k <= need; k++) {
54
+ if ((buf[i + k] & 0xC0) !== 0x80)
55
+ return false;
56
+ }
57
+ i += need + 1;
58
+ }
59
+ return true;
60
+ }
61
+ //# sourceMappingURL=charset.js.map
package/index.d.ts CHANGED
@@ -5,6 +5,8 @@
5
5
  export { MailxDB } from "./db.js";
6
6
  export { FileMessageStore } from "./file-store.js";
7
7
  export { parseSerial, prewarmParseWorker } from "./parse-serial.js";
8
+ export { Store } from "./store.js";
9
+ export type { StoreMessage } from "./store.js";
8
10
  export { StoreBus, storeBus } from "@bobfrankston/mailx-bus";
9
11
  export type { StoreEvent, StoreEventKind, StoreEventHandler } from "@bobfrankston/mailx-bus";
10
12
  //# sourceMappingURL=index.d.ts.map
package/index.js CHANGED
@@ -5,6 +5,8 @@
5
5
  export { MailxDB } from "./db.js";
6
6
  export { FileMessageStore } from "./file-store.js";
7
7
  export { parseSerial, prewarmParseWorker } from "./parse-serial.js";
8
+ // Store — the nexus. Owns DB + .eml files + operations + bus.
9
+ export { Store } from "./store.js";
8
10
  // Store-event bus lives in `@bobfrankston/mailx-bus` so the browser-side
9
11
  // store (mailx-store-web) and the desktop-side store (this package) share
10
12
  // the same bus. Re-exported here so existing callers don't have to learn
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx-store",
3
- "version": "0.1.25",
3
+ "version": "0.1.27",
4
4
  "type": "module",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",
@@ -9,9 +9,9 @@
9
9
  },
10
10
  "license": "ISC",
11
11
  "dependencies": {
12
- "@bobfrankston/mailx-types": "^0.1.13",
12
+ "@bobfrankston/mailx-types": "^0.1.14",
13
13
  "@bobfrankston/mailx-settings": "^0.1.17",
14
- "@bobfrankston/mailx-bus": "^0.1.0",
14
+ "@bobfrankston/mailx-bus": "^0.1.2",
15
15
  "mailparser": "^3.7.2"
16
16
  },
17
17
  "repository": {
@@ -29,9 +29,9 @@
29
29
  },
30
30
  ".transformedSnapshot": {
31
31
  "dependencies": {
32
- "@bobfrankston/mailx-types": "^0.1.13",
32
+ "@bobfrankston/mailx-types": "^0.1.14",
33
33
  "@bobfrankston/mailx-settings": "^0.1.17",
34
- "@bobfrankston/mailx-bus": "^0.1.0",
34
+ "@bobfrankston/mailx-bus": "^0.1.2",
35
35
  "mailparser": "^3.7.2"
36
36
  }
37
37
  }
package/parse-worker.js CHANGED
@@ -21,18 +21,43 @@ import { simpleParser } from "mailparser";
21
21
  if (!parentPort) {
22
22
  throw new Error("parse-worker: must be spawned as a worker, parentPort is null");
23
23
  }
24
- // Self-warmup: parse a synthetic RFC 5322 message at worker startup so V8
25
- // JIT, libmime / iconv-lite module loading, and mailparser's lazy
26
- // initialisation all complete *before* the worker accepts its first real
27
- // request. Sub-50 ms parses become the norm even on the first user click,
28
- // instead of the 14-25 s cold-start observed when this work happened
29
- // on-demand. Buffered messages received during warmup queue behind it.
24
+ // Self-warmup: parse a synthetic message at worker startup so V8 JIT,
25
+ // libmime / iconv-lite loading, and mailparser's lazy init all complete
26
+ // *before* the worker accepts its first real request.
27
+ //
28
+ // CRITICAL (Bob 2026-05-15): the warmup message MUST exercise every heavy
29
+ // code path a real email hits, or the cold-start cost just moves to the
30
+ // first real parse. The previous warmup was a trivial text/plain message
31
+ // — it JIT'd the basic path but NOT the HTML pipeline, quoted-printable
32
+ // decode, multipart boundary handling, or attachment extraction. Result:
33
+ // the first real parse (a 96 KB multipart/alternative) took 33 SECONDS
34
+ // in the log — the cold start was never actually absorbed. This warmup
35
+ // message is multipart/mixed → multipart/alternative(text + QP-encoded
36
+ // HTML) + a base64 attachment, with HTML entities and a non-ASCII char,
37
+ // so the first real parse rides warm JIT for all of them.
30
38
  const _warmupT0 = Date.now();
31
- const _warmupPromise = simpleParser(Buffer.from("From: warmup@mailx.local\r\nTo: warmup@mailx.local\r\n"
32
- + "Subject: warmup\r\nMIME-Version: 1.0\r\n"
39
+ const _warmupMessage = "From: warmup@mailx.local\r\nTo: warmup@mailx.local\r\n"
40
+ + "Subject: =?UTF-8?Q?warm=E2=80=91up?=\r\nMIME-Version: 1.0\r\n"
41
+ + "Content-Type: multipart/mixed; boundary=\"MIX\"\r\n\r\n"
42
+ + "--MIX\r\n"
43
+ + "Content-Type: multipart/alternative; boundary=\"ALT\"\r\n\r\n"
44
+ + "--ALT\r\n"
33
45
  + "Content-Type: text/plain; charset=UTF-8\r\n\r\n"
34
- + "parse-worker cold-start absorber. Discard.\r\n", "utf8")).then(() => {
35
- // Optional: emit a heartbeat so the main thread can log the cost.
46
+ + "parse-worker cold-start absorber — discard.\r\n"
47
+ + "--ALT\r\n"
48
+ + "Content-Type: text/html; charset=UTF-8\r\n"
49
+ + "Content-Transfer-Encoding: quoted-printable\r\n\r\n"
50
+ + "<html><body><p>warm=E2=80=91up &amp; discard</p>"
51
+ + "<blockquote>quoted</blockquote><a href=3D\"http://x\">link</a></body></html>\r\n"
52
+ + "--ALT--\r\n"
53
+ + "--MIX\r\n"
54
+ + "Content-Type: application/octet-stream; name=\"w.bin\"\r\n"
55
+ + "Content-Transfer-Encoding: base64\r\n"
56
+ + "Content-Disposition: attachment; filename=\"w.bin\"\r\n\r\n"
57
+ + "d2FybXVw\r\n"
58
+ + "--MIX--\r\n";
59
+ const _warmupPromise = simpleParser(Buffer.from(_warmupMessage, "utf8")).then(() => {
60
+ // Heartbeat so the main thread can log the absorbed cost.
36
61
  parentPort.postMessage({ warmupMs: Date.now() - _warmupT0 });
37
62
  }).catch(() => { });
38
63
  parentPort.on("message", async (msg) => {
package/store.d.ts ADDED
@@ -0,0 +1,178 @@
1
+ /**
2
+ * Store — the nexus. Owns the local database, the .eml file store, the
3
+ * operations API, and the event bus. Single source of truth for everything
4
+ * about a mailx account that lives on this device.
5
+ *
6
+ * UI ──┐ ┌── Sync clients (IMAP, Gmail API, …)
7
+ * │ reads / writes / subscribes │ read pending actions, commit
8
+ * ↓ ↓ server-discovered changes
9
+ * [ Store ]
10
+ *
11
+ * Contract: every method here mutates local-only state. No IMAP, no Gmail,
12
+ * no SMTP, no DNS, no Drive API. Mutations emit on the bus; subscribers
13
+ * (UI in another process, in-process reconciler, etc.) react. Sync clients
14
+ * (mailx-imap, mailx-sync) hold a Store reference and never touch the
15
+ * underlying MailxDB / FileMessageStore directly.
16
+ *
17
+ * This was previously named `Store` and lived in mailx-service. It
18
+ * moved to mailx-store because mailx-imap (and other sync clients) must
19
+ * be able to consume it without depending on mailx-service — that's the
20
+ * "arrow points the right way" property of the architecture.
21
+ */
22
+ import { MailxDB } from "./db.js";
23
+ import { FileMessageStore } from "./file-store.js";
24
+ import type { StoreBus } from "./bus.js";
25
+ import type { MessageEnvelope, MessageQuery, PagedResult } from "@bobfrankston/mailx-types";
26
+ /** What the UI gets back from a body read. Mirrors the historical
27
+ * `getMessage` shape so call-site migration is mechanical. `cached: false`
28
+ * means the body isn't on disk yet — the UI shows a "downloading…"
29
+ * placeholder and listens for `bodyAvailable` to re-render. The reconciler
30
+ * is responsible for actually fetching and emitting the event. */
31
+ export interface StoreMessage extends MessageEnvelope {
32
+ bodyHtml: string;
33
+ bodyText: string;
34
+ hasRemoteContent: boolean;
35
+ remoteAllowed: boolean;
36
+ attachments: Array<{
37
+ id: number;
38
+ filename: string;
39
+ mimeType: string;
40
+ size: number;
41
+ contentId: string;
42
+ }>;
43
+ cached: boolean;
44
+ /** Disambiguates `cached: false`:
45
+ * - bodyOnDisk false → the .eml is NOT on disk; caller should queue
46
+ * an IMAP/Gmail fetch.
47
+ * - bodyOnDisk true → the .eml IS on disk; a parse is in flight.
48
+ * Caller MUST NOT queue a fetch — just wait for the `bodyAvailable`
49
+ * bus event. Queuing a fetch here caused the 10-Hz "Loading body…"
50
+ * loop (Bob 2026-05-15): cached:false → MailxService fetched →
51
+ * reconciler re-published bodyAvailable → viewer re-called → repeat. */
52
+ bodyOnDisk: boolean;
53
+ deliveredTo: string;
54
+ returnPath: string;
55
+ listUnsubscribe: string;
56
+ listUnsubscribeMail: string;
57
+ listUnsubscribeHttp: string;
58
+ listUnsubscribeOneClick: boolean;
59
+ emlPath: string;
60
+ isFlagged: boolean;
61
+ }
62
+ export declare class Store {
63
+ /** SQLite metadata index. Exposed as a public field — sync clients
64
+ * (mailx-imap, mailx-sync) read/write through it during the
65
+ * refactor. Future state: every external mutation routes through
66
+ * a Store method that emits a bus event; raw `store.db.X()` calls
67
+ * shrink to zero. Today, this is read mostly. */
68
+ readonly db: MailxDB;
69
+ /** .eml file backend. Same migration story as `db` — sync clients
70
+ * read/write directly today, will route through Store methods. */
71
+ readonly bodyStore: FileMessageStore;
72
+ /** Event bus for Store mutations. Defaults to the process-singleton
73
+ * so cross-package subscribers (bin/mailx.ts forwarder → WebView,
74
+ * in-process reconciler triggers) see all writes without explicit
75
+ * wiring. Tests can pass a fresh StoreBus for isolation. */
76
+ readonly bus: StoreBus;
77
+ private static readonly PARSED_LRU_CAPACITY;
78
+ private parsedLru;
79
+ private parsedLruGet;
80
+ private parsedLruPut;
81
+ private _allowlistCache;
82
+ private _settingsCache;
83
+ private getCachedAllowlist;
84
+ private getCachedSettings;
85
+ invalidateConfigCaches(): void;
86
+ constructor(
87
+ /** SQLite metadata index. Exposed as a public field — sync clients
88
+ * (mailx-imap, mailx-sync) read/write through it during the
89
+ * refactor. Future state: every external mutation routes through
90
+ * a Store method that emits a bus event; raw `store.db.X()` calls
91
+ * shrink to zero. Today, this is read mostly. */
92
+ db: MailxDB,
93
+ /** .eml file backend. Same migration story as `db` — sync clients
94
+ * read/write directly today, will route through Store methods. */
95
+ bodyStore: FileMessageStore,
96
+ /** Event bus for Store mutations. Defaults to the process-singleton
97
+ * so cross-package subscribers (bin/mailx.ts forwarder → WebView,
98
+ * in-process reconciler triggers) see all writes without explicit
99
+ * wiring. Tests can pass a fresh StoreBus for isolation. */
100
+ bus?: StoreBus);
101
+ /** DB-shape account list (id/name/email/lastSync). The richer
102
+ * AccountConfig (with imap/smtp/etc.) lives in accounts.jsonc and is
103
+ * loaded by mailx-settings, not the DB — that path stays in
104
+ * MailxService until step 3 of the local-first plan. */
105
+ getAccounts(): {
106
+ id: string;
107
+ name: string;
108
+ email: string;
109
+ lastSync: number;
110
+ }[];
111
+ getFolders(accountId: string): any[];
112
+ /** Look up a folder by RFC 6154 specialUse tag (`trash`, `drafts`, `sent`,
113
+ * `junk`, etc.) for the given account. Falls back to a case-insensitive
114
+ * path match for legacy rows where specialUse never got tagged.
115
+ * Returns null when the account has no such folder configured. */
116
+ findSpecialFolder(accountId: string, specialUse: string): {
117
+ id: number;
118
+ path: string;
119
+ } | null;
120
+ /** Single envelope by (account, uid, folder). Null when the row isn't
121
+ * in the DB — caller decides whether to show "deleted" or queue a
122
+ * server lookup via the reconciler. */
123
+ getMessageEnvelope(accountId: string, uid: number, folderId?: number): MessageEnvelope | null;
124
+ /** Paginated message list for a (account, folder, ...) query. */
125
+ getMessages(query: MessageQuery): PagedResult<MessageEnvelope>;
126
+ /** All-Inboxes view: union of every account's INBOX, paginated. */
127
+ getUnifiedInbox(page?: number, pageSize?: number): PagedResult<MessageEnvelope>;
128
+ /** Local FTS5 search. Server-scope search is the reconciler's job. */
129
+ searchMessages(query: string, page?: number, pageSize?: number, accountId?: string, folderId?: number, includeTrashSpam?: boolean): PagedResult<MessageEnvelope>;
130
+ /** Read a fully-parsed message (envelope + body + attachments) entirely
131
+ * from local state. Returns null when the envelope isn't known.
132
+ * Returns `{ ...envelope, cached: false }` when the envelope is known
133
+ * but the body file isn't on disk — UI shows a placeholder and the
134
+ * reconciler queues the fetch.
135
+ *
136
+ * `allowRemote=true` skips HTML sanitization. Used when the user has
137
+ * explicitly allowed remote content for this sender / domain. */
138
+ getMessage(accountId: string, uid: number, allowRemote: boolean, folderId?: number): Promise<StoreMessage | null>;
139
+ getCalendarEvents(accountId: string, fromMs: number, toMs: number): any[];
140
+ getTasks(accountId: string, includeCompleted?: boolean): any[];
141
+ searchContacts(query: string, limit?: number): any[];
142
+ listContacts(query: string, page?: number, pageSize?: number): any;
143
+ /** Update a message's flag set. Local DB write completes synchronously;
144
+ * the server-mirror enqueue is the caller's responsibility (typically
145
+ * via SyncQueue.enqueueFlag) so callers that don't want a server push
146
+ * — pure-local UI state like "pin in pane" — can skip it.
147
+ *
148
+ * Publishes:
149
+ * `message:<uuid>` { kind: "flagsChanged" }
150
+ * `folder:<id>` (auto fan-out)
151
+ */
152
+ updateFlags(accountId: string, uid: number, folderId: number, flags: string[]): void;
153
+ /** Move a message between folders in the same account. Adds a tombstone
154
+ * on the Message-ID so the next sync doesn't re-import the pre-move row
155
+ * in the source folder before the server-side MOVE completes; tombstone
156
+ * is cleared on terminal IMAP failure (see processSyncActions).
157
+ *
158
+ * Returns true if a local row existed and was moved, false otherwise.
159
+ *
160
+ * Publishes:
161
+ * `message:<uuid>` { kind: "messageMoved", folderId: source, targetFolderId }
162
+ * `folder:<source>` and `folder:<target>` (auto fan-out + explicit count)
163
+ */
164
+ moveMessage(accountId: string, uid: number, fromFolderId: number, targetFolderId: number): boolean;
165
+ /** Trash a message. If a trash folder is configured and the message is
166
+ * not already in it, this is a move-to-trash. If the message is already
167
+ * in trash (or no trash exists), it's a hard delete + body unlink.
168
+ *
169
+ * Returns "moved-to-trash" or "expunged" so the caller knows whether
170
+ * to enqueue an IMAP MOVE or a DELETE+EXPUNGE on the queue.
171
+ */
172
+ trashMessage(accountId: string, uid: number, folderId: number, trashFolderId: number | null): "moved-to-trash" | "expunged";
173
+ /** Restore a message from trash back to its original folder. Local-only;
174
+ * caller handles the queue (cancel-pending-MOVE vs queue-counter-MOVE).
175
+ * Returns true if a local row was moved. */
176
+ undeleteMessage(accountId: string, uid: number, trashFolderId: number, originalFolderId: number): boolean;
177
+ }
178
+ //# sourceMappingURL=store.d.ts.map
package/store.js ADDED
@@ -0,0 +1,518 @@
1
+ /**
2
+ * Store — the nexus. Owns the local database, the .eml file store, the
3
+ * operations API, and the event bus. Single source of truth for everything
4
+ * about a mailx account that lives on this device.
5
+ *
6
+ * UI ──┐ ┌── Sync clients (IMAP, Gmail API, …)
7
+ * │ reads / writes / subscribes │ read pending actions, commit
8
+ * ↓ ↓ server-discovered changes
9
+ * [ Store ]
10
+ *
11
+ * Contract: every method here mutates local-only state. No IMAP, no Gmail,
12
+ * no SMTP, no DNS, no Drive API. Mutations emit on the bus; subscribers
13
+ * (UI in another process, in-process reconciler, etc.) react. Sync clients
14
+ * (mailx-imap, mailx-sync) hold a Store reference and never touch the
15
+ * underlying MailxDB / FileMessageStore directly.
16
+ *
17
+ * This was previously named `Store` and lived in mailx-service. It
18
+ * moved to mailx-store because mailx-imap (and other sync clients) must
19
+ * be able to consume it without depending on mailx-service — that's the
20
+ * "arrow points the right way" property of the architecture.
21
+ */
22
+ import * as fs from "node:fs";
23
+ import { parseSerial } from "./parse-serial.js";
24
+ import { storeBus } from "./bus.js";
25
+ import { sanitizeHtml } from "@bobfrankston/mailx-types";
26
+ import { loadSettings, loadAllowlist } from "@bobfrankston/mailx-settings";
27
+ import { sniffAndFixCharset } from "./charset.js";
28
+ /** Parse `List-Unsubscribe` (RFC 2369) and `List-Unsubscribe-Post` (RFC 8058).
29
+ * mailparser only exposes ONE of mail/url even when both are present, so we
30
+ * also scan the raw header text for the full set of angle-bracketed URIs. */
31
+ function parseListUnsubscribe(headers) {
32
+ let mail = "";
33
+ let http = "";
34
+ let oneClick = false;
35
+ const raw = headers.get("list-unsubscribe");
36
+ const rawStr = typeof raw === "string" ? raw : (raw && typeof raw.text === "string" ? raw.text : "");
37
+ if (rawStr) {
38
+ const matches = rawStr.match(/<([^>]+)>/g) || [];
39
+ for (const m of matches) {
40
+ const url = m.slice(1, -1).trim();
41
+ if (!mail && /^mailto:/i.test(url))
42
+ mail = url;
43
+ else if (!http && /^https?:/i.test(url))
44
+ http = url;
45
+ }
46
+ }
47
+ if (!mail && !http) {
48
+ const listHeaders = headers.get("list");
49
+ if (listHeaders?.unsubscribe) {
50
+ const unsub = listHeaders.unsubscribe;
51
+ if (unsub.url)
52
+ http = Array.isArray(unsub.url) ? unsub.url[0] : unsub.url;
53
+ if (unsub.mail)
54
+ mail = `mailto:${Array.isArray(unsub.mail) ? unsub.mail[0] : unsub.mail}`;
55
+ }
56
+ }
57
+ const post = headers.get("list-unsubscribe-post");
58
+ const postStr = typeof post === "string" ? post : (post && typeof post.text === "string" ? post.text : "");
59
+ if (postStr && /one-?click/i.test(postStr))
60
+ oneClick = true;
61
+ return { listUnsubscribeMail: mail, listUnsubscribeHttp: http, listUnsubscribeOneClick: oneClick };
62
+ }
63
+ export class Store {
64
+ db;
65
+ bodyStore;
66
+ bus;
67
+ // Parsed-body LRU. `simpleParser` is the dominant cost of getMessage on
68
+ // a body that's already on disk — 100-500 ms for a typical Gmail message
69
+ // (heavier multipart/alternative + embedded-image swizzling than plainer
70
+ // IMAP mail). Stash the parsed result keyed by .eml path + mtime so
71
+ // repeat views of the same message return in microseconds.
72
+ //
73
+ // Invalidation is implicit: when the underlying .eml is overwritten
74
+ // (rare — bodies are immutable once cached, but reconcile can re-fetch
75
+ // on UIDVALIDITY change), the new mtime won't match the cached key.
76
+ // Capacity 200 entries × ~50 kB parsed envelope = ~10 MB ceiling.
77
+ static PARSED_LRU_CAPACITY = 200;
78
+ parsedLru = new Map();
79
+ parsedLruGet(key) {
80
+ const v = this.parsedLru.get(key);
81
+ if (!v)
82
+ return undefined;
83
+ // Bump recency.
84
+ this.parsedLru.delete(key);
85
+ this.parsedLru.set(key, v);
86
+ return v;
87
+ }
88
+ parsedLruPut(key, msg) {
89
+ this.parsedLru.delete(key);
90
+ this.parsedLru.set(key, msg);
91
+ while (this.parsedLru.size > Store.PARSED_LRU_CAPACITY) {
92
+ const oldest = this.parsedLru.keys().next().value;
93
+ if (oldest === undefined)
94
+ break;
95
+ this.parsedLru.delete(oldest);
96
+ }
97
+ }
98
+ // Allowlist + settings caches. Both files live on the GDrive-mounted
99
+ // shared dir; their sync `readFileSync` calls in `loadAllowlist()` and
100
+ // `loadSettings()` can stall for seconds per call. getMessage runs on
101
+ // every preview click, so paying that twice each click made .eml
102
+ // display "go to GDrive" even though the body itself is local. Cached
103
+ // here; invalidated externally via invalidateConfigCaches() when the
104
+ // parent service receives a `configChanged` event.
105
+ _allowlistCache = null;
106
+ _settingsCache = null;
107
+ getCachedAllowlist() {
108
+ if (!this._allowlistCache)
109
+ this._allowlistCache = loadAllowlist();
110
+ return this._allowlistCache;
111
+ }
112
+ getCachedSettings() {
113
+ if (!this._settingsCache)
114
+ this._settingsCache = loadSettings();
115
+ return this._settingsCache;
116
+ }
117
+ invalidateConfigCaches() {
118
+ this._allowlistCache = null;
119
+ this._settingsCache = null;
120
+ }
121
+ constructor(
122
+ /** SQLite metadata index. Exposed as a public field — sync clients
123
+ * (mailx-imap, mailx-sync) read/write through it during the
124
+ * refactor. Future state: every external mutation routes through
125
+ * a Store method that emits a bus event; raw `store.db.X()` calls
126
+ * shrink to zero. Today, this is read mostly. */
127
+ db,
128
+ /** .eml file backend. Same migration story as `db` — sync clients
129
+ * read/write directly today, will route through Store methods. */
130
+ bodyStore,
131
+ /** Event bus for Store mutations. Defaults to the process-singleton
132
+ * so cross-package subscribers (bin/mailx.ts forwarder → WebView,
133
+ * in-process reconciler triggers) see all writes without explicit
134
+ * wiring. Tests can pass a fresh StoreBus for isolation. */
135
+ bus = storeBus) {
136
+ this.db = db;
137
+ this.bodyStore = bodyStore;
138
+ this.bus = bus;
139
+ }
140
+ // ── Account list (read-only here; mutations go through MailxService
141
+ // until the cloud-write path is part of the queue too) ──
142
+ /** DB-shape account list (id/name/email/lastSync). The richer
143
+ * AccountConfig (with imap/smtp/etc.) lives in accounts.jsonc and is
144
+ * loaded by mailx-settings, not the DB — that path stays in
145
+ * MailxService until step 3 of the local-first plan. */
146
+ getAccounts() {
147
+ return this.db.getAccounts();
148
+ }
149
+ // ── Folders ──
150
+ getFolders(accountId) {
151
+ return this.db.getFolders(accountId);
152
+ }
153
+ /** Look up a folder by RFC 6154 specialUse tag (`trash`, `drafts`, `sent`,
154
+ * `junk`, etc.) for the given account. Falls back to a case-insensitive
155
+ * path match for legacy rows where specialUse never got tagged.
156
+ * Returns null when the account has no such folder configured. */
157
+ findSpecialFolder(accountId, specialUse) {
158
+ const folders = this.db.getFolders(accountId);
159
+ const f = folders.find(x => x.specialUse === specialUse ||
160
+ x.path.toLowerCase() === specialUse.toLowerCase());
161
+ return f ? { id: f.id, path: f.path } : null;
162
+ }
163
+ // ── Message envelopes ──
164
+ /** Single envelope by (account, uid, folder). Null when the row isn't
165
+ * in the DB — caller decides whether to show "deleted" or queue a
166
+ * server lookup via the reconciler. */
167
+ getMessageEnvelope(accountId, uid, folderId) {
168
+ const env = this.db.getMessageByUid(accountId, uid, folderId);
169
+ return env || null;
170
+ }
171
+ /** Paginated message list for a (account, folder, ...) query. */
172
+ getMessages(query) {
173
+ return this.db.getMessages(query);
174
+ }
175
+ /** All-Inboxes view: union of every account's INBOX, paginated. */
176
+ getUnifiedInbox(page = 1, pageSize = 50) {
177
+ return this.db.getUnifiedInbox(page, pageSize);
178
+ }
179
+ /** Local FTS5 search. Server-scope search is the reconciler's job. */
180
+ searchMessages(query, page = 1, pageSize = 50, accountId, folderId, includeTrashSpam = false) {
181
+ return this.db.searchMessages(query, page, pageSize, accountId, folderId, includeTrashSpam);
182
+ }
183
+ // ── Message body (read-from-disk only) ──
184
+ /** Read a fully-parsed message (envelope + body + attachments) entirely
185
+ * from local state. Returns null when the envelope isn't known.
186
+ * Returns `{ ...envelope, cached: false }` when the envelope is known
187
+ * but the body file isn't on disk — UI shows a placeholder and the
188
+ * reconciler queues the fetch.
189
+ *
190
+ * `allowRemote=true` skips HTML sanitization. Used when the user has
191
+ * explicitly allowed remote content for this sender / domain. */
192
+ async getMessage(accountId, uid, allowRemote, folderId) {
193
+ const envelope = this.db.getMessageByUid(accountId, uid, folderId);
194
+ if (!envelope)
195
+ return null;
196
+ const allowList = this.getCachedAllowlist();
197
+ const senderAddr = (envelope.from?.address || "").toLowerCase();
198
+ const senderDomain = senderAddr.split("@")[1] || "";
199
+ const toAddrs = (envelope.to || []).map((a) => (a.address || "").toLowerCase());
200
+ // Allowlist auto-allow: trusted sender / domain / recipient skips
201
+ // sanitization. Same rule as the legacy service implementation.
202
+ if (!allowRemote) {
203
+ const senders = (allowList.senders || []).map((s) => (s || "").toLowerCase());
204
+ const domains = (allowList.domains || []).map((d) => (d || "").toLowerCase());
205
+ const recipients = (allowList.recipients || []).map((r) => (r || "").toLowerCase());
206
+ if (senders.includes(senderAddr) ||
207
+ domains.includes(senderDomain) ||
208
+ toAddrs.some((a) => recipients.includes(a))) {
209
+ allowRemote = true;
210
+ }
211
+ }
212
+ const isFlagged = !!((allowList.flaggedSenders || []).some((s) => (s || "").toLowerCase() === senderAddr) ||
213
+ (allowList.flaggedDomains || []).some((d) => (d || "").toLowerCase() === senderDomain));
214
+ // Resolve body path: prefer the row's own bodyPath, fall back to
215
+ // the historical lookup. Either may be empty (body never fetched).
216
+ let storedPath = envelope.bodyPath || "";
217
+ if (!storedPath)
218
+ storedPath = this.db.getMessageBodyPath(accountId, uid) || "";
219
+ const empty = {
220
+ ...envelope,
221
+ bodyHtml: "", bodyText: "",
222
+ hasRemoteContent: false, remoteAllowed: allowRemote,
223
+ attachments: [],
224
+ cached: false,
225
+ bodyOnDisk: false, // overridden to true on the parse-pending return
226
+ deliveredTo: "", returnPath: "",
227
+ listUnsubscribe: "", listUnsubscribeMail: "", listUnsubscribeHttp: "", listUnsubscribeOneClick: false,
228
+ emlPath: "",
229
+ isFlagged,
230
+ };
231
+ // No body file → caller should queue a fetch (bodyOnDisk stays false).
232
+ if (!storedPath)
233
+ return empty;
234
+ if (!await this.bodyStore.hasByPath(storedPath))
235
+ return empty;
236
+ // Parse-cache lookup. Key = path + mtime so a re-fetched body
237
+ // (mtime changes) invalidates automatically. mtime stat is cheap
238
+ // (sub-ms) compared to the parse it avoids (~100-500 ms).
239
+ let mtimeMs = 0;
240
+ try {
241
+ mtimeMs = (await fs.promises.stat(storedPath)).mtimeMs;
242
+ }
243
+ catch { /* will fall through and fail at readByPath */ }
244
+ const cacheKey = `${storedPath}|${mtimeMs}`;
245
+ const cached = this.parsedLruGet(cacheKey);
246
+ if (cached) {
247
+ // Allowlist state can change between views even with the same
248
+ // body cached; recompute the volatile fields and overlay.
249
+ return { ...cached, remoteAllowed: allowRemote, isFlagged };
250
+ }
251
+ // Synchronous parse. The .eml is on disk; read + parse it inline
252
+ // and return the fully-rendered message. The parse runs on the
253
+ // parse-worker thread (off the main event loop) — the IPC promise
254
+ // just awaits the worker's reply, ~50-200 ms on a warm worker.
255
+ //
256
+ // 2026-05-15: this REPLACED an async "return envelope now, parse
257
+ // later, emit bodyAvailable" split. That split overloaded
258
+ // `cached:false` (was it "no body file" or "parse pending"?),
259
+ // which made MailxService queue redundant fetches, which churned
260
+ // the .eml mtime, which invalidated the cache key, which spawned
261
+ // duplicate parses — a 10 Hz "Loading body…" loop and 30-second
262
+ // queue-contention parses. Synchronous parse makes the whole
263
+ // class structurally impossible: getMessage either returns the
264
+ // body or returns `cached:false` meaning exactly "fetch it."
265
+ const raw = await this.bodyStore.readByPath(storedPath);
266
+ const adjusted = sniffAndFixCharset(raw);
267
+ const _parseT0 = Date.now();
268
+ const parsed = await parseSerial(adjusted);
269
+ const _parseMs = Date.now() - _parseT0;
270
+ if (_parseMs > 50) {
271
+ console.log(` [parse] simpleParser ${_parseMs}ms for ${(raw.length / 1024).toFixed(0)} KB (${storedPath})`);
272
+ }
273
+ let bodyHtml = parsed.html || "";
274
+ const bodyText = parsed.text || "";
275
+ // Backfill FTS body_text now that we've parsed the body. upsertMessage
276
+ // only had the short `preview` snippet at index time; without this
277
+ // backfill, searches miss any word that only appears deeper in the
278
+ // body. Fire-and-forget — failures are non-fatal, never block the
279
+ // user's preview render.
280
+ if (bodyText) {
281
+ try {
282
+ this.db.updateFtsBodyByUid(accountId, envelope.folderId, uid, bodyText);
283
+ }
284
+ catch { /* */ }
285
+ }
286
+ let hasRemoteContent = false;
287
+ // Filter out "spurious" attachments: mailing-list footers and signature
288
+ // blocks frequently arrive as a separate text/plain MIME part with
289
+ // Content-Disposition: inline and no filename. mailparser dutifully
290
+ // surfaces them as attachments. Showing them as attachment chips
291
+ // confuses the user (Bob 2026-05-13: spurious image-shaped chip on
292
+ // a list message that had no real attachment). Rule: if the part is
293
+ // text/* AND has no filename AND is dispositionally inline (or no
294
+ // disposition at all), it's body content, not a real attachment.
295
+ // Keep the original index even for filtered rows so getAttachment's
296
+ // index lookup still works for the surviving ones.
297
+ const attachments = (parsed.attachments || [])
298
+ .map((a, i) => {
299
+ const mime = (a.contentType || "application/octet-stream").toLowerCase();
300
+ const disposition = (a.contentDisposition || "").toLowerCase();
301
+ const isSpuriousTextPart = mime.startsWith("text/")
302
+ && !a.filename
303
+ && (disposition === "inline" || disposition === "");
304
+ return { a, i, isSpuriousTextPart };
305
+ })
306
+ .filter(x => !x.isSpuriousTextPart)
307
+ .map(x => ({
308
+ id: x.i,
309
+ filename: x.a.filename || `attachment-${x.i}`,
310
+ mimeType: x.a.contentType || "application/octet-stream",
311
+ size: x.a.size || 0,
312
+ contentId: x.a.contentId || "",
313
+ }));
314
+ if (bodyHtml && !allowRemote) {
315
+ const result = sanitizeHtml(bodyHtml);
316
+ bodyHtml = result.html;
317
+ hasRemoteContent = result.hasRemoteContent;
318
+ }
319
+ // Header extraction — Delivered-To, Return-Path, List-Unsubscribe.
320
+ // mlproc preprocesses Delivered-To server-side, so the inner
321
+ // address is already clean by the time mailx sees it. We take the
322
+ // last entry of the chain (the final-delivery hop). The
323
+ // `relayDomains` filtering layer was retired 2026-05-13: nothing
324
+ // configures it in practice and the conditional made the code
325
+ // harder to reason about than the one-liner that replaces it.
326
+ let deliveredTo = "";
327
+ const rawDelivered = parsed.headers.get("delivered-to");
328
+ if (rawDelivered) {
329
+ const deliveredList = Array.isArray(rawDelivered) ? rawDelivered : [rawDelivered];
330
+ const d = deliveredList[deliveredList.length - 1];
331
+ deliveredTo = typeof d === "string" ? d : d?.text || d?.address || String(d);
332
+ }
333
+ const hdr = (key) => {
334
+ let v = parsed.headers.get(key);
335
+ if (!v)
336
+ return "";
337
+ if (Array.isArray(v))
338
+ v = v[0];
339
+ if (typeof v === "string")
340
+ return v;
341
+ if (typeof v === "object" && v !== null) {
342
+ if ("text" in v)
343
+ return v.text || "";
344
+ if ("value" in v)
345
+ return String(v.value);
346
+ if ("address" in v)
347
+ return v.address || "";
348
+ }
349
+ return String(v);
350
+ };
351
+ const returnPath = hdr("return-path").replace(/[<>]/g, "");
352
+ const { listUnsubscribeMail, listUnsubscribeHttp, listUnsubscribeOneClick } = parseListUnsubscribe(parsed.headers);
353
+ const listUnsubscribe = listUnsubscribeHttp || listUnsubscribeMail;
354
+ const result = {
355
+ ...envelope,
356
+ bodyHtml, bodyText,
357
+ hasRemoteContent, remoteAllowed: allowRemote,
358
+ attachments,
359
+ cached: true,
360
+ deliveredTo, returnPath,
361
+ listUnsubscribe, listUnsubscribeMail, listUnsubscribeHttp, listUnsubscribeOneClick,
362
+ // body_path in the DB is stored relative to the body-store
363
+ // basePath; resolve to absolute so the UI's "Source" button
364
+ // can hand the path to an OS file open without re-deriving
365
+ // basePath every time.
366
+ emlPath: this.bodyStore.absolutePath(storedPath),
367
+ isFlagged,
368
+ };
369
+ // Memoize for instant re-view of the same message. cacheKey is
370
+ // path+mtime so a re-fetch (mtime changes) invalidates naturally.
371
+ // The LRU is a perf tier only — correctness never depends on it.
372
+ if (mtimeMs > 0)
373
+ this.parsedLruPut(cacheKey, result);
374
+ return result;
375
+ }
376
+ // ── Calendar / tasks / contacts (read paths) ──
377
+ getCalendarEvents(accountId, fromMs, toMs) {
378
+ return this.db.getCalendarEvents(accountId, fromMs, toMs);
379
+ }
380
+ getTasks(accountId, includeCompleted = false) {
381
+ return this.db.getTasks(accountId, includeCompleted);
382
+ }
383
+ searchContacts(query, limit = 10) {
384
+ return this.db.searchContacts(query, limit);
385
+ }
386
+ listContacts(query, page = 1, pageSize = 100) {
387
+ return this.db.listContacts(query, page, pageSize);
388
+ }
389
+ // ── Write paths (local-only; mirror to server is queued separately) ──
390
+ /** Update a message's flag set. Local DB write completes synchronously;
391
+ * the server-mirror enqueue is the caller's responsibility (typically
392
+ * via SyncQueue.enqueueFlag) so callers that don't want a server push
393
+ * — pure-local UI state like "pin in pane" — can skip it.
394
+ *
395
+ * Publishes:
396
+ * `message:<uuid>` { kind: "flagsChanged" }
397
+ * `folder:<id>` (auto fan-out)
398
+ */
399
+ updateFlags(accountId, uid, folderId, flags) {
400
+ this.db.updateMessageFlags(accountId, uid, flags);
401
+ const env = this.db.getMessageByUid(accountId, uid, folderId);
402
+ const msgUuid = env?.uuid;
403
+ if (msgUuid) {
404
+ this.bus.publish({
405
+ topic: `message:${msgUuid}`,
406
+ kind: "flagsChanged",
407
+ accountId, folderId, uid, msgUuid, flags,
408
+ });
409
+ }
410
+ }
411
+ /** Move a message between folders in the same account. Adds a tombstone
412
+ * on the Message-ID so the next sync doesn't re-import the pre-move row
413
+ * in the source folder before the server-side MOVE completes; tombstone
414
+ * is cleared on terminal IMAP failure (see processSyncActions).
415
+ *
416
+ * Returns true if a local row existed and was moved, false otherwise.
417
+ *
418
+ * Publishes:
419
+ * `message:<uuid>` { kind: "messageMoved", folderId: source, targetFolderId }
420
+ * `folder:<source>` and `folder:<target>` (auto fan-out + explicit count)
421
+ */
422
+ moveMessage(accountId, uid, fromFolderId, targetFolderId) {
423
+ const env = this.db.getMessageByUid(accountId, uid, fromFolderId);
424
+ if (!env)
425
+ return false;
426
+ if (env.messageId)
427
+ this.db.addTombstone(accountId, env.messageId, env.subject || "");
428
+ const moved = this.db.moveMessageLocal(accountId, uid, fromFolderId, targetFolderId);
429
+ if (!moved)
430
+ return false;
431
+ this.db.recalcFolderCounts(fromFolderId);
432
+ this.db.recalcFolderCounts(targetFolderId);
433
+ const msgUuid = env?.uuid;
434
+ if (msgUuid) {
435
+ this.bus.publish({
436
+ topic: `message:${msgUuid}`,
437
+ kind: "messageMoved",
438
+ accountId, folderId: fromFolderId, targetFolderId, uid, msgUuid,
439
+ });
440
+ }
441
+ // Folder count change isn't tied to a specific message uuid — publish
442
+ // to both folder topics directly. (The fan-out only covers the source
443
+ // via the message event's folderId; target needs its own publish.)
444
+ this.bus.publish({
445
+ topic: `folder:${targetFolderId}`,
446
+ kind: "folderCountsChanged",
447
+ accountId, folderId: targetFolderId,
448
+ });
449
+ return true;
450
+ }
451
+ /** Trash a message. If a trash folder is configured and the message is
452
+ * not already in it, this is a move-to-trash. If the message is already
453
+ * in trash (or no trash exists), it's a hard delete + body unlink.
454
+ *
455
+ * Returns "moved-to-trash" or "expunged" so the caller knows whether
456
+ * to enqueue an IMAP MOVE or a DELETE+EXPUNGE on the queue.
457
+ */
458
+ trashMessage(accountId, uid, folderId, trashFolderId) {
459
+ const env = this.db.getMessageByUid(accountId, uid, folderId);
460
+ if (env?.messageId)
461
+ this.db.addTombstone(accountId, env.messageId, env.subject || "");
462
+ const msgUuid = env?.uuid;
463
+ if (trashFolderId != null && trashFolderId !== folderId) {
464
+ this.db.moveMessageLocal(accountId, uid, folderId, trashFolderId);
465
+ this.db.recalcFolderCounts(folderId);
466
+ this.db.recalcFolderCounts(trashFolderId);
467
+ if (msgUuid) {
468
+ this.bus.publish({
469
+ topic: `message:${msgUuid}`,
470
+ kind: "messageMoved",
471
+ accountId, folderId, targetFolderId: trashFolderId, uid, msgUuid,
472
+ });
473
+ }
474
+ this.bus.publish({
475
+ topic: `folder:${trashFolderId}`,
476
+ kind: "folderCountsChanged",
477
+ accountId, folderId: trashFolderId,
478
+ });
479
+ return "moved-to-trash";
480
+ }
481
+ this.db.deleteMessage(accountId, uid, "user-initiated trash (already in trash → expunge)", "Store.trashMessage");
482
+ this.db.recalcFolderCounts(folderId);
483
+ if (msgUuid) {
484
+ this.bus.publish({
485
+ topic: `message:${msgUuid}`,
486
+ kind: "messageRemoved",
487
+ accountId, folderId, uid, msgUuid,
488
+ });
489
+ }
490
+ return "expunged";
491
+ }
492
+ /** Restore a message from trash back to its original folder. Local-only;
493
+ * caller handles the queue (cancel-pending-MOVE vs queue-counter-MOVE).
494
+ * Returns true if a local row was moved. */
495
+ undeleteMessage(accountId, uid, trashFolderId, originalFolderId) {
496
+ const moved = this.db.moveMessageLocal(accountId, uid, trashFolderId, originalFolderId);
497
+ if (!moved)
498
+ return false;
499
+ this.db.recalcFolderCounts(trashFolderId);
500
+ this.db.recalcFolderCounts(originalFolderId);
501
+ const env = this.db.getMessageByUid(accountId, uid, originalFolderId);
502
+ const msgUuid = env?.uuid;
503
+ if (msgUuid) {
504
+ this.bus.publish({
505
+ topic: `message:${msgUuid}`,
506
+ kind: "messageMoved",
507
+ accountId, folderId: trashFolderId, targetFolderId: originalFolderId, uid, msgUuid,
508
+ });
509
+ }
510
+ this.bus.publish({
511
+ topic: `folder:${originalFolderId}`,
512
+ kind: "folderCountsChanged",
513
+ accountId, folderId: originalFolderId,
514
+ });
515
+ return true;
516
+ }
517
+ }
518
+ //# sourceMappingURL=store.js.map