@bobfrankston/mailx-store 0.1.26 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx-store",
3
- "version": "0.1.26",
3
+ "version": "0.1.27",
4
4
  "type": "module",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",
@@ -9,7 +9,7 @@
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
14
  "@bobfrankston/mailx-bus": "^0.1.2",
15
15
  "mailparser": "^3.7.2"
@@ -29,7 +29,7 @@
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
34
  "@bobfrankston/mailx-bus": "^0.1.2",
35
35
  "mailparser": "^3.7.2"
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 CHANGED
@@ -41,6 +41,15 @@ export interface StoreMessage extends MessageEnvelope {
41
41
  contentId: string;
42
42
  }>;
43
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;
44
53
  deliveredTo: string;
45
54
  returnPath: string;
46
55
  listUnsubscribe: string;
package/store.js CHANGED
@@ -222,11 +222,13 @@ export class Store {
222
222
  hasRemoteContent: false, remoteAllowed: allowRemote,
223
223
  attachments: [],
224
224
  cached: false,
225
+ bodyOnDisk: false, // overridden to true on the parse-pending return
225
226
  deliveredTo: "", returnPath: "",
226
227
  listUnsubscribe: "", listUnsubscribeMail: "", listUnsubscribeHttp: "", listUnsubscribeOneClick: false,
227
228
  emlPath: "",
228
229
  isFlagged,
229
230
  };
231
+ // No body file → caller should queue a fetch (bodyOnDisk stays false).
230
232
  if (!storedPath)
231
233
  return empty;
232
234
  if (!await this.bodyStore.hasByPath(storedPath))
@@ -246,25 +248,22 @@ export class Store {
246
248
  // body cached; recompute the volatile fields and overlay.
247
249
  return { ...cached, remoteAllowed: allowRemote, isFlagged };
248
250
  }
249
- let raw;
250
- try {
251
- raw = await this.bodyStore.readByPath(storedPath);
252
- }
253
- catch {
254
- // File path is in DB but the file is missing — the prefetch
255
- // dot is lying. Caller (UI) should mark the row as broken and
256
- // the reconciler should re-fetch. Don't crash; return as if
257
- // not cached.
258
- return empty;
259
- }
260
- // Parse + sanitize. Same logic as today's MailxService.getMessage,
261
- // but executed only when the body is local — never on a fresh
262
- // server fetch.
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);
263
266
  const adjusted = sniffAndFixCharset(raw);
264
- // simpleParser blocks the event loop while it parses — visible cost
265
- // on >100 KB messages. Time it so the log makes the cost concrete:
266
- // when this number is high, that's why other IPC calls (the next
267
- // message click, the folder-count update tick) stall.
268
267
  const _parseT0 = Date.now();
269
268
  const parsed = await parseSerial(adjusted);
270
269
  const _parseMs = Date.now() - _parseT0;
@@ -367,19 +366,10 @@ export class Store {
367
366
  emlPath: this.bodyStore.absolutePath(storedPath),
368
367
  isFlagged,
369
368
  };
370
- // Memoize the parsed result for subsequent views of the same UID.
371
- // `remoteAllowed` and `isFlagged` get re-overlaid on read so a flag
372
- // toggle or allowlist edit doesn't require a re-parse to take
373
- // effect (see the parsedLruGet path above).
374
- //
375
- // BUT don't poison the cache with an empty parse — a malformed .eml
376
- // or a fetch that left a stub file gives bodyHtml === "" AND
377
- // bodyText === ""; caching that means future views serve emptiness
378
- // until the daemon restarts. Let the next view try again; the
379
- // parse cost on a 9 kB .eml is ~20 ms, so re-parsing an oddity
380
- // is cheap.
381
- const hasContent = (bodyHtml && bodyHtml.length > 0) || (bodyText && bodyText.length > 0);
382
- if (mtimeMs > 0 && hasContent)
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)
383
373
  this.parsedLruPut(cacheKey, result);
384
374
  return result;
385
375
  }