@bobfrankston/mailx-store 0.1.23 → 0.1.25

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/bus.d.ts ADDED
@@ -0,0 +1,79 @@
1
+ /**
2
+ * StoreBus — typed pub/sub for Store events.
3
+ *
4
+ * Postmessage-style: subscribers register a topic string + handler; publishers
5
+ * emit a {topic, kind, ...} object. The exact same shape works inside Node,
6
+ * across worker_threads (postMessage), and across the WebView⇄host boundary
7
+ * (also postMessage). That's the load-bearing property: one mental model from
8
+ * DB write to UI re-render, on any platform.
9
+ *
10
+ * Topics
11
+ * ──────
12
+ * `message:<uuid>` — a specific message changed (flags, body, removal)
13
+ * `folder:<id>` — anything inside folder <id> changed; the bus fans
14
+ * out per-message events to the parent folder topic
15
+ * automatically (see fanOutToFolder())
16
+ * `account:<id>` — account-scoped event (sync status, auth, quota)
17
+ * `*` — wildcard; receives every event. Use for the IPC
18
+ * forwarder that pushes to the WebView, and for
19
+ * diagnostics. Avoid in normal UI subscribers.
20
+ *
21
+ * Batching
22
+ * ────────
23
+ * Bulk writes (sync round inserts 200 envelopes) would emit 200 events. The
24
+ * `withBatch(fn)` wrapper buffers events fired during fn() and coalesces
25
+ * per-topic into one {kind:"batch", changedUuids, summary} event at the end.
26
+ * Subscribers either iterate `changedUuids` or treat batch as "rerender this
27
+ * topic." Nested withBatch() is supported; inner scopes just append to the
28
+ * outer's buffer.
29
+ */
30
+ export type StoreEventKind = "messageInserted" | "messageUpdated" | "messageRemoved" | "messageMoved" | "flagsChanged" | "bodyAvailable" | "bodyFetchError" | "folderCountsChanged" | "draftSaved" | "draftSaveDeferred" | "batch";
31
+ export interface StoreEvent {
32
+ topic: string;
33
+ kind: StoreEventKind;
34
+ accountId?: string;
35
+ folderId?: number;
36
+ targetFolderId?: number;
37
+ uid?: number;
38
+ msgUuid?: string;
39
+ flags?: string[];
40
+ error?: string;
41
+ /** Set on `batch` events. UUIDs of every message touched in the scope. */
42
+ changedUuids?: string[];
43
+ /** Set on `batch` events. */
44
+ summary?: {
45
+ inserted: number;
46
+ updated: number;
47
+ deleted: number;
48
+ };
49
+ /** Arbitrary additional payload for kind-specific data the typed fields
50
+ * above don't cover (e.g. flag-set details, body path). Keep small. */
51
+ [extra: string]: unknown;
52
+ }
53
+ export type StoreEventHandler = (event: StoreEvent) => void;
54
+ export declare class StoreBus {
55
+ private subscribers;
56
+ private batchDepth;
57
+ private buffer;
58
+ subscribe(topic: string, handler: StoreEventHandler): () => void;
59
+ /** Publish an event. If a folderId is set and the topic is a message
60
+ * topic, the bus also publishes a copy to the parent folder topic so
61
+ * list views (subscribed to folder:<id>) wake without subscribing to
62
+ * every message individually. */
63
+ publish(event: StoreEvent): void;
64
+ /** Run `fn` with publishes buffered. On exit, coalesces per-topic and
65
+ * delivers either the single event (when only one fired for that topic)
66
+ * or a synthetic batch event summarizing the changes. */
67
+ withBatch<T>(fn: () => T): T;
68
+ private flush;
69
+ private deliver;
70
+ /** When a message-topic event fires and carries a folderId, generate
71
+ * the parallel folder-topic event so folder subscribers don't have to
72
+ * also subscribe to every message individually. Returns null when no
73
+ * fan-out applies. */
74
+ private fanOutToFolder;
75
+ }
76
+ /** Singleton bus shared by every Store consumer in the process. Workers
77
+ * get their own; the worker boundary serializes events via postMessage. */
78
+ export declare const storeBus: StoreBus;
79
+ //# sourceMappingURL=bus.d.ts.map
package/bus.js ADDED
@@ -0,0 +1,155 @@
1
+ /**
2
+ * StoreBus — typed pub/sub for Store events.
3
+ *
4
+ * Postmessage-style: subscribers register a topic string + handler; publishers
5
+ * emit a {topic, kind, ...} object. The exact same shape works inside Node,
6
+ * across worker_threads (postMessage), and across the WebView⇄host boundary
7
+ * (also postMessage). That's the load-bearing property: one mental model from
8
+ * DB write to UI re-render, on any platform.
9
+ *
10
+ * Topics
11
+ * ──────
12
+ * `message:<uuid>` — a specific message changed (flags, body, removal)
13
+ * `folder:<id>` — anything inside folder <id> changed; the bus fans
14
+ * out per-message events to the parent folder topic
15
+ * automatically (see fanOutToFolder())
16
+ * `account:<id>` — account-scoped event (sync status, auth, quota)
17
+ * `*` — wildcard; receives every event. Use for the IPC
18
+ * forwarder that pushes to the WebView, and for
19
+ * diagnostics. Avoid in normal UI subscribers.
20
+ *
21
+ * Batching
22
+ * ────────
23
+ * Bulk writes (sync round inserts 200 envelopes) would emit 200 events. The
24
+ * `withBatch(fn)` wrapper buffers events fired during fn() and coalesces
25
+ * per-topic into one {kind:"batch", changedUuids, summary} event at the end.
26
+ * Subscribers either iterate `changedUuids` or treat batch as "rerender this
27
+ * topic." Nested withBatch() is supported; inner scopes just append to the
28
+ * outer's buffer.
29
+ */
30
+ export class StoreBus {
31
+ subscribers = new Map();
32
+ batchDepth = 0;
33
+ buffer = [];
34
+ subscribe(topic, handler) {
35
+ let set = this.subscribers.get(topic);
36
+ if (!set) {
37
+ set = new Set();
38
+ this.subscribers.set(topic, set);
39
+ }
40
+ set.add(handler);
41
+ return () => {
42
+ const s = this.subscribers.get(topic);
43
+ if (s) {
44
+ s.delete(handler);
45
+ if (s.size === 0)
46
+ this.subscribers.delete(topic);
47
+ }
48
+ };
49
+ }
50
+ /** Publish an event. If a folderId is set and the topic is a message
51
+ * topic, the bus also publishes a copy to the parent folder topic so
52
+ * list views (subscribed to folder:<id>) wake without subscribing to
53
+ * every message individually. */
54
+ publish(event) {
55
+ if (this.batchDepth > 0) {
56
+ this.buffer.push(event);
57
+ // Folder fan-out also buffers; coalesces at flush time.
58
+ const fanned = this.fanOutToFolder(event);
59
+ if (fanned)
60
+ this.buffer.push(fanned);
61
+ return;
62
+ }
63
+ this.deliver(event);
64
+ const fanned = this.fanOutToFolder(event);
65
+ if (fanned)
66
+ this.deliver(fanned);
67
+ }
68
+ /** Run `fn` with publishes buffered. On exit, coalesces per-topic and
69
+ * delivers either the single event (when only one fired for that topic)
70
+ * or a synthetic batch event summarizing the changes. */
71
+ withBatch(fn) {
72
+ this.batchDepth++;
73
+ try {
74
+ return fn();
75
+ }
76
+ finally {
77
+ this.batchDepth--;
78
+ if (this.batchDepth === 0)
79
+ this.flush();
80
+ }
81
+ }
82
+ flush() {
83
+ const buf = this.buffer;
84
+ this.buffer = [];
85
+ if (buf.length === 0)
86
+ return;
87
+ const byTopic = new Map();
88
+ for (const e of buf) {
89
+ let arr = byTopic.get(e.topic);
90
+ if (!arr) {
91
+ arr = [];
92
+ byTopic.set(e.topic, arr);
93
+ }
94
+ arr.push(e);
95
+ }
96
+ for (const [topic, events] of byTopic) {
97
+ if (events.length === 1) {
98
+ this.deliver(events[0]);
99
+ continue;
100
+ }
101
+ const summary = { inserted: 0, updated: 0, deleted: 0 };
102
+ const uuids = new Set();
103
+ for (const e of events) {
104
+ if (e.kind === "messageInserted")
105
+ summary.inserted++;
106
+ else if (e.kind === "messageRemoved")
107
+ summary.deleted++;
108
+ else
109
+ summary.updated++;
110
+ if (e.msgUuid)
111
+ uuids.add(e.msgUuid);
112
+ }
113
+ this.deliver({ topic, kind: "batch", changedUuids: [...uuids], summary });
114
+ }
115
+ }
116
+ deliver(event) {
117
+ const exact = this.subscribers.get(event.topic);
118
+ if (exact)
119
+ for (const h of exact) {
120
+ try {
121
+ h(event);
122
+ }
123
+ catch (e) {
124
+ console.error("[store-bus]", e);
125
+ }
126
+ }
127
+ const wild = this.subscribers.get("*");
128
+ if (wild)
129
+ for (const h of wild) {
130
+ try {
131
+ h(event);
132
+ }
133
+ catch (e) {
134
+ console.error("[store-bus]", e);
135
+ }
136
+ }
137
+ }
138
+ /** When a message-topic event fires and carries a folderId, generate
139
+ * the parallel folder-topic event so folder subscribers don't have to
140
+ * also subscribe to every message individually. Returns null when no
141
+ * fan-out applies. */
142
+ fanOutToFolder(event) {
143
+ if (event.kind === "batch")
144
+ return null;
145
+ if (!event.topic.startsWith("message:"))
146
+ return null;
147
+ if (event.folderId == null)
148
+ return null;
149
+ return { ...event, topic: `folder:${event.folderId}` };
150
+ }
151
+ }
152
+ /** Singleton bus shared by every Store consumer in the process. Workers
153
+ * get their own; the worker boundary serializes events via postMessage. */
154
+ export const storeBus = new StoreBus();
155
+ //# sourceMappingURL=bus.js.map
package/db.d.ts CHANGED
@@ -53,15 +53,46 @@ export declare class MailxDB {
53
53
  * user's own Sent copy, so it's still a reliable signal that this user
54
54
  * habitually Bccs when writing to this recipient. */
55
55
  hasBccHistoryTo(recipientEmail: string): boolean;
56
- /** Mark a Message-ID as locally-deleted for an account. No-op if messageId
57
- * is empty (e.g. provider stripped the header) without a stable id we
58
- * can't check against future sync results anyway. */
56
+ /** Mark a local row as "delete pending server confirmation". The row
57
+ * stays visible (UI greys / strikes it through) until the server
58
+ * confirms via QRESYNC VANISHED or its UID drops out of the set-diff. */
59
+ setMessagePendingDelete(accountId: string, uid: number, folderId: number): void;
60
+ /** Clear the pending-delete flag — used when the IMAP MOVE/EXPUNGE is
61
+ * refused by the server and the row should revert to a normal visible
62
+ * state, and by Ctrl-Z undelete. */
63
+ clearMessagePendingDelete(accountId: string, uid: number, folderId: number): void;
64
+ /** Query: is this (folder, uid) pending server-side delete? */
65
+ isMessagePendingDelete(accountId: string, uid: number, folderId: number): boolean;
66
+ /** @deprecated — superseded by setMessagePendingDelete. Retained as a
67
+ * no-op so legacy delete paths still compile; the table is preserved
68
+ * but never read by the sync path anymore. */
59
69
  addTombstone(accountId: string, messageId: string, subject?: string): void;
60
- /** Is this Message-ID tombstoned for this account? */
70
+ /** Is this Message-ID tombstoned for this account? Tombstones suppress
71
+ * re-import of a row the user just deleted/moved while the server-side
72
+ * IMAP action is still in flight; without this, the moved-out source
73
+ * folder would re-insert the message on the next sync (server still
74
+ * sees the UID; mailx has no other way to know "I just moved this").
75
+ *
76
+ * Lifetime: created at the local-delete/move moment, removed on
77
+ * successful IMAP action complete OR on PERMANENT IMAP action failure.
78
+ * The failure-clear is the 2026-05-13 fix for the "tombstones outlive
79
+ * failed deletes" bug — `failSyncAction` calls `clearTombstoneForUid`
80
+ * after the final retry attempt so the user sees the row reappear on
81
+ * the next sync (server still has it, mailx now agrees). QRESYNC
82
+ * servers don't need this fallback because VANISHED is authoritative;
83
+ * tombstones are no-ops on the QRESYNC fast path. */
61
84
  hasTombstone(accountId: string, messageId: string): boolean;
62
- /** Remove a tombstone used by "undelete" (Ctrl-Z) so a subsequent sync
63
- * re-imports the message as normal. Also lets the user recover from a
64
- * mistaken local delete. */
85
+ /** Clear the tombstone for the message currently at (accountId, uid,
86
+ * folderId). Used by the sync-action failure path: when an IMAP MOVE
87
+ * or EXPUNGE fails terminally, the row is still on the server and the
88
+ * user should see it again — so the suppression must lift. The lookup
89
+ * is by (accountId, uid, folderId) → message_id, since the action
90
+ * table stores UID not Message-ID. No-op if the row no longer exists
91
+ * (concurrent server-side EXPUNGE etc.). */
92
+ clearTombstoneForUid(accountId: string, uid: number, folderId: number): void;
93
+ /** @deprecated — use clearMessagePendingDelete instead. Retained as a
94
+ * no-op for legacy callers (Ctrl-Z undelete paths still work because
95
+ * their sibling clearMessagePendingDelete is called). */
65
96
  removeTombstone(accountId: string, messageId: string): void;
66
97
  /** Age-out tombstones older than the given cutoff. Keeps the table from
67
98
  * growing unboundedly. Default retention is 30 days; caller passes the
package/db.js CHANGED
@@ -357,6 +357,14 @@ const SCHEMA = `
357
357
  folder_id INTEGER NOT NULL,
358
358
  uid INTEGER NOT NULL,
359
359
  last_seen_at INTEGER NOT NULL,
360
+ -- 0 = normal, 1 = user initiated delete, server-sync in flight.
361
+ -- Replaces the old tombstones table: rather than hide a row server
362
+ -- still has, we keep it visible with this flag and let the UI render
363
+ -- it as a struck-through "in flight" row. On server-confirmed
364
+ -- deletion (QRESYNC VANISHED, or absence from a set-diff with the
365
+ -- 50% safety guard) the row is dropped. On server-refused deletion
366
+ -- the flag is cleared and the row reverts to normal.
367
+ pending_delete INTEGER DEFAULT 0,
360
368
  UNIQUE(folder_id, uid)
361
369
  );
362
370
  CREATE INDEX IF NOT EXISTS idx_message_folders_msg ON message_folders(message_row_id);
@@ -420,6 +428,9 @@ export class MailxDB {
420
428
  // calendar's defaultReminders pre-resolved server-side and pulled
421
429
  // through here too.
422
430
  this.addColumnIfMissing("calendar_events", "reminder_minutes_json", "TEXT DEFAULT '[]'");
431
+ // pending_delete on message_folders replaces the old tombstones table.
432
+ // See the table comment for semantics — visible in-flight delete state.
433
+ this.addColumnIfMissing("message_folders", "pending_delete", "INTEGER DEFAULT 0");
423
434
  // Backfill UUIDs for any pre-existing rows that were inserted before
424
435
  // this column landed. One UPDATE + an id roundtrip per row — cheap
425
436
  // at our row counts, runs once per DB upgrade.
@@ -692,10 +703,61 @@ export class MailxDB {
692
703
  return false;
693
704
  }
694
705
  }
695
- // ── Tombstones (local-delete record so server echo can't resurrect) ──
696
- /** Mark a Message-ID as locally-deleted for an account. No-op if messageId
697
- * is empty (e.g. provider stripped the header) without a stable id we
698
- * can't check against future sync results anyway. */
706
+ // ── Pending-delete state (replaces tombstones) ──
707
+ //
708
+ // Tombstones were a client-side invention that hid messages the server
709
+ // still considered present. With QRESYNC (RFC 7162) the server is
710
+ // authoritative — VANISHED tells us definitively what's deleted, so the
711
+ // tombstone hack is obsolete. For the non-QRESYNC case, the natural
712
+ // upsert-by-(folder, uid) behaviour of the sync path is already correct:
713
+ // a row pending deletion just has `pending_delete = 1`; the sync upserts
714
+ // refresh `last_seen_at` but don't reset the flag.
715
+ //
716
+ // The old tombstone API stubs below remain temporarily so legacy callers
717
+ // compile; they delegate to pending_delete by (folder, uid) when possible
718
+ // and no-op otherwise. Plan is to remove the stubs once every caller has
719
+ // been migrated to the (uid, folderId) form.
720
+ /** Mark a local row as "delete pending server confirmation". The row
721
+ * stays visible (UI greys / strikes it through) until the server
722
+ * confirms via QRESYNC VANISHED or its UID drops out of the set-diff. */
723
+ setMessagePendingDelete(accountId, uid, folderId) {
724
+ try {
725
+ this.db.prepare(`UPDATE message_folders SET pending_delete = 1
726
+ WHERE folder_id = ? AND uid = ?
727
+ AND message_row_id IN (SELECT id FROM messages WHERE account_id = ?)`).run(folderId, uid, accountId);
728
+ }
729
+ catch (e) {
730
+ console.error(` [pending-delete] failed to mark ${accountId}/${folderId}/${uid}: ${e.message}`);
731
+ }
732
+ }
733
+ /** Clear the pending-delete flag — used when the IMAP MOVE/EXPUNGE is
734
+ * refused by the server and the row should revert to a normal visible
735
+ * state, and by Ctrl-Z undelete. */
736
+ clearMessagePendingDelete(accountId, uid, folderId) {
737
+ try {
738
+ this.db.prepare(`UPDATE message_folders SET pending_delete = 0
739
+ WHERE folder_id = ? AND uid = ?
740
+ AND message_row_id IN (SELECT id FROM messages WHERE account_id = ?)`).run(folderId, uid, accountId);
741
+ }
742
+ catch (e) {
743
+ console.error(` [pending-delete] failed to clear ${accountId}/${folderId}/${uid}: ${e.message}`);
744
+ }
745
+ }
746
+ /** Query: is this (folder, uid) pending server-side delete? */
747
+ isMessagePendingDelete(accountId, uid, folderId) {
748
+ const row = this.db.prepare(`SELECT mf.pending_delete
749
+ FROM message_folders mf
750
+ JOIN messages m ON m.id = mf.message_row_id
751
+ WHERE mf.folder_id = ? AND mf.uid = ? AND m.account_id = ?
752
+ LIMIT 1`).get(folderId, uid, accountId);
753
+ return !!row && row.pending_delete === 1;
754
+ }
755
+ // ── Legacy tombstone API — stubs for backwards compatibility ────────────
756
+ // TODO(tombstone-removal): once all call sites use the (uid, folderId)
757
+ // pending_delete API, delete these stubs and drop the tombstones table.
758
+ /** @deprecated — superseded by setMessagePendingDelete. Retained as a
759
+ * no-op so legacy delete paths still compile; the table is preserved
760
+ * but never read by the sync path anymore. */
699
761
  addTombstone(accountId, messageId, subject = "") {
700
762
  if (!messageId)
701
763
  return;
@@ -706,16 +768,42 @@ export class MailxDB {
706
768
  console.error(` [tombstones] failed to record ${messageId}: ${e.message}`);
707
769
  }
708
770
  }
709
- /** Is this Message-ID tombstoned for this account? */
771
+ /** Is this Message-ID tombstoned for this account? Tombstones suppress
772
+ * re-import of a row the user just deleted/moved while the server-side
773
+ * IMAP action is still in flight; without this, the moved-out source
774
+ * folder would re-insert the message on the next sync (server still
775
+ * sees the UID; mailx has no other way to know "I just moved this").
776
+ *
777
+ * Lifetime: created at the local-delete/move moment, removed on
778
+ * successful IMAP action complete OR on PERMANENT IMAP action failure.
779
+ * The failure-clear is the 2026-05-13 fix for the "tombstones outlive
780
+ * failed deletes" bug — `failSyncAction` calls `clearTombstoneForUid`
781
+ * after the final retry attempt so the user sees the row reappear on
782
+ * the next sync (server still has it, mailx now agrees). QRESYNC
783
+ * servers don't need this fallback because VANISHED is authoritative;
784
+ * tombstones are no-ops on the QRESYNC fast path. */
710
785
  hasTombstone(accountId, messageId) {
711
786
  if (!messageId)
712
787
  return false;
713
788
  const row = this.db.prepare("SELECT 1 FROM tombstones WHERE account_id = ? AND message_id = ? LIMIT 1").get(accountId, messageId);
714
789
  return !!row;
715
790
  }
716
- /** Remove a tombstone used by "undelete" (Ctrl-Z) so a subsequent sync
717
- * re-imports the message as normal. Also lets the user recover from a
718
- * mistaken local delete. */
791
+ /** Clear the tombstone for the message currently at (accountId, uid,
792
+ * folderId). Used by the sync-action failure path: when an IMAP MOVE
793
+ * or EXPUNGE fails terminally, the row is still on the server and the
794
+ * user should see it again — so the suppression must lift. The lookup
795
+ * is by (accountId, uid, folderId) → message_id, since the action
796
+ * table stores UID not Message-ID. No-op if the row no longer exists
797
+ * (concurrent server-side EXPUNGE etc.). */
798
+ clearTombstoneForUid(accountId, uid, folderId) {
799
+ const env = this.getMessageByUid(accountId, uid, folderId);
800
+ if (!env?.messageId)
801
+ return;
802
+ this.removeTombstone(accountId, env.messageId);
803
+ }
804
+ /** @deprecated — use clearMessagePendingDelete instead. Retained as a
805
+ * no-op for legacy callers (Ctrl-Z undelete paths still work because
806
+ * their sibling clearMessagePendingDelete is called). */
719
807
  removeTombstone(accountId, messageId) {
720
808
  if (!messageId)
721
809
  return;
package/index.d.ts CHANGED
@@ -4,5 +4,7 @@
4
4
  */
5
5
  export { MailxDB } from "./db.js";
6
6
  export { FileMessageStore } from "./file-store.js";
7
- export { parseSerial } from "./parse-serial.js";
7
+ export { parseSerial, prewarmParseWorker } from "./parse-serial.js";
8
+ export { StoreBus, storeBus } from "@bobfrankston/mailx-bus";
9
+ export type { StoreEvent, StoreEventKind, StoreEventHandler } from "@bobfrankston/mailx-bus";
8
10
  //# sourceMappingURL=index.d.ts.map
package/index.js CHANGED
@@ -4,5 +4,10 @@
4
4
  */
5
5
  export { MailxDB } from "./db.js";
6
6
  export { FileMessageStore } from "./file-store.js";
7
- export { parseSerial } from "./parse-serial.js";
7
+ export { parseSerial, prewarmParseWorker } from "./parse-serial.js";
8
+ // Store-event bus lives in `@bobfrankston/mailx-bus` so the browser-side
9
+ // store (mailx-store-web) and the desktop-side store (this package) share
10
+ // the same bus. Re-exported here so existing callers don't have to learn
11
+ // the new import path.
12
+ export { StoreBus, storeBus } from "@bobfrankston/mailx-bus";
8
13
  //# sourceMappingURL=index.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx-store",
3
- "version": "0.1.23",
3
+ "version": "0.1.25",
4
4
  "type": "module",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",
@@ -9,8 +9,9 @@
9
9
  },
10
10
  "license": "ISC",
11
11
  "dependencies": {
12
- "@bobfrankston/mailx-types": "^0.1.11",
13
- "@bobfrankston/mailx-settings": "^0.1.16",
12
+ "@bobfrankston/mailx-types": "^0.1.13",
13
+ "@bobfrankston/mailx-settings": "^0.1.17",
14
+ "@bobfrankston/mailx-bus": "^0.1.0",
14
15
  "mailparser": "^3.7.2"
15
16
  },
16
17
  "repository": {
@@ -23,12 +24,14 @@
23
24
  ".dependencies": {
24
25
  "@bobfrankston/mailx-types": "file:../mailx-types",
25
26
  "@bobfrankston/mailx-settings": "file:../mailx-settings",
27
+ "@bobfrankston/mailx-bus": "file:../mailx-bus",
26
28
  "mailparser": "^3.7.2"
27
29
  },
28
30
  ".transformedSnapshot": {
29
31
  "dependencies": {
30
- "@bobfrankston/mailx-types": "^0.1.11",
31
- "@bobfrankston/mailx-settings": "^0.1.16",
32
+ "@bobfrankston/mailx-types": "^0.1.13",
33
+ "@bobfrankston/mailx-settings": "^0.1.17",
34
+ "@bobfrankston/mailx-bus": "^0.1.0",
32
35
  "mailparser": "^3.7.2"
33
36
  }
34
37
  }
package/parse-serial.d.ts CHANGED
@@ -1,34 +1,50 @@
1
1
  /**
2
- * Process-wide serialized simpleParser.
2
+ * Worker-thread-backed simpleParser dispatcher.
3
3
  *
4
4
  * mailparser's `simpleParser` is declared `async` but its work is CPU-bound
5
- * (Node Streams + libmime decoding). When N parses run concurrently on the
6
- * single-threaded event loop they share CPU and each finishes at roughly
7
- * wall-clock. Real evidence (2026-05-13): four near-concurrent parses
8
- * each reported `14691ms for 3 KB` — pure contention, not size.
5
+ * (Node Streams + libmime decoding). When it runs on the main event loop:
6
+ * - The first parse after a fresh process takes 14-25 seconds (cold V8
7
+ * JIT + libmime + iconv-lite + charset-table loading). See log
8
+ * 2026-05-14 01:11:44 simpleParser 14524ms for 5 KB.
9
+ * - Every parse, cold or warm, blocks the IPC stdin pump for its
10
+ * duration. A 200 ms parse delays every queued IPC message by 200 ms.
9
11
  *
10
- * The downstream symptom is the IPC pipe: while the event loop is
11
- * saturated by interleaving parses, unrelated IPC operations (mark-as-spam,
12
- * move, delete) wait their turn and the WebView-side `mailxapi` shim
13
- * times out at 120s with a misleading "stayed in the list" alert.
12
+ * Moving the parse to a `worker_threads` Worker fixes both problems:
13
+ * - The main event loop stays responsive — IPC, sync, timers all run
14
+ * during the parse. Click-to-render latency is bounded by post-message
15
+ * RTT (sub-ms) + parse time on the worker, but the rest of the app
16
+ * stays alive.
17
+ * - The worker absorbs cold-start once. Subsequent parses ride the
18
+ * worker's warm JIT and run in their natural 50-500 ms budget.
14
19
  *
15
- * Serializing through a module-level promise chain bounds the damage with
16
- * minimal plumbing: a single parse runs at full CPU and finishes in its
17
- * natural ~50-500ms budget; the next parse starts when it's done. Each
18
- * UI click produces a clean preview latency instead of a 14-second stall.
20
+ * Priority queue: foreground (UI clicks) jumps over background (sync /
21
+ * prefetch) when more than one parse is pending. Each parse still runs
22
+ * sequentially on the worker the single-worker design intentionally
23
+ * mirrors the prior in-process serialization to keep CPU bounded.
19
24
  *
20
- * Lives in mailx-store rather than mailx-service so both the UI-hot path
21
- * (mailx-service/local-store.ts) and the sync path (mailx-imap) can share
22
- * one queue — otherwise sync-time parses would still contend with UI
23
- * parses through the event loop, just not through this module's chain.
25
+ * Lazy spawn: the worker is created on first call. boot/setup code that
26
+ * runs no parses pays nothing.
27
+ *
28
+ * Worker lifecycle: the worker is process-singleton, never explicitly
29
+ * terminated. Node exits cleanly because the worker is `unref()`d so it
30
+ * doesn't keep the event loop alive.
24
31
  *
25
- * Future work: replace the in-process chain with a `node:worker_threads`
26
- * pool. The chain is the precursor that bounds the worst case while the
27
- * pool is built.
32
+ * Lives in mailx-store rather than mailx-service so both the UI-hot path
33
+ * (mailx-service/local-store.ts) and the sync path (mailx-imap) share one
34
+ * worker otherwise each module would spawn its own (parallel workers
35
+ * would defeat the bound on CPU, and each pays its own cold start).
28
36
  */
29
- import { type ParsedMail, type Source } from "mailparser";
30
- /** Serialized wrapper around `mailparser.simpleParser`. `priority` defaults
31
- * to "foreground" only sync/background callers should pass "background"
32
- * so user clicks aren't stuck behind a back-fill. */
37
+ import type { ParsedMail, Source } from "mailparser";
38
+ /** Spawn the parse worker early so its cold-start (mailparser module
39
+ * loading, V8 JIT, libmime / iconv-lite tables empirically 14-25 s)
40
+ * runs in parallel with the rest of boot. Safe to call multiple times;
41
+ * the worker is a process-singleton. Returns immediately; the actual
42
+ * cold-start completes in the worker's internal self-warm. */
43
+ export declare function prewarmParseWorker(): void;
44
+ /** Serialized wrapper around `mailparser.simpleParser` running in a worker
45
+ * thread. `priority` defaults to "foreground" — only sync/background
46
+ * callers should pass "background" so user clicks aren't stuck behind a
47
+ * back-fill. If the worker can't be spawned (rare), falls back to an
48
+ * in-main-thread serial pump so the system still functions. */
33
49
  export declare function parseSerial(source: Source, priority?: "foreground" | "background"): Promise<ParsedMail>;
34
50
  //# sourceMappingURL=parse-serial.d.ts.map
package/parse-serial.js CHANGED
@@ -1,40 +1,111 @@
1
1
  /**
2
- * Process-wide serialized simpleParser.
2
+ * Worker-thread-backed simpleParser dispatcher.
3
3
  *
4
4
  * mailparser's `simpleParser` is declared `async` but its work is CPU-bound
5
- * (Node Streams + libmime decoding). When N parses run concurrently on the
6
- * single-threaded event loop they share CPU and each finishes at roughly
7
- * wall-clock. Real evidence (2026-05-13): four near-concurrent parses
8
- * each reported `14691ms for 3 KB` — pure contention, not size.
5
+ * (Node Streams + libmime decoding). When it runs on the main event loop:
6
+ * - The first parse after a fresh process takes 14-25 seconds (cold V8
7
+ * JIT + libmime + iconv-lite + charset-table loading). See log
8
+ * 2026-05-14 01:11:44 simpleParser 14524ms for 5 KB.
9
+ * - Every parse, cold or warm, blocks the IPC stdin pump for its
10
+ * duration. A 200 ms parse delays every queued IPC message by 200 ms.
9
11
  *
10
- * The downstream symptom is the IPC pipe: while the event loop is
11
- * saturated by interleaving parses, unrelated IPC operations (mark-as-spam,
12
- * move, delete) wait their turn and the WebView-side `mailxapi` shim
13
- * times out at 120s with a misleading "stayed in the list" alert.
12
+ * Moving the parse to a `worker_threads` Worker fixes both problems:
13
+ * - The main event loop stays responsive — IPC, sync, timers all run
14
+ * during the parse. Click-to-render latency is bounded by post-message
15
+ * RTT (sub-ms) + parse time on the worker, but the rest of the app
16
+ * stays alive.
17
+ * - The worker absorbs cold-start once. Subsequent parses ride the
18
+ * worker's warm JIT and run in their natural 50-500 ms budget.
14
19
  *
15
- * Serializing through a module-level promise chain bounds the damage with
16
- * minimal plumbing: a single parse runs at full CPU and finishes in its
17
- * natural ~50-500ms budget; the next parse starts when it's done. Each
18
- * UI click produces a clean preview latency instead of a 14-second stall.
20
+ * Priority queue: foreground (UI clicks) jumps over background (sync /
21
+ * prefetch) when more than one parse is pending. Each parse still runs
22
+ * sequentially on the worker the single-worker design intentionally
23
+ * mirrors the prior in-process serialization to keep CPU bounded.
19
24
  *
20
- * Lives in mailx-store rather than mailx-service so both the UI-hot path
21
- * (mailx-service/local-store.ts) and the sync path (mailx-imap) can share
22
- * one queue — otherwise sync-time parses would still contend with UI
23
- * parses through the event loop, just not through this module's chain.
25
+ * Lazy spawn: the worker is created on first call. boot/setup code that
26
+ * runs no parses pays nothing.
27
+ *
28
+ * Worker lifecycle: the worker is process-singleton, never explicitly
29
+ * terminated. Node exits cleanly because the worker is `unref()`d so it
30
+ * doesn't keep the event loop alive.
24
31
  *
25
- * Future work: replace the in-process chain with a `node:worker_threads`
26
- * pool. The chain is the precursor that bounds the worst case while the
27
- * pool is built.
32
+ * Lives in mailx-store rather than mailx-service so both the UI-hot path
33
+ * (mailx-service/local-store.ts) and the sync path (mailx-imap) share one
34
+ * worker otherwise each module would spawn its own (parallel workers
35
+ * would defeat the bound on CPU, and each pays its own cold start).
28
36
  */
29
- import { simpleParser } from "mailparser";
30
- const _head = []; // UI / foreground — drained first
31
- const _tail = []; // sync / background
37
+ import { Worker } from "node:worker_threads";
38
+ import { fileURLToPath } from "node:url";
39
+ import { dirname, join } from "node:path";
40
+ const _head = [];
41
+ const _tail = [];
32
42
  let _pumping = false;
33
- async function pump() {
43
+ let _worker = null;
44
+ let _nextId = 1;
45
+ const _inflight = new Map();
46
+ function getWorker() {
47
+ if (_worker)
48
+ return _worker;
49
+ // Resolve parse-worker.js alongside the compiled parse-serial.js.
50
+ // import.meta.url is the running .js URL after tsc compilation, so
51
+ // the worker resolves cleanly from the package install location.
52
+ const here = dirname(fileURLToPath(import.meta.url));
53
+ const workerPath = join(here, "parse-worker.js");
54
+ const w = new Worker(workerPath);
55
+ w.on("message", (msg) => {
56
+ // Warmup heartbeat (no `id`). Logs the worker cold-start cost so
57
+ // we can confirm the absorber is working on each boot.
58
+ if (typeof msg.warmupMs === "number") {
59
+ console.log(` [parse-worker] cold-start absorbed in worker in ${msg.warmupMs}ms`);
60
+ return;
61
+ }
62
+ if (typeof msg.id !== "number")
63
+ return;
64
+ const entry = _inflight.get(msg.id);
65
+ if (!entry)
66
+ return;
67
+ _inflight.delete(msg.id);
68
+ if (msg.ok)
69
+ entry.resolve(msg.result);
70
+ else
71
+ entry.reject(new Error(msg.error || "parse-worker error"));
72
+ signalCompletion();
73
+ });
74
+ w.on("error", (e) => {
75
+ // Fatal worker crash — fail every in-flight parse, then null the
76
+ // singleton so the next call respawns.
77
+ for (const entry of _inflight.values())
78
+ entry.reject(e);
79
+ _inflight.clear();
80
+ _worker = null;
81
+ signalCompletion();
82
+ });
83
+ w.on("exit", (code) => {
84
+ if (_inflight.size > 0) {
85
+ const err = new Error(`parse-worker exited (code=${code}) with ${_inflight.size} in-flight`);
86
+ for (const entry of _inflight.values())
87
+ entry.reject(err);
88
+ _inflight.clear();
89
+ }
90
+ _worker = null;
91
+ signalCompletion();
92
+ });
93
+ // Don't let the worker prevent process exit.
94
+ w.unref();
95
+ _worker = w;
96
+ return w;
97
+ }
98
+ /** In-main-thread fallback. Used when the worker spawn itself fails (e.g.,
99
+ * a packaged build where parse-worker.js can't be resolved). Falls back
100
+ * to the original behavior — blocks the event loop, but at least works.
101
+ * Lazy-imports mailparser so the main thread doesn't pay the (heavy)
102
+ * module-init cost unless the fallback actually fires. */
103
+ async function pumpInMain() {
34
104
  if (_pumping)
35
105
  return;
36
106
  _pumping = true;
37
107
  try {
108
+ const { simpleParser } = await import("mailparser");
38
109
  for (;;) {
39
110
  const next = _head.shift() ?? _tail.shift();
40
111
  if (!next)
@@ -52,17 +123,78 @@ async function pump() {
52
123
  _pumping = false;
53
124
  }
54
125
  }
55
- /** Serialized wrapper around `mailparser.simpleParser`. `priority` defaults
56
- * to "foreground" only sync/background callers should pass "background"
57
- * so user clicks aren't stuck behind a back-fill. */
126
+ // Notifier the pump awaits to drive the next iteration. When a reply
127
+ // arrives from the worker, completing the in-flight parse, this fires
128
+ // and unblocks the pump so it can pop the next queued parse. Avoids the
129
+ // busy-wait setTimeout-poll version.
130
+ let _completionSignal = null;
131
+ function signalCompletion() {
132
+ const fn = _completionSignal;
133
+ _completionSignal = null;
134
+ if (fn)
135
+ fn();
136
+ }
137
+ async function pumpViaWorker() {
138
+ if (_pumping)
139
+ return;
140
+ _pumping = true;
141
+ try {
142
+ for (;;) {
143
+ const next = _head.shift() ?? _tail.shift();
144
+ if (!next)
145
+ break;
146
+ _inflight.set(next.id, next);
147
+ try {
148
+ getWorker().postMessage({ id: next.id, source: next.source });
149
+ }
150
+ catch (e) {
151
+ _inflight.delete(next.id);
152
+ next.reject(e);
153
+ continue;
154
+ }
155
+ // Wait for THIS parse to complete before sending the next.
156
+ // Preserves the priority order — a foreground entry that arrives
157
+ // after we've already postMessage'd a background parse will be
158
+ // next in line, NOT after several already-queued backgrounds.
159
+ await new Promise(resolve => { _completionSignal = resolve; });
160
+ }
161
+ }
162
+ finally {
163
+ _pumping = false;
164
+ }
165
+ }
166
+ /** Spawn the parse worker early so its cold-start (mailparser module
167
+ * loading, V8 JIT, libmime / iconv-lite tables — empirically 14-25 s)
168
+ * runs in parallel with the rest of boot. Safe to call multiple times;
169
+ * the worker is a process-singleton. Returns immediately; the actual
170
+ * cold-start completes in the worker's internal self-warm. */
171
+ export function prewarmParseWorker() {
172
+ try {
173
+ getWorker();
174
+ }
175
+ catch { /* fallback path handles it on first parseSerial */ }
176
+ }
177
+ /** Serialized wrapper around `mailparser.simpleParser` running in a worker
178
+ * thread. `priority` defaults to "foreground" — only sync/background
179
+ * callers should pass "background" so user clicks aren't stuck behind a
180
+ * back-fill. If the worker can't be spawned (rare), falls back to an
181
+ * in-main-thread serial pump so the system still functions. */
58
182
  export async function parseSerial(source, priority = "foreground") {
59
183
  return new Promise((resolve, reject) => {
60
- const entry = { source, resolve, reject };
184
+ const entry = { id: _nextId++, source, resolve, reject };
61
185
  if (priority === "foreground")
62
186
  _head.push(entry);
63
187
  else
64
188
  _tail.push(entry);
65
- pump();
189
+ // Try the worker first; fall back transparently if the spawn fails.
190
+ try {
191
+ getWorker();
192
+ pumpViaWorker();
193
+ }
194
+ catch (e) {
195
+ console.error(` [parse-serial] worker unavailable, falling back to main-thread parse: ${e instanceof Error ? e.message : String(e)}`);
196
+ pumpInMain();
197
+ }
66
198
  });
67
199
  }
68
200
  //# sourceMappingURL=parse-serial.js.map
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=parse-worker.d.ts.map
@@ -0,0 +1,69 @@
1
+ /**
2
+ * mailparser worker thread.
3
+ *
4
+ * Runs simpleParser off the main event loop so a 14-25 second cold-start
5
+ * (or any slow parse) can't block IPC. The main thread's `parse-serial.ts`
6
+ * spawns this worker once at first use, then dispatches every parse here
7
+ * via postMessage and awaits the reply.
8
+ *
9
+ * Protocol:
10
+ * main → worker: { id: number, source: Buffer | string }
11
+ * worker → main: { id: number, ok: true, result: ParsedMail }
12
+ * | { id: number, ok: false, error: string }
13
+ *
14
+ * The worker holds its own mailparser module instance — JIT cost and
15
+ * libmime/iconv-lite loading happen exactly once per worker process. The
16
+ * main thread sees only the postMessage round-trip (~1 ms on local
17
+ * structured-clone of a small Buffer + parsed object).
18
+ */
19
+ import { parentPort } from "node:worker_threads";
20
+ import { simpleParser } from "mailparser";
21
+ if (!parentPort) {
22
+ throw new Error("parse-worker: must be spawned as a worker, parentPort is null");
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.
30
+ 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"
33
+ + "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.
36
+ parentPort.postMessage({ warmupMs: Date.now() - _warmupT0 });
37
+ }).catch(() => { });
38
+ parentPort.on("message", async (msg) => {
39
+ const { id, source } = msg;
40
+ // If a real parse arrives during warmup, queue behind it. The await
41
+ // resolves immediately once warmup is done; trivially fast on hot
42
+ // worker since _warmupPromise is already resolved.
43
+ await _warmupPromise;
44
+ try {
45
+ // CRITICAL: `Buffer` sent across `worker_threads.postMessage` arrives
46
+ // as plain `Uint8Array` (Node docs: "Buffer instances passed to
47
+ // Worker.postMessage() are passed as Uint8Array instances when
48
+ // received"). mailparser's `simpleParser` uses `Buffer.isBuffer(input)`
49
+ // to discriminate Buffer / string / Stream. A Uint8Array fails that
50
+ // check and gets routed to the Stream branch, which calls
51
+ // `input.once("end", …)` and immediately throws
52
+ // `input.once is not a function`
53
+ // Re-wrap with `Buffer.from(src.buffer, src.byteOffset, src.byteLength)`
54
+ // — zero-copy view over the same memory, but with the Buffer prototype
55
+ // so `Buffer.isBuffer` is true.
56
+ let normalized = source;
57
+ if (source instanceof Uint8Array && !Buffer.isBuffer(source)) {
58
+ const u = source;
59
+ normalized = Buffer.from(u.buffer, u.byteOffset, u.byteLength);
60
+ }
61
+ const result = await simpleParser(normalized);
62
+ parentPort.postMessage({ id, ok: true, result });
63
+ }
64
+ catch (e) {
65
+ const error = e instanceof Error ? e.message : String(e);
66
+ parentPort.postMessage({ id, ok: false, error });
67
+ }
68
+ });
69
+ //# sourceMappingURL=parse-worker.js.map