@bobfrankston/rmfmail 1.2.4 → 1.2.5

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.
Files changed (34) hide show
  1. package/bin/mailx.js +22 -2
  2. package/bin/mailx.js.map +1 -1
  3. package/bin/mailx.ts +21 -2
  4. package/package.json +1 -1
  5. package/packages/mailx-imap/index.d.ts +15 -0
  6. package/packages/mailx-imap/index.d.ts.map +1 -1
  7. package/packages/mailx-imap/index.js +146 -0
  8. package/packages/mailx-imap/index.js.map +1 -1
  9. package/packages/mailx-imap/index.ts +107 -0
  10. package/packages/mailx-imap/package-lock.json +2 -2
  11. package/packages/mailx-imap/package.json +1 -1
  12. package/packages/mailx-service/index.d.ts +1 -5
  13. package/packages/mailx-service/index.d.ts.map +1 -1
  14. package/packages/mailx-service/index.js +18 -171
  15. package/packages/mailx-service/index.js.map +1 -1
  16. package/packages/mailx-service/index.ts +19 -128
  17. package/packages/mailx-service/package.json +1 -0
  18. package/packages/mailx-service/sync-worker-client.d.ts +33 -0
  19. package/packages/mailx-service/sync-worker-client.d.ts.map +1 -0
  20. package/packages/mailx-service/sync-worker-client.js +89 -0
  21. package/packages/mailx-service/sync-worker-client.js.map +1 -0
  22. package/packages/mailx-service/sync-worker-client.ts +92 -0
  23. package/packages/mailx-service/sync-worker.d.ts +33 -0
  24. package/packages/mailx-service/sync-worker.d.ts.map +1 -0
  25. package/packages/mailx-service/sync-worker.js +92 -0
  26. package/packages/mailx-service/sync-worker.js.map +1 -0
  27. package/packages/mailx-service/sync-worker.ts +101 -0
  28. package/packages/mailx-store/db.d.ts +1 -0
  29. package/packages/mailx-store/db.d.ts.map +1 -1
  30. package/packages/mailx-store/db.js +19 -0
  31. package/packages/mailx-store/db.js.map +1 -1
  32. package/packages/mailx-store/db.ts +20 -1
  33. package/packages/mailx-store/package.json +1 -1
  34. package/test/sync-worker-smoke.mjs +60 -0
@@ -17,6 +17,7 @@ import { Store } from "@bobfrankston/mailx-store";
17
17
  import { SyncQueue } from "./sync-queue.js";
18
18
  import { Reconciler } from "./reconciler.js";
19
19
  import { spawnDbWorker, type SpawnedDbWorker } from "./db-worker-client.js";
20
+ export { spawnSyncWorker, type SpawnedSyncWorker } from "./sync-worker-client.js";
20
21
  import { loadSettings, saveSettings, loadAccounts, loadAccountsAsync, saveAccounts, initCloudConfig, loadAllowlist, saveAllowlist, loadAutocomplete, saveAutocomplete, loadKeys, saveKeys, ensureKeysSectionExists, getStorePath, getStorageInfo, getConfigDir, loadUserDict, saveUserDict } from "@bobfrankston/mailx-settings";
21
22
  import type { AccountConfig, Folder, AutocompleteRequest, AutocompleteResponse, AutocompleteSettings, AiTransformRequest, AiTransformResponse, ExtractedEvent, MailxApi } from "@bobfrankston/mailx-types";
22
23
  import { sanitizeHtml, encodeQuotedPrintable, htmlToPlainText } from "@bobfrankston/mailx-types";
@@ -2485,25 +2486,14 @@ export class MailxService implements MailxApi {
2485
2486
  const delimiter = parent?.delimiter || allFolders[0]?.delimiter || ".";
2486
2487
  const fullPath = parentPath ? `${parentPath}${delimiter}${name}` : name;
2487
2488
 
2488
- const client = await this.imapManager.createPublicClient(accountId);
2489
- try {
2490
- await client.createmailbox(fullPath);
2491
- // Local-first: insert the new folder into the DB immediately so
2492
- // the UI shows it on the next tree refresh.
2493
- this.db.upsertFolder(accountId, fullPath, name, null, delimiter);
2494
- this.imapManager.emit("folderCountsChanged", accountId, {});
2495
- // VERIFY the server actually created it, and AWAIT it. createmailbox
2496
- // on a wedged/congested connection has returned without error while
2497
- // the CREATE never reached the server — so the IPC reported success,
2498
- // the optimistic row got dropped by the next reconcile, and the
2499
- // folder silently never appeared (Bob 2026-06-12, Archive/dbgtrash).
2500
- // A folder create is infrequent; correctness beats the fast return.
2501
- await this.imapManager.syncFolders(accountId, client);
2502
- const created = this.db.getFolders(accountId).some(f => f.path === fullPath);
2503
- if (!created) {
2504
- throw new Error(`Folder "${name}" was not created on the server — please try again (the connection may have been busy).`);
2505
- }
2506
- } finally { try { await client.logout(); } catch { /* */ } }
2489
+ // Server CREATE + local folder-row write + verify, all on the sync
2490
+ // worker (the verify catches the wedged-connection "returned OK but
2491
+ // never reached the server" case — Bob 2026-06-12 Archive/dbgtrash).
2492
+ const created = await this.imapManager.createFolderViaServer(accountId, fullPath, name, delimiter);
2493
+ this.imapManager.emit("folderCountsChanged", accountId, {});
2494
+ if (!created) {
2495
+ throw new Error(`Folder "${name}" was not created on the server — please try again (the connection may have been busy).`);
2496
+ }
2507
2497
  }
2508
2498
 
2509
2499
  async renameFolder(accountId: string, folderId: number, newName: string): Promise<void> {
@@ -2512,47 +2502,16 @@ export class MailxService implements MailxApi {
2512
2502
  const parts = folder.path.split(folder.delimiter || ".");
2513
2503
  parts[parts.length - 1] = newName;
2514
2504
  const newPath = parts.join(folder.delimiter || ".");
2515
- const client = await this.imapManager.createPublicClient(accountId);
2516
- try {
2517
- if (client.renameMailbox) {
2518
- await client.renameMailbox(folder.path, newPath);
2519
- } else {
2520
- await (client as any).withConnection(async () => {
2521
- await (client as any).client.mailboxRename(folder.path, newPath);
2522
- });
2523
- }
2524
- await this.imapManager.syncFolders(accountId, client);
2525
- await client.logout();
2526
- } finally { try { await client.logout(); } catch { /* */ } }
2505
+ await this.imapManager.renameFolderViaServer(accountId, folder.path, newPath);
2527
2506
  }
2528
2507
 
2529
2508
  async deleteFolder(accountId: string, folderId: number): Promise<void> {
2530
2509
  const folder = this.db.getFolders(accountId).find(f => f.id === folderId);
2531
2510
  if (!folder) throw new Error("Folder not found");
2532
- const client = await this.imapManager.createPublicClient(accountId);
2533
- try {
2534
- try {
2535
- if (client.deleteMailbox) {
2536
- await client.deleteMailbox(folder.path);
2537
- } else {
2538
- await (client as any).withConnection(async () => {
2539
- await (client as any).client.mailboxDelete(folder.path);
2540
- });
2541
- }
2542
- } catch (e: any) {
2543
- // Server already doesn't have this folder — common case when
2544
- // the user deleted / renamed it from another client and mailx
2545
- // is still showing the stale local row. Silently treat as
2546
- // success and proceed with local cleanup; the user's intent
2547
- // ("make this go away") is met either way.
2548
- const msg = String(e?.message || e || "").toLowerCase();
2549
- const alreadyGone = /nonexistent|does not exist|no such|not found|NO \[.*\] Mailbox|404/i.test(msg);
2550
- if (!alreadyGone) throw e;
2551
- console.log(` [folder] ${accountId} delete "${folder.path}": server says already gone — cleaning local DB`);
2552
- }
2553
- this.db.deleteFolder(folderId);
2554
- try { await client.logout(); } catch { /* ignore */ }
2555
- } finally { try { await client.logout(); } catch { /* */ } }
2511
+ // Server delete (tolerant of already-gone) on the worker, then drop the
2512
+ // local folder row here.
2513
+ await this.imapManager.deleteFolderViaServer(accountId, folder.path);
2514
+ this.db.deleteFolder(folderId);
2556
2515
  }
2557
2516
 
2558
2517
  /** Move a folder into the account's Trash. Default delete action; user
@@ -2600,73 +2559,10 @@ export class MailxService implements MailxApi {
2600
2559
  }
2601
2560
  if (!targetPath) throw new Error(`Too many "${baseName}" copies in Trash — empty Trash or use Delete permanently.`);
2602
2561
 
2603
- const client = await this.imapManager.createPublicClient(accountId);
2604
- try {
2605
- try {
2606
- if (client.renameMailbox) {
2607
- await client.renameMailbox(folder.path, targetPath);
2608
- } else {
2609
- await (client as any).withConnection(async () => {
2610
- await (client as any).client.mailboxRename(folder.path, targetPath);
2611
- });
2612
- }
2613
- console.log(` [folder] ${accountId} moved "${folder.path}" → "${targetPath}"`);
2614
- } catch (e: any) {
2615
- // Some Dovecot / Cyrus setups mark Trash \Noinferiors. Fall
2616
- // back to messages-only move + folder delete. We do this
2617
- // recursively for any child folders so the user's intent
2618
- // ("get rid of this subtree") is met.
2619
- const msg = String(e?.message || e || "").toLowerCase();
2620
- const cantNest = /noinferiors|hierarchy|invalid (mailbox )?name|cannot rename|inferiors/i.test(msg);
2621
- if (!cantNest) throw e;
2622
- console.log(` [folder] ${accountId} cannot RENAME under Trash (${msg.split("\n")[0]}); falling back to message-move`);
2623
- await this.spillFolderContentsToTrash(accountId, folder, trash, client);
2624
- }
2625
- await this.imapManager.syncFolders(accountId, client);
2626
- try { await client.logout(); } catch { /* */ }
2627
- } finally { try { await client.logout(); } catch { /* */ } }
2628
- }
2629
-
2630
- /** Fallback for `moveFolderToTrash` when the server rejects nesting
2631
- * under Trash. Moves every message in `folder` (and recursively in
2632
- * its children) into Trash root, then deletes the empty folders. */
2633
- private async spillFolderContentsToTrash(
2634
- accountId: string,
2635
- folder: { id: number; path: string; delimiter?: string | null },
2636
- trash: { id: number; path: string },
2637
- client: any,
2638
- ): Promise<void> {
2639
- const delim = folder.delimiter || "/";
2640
- // Process children first so the parent ends up empty.
2641
- const childPrefix = folder.path + delim;
2642
- const allFolders = this.db.getFolders(accountId);
2643
- const children = allFolders.filter(f => f.path.startsWith(childPrefix));
2644
- for (const child of children) {
2645
- await this.spillFolderContentsToTrash(accountId, child as any, trash, client);
2646
- }
2647
- // Move messages to Trash root via the Store + queue. withBatch
2648
- // coalesces folder-count events into one re-render per topic.
2649
- const uids = await client.getUids(folder.path).catch(() => [] as number[]);
2650
- if (uids.length > 0) {
2651
- this.localStore.bus.withBatch(() => {
2652
- for (const uid of uids) {
2653
- const result = this.localStore.trashMessage(accountId, uid, folder.id, trash.id);
2654
- if (result === "moved-to-trash") this.syncQueue.enqueueMove(accountId, uid, folder.id, trash.id);
2655
- else this.syncQueue.enqueueDelete(accountId, uid, folder.id, "delete");
2656
- }
2657
- });
2658
- }
2659
- // Folder should now be empty; delete it on the server.
2660
- try {
2661
- if (client.deleteMailbox) await client.deleteMailbox(folder.path);
2662
- else await client.withConnection(async () => {
2663
- await client.client.mailboxDelete(folder.path);
2664
- });
2665
- } catch (e: any) {
2666
- const m = String(e?.message || e || "").toLowerCase();
2667
- if (!/nonexistent|does not exist|no such/.test(m)) throw e;
2668
- }
2669
- this.db.deleteFolder(folder.id);
2562
+ // RENAME-into-Trash (or message-spill fallback) + folder-row cleanup,
2563
+ // all on the sync worker.
2564
+ await this.imapManager.moveFolderToTrashViaServer(accountId, folder.id, folder.path, targetPath, trash.id, trash.path, delim);
2565
+ this.imapManager.emit("folderCountsChanged", accountId, {});
2670
2566
  }
2671
2567
 
2672
2568
  markFolderRead(folderId: number): void {
@@ -2684,12 +2580,7 @@ export class MailxService implements MailxApi {
2684
2580
  try {
2685
2581
  (this.imapManager as any).emit?.("folderCountsChanged", accountId, {});
2686
2582
  } catch { /* non-fatal */ }
2687
- const client = await this.imapManager.createPublicClient(accountId);
2688
- try {
2689
- const uids = await client.getUids(folder.path);
2690
- for (const uid of uids) await client.deleteMessageByUid(folder.path, uid);
2691
- await client.logout();
2692
- } finally { try { await client.logout(); } catch { /* */ } }
2583
+ await this.imapManager.emptyFolderViaServer(accountId, folder.path);
2693
2584
  }
2694
2585
 
2695
2586
  // ── Attachments ──
@@ -13,6 +13,7 @@
13
13
  "@bobfrankston/mailx-imap": "file:../mailx-imap",
14
14
  "@bobfrankston/mailx-settings": "file:../mailx-settings",
15
15
  "@bobfrankston/mailx-store": "file:../mailx-store",
16
+ "@bobfrankston/node-tcp-transport": "file:../../../../MailApps/node-tcp-transport",
16
17
  "@bobfrankston/mailx-types": "file:../mailx-types",
17
18
  "html-to-docx": "^1.8.0",
18
19
  "mailparser": "^3.7.2",
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Main-thread client for the sync factor worker.
3
+ *
4
+ * Spawns `sync-worker.js`, performs the init handshake, and returns a
5
+ * `SyncProxy` that is a DROP-IN for `ImapManager` from the main thread's point
6
+ * of view: it's an EventEmitter (so `bin`'s `.on(...)` forwarders and the
7
+ * service's `.emit(...)` keep working locally), and any *other* method call is
8
+ * forwarded to the worker over the bus as `{method, args}` and returns the
9
+ * worker's result as a Promise.
10
+ *
11
+ * Two method names are handled locally instead of being forwarded:
12
+ * - the EventEmitter surface (on/once/off/emit/…) — local event routing.
13
+ * - getBodyStore() — returns a local FileMessageStore (a path wrapper; the
14
+ * bytes live on disk, so main can read them without crossing the bus).
15
+ *
16
+ * Worker → main events arrive on the bus topic `sync:event` and are re-emitted
17
+ * on the local EventEmitter, so a `syncProgress` raised inside the worker fires
18
+ * bin's listener exactly as before.
19
+ */
20
+ import { Worker } from "node:worker_threads";
21
+ import type { ImapManager } from "@bobfrankston/mailx-imap";
22
+ export interface SpawnedSyncWorker {
23
+ /** Drop-in for ImapManager. Method calls return Promises (forwarded to the
24
+ * worker); event methods are local. */
25
+ proxy: ImapManager;
26
+ worker: Worker;
27
+ close(): Promise<void>;
28
+ }
29
+ export declare function spawnSyncWorker(opts: {
30
+ configDir: string;
31
+ storePath: string;
32
+ }): Promise<SpawnedSyncWorker>;
33
+ //# sourceMappingURL=sync-worker-client.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"sync-worker-client.d.ts","sourceRoot":"","sources":["sync-worker-client.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAEH,OAAO,EAAE,MAAM,EAAE,MAAM,qBAAqB,CAAC;AAM7C,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,0BAA0B,CAAC;AAE5D,MAAM,WAAW,iBAAiB;IAC9B;4CACwC;IACxC,KAAK,EAAE,WAAW,CAAC;IACnB,MAAM,EAAE,MAAM,CAAC;IACf,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;CAC1B;AAQD,wBAAsB,eAAe,CAAC,IAAI,EAAE;IAAE,SAAS,EAAE,MAAM,CAAC;IAAC,SAAS,EAAE,MAAM,CAAA;CAAE,GAAG,OAAO,CAAC,iBAAiB,CAAC,CAiDhH"}
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Main-thread client for the sync factor worker.
3
+ *
4
+ * Spawns `sync-worker.js`, performs the init handshake, and returns a
5
+ * `SyncProxy` that is a DROP-IN for `ImapManager` from the main thread's point
6
+ * of view: it's an EventEmitter (so `bin`'s `.on(...)` forwarders and the
7
+ * service's `.emit(...)` keep working locally), and any *other* method call is
8
+ * forwarded to the worker over the bus as `{method, args}` and returns the
9
+ * worker's result as a Promise.
10
+ *
11
+ * Two method names are handled locally instead of being forwarded:
12
+ * - the EventEmitter surface (on/once/off/emit/…) — local event routing.
13
+ * - getBodyStore() — returns a local FileMessageStore (a path wrapper; the
14
+ * bytes live on disk, so main can read them without crossing the bus).
15
+ *
16
+ * Worker → main events arrive on the bus topic `sync:event` and are re-emitted
17
+ * on the local EventEmitter, so a `syncProgress` raised inside the worker fires
18
+ * bin's listener exactly as before.
19
+ */
20
+ import { Worker } from "node:worker_threads";
21
+ import { EventEmitter } from "node:events";
22
+ import { fileURLToPath } from "node:url";
23
+ import { dirname, join } from "node:path";
24
+ import { WorkerBus } from "@bobfrankston/mailx-bus";
25
+ import { FileMessageStore } from "@bobfrankston/mailx-store";
26
+ const EMITTER_METHODS = new Set([
27
+ "on", "once", "off", "addListener", "removeListener", "removeAllListeners",
28
+ "emit", "listeners", "rawListeners", "listenerCount", "eventNames",
29
+ "prependListener", "prependOnceListener", "setMaxListeners", "getMaxListeners",
30
+ ]);
31
+ export async function spawnSyncWorker(opts) {
32
+ const here = dirname(fileURLToPath(import.meta.url));
33
+ const workerPath = join(here, "sync-worker.js");
34
+ const worker = new Worker(workerPath);
35
+ worker.unref();
36
+ await new Promise((resolve, reject) => {
37
+ const onMessage = (msg) => {
38
+ if (!msg || typeof msg !== "object")
39
+ return;
40
+ if (msg.kind === "init-ok") {
41
+ worker.off("message", onMessage);
42
+ resolve();
43
+ }
44
+ else if (msg.kind === "init-error") {
45
+ worker.off("message", onMessage);
46
+ reject(new Error(`sync-worker init: ${msg.error}`));
47
+ }
48
+ };
49
+ worker.on("message", onMessage);
50
+ worker.on("error", (e) => { worker.off("message", onMessage); reject(e); });
51
+ worker.postMessage({ kind: "init", configDir: opts.configDir, storePath: opts.storePath });
52
+ });
53
+ const bus = new WorkerBus(worker);
54
+ const emitter = new EventEmitter();
55
+ emitter.setMaxListeners(0);
56
+ const localBodyStore = new FileMessageStore(opts.storePath);
57
+ // Worker events → local re-emit (drives bin's WebView forwarders).
58
+ bus.subscribe("sync:event", ({ event, args }) => {
59
+ try {
60
+ emitter.emit(event, ...(args || []));
61
+ }
62
+ catch (e) {
63
+ console.error(` [sync-worker] event '${event}' handler threw: ${e?.message || e}`);
64
+ }
65
+ });
66
+ const proxy = new Proxy(emitter, {
67
+ get(target, prop) {
68
+ if (typeof prop === "string" && EMITTER_METHODS.has(prop)) {
69
+ const v = target[prop];
70
+ return typeof v === "function" ? v.bind(target) : v;
71
+ }
72
+ if (prop === "getBodyStore")
73
+ return () => localBodyStore;
74
+ if (prop === "then")
75
+ return undefined; // not a thenable
76
+ // Everything else is an ImapManager method → forward to the worker.
77
+ // structured-clone turns a returned Node Buffer into a plain
78
+ // Uint8Array; re-wrap so callers that test Buffer.isBuffer / hand
79
+ // the bytes to simpleParser (fetchMessageBody) still work.
80
+ return (...args) => bus.request("sync:call", { method: prop, args }).then((r) => (r instanceof Uint8Array && !Buffer.isBuffer(r)) ? Buffer.from(r.buffer, r.byteOffset, r.byteLength) : r);
81
+ },
82
+ });
83
+ return {
84
+ proxy,
85
+ worker,
86
+ async close() { await worker.terminate(); },
87
+ };
88
+ }
89
+ //# sourceMappingURL=sync-worker-client.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"sync-worker-client.js","sourceRoot":"","sources":["sync-worker-client.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAEH,OAAO,EAAE,MAAM,EAAE,MAAM,qBAAqB,CAAC;AAC7C,OAAO,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAC3C,OAAO,EAAE,aAAa,EAAE,MAAM,UAAU,CAAC;AACzC,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,MAAM,WAAW,CAAC;AAC1C,OAAO,EAAE,SAAS,EAAwB,MAAM,yBAAyB,CAAC;AAC1E,OAAO,EAAE,gBAAgB,EAAE,MAAM,2BAA2B,CAAC;AAW7D,MAAM,eAAe,GAAG,IAAI,GAAG,CAAC;IAC5B,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,aAAa,EAAE,gBAAgB,EAAE,oBAAoB;IAC1E,MAAM,EAAE,WAAW,EAAE,cAAc,EAAE,eAAe,EAAE,YAAY;IAClE,iBAAiB,EAAE,qBAAqB,EAAE,iBAAiB,EAAE,iBAAiB;CACjF,CAAC,CAAC;AAEH,MAAM,CAAC,KAAK,UAAU,eAAe,CAAC,IAA8C;IAChF,MAAM,IAAI,GAAG,OAAO,CAAC,aAAa,CAAC,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC;IACrD,MAAM,UAAU,GAAG,IAAI,CAAC,IAAI,EAAE,gBAAgB,CAAC,CAAC;IAChD,MAAM,MAAM,GAAG,IAAI,MAAM,CAAC,UAAU,CAAC,CAAC;IACtC,MAAM,CAAC,KAAK,EAAE,CAAC;IAEf,MAAM,IAAI,OAAO,CAAO,CAAC,OAAO,EAAE,MAAM,EAAE,EAAE;QACxC,MAAM,SAAS,GAAG,CAAC,GAAQ,EAAQ,EAAE;YACjC,IAAI,CAAC,GAAG,IAAI,OAAO,GAAG,KAAK,QAAQ;gBAAE,OAAO;YAC5C,IAAI,GAAG,CAAC,IAAI,KAAK,SAAS,EAAE,CAAC;gBAAC,MAAM,CAAC,GAAG,CAAC,SAAS,EAAE,SAAS,CAAC,CAAC;gBAAC,OAAO,EAAE,CAAC;YAAC,CAAC;iBACvE,IAAI,GAAG,CAAC,IAAI,KAAK,YAAY,EAAE,CAAC;gBAAC,MAAM,CAAC,GAAG,CAAC,SAAS,EAAE,SAAS,CAAC,CAAC;gBAAC,MAAM,CAAC,IAAI,KAAK,CAAC,qBAAqB,GAAG,CAAC,KAAK,EAAE,CAAC,CAAC,CAAC;YAAC,CAAC;QAClI,CAAC,CAAC;QACF,MAAM,CAAC,EAAE,CAAC,SAAS,EAAE,SAAS,CAAC,CAAC;QAChC,MAAM,CAAC,EAAE,CAAC,OAAO,EAAE,CAAC,CAAQ,EAAE,EAAE,GAAG,MAAM,CAAC,GAAG,CAAC,SAAS,EAAE,SAAS,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;QACnF,MAAM,CAAC,WAAW,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,SAAS,EAAE,IAAI,CAAC,SAAS,EAAE,SAAS,EAAE,IAAI,CAAC,SAAS,EAAE,CAAC,CAAC;IAC/F,CAAC,CAAC,CAAC;IAEH,MAAM,GAAG,GAAG,IAAI,SAAS,CAAC,MAAoC,CAAC,CAAC;IAChE,MAAM,OAAO,GAAG,IAAI,YAAY,EAAE,CAAC;IACnC,OAAO,CAAC,eAAe,CAAC,CAAC,CAAC,CAAC;IAC3B,MAAM,cAAc,GAAG,IAAI,gBAAgB,CAAC,IAAI,CAAC,SAAS,CAAC,CAAC;IAE5D,mEAAmE;IACnE,GAAG,CAAC,SAAS,CAAiC,YAAY,EAAE,CAAC,EAAE,KAAK,EAAE,IAAI,EAAE,EAAE,EAAE;QAC5E,IAAI,CAAC;YAAC,OAAO,CAAC,IAAI,CAAC,KAAK,EAAE,GAAG,CAAC,IAAI,IAAI,EAAE,CAAC,CAAC,CAAC;QAAC,CAAC;QAAC,OAAO,CAAM,EAAE,CAAC;YAAC,OAAO,CAAC,KAAK,CAAC,0BAA0B,KAAK,oBAAoB,CAAC,EAAE,OAAO,IAAI,CAAC,EAAE,CAAC,CAAC;QAAC,CAAC;IACzJ,CAAC,CAAC,CAAC;IAEH,MAAM,KAAK,GAAG,IAAI,KAAK,CAAC,OAAO,EAAE;QAC7B,GAAG,CAAC,MAAM,EAAE,IAAY;YACpB,IAAI,OAAO,IAAI,KAAK,QAAQ,IAAI,eAAe,CAAC,GAAG,CAAC,IAAI,CAAC,EAAE,CAAC;gBACxD,MAAM,CAAC,GAAI,MAAc,CAAC,IAAI,CAAC,CAAC;gBAChC,OAAO,OAAO,CAAC,KAAK,UAAU,CAAC,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;YACxD,CAAC;YACD,IAAI,IAAI,KAAK,cAAc;gBAAE,OAAO,GAAG,EAAE,CAAC,cAAc,CAAC;YACzD,IAAI,IAAI,KAAK,MAAM;gBAAE,OAAO,SAAS,CAAC,CAAC,iBAAiB;YACxD,oEAAoE;YACpE,6DAA6D;YAC7D,kEAAkE;YAClE,2DAA2D;YAC3D,OAAO,CAAC,GAAG,IAAW,EAAE,EAAE,CAAC,GAAG,CAAC,OAAO,CAAC,WAAW,EAAE,EAAE,MAAM,EAAE,IAAI,EAAE,IAAI,EAAE,CAAC,CAAC,IAAI,CAAC,CAAC,CAAM,EAAE,EAAE,CACxF,CAAC,CAAC,YAAY,UAAU,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC,CAAC,MAAM,EAAE,CAAC,CAAC,UAAU,EAAE,CAAC,CAAC,UAAU,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;QAClH,CAAC;KACJ,CAA2B,CAAC;IAE7B,OAAO;QACH,KAAK;QACL,MAAM;QACN,KAAK,CAAC,KAAK,KAAoB,MAAM,MAAM,CAAC,SAAS,EAAE,CAAC,CAAC,CAAC;KAC7D,CAAC;AACN,CAAC"}
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Main-thread client for the sync factor worker.
3
+ *
4
+ * Spawns `sync-worker.js`, performs the init handshake, and returns a
5
+ * `SyncProxy` that is a DROP-IN for `ImapManager` from the main thread's point
6
+ * of view: it's an EventEmitter (so `bin`'s `.on(...)` forwarders and the
7
+ * service's `.emit(...)` keep working locally), and any *other* method call is
8
+ * forwarded to the worker over the bus as `{method, args}` and returns the
9
+ * worker's result as a Promise.
10
+ *
11
+ * Two method names are handled locally instead of being forwarded:
12
+ * - the EventEmitter surface (on/once/off/emit/…) — local event routing.
13
+ * - getBodyStore() — returns a local FileMessageStore (a path wrapper; the
14
+ * bytes live on disk, so main can read them without crossing the bus).
15
+ *
16
+ * Worker → main events arrive on the bus topic `sync:event` and are re-emitted
17
+ * on the local EventEmitter, so a `syncProgress` raised inside the worker fires
18
+ * bin's listener exactly as before.
19
+ */
20
+
21
+ import { Worker } from "node:worker_threads";
22
+ import { EventEmitter } from "node:events";
23
+ import { fileURLToPath } from "node:url";
24
+ import { dirname, join } from "node:path";
25
+ import { WorkerBus, type MessagePortLike } from "@bobfrankston/mailx-bus";
26
+ import { FileMessageStore } from "@bobfrankston/mailx-store";
27
+ import type { ImapManager } from "@bobfrankston/mailx-imap";
28
+
29
+ export interface SpawnedSyncWorker {
30
+ /** Drop-in for ImapManager. Method calls return Promises (forwarded to the
31
+ * worker); event methods are local. */
32
+ proxy: ImapManager;
33
+ worker: Worker;
34
+ close(): Promise<void>;
35
+ }
36
+
37
+ const EMITTER_METHODS = new Set([
38
+ "on", "once", "off", "addListener", "removeListener", "removeAllListeners",
39
+ "emit", "listeners", "rawListeners", "listenerCount", "eventNames",
40
+ "prependListener", "prependOnceListener", "setMaxListeners", "getMaxListeners",
41
+ ]);
42
+
43
+ export async function spawnSyncWorker(opts: { configDir: string; storePath: string }): Promise<SpawnedSyncWorker> {
44
+ const here = dirname(fileURLToPath(import.meta.url));
45
+ const workerPath = join(here, "sync-worker.js");
46
+ const worker = new Worker(workerPath);
47
+ worker.unref();
48
+
49
+ await new Promise<void>((resolve, reject) => {
50
+ const onMessage = (msg: any): void => {
51
+ if (!msg || typeof msg !== "object") return;
52
+ if (msg.kind === "init-ok") { worker.off("message", onMessage); resolve(); }
53
+ else if (msg.kind === "init-error") { worker.off("message", onMessage); reject(new Error(`sync-worker init: ${msg.error}`)); }
54
+ };
55
+ worker.on("message", onMessage);
56
+ worker.on("error", (e: Error) => { worker.off("message", onMessage); reject(e); });
57
+ worker.postMessage({ kind: "init", configDir: opts.configDir, storePath: opts.storePath });
58
+ });
59
+
60
+ const bus = new WorkerBus(worker as unknown as MessagePortLike);
61
+ const emitter = new EventEmitter();
62
+ emitter.setMaxListeners(0);
63
+ const localBodyStore = new FileMessageStore(opts.storePath);
64
+
65
+ // Worker events → local re-emit (drives bin's WebView forwarders).
66
+ bus.subscribe<{ event: string; args: any[] }>("sync:event", ({ event, args }) => {
67
+ try { emitter.emit(event, ...(args || [])); } catch (e: any) { console.error(` [sync-worker] event '${event}' handler threw: ${e?.message || e}`); }
68
+ });
69
+
70
+ const proxy = new Proxy(emitter, {
71
+ get(target, prop: string) {
72
+ if (typeof prop === "string" && EMITTER_METHODS.has(prop)) {
73
+ const v = (target as any)[prop];
74
+ return typeof v === "function" ? v.bind(target) : v;
75
+ }
76
+ if (prop === "getBodyStore") return () => localBodyStore;
77
+ if (prop === "then") return undefined; // not a thenable
78
+ // Everything else is an ImapManager method → forward to the worker.
79
+ // structured-clone turns a returned Node Buffer into a plain
80
+ // Uint8Array; re-wrap so callers that test Buffer.isBuffer / hand
81
+ // the bytes to simpleParser (fetchMessageBody) still work.
82
+ return (...args: any[]) => bus.request("sync:call", { method: prop, args }).then((r: any) =>
83
+ (r instanceof Uint8Array && !Buffer.isBuffer(r)) ? Buffer.from(r.buffer, r.byteOffset, r.byteLength) : r);
84
+ },
85
+ }) as unknown as ImapManager;
86
+
87
+ return {
88
+ proxy,
89
+ worker,
90
+ async close(): Promise<void> { await worker.terminate(); },
91
+ };
92
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Sync factor — Node worker thread.
3
+ *
4
+ * Hosts the entire network/sync backend (`ImapManager`: IMAP + Gmail sync,
5
+ * body prefetch, the outbox SMTP/IMAP-append drain, IDLE) on its OWN thread,
6
+ * with its OWN write connection to the SQLite file. The main thread keeps the
7
+ * IPC pump and fast local reads (read-worker); it drives this worker through
8
+ * a proxy (`sync-worker-client.ts`) and receives ImapManager's events back
9
+ * over the bus.
10
+ *
11
+ * **Why.** Until now sync ran on the main event loop. A hung Dovecot FETCH, a
12
+ * 30 MB body pull, a dead-network reconnect storm, a 5-minute outbox hang —
13
+ * any of them froze the main loop, which starved the IPC relay, so clicks and
14
+ * the message list timed out even though the read-worker had the answer ready.
15
+ * On its own thread, sync can stall or thrash forever and the UI never feels
16
+ * it (Bob 2026-06-13: "shouldn't it be a worker for any such operation").
17
+ *
18
+ * **DB.** Opens with `{skipMigrations:true}` — main built the schema before we
19
+ * spawned. WAL + busy_timeout let this writer and the main thread's
20
+ * local-first writer coexist; the read-worker reads concurrently.
21
+ *
22
+ * **RPC.** One generic handler: the proxy sends `{method, args}` and we invoke
23
+ * `imapManager[method](...args)`. Return values must be structured-clone-safe
24
+ * (they are — JSON-ish status objects, tokens, bodies-as-strings). Methods
25
+ * that hand back live objects (createPublicClient) are NOT called this way;
26
+ * the service uses dedicated server-side folder-op methods instead.
27
+ *
28
+ * **Events.** We wrap `imapManager.emit` to also `bus.publish("sync:event")`,
29
+ * so every event ImapManager/Reconciler raise on this thread reaches the main
30
+ * proxy, which re-emits them locally for bin's WebView forwarders.
31
+ */
32
+ export {};
33
+ //# sourceMappingURL=sync-worker.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"sync-worker.d.ts","sourceRoot":"","sources":["sync-worker.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8BG"}
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Sync factor — Node worker thread.
3
+ *
4
+ * Hosts the entire network/sync backend (`ImapManager`: IMAP + Gmail sync,
5
+ * body prefetch, the outbox SMTP/IMAP-append drain, IDLE) on its OWN thread,
6
+ * with its OWN write connection to the SQLite file. The main thread keeps the
7
+ * IPC pump and fast local reads (read-worker); it drives this worker through
8
+ * a proxy (`sync-worker-client.ts`) and receives ImapManager's events back
9
+ * over the bus.
10
+ *
11
+ * **Why.** Until now sync ran on the main event loop. A hung Dovecot FETCH, a
12
+ * 30 MB body pull, a dead-network reconnect storm, a 5-minute outbox hang —
13
+ * any of them froze the main loop, which starved the IPC relay, so clicks and
14
+ * the message list timed out even though the read-worker had the answer ready.
15
+ * On its own thread, sync can stall or thrash forever and the UI never feels
16
+ * it (Bob 2026-06-13: "shouldn't it be a worker for any such operation").
17
+ *
18
+ * **DB.** Opens with `{skipMigrations:true}` — main built the schema before we
19
+ * spawned. WAL + busy_timeout let this writer and the main thread's
20
+ * local-first writer coexist; the read-worker reads concurrently.
21
+ *
22
+ * **RPC.** One generic handler: the proxy sends `{method, args}` and we invoke
23
+ * `imapManager[method](...args)`. Return values must be structured-clone-safe
24
+ * (they are — JSON-ish status objects, tokens, bodies-as-strings). Methods
25
+ * that hand back live objects (createPublicClient) are NOT called this way;
26
+ * the service uses dedicated server-side folder-op methods instead.
27
+ *
28
+ * **Events.** We wrap `imapManager.emit` to also `bus.publish("sync:event")`,
29
+ * so every event ImapManager/Reconciler raise on this thread reaches the main
30
+ * proxy, which re-emits them locally for bin's WebView forwarders.
31
+ */
32
+ import { parentPort } from "node:worker_threads";
33
+ import { WorkerBus } from "@bobfrankston/mailx-bus";
34
+ import { MailxDB, FileMessageStore, Store } from "@bobfrankston/mailx-store";
35
+ import { ImapManager } from "@bobfrankston/mailx-imap";
36
+ import { NodeTcpTransport } from "@bobfrankston/node-tcp-transport";
37
+ if (!parentPort) {
38
+ throw new Error("sync-worker: must be spawned as a worker, parentPort is null");
39
+ }
40
+ let imapManager = null;
41
+ let initialized = false;
42
+ parentPort.once("message", async (msg) => {
43
+ if (msg?.kind !== "init") {
44
+ parentPort.postMessage({ kind: "init-error", error: `expected "init", got ${JSON.stringify(msg)}` });
45
+ return;
46
+ }
47
+ try {
48
+ const db = new MailxDB(msg.configDir, { skipMigrations: true });
49
+ const store = new Store(db, new FileMessageStore(msg.storePath));
50
+ imapManager = new ImapManager(store, () => new NodeTcpTransport());
51
+ registerHandlers(imapManager);
52
+ initialized = true;
53
+ parentPort.postMessage({ kind: "init-ok" });
54
+ }
55
+ catch (e) {
56
+ parentPort.postMessage({ kind: "init-error", error: e?.message || String(e) });
57
+ }
58
+ });
59
+ function registerHandlers(mgr) {
60
+ const bus = new WorkerBus(parentPort);
61
+ // Forward every ImapManager/Reconciler event to the main proxy. Wrap emit
62
+ // so we don't have to enumerate the event list (it drifts); the proxy
63
+ // re-emits locally and bin's forwarders push to the WebView.
64
+ const origEmit = mgr.emit.bind(mgr);
65
+ mgr.emit = (event, ...args) => {
66
+ try {
67
+ bus.publish("sync:event", { event, args });
68
+ }
69
+ catch { /* bus down — local listeners still fire */ }
70
+ return origEmit(event, ...args);
71
+ };
72
+ // Generic method dispatch. Anything the main thread used to call on
73
+ // `imapManager` now arrives here.
74
+ bus.register("sync:call", async ({ method, args }) => {
75
+ const fn = mgr[method];
76
+ if (typeof fn !== "function") {
77
+ throw new Error(`sync-worker: ImapManager has no method '${method}'`);
78
+ }
79
+ return await fn.apply(mgr, args || []);
80
+ });
81
+ }
82
+ // Pre-init guard: a request that races construction gets a clear error, not a hang.
83
+ parentPort.on("message", (msg) => {
84
+ if (!initialized && msg && typeof msg === "object" && msg.kind === "request") {
85
+ parentPort.postMessage({
86
+ kind: "reply",
87
+ id: msg.id,
88
+ error: "sync-worker: not initialized — send {kind:'init'} first",
89
+ });
90
+ }
91
+ });
92
+ //# sourceMappingURL=sync-worker.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"sync-worker.js","sourceRoot":"","sources":["sync-worker.ts"],"names":[],"mappings":"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"}