@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 +79 -0
- package/bus.js +155 -0
- package/db.d.ts +38 -7
- package/db.js +96 -8
- package/index.d.ts +3 -1
- package/index.js +6 -1
- package/package.json +8 -5
- package/parse-serial.d.ts +40 -24
- package/parse-serial.js +161 -29
- package/parse-worker.d.ts +2 -0
- package/parse-worker.js +69 -0
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
|
|
57
|
-
*
|
|
58
|
-
*
|
|
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
|
-
/**
|
|
63
|
-
*
|
|
64
|
-
*
|
|
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
|
-
// ──
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
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
|
-
/**
|
|
717
|
-
*
|
|
718
|
-
*
|
|
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.
|
|
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.
|
|
13
|
-
"@bobfrankston/mailx-settings": "^0.1.
|
|
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.
|
|
31
|
-
"@bobfrankston/mailx-settings": "^0.1.
|
|
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
|
-
*
|
|
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
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
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
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
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
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
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
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
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 {
|
|
30
|
-
/**
|
|
31
|
-
*
|
|
32
|
-
*
|
|
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
|
-
*
|
|
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
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
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
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
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
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
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
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
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 {
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
|
|
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
|
package/parse-worker.js
ADDED
|
@@ -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
|