@bobfrankston/rmfmail 1.0.545 → 1.0.548

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 (46) hide show
  1. package/bin/mailx.js +21 -1
  2. package/bin/mailx.js.map +1 -1
  3. package/bin/mailx.ts +20 -1
  4. package/client/app.js +51 -1
  5. package/client/app.js.map +1 -1
  6. package/client/app.ts +50 -1
  7. package/client/components/message-list.js +1 -3
  8. package/client/components/message-list.js.map +1 -1
  9. package/client/components/message-list.ts +1 -3
  10. package/client/components/message-viewer.js +65 -97
  11. package/client/components/message-viewer.js.map +1 -1
  12. package/client/components/message-viewer.ts +51 -90
  13. package/client/lib/api-client.js +5 -0
  14. package/client/lib/api-client.js.map +1 -1
  15. package/client/lib/api-client.ts +5 -1
  16. package/client/styles/components.css +0 -12
  17. package/package.json +1 -1
  18. package/packages/mailx-server/index.d.ts.map +1 -1
  19. package/packages/mailx-server/index.js +13 -0
  20. package/packages/mailx-server/index.js.map +1 -1
  21. package/packages/mailx-server/index.ts +14 -0
  22. package/packages/mailx-service/charset.d.ts +15 -0
  23. package/packages/mailx-service/charset.d.ts.map +1 -0
  24. package/packages/mailx-service/charset.js +61 -0
  25. package/packages/mailx-service/charset.js.map +1 -0
  26. package/packages/mailx-service/charset.ts +45 -0
  27. package/packages/mailx-service/index.d.ts +26 -0
  28. package/packages/mailx-service/index.d.ts.map +1 -1
  29. package/packages/mailx-service/index.js +84 -281
  30. package/packages/mailx-service/index.js.map +1 -1
  31. package/packages/mailx-service/index.ts +88 -266
  32. package/packages/mailx-service/local-store.d.ts +86 -0
  33. package/packages/mailx-service/local-store.d.ts.map +1 -0
  34. package/packages/mailx-service/local-store.js +259 -0
  35. package/packages/mailx-service/local-store.js.map +1 -0
  36. package/packages/mailx-service/local-store.ts +307 -0
  37. package/packages/mailx-service/reconciler.d.ts +62 -0
  38. package/packages/mailx-service/reconciler.d.ts.map +1 -0
  39. package/packages/mailx-service/reconciler.js +141 -0
  40. package/packages/mailx-service/reconciler.js.map +1 -0
  41. package/packages/mailx-service/reconciler.ts +151 -0
  42. package/packages/mailx-service/sync-queue.d.ts +79 -0
  43. package/packages/mailx-service/sync-queue.d.ts.map +1 -0
  44. package/packages/mailx-service/sync-queue.js +118 -0
  45. package/packages/mailx-service/sync-queue.js.map +1 -0
  46. package/packages/mailx-service/sync-queue.ts +134 -0
@@ -12,6 +12,10 @@ import { MailxDB } from "@bobfrankston/mailx-store";
12
12
  const __dirname = import.meta.dirname;
13
13
  import { ImapManager } from "@bobfrankston/mailx-imap";
14
14
  import * as gsync from "./google-sync.js";
15
+ import { sniffAndFixCharset } from "./charset.js";
16
+ import { LocalStore } from "./local-store.js";
17
+ import { SyncQueue } from "./sync-queue.js";
18
+ import { Reconciler } from "./reconciler.js";
15
19
  import { loadSettings, saveSettings, loadAccounts, loadAccountsAsync, saveAccounts, initCloudConfig, loadAllowlist, saveAllowlist, loadAutocomplete, saveAutocomplete, loadKeys, saveKeys, ensureKeysSectionExists, getStorePath, getStorageInfo, getConfigDir } from "@bobfrankston/mailx-settings";
16
20
  import type { AccountConfig, Folder, AutocompleteRequest, AutocompleteResponse, AutocompleteSettings, AiTransformRequest, AiTransformResponse, ExtractedEvent } from "@bobfrankston/mailx-types";
17
21
  import { sanitizeHtml, encodeQuotedPrintable, htmlToPlainText } from "@bobfrankston/mailx-types";
@@ -27,71 +31,11 @@ import { simpleParser } from "mailparser";
27
31
  * Operates byte-wise so MIME boundaries / base64 / etc. are preserved.
28
32
  * The `utf-8` validity test rejects 0xC0–0xC1 / 0xF5–0xFF and continuation
29
33
  * bytes out of place, which would be common in genuine Latin-1 text. */
30
- function sniffAndFixCharset(raw: Buffer): Buffer {
31
- const HEAD_LIMIT = 16384;
32
- const head = raw.subarray(0, Math.min(HEAD_LIMIT, raw.length)).toString("latin1");
33
- const re = /charset\s*=\s*"?(iso-8859-1|us-ascii|windows-1252|latin1)"?/gi;
34
- if (!re.test(head)) return raw;
35
- if (!isValidUtf8(raw)) return raw;
36
- const fixed = head.replace(/charset\s*=\s*"?(iso-8859-1|us-ascii|windows-1252|latin1)"?/gi, "charset=utf-8");
37
- return Buffer.concat([Buffer.from(fixed, "latin1"), raw.subarray(head.length)]);
38
- }
39
-
40
- /** Strict UTF-8 validity check: rejects overlong forms, invalid start
41
- * bytes, and dangling continuations. Used to confirm the body is really
42
- * UTF-8 before overriding a Latin-1 declaration. */
43
- function isValidUtf8(buf: Buffer): boolean {
44
- let i = 0;
45
- while (i < buf.length) {
46
- const b = buf[i];
47
- if (b < 0x80) { i++; continue; }
48
- let need: number;
49
- if ((b & 0xE0) === 0xC0) { if (b < 0xC2) return false; need = 1; }
50
- else if ((b & 0xF0) === 0xE0) need = 2;
51
- else if ((b & 0xF8) === 0xF0) { if (b > 0xF4) return false; need = 3; }
52
- else return false;
53
- if (i + need >= buf.length) return false;
54
- for (let k = 1; k <= need; k++) {
55
- if ((buf[i + k] & 0xC0) !== 0x80) return false;
56
- }
57
- i += need + 1;
58
- }
59
- return true;
60
- }
61
-
62
- /** Parse `List-Unsubscribe` (RFC 2369) and `List-Unsubscribe-Post` (RFC 8058).
63
- * mailparser only exposes ONE of mail/url even when both are present, so we
64
- * also scan the raw header text for the full set of angle-bracketed URIs. */
65
- function parseListUnsubscribe(headers: any): { listUnsubscribeMail: string; listUnsubscribeHttp: string; listUnsubscribeOneClick: boolean } {
66
- let mail = "";
67
- let http = "";
68
- let oneClick = false;
34
+ // sniffAndFixCharset moved to ./charset.ts so LocalStore can use it
35
+ // without creating a circular dep with index.ts.
69
36
 
70
- const raw = headers.get("list-unsubscribe");
71
- const rawStr = typeof raw === "string" ? raw : (raw && typeof (raw as any).text === "string" ? (raw as any).text : "");
72
- if (rawStr) {
73
- const matches = rawStr.match(/<([^>]+)>/g) || [];
74
- for (const m of matches) {
75
- const url = m.slice(1, -1).trim();
76
- if (!mail && /^mailto:/i.test(url)) mail = url;
77
- else if (!http && /^https?:/i.test(url)) http = url;
78
- }
79
- }
80
- if (!mail && !http) {
81
- const listHeaders = headers.get("list");
82
- if (listHeaders?.unsubscribe) {
83
- const unsub = listHeaders.unsubscribe;
84
- if (unsub.url) http = Array.isArray(unsub.url) ? unsub.url[0] : unsub.url;
85
- if (unsub.mail) mail = `mailto:${Array.isArray(unsub.mail) ? unsub.mail[0] : unsub.mail}`;
86
- }
87
- }
88
-
89
- const post = headers.get("list-unsubscribe-post");
90
- const postStr = typeof post === "string" ? post : (post && typeof (post as any).text === "string" ? (post as any).text : "");
91
- if (postStr && /one-?click/i.test(postStr)) oneClick = true;
92
-
93
- return { listUnsubscribeMail: mail, listUnsubscribeHttp: http, listUnsubscribeOneClick: oneClick };
94
- }
37
+ // parseListUnsubscribe moved to ./local-store.ts (only consumer is the
38
+ // body-read path, which is now part of LocalStore).
95
39
 
96
40
  // ── Email provider detection (MX-based) ──
97
41
 
@@ -177,10 +121,28 @@ export class MailxService {
177
121
  * showReminderPopup returns a "no host" reason. */
178
122
  setPopupFn(fn: PopupFn): void { this.popupFn = fn; }
179
123
 
124
+ /** Local-first read/write facade. Every UI IPC handler that touches the
125
+ * local DB or body store goes through this — no awaiting IMAP, no
126
+ * awaiting Gmail API, no awaiting SMTP. See docs/local-first-plan.md. */
127
+ private localStore: LocalStore;
128
+
129
+ /** Persistent (and in-memory body-fetch) queue. UI handlers commit
130
+ * locally, then enqueue a server-mirror task here. */
131
+ private syncQueue: SyncQueue;
132
+
133
+ /** Background loop: drains body-fetch tasks, retries failed message
134
+ * actions, emits sync-state events for the status pill. */
135
+ private reconciler: Reconciler;
136
+
180
137
  constructor(
181
138
  private db: MailxDB,
182
139
  private imapManager: ImapManager,
183
140
  ) {
141
+ this.localStore = new LocalStore(db, imapManager.getBodyStore());
142
+ this.syncQueue = new SyncQueue(db, imapManager);
143
+ this.reconciler = new Reconciler(db, imapManager, this.syncQueue);
144
+ this.reconciler.start();
145
+
184
146
  // Invalidate account cache when accounts.jsonc changes on disk or GDrive.
185
147
  this.imapManager.on?.("configChanged", (filename: string) => {
186
148
  if (filename === "accounts.jsonc") this._accountsCache = null;
@@ -412,204 +374,54 @@ export class MailxService {
412
374
  // ── Messages ──
413
375
 
414
376
  getUnifiedInbox(page = 1, pageSize = 50): any {
415
- return this.db.getUnifiedInbox(page, pageSize);
377
+ return this.localStore.getUnifiedInbox(page, pageSize);
416
378
  }
417
379
 
418
380
  getMessages(accountId: string, folderId: number, page = 1, pageSize = 50, sort = "date", sortDir = "desc", search?: string, flaggedOnly = false): any {
419
- return this.db.getMessages({ accountId, folderId, page, pageSize, sort: sort as any, sortDir: sortDir as any, search, flaggedOnly });
381
+ return this.localStore.getMessages({ accountId, folderId, page, pageSize, sort: sort as any, sortDir: sortDir as any, search, flaggedOnly });
420
382
  }
421
383
 
384
+ /** UI body read — local-first. Returns immediately from the local cache:
385
+ * `cached: true` with the full parsed body when on disk, or `cached: false`
386
+ * with envelope-only when not. In the cache-miss case we kick off a
387
+ * fire-and-forget IMAP fetch in the background and emit `bodyAvailable`
388
+ * when the body lands; the UI listens for that event and re-requests.
389
+ *
390
+ * No `await imap*` in the click → render path. The 60s body-fetch race
391
+ * and structured `bodyError` shape that lived here previously are gone —
392
+ * step 1 of the local-first refactor (docs/local-first-plan.md). */
422
393
  async getMessage(accountId: string, uid: number, allowRemote = false, folderId?: number): Promise<any> {
423
- const envelope = this.db.getMessageByUid(accountId, uid, folderId);
424
- if (!envelope) throw new Error("Message not found");
425
-
426
- let bodyHtml = "";
427
- let bodyText = "";
428
- let hasRemoteContent = false;
429
- let attachments: { id: number; filename: string; mimeType: string; size: number; contentId: string }[] = [];
430
-
431
- // The per-account ops queue inside ImapManager has its own per-task
432
- // timeout that destroys a wedged client and unblocks the queue. This
433
- // outer race is a safety net only — the underlying timeout in
434
- // withConnection should trigger first.
435
- const BODY_FETCH_TIMEOUT_MS = 60_000;
436
- let raw: Buffer | null = null;
437
- try {
438
- raw = await Promise.race([
439
- this.imapManager.fetchMessageBody(accountId, envelope.folderId, envelope.uid),
440
- new Promise<never>((_, reject) =>
441
- setTimeout(() => reject(new Error("body fetch timed out — try again")), BODY_FETCH_TIMEOUT_MS)
442
- ),
443
- ]);
444
- } catch (fetchErr: any) {
445
- // Message was deleted from the server (another device, expunge, etc.) —
446
- // drop the stale local row so the UI removes it instead of showing a
447
- // confusing error. Throwing a tagged error lets the client react.
448
- if ((fetchErr as any)?.isNotFound) {
449
- try {
450
- this.db.deleteMessage(accountId, envelope.uid);
451
- this.db.recalcFolderCounts(envelope.folderId);
452
- } catch { /* ignore */ }
453
- const err = new Error("Message was deleted from the server");
454
- (err as any).isNotFound = true;
455
- throw err;
456
- }
457
- // Don't stuff the error text into bodyText — it bleeds into the
458
- // viewer's main content area. Surface as a structured error field
459
- // so the UI can render a banner with retry UX above the (empty)
460
- // body. The caller keeps the envelope so the header still shows.
461
- const rawErr = fetchErr.message || "connection failed";
462
- const isTransient = /connection|Too many|UNAVAILABLE|rate|429|5\d\d|timeout|ENOTFOUND|ECONNRESET|ETIMEDOUT/i.test(rawErr);
463
- return {
464
- ...envelope, bodyHtml: "", bodyText: "",
465
- bodyError: rawErr,
466
- bodyErrorTransient: isTransient,
467
- hasRemoteContent: false, remoteAllowed: false, attachments: [], emlPath: "", deliveredTo: "", returnPath: "", listUnsubscribe: ""
468
- };
469
- }
470
-
471
- if (!raw) {
472
- // Same treatment as the thrown-error case: structured field, not body text.
473
- return {
474
- ...envelope, bodyHtml: "", bodyText: "",
475
- bodyError: "Message body not cached locally and the server fetch returned nothing.",
476
- bodyErrorTransient: true,
477
- hasRemoteContent: false, remoteAllowed: false, attachments: [], emlPath: "", deliveredTo: "", returnPath: "", listUnsubscribe: ""
478
- };
479
- } else {
480
- // Many senders (esp. PHPMailer-driven marketing) declare
481
- // `charset=iso-8859-1` but emit UTF-8 bytes. simpleParser
482
- // honors the declared charset and produces "â??" garbage for
483
- // every non-ASCII codepoint (em-dash, smart quotes, …). If
484
- // the raw body bytes are valid UTF-8, rewrite the charset
485
- // header before parsing. We only override the obviously-
486
- // wrong legacy declarations; explicit utf-8 / koi8 / etc.
487
- // pass through.
488
- const adjusted = sniffAndFixCharset(raw);
489
- const parsed = await simpleParser(adjusted);
490
- bodyHtml = parsed.html || "";
491
- bodyText = parsed.text || "";
492
- attachments = (parsed.attachments || []).map((a, i) => ({
493
- id: i,
494
- filename: a.filename || `attachment-${i}`,
495
- mimeType: a.contentType || "application/octet-stream",
496
- size: a.size || 0,
497
- contentId: a.contentId || ""
498
- }));
499
- }
500
-
501
- // Sanitize HTML
502
- if (bodyHtml && !allowRemote) {
503
- const allowList = loadAllowlist();
504
- const senderAddr = envelope.from?.address || "";
505
- const senderDomain = senderAddr.split("@")[1] || "";
506
- const toAddrs = (envelope.to || []).map((a: any) => a.address);
507
- const isAllowed = allowList.senders.includes(senderAddr) ||
508
- allowList.domains.includes(senderDomain) ||
509
- toAddrs.some((a: string) => allowList.recipients?.includes(a));
510
-
511
- if (isAllowed) {
512
- allowRemote = true;
513
- } else {
514
- const result = sanitizeHtml(bodyHtml);
515
- bodyHtml = result.html;
516
- hasRemoteContent = result.hasRemoteContent;
517
- }
518
- }
519
-
520
- // Extract headers
521
- let deliveredTo = "";
522
- let returnPath = "";
523
- let listUnsubscribe = "";
524
- let listUnsubscribeMail = "";
525
- let listUnsubscribeHttp = "";
526
- let listUnsubscribeOneClick = false;
527
- if (raw) {
528
- const parsed2 = await simpleParser(raw);
529
- const hdr = (key: string): string => {
530
- let v = parsed2.headers.get(key);
531
- if (!v) return "";
532
- if (Array.isArray(v)) v = v[0];
533
- if (typeof v === "string") return v;
534
- if (typeof v === "object" && v !== null) {
535
- if ("text" in v) return (v as any).text || "";
536
- if ("value" in v) return String((v as any).value);
537
- if ("address" in v) return (v as any).address || "";
538
- }
539
- return String(v);
540
- };
541
-
542
- const msgSettings = loadSettings();
543
- const acctConfig = msgSettings.accounts.find((a: any) => a.id === accountId);
544
- const relayDomains: string[] = acctConfig?.relayDomains || [];
545
- const prefixes: string[] = acctConfig?.deliveredToPrefix || [];
546
- const rawDelivered = parsed2.headers.get("delivered-to");
547
- if (rawDelivered) {
548
- const deliveredList = Array.isArray(rawDelivered) ? rawDelivered : [rawDelivered];
549
- for (let i = deliveredList.length - 1; i >= 0; i--) {
550
- const d = deliveredList[i];
551
- const addr = typeof d === "string" ? d : (d as any)?.text || (d as any)?.address || String(d);
552
- if (!relayDomains.some(rd => addr.includes(`@${rd}`))) {
553
- deliveredTo = addr;
554
- break;
555
- }
556
- }
557
- if (!deliveredTo && deliveredList.length > 0) {
558
- const d = deliveredList[deliveredList.length - 1];
559
- deliveredTo = typeof d === "string" ? d : (d as any)?.text || (d as any)?.address || String(d);
560
- }
561
- if (deliveredTo && prefixes.length > 0) {
562
- const [local, domain] = deliveredTo.split("@");
563
- for (const prefix of prefixes) {
564
- if (local.startsWith(prefix)) {
565
- deliveredTo = `${local.slice(prefix.length)}@${domain}`;
566
- break;
567
- }
568
- }
569
- }
570
- }
571
- returnPath = hdr("return-path").replace(/[<>]/g, "");
572
- ({ listUnsubscribeMail, listUnsubscribeHttp, listUnsubscribeOneClick } =
573
- parseListUnsubscribe(parsed2.headers));
574
- listUnsubscribe = listUnsubscribeHttp || listUnsubscribeMail;
575
- }
576
-
577
- // EML path: re-read the row after the fetch — `fetchMessageBody`
578
- // writes the body to disk and updates `body_path` on success, but the
579
- // `envelope` snapshot above pre-dates that write, so trusting it
580
- // hides the Source button on every just-opened message.
581
- const refreshed: any = this.db.getMessageByUid(accountId, uid, folderId);
582
- const emlPath = refreshed?.bodyPath || envelope.bodyPath || "";
583
-
584
- // Flag check — surfaced in the remote-content banner as a red
585
- // warning when the sender's address or domain is on the user's
586
- // flagged list. Cheap lookup; loadAllowlist is already cached.
587
- const allowList = loadAllowlist() as any;
588
- const senderAddr = (envelope.from?.address || "").toLowerCase();
589
- const senderDomain = senderAddr.split("@")[1] || "";
590
- const isFlagged = !!(
591
- (allowList.flaggedSenders || []).some((s: string) => (s || "").toLowerCase() === senderAddr) ||
592
- (allowList.flaggedDomains || []).some((d: string) => (d || "").toLowerCase() === senderDomain)
593
- );
594
-
595
- // External reputation check — Spamhaus DBL + SURBL + URIBL in parallel.
596
- // Off by default (privacy: the domain leaks to those DNSBLs and the
597
- // user's local resolver). User opts in via Settings → "Check sender
598
- // reputation". Each lookup is bounded at 500 ms; the whole check is
599
- // bounded by the slowest, ~500 ms worst case.
394
+ const local = await this.localStore.getMessage(accountId, uid, allowRemote, folderId);
395
+ if (!local) throw new Error("Message not found");
396
+
397
+ if (!local.cached) {
398
+ // Body not on disk. Enqueue an interactive-lane fetch — the
399
+ // reconciler picks it up, emits `bodyAvailable` on success or
400
+ // `bodyFetchError` on failure. Return envelope-only; UI shows
401
+ // a placeholder and re-renders on the event.
402
+ this.syncQueue.enqueueBodyFetch(accountId, local.folderId as any, local.uid as any, "interactive");
403
+ this.reconciler.kick();
404
+ return local;
405
+ }
406
+
407
+ // Optional sender-reputation check. This is the one residual server
408
+ // touch in the read path (DNSBL lookup, ~500 ms). Opt-in via Settings;
409
+ // moves to the reconciler in step 4. See feedback_no_bandaids.md.
600
410
  let reputation: ReputationResult | null = null;
601
411
  const settings = (loadSettings() as any) || {};
602
- if (settings.checkDomainReputation && senderDomain && hasRemoteContent) {
412
+ const senderDomain = (local.from?.address || "").toLowerCase().split("@")[1] || "";
413
+ if (settings.checkDomainReputation && senderDomain && local.hasRemoteContent) {
603
414
  reputation = await this.checkDomainReputation(senderDomain);
604
415
  }
605
416
 
606
- return {
607
- ...envelope, bodyHtml, bodyText, hasRemoteContent, remoteAllowed: allowRemote,
608
- attachments, emlPath, deliveredTo, returnPath,
609
- listUnsubscribe, listUnsubscribeMail, listUnsubscribeHttp, listUnsubscribeOneClick,
610
- isFlagged,
611
- reputation,
612
- };
417
+ return { ...local, reputation };
418
+ }
419
+
420
+ /** Diagnostic accessor — exposed for the sync-status pill / debug UI.
421
+ * Returns the current queue counts so the UI can render
422
+ * "Sync OK / Syncing N items" without polling per-account state. */
423
+ getSyncStatus(): { messageActions: number; bodyFetches: number } {
424
+ return this.syncQueue.pendingCount();
613
425
  }
614
426
 
615
427
  /** RFC 8058 one-click unsubscribe: POST `List-Unsubscribe=One-Click` to the
@@ -1085,9 +897,9 @@ export class MailxService {
1085
897
  const sliced = items.slice((page - 1) * pageSize, page * pageSize);
1086
898
  return { items: sliced, total, page, pageSize };
1087
899
  } else if (scope === "current" && accountId && folderId) {
1088
- return this.db.searchMessages(q, page, pageSize, accountId, folderId);
900
+ return this.localStore.searchMessages(q, page, pageSize, accountId, folderId);
1089
901
  } else {
1090
- return this.db.searchMessages(q, page, pageSize);
902
+ return this.localStore.searchMessages(q, page, pageSize);
1091
903
  }
1092
904
  }
1093
905
 
@@ -1248,7 +1060,7 @@ export class MailxService {
1248
1060
  .catch(e => this.handleGoogleRefreshError("calendar", e));
1249
1061
  }
1250
1062
  }
1251
- return this.db.getCalendarEvents(acctId, fromMs, toMs);
1063
+ return this.localStore.getCalendarEvents(acctId, fromMs, toMs);
1252
1064
  }
1253
1065
 
1254
1066
  /** Returns true if the feature is currently in a quota-exceeded cooldown. */
@@ -1405,7 +1217,7 @@ export class MailxService {
1405
1217
  .catch(e => this.handleGoogleRefreshError("tasks", e));
1406
1218
  }
1407
1219
  }
1408
- return this.db.getTasks(acctId, includeCompleted);
1220
+ return this.localStore.getTasks(acctId, includeCompleted);
1409
1221
  }
1410
1222
 
1411
1223
  private async refreshTasks(accountId: string, includeCompleted: boolean): Promise<boolean> {
@@ -2056,8 +1868,22 @@ export class MailxService {
2056
1868
  async getAttachment(accountId: string, uid: number, attachmentId: number, folderId?: number): Promise<{ content: Buffer; contentType: string; filename: string }> {
2057
1869
  const envelope = this.db.getMessageByUid(accountId, uid, folderId);
2058
1870
  if (!envelope) throw new Error("Message not found");
2059
- const raw = await this.imapManager.fetchMessageBody(accountId, envelope.folderId, envelope.uid);
1871
+
1872
+ // Prefer the on-disk body — when the user just opened the message,
1873
+ // LocalStore.getMessage either returned cached:true (body already on
1874
+ // disk) or kicked a background fetch that wrote it. Either way a
1875
+ // re-fetch here would be wasted IMAP work and risks racing.
1876
+ const bodyStore = this.imapManager.getBodyStore();
1877
+ let raw: Buffer | null = null;
1878
+ const storedPath = envelope.bodyPath || this.db.getMessageBodyPath(accountId, uid) || "";
1879
+ if (storedPath && await bodyStore.hasByPath(storedPath)) {
1880
+ try { raw = await bodyStore.readByPath(storedPath); } catch { raw = null; }
1881
+ }
1882
+ if (!raw) {
1883
+ raw = await this.imapManager.fetchMessageBody(accountId, envelope.folderId, envelope.uid);
1884
+ }
2060
1885
  if (!raw) throw new Error("Message body not available");
1886
+
2061
1887
  const parsed = await simpleParser(raw);
2062
1888
  const att = parsed.attachments?.[attachmentId];
2063
1889
  if (!att) throw new Error("Attachment not found");
@@ -2123,14 +1949,10 @@ export class MailxService {
2123
1949
  }
2124
1950
  } catch { /* non-fatal — draft stays in memory at least */ }
2125
1951
 
2126
- // Background reconcile to server Drafts folder. Fire-and-forget
2127
- // the ACK to the client is already on its way.
2128
- this.imapManager.saveDraft(accountId, raw, previousDraftUid, id).catch((e: any) => {
2129
- console.error(` [draft] background IMAP save failed for ${id}: ${e?.message || e}`);
2130
- // Surface as an event so the UI can show a status-bar hint without
2131
- // blocking the caller. Draft is preserved on disk regardless.
2132
- (this as any).emit?.("draftSaveDeferred", { accountId, draftId: id, error: String(e?.message || e) });
2133
- });
1952
+ // Background reconcile to server Drafts folder via the SyncQueue
1953
+ // seam. Fire-and-forget; deferred failures surface as a `draftSaveDeferred`
1954
+ // event the UI can render in the status bar.
1955
+ this.syncQueue.enqueueDraftPush(accountId, raw, previousDraftUid, id);
2134
1956
 
2135
1957
  return { draftUid: null, draftId: id };
2136
1958
  }
@@ -2144,7 +1966,7 @@ export class MailxService {
2144
1966
  searchContacts(query: string): any[] {
2145
1967
  query = (query || "").trim();
2146
1968
  if (query.length < 1) return [];
2147
- return this.db.searchContacts(query);
1969
+ return this.localStore.searchContacts(query);
2148
1970
  }
2149
1971
 
2150
1972
  /** Q49: boolean hint for compose to auto-expand Cc when replying to this
@@ -2181,7 +2003,7 @@ export class MailxService {
2181
2003
 
2182
2004
  /** Address-book listing — paginated, filterable. */
2183
2005
  listContacts(query: string, page = 1, pageSize = 100): any {
2184
- return this.db.listContacts(query || "", page, pageSize);
2006
+ return this.localStore.listContacts(query || "", page, pageSize);
2185
2007
  }
2186
2008
 
2187
2009
  /** Upsert a contact from the address book UI (edit name). Two-way cache:
@@ -0,0 +1,86 @@
1
+ /**
2
+ * LocalStore — the formal API surface for UI-bound reads and writes.
3
+ *
4
+ * Contract: every method here is local-only. No IMAP, no Gmail API, no
5
+ * SMTP, no DNS, no Drive API. Methods complete in microseconds (DB reads)
6
+ * or whatever a single local file IO takes (body reads). They never await
7
+ * network calls.
8
+ *
9
+ * UI-side IPC dispatch lands here. The reconciler / sync queue is a
10
+ * separate concern — it owns network IO and signals completed work via
11
+ * events that the UI listens for. The two never call each other directly.
12
+ *
13
+ * This file is part of the local-first refactor (docs/local-first-plan.md).
14
+ * Step 1 of that plan: introduce the facade, prove it compiles, route
15
+ * one IPC method through it as proof-of-pattern. Subsequent steps migrate
16
+ * remaining call sites and remove the server-fetch path from the UI.
17
+ */
18
+ import type { MailxDB, FileMessageStore } from "@bobfrankston/mailx-store";
19
+ import type { MessageEnvelope, MessageQuery, PagedResult } from "@bobfrankston/mailx-types";
20
+ /** What the UI gets back from a body read. Mirrors the historical
21
+ * `getMessage` shape so call-site migration is mechanical. `cached: false`
22
+ * means the body isn't on disk yet — the UI shows a "downloading…"
23
+ * placeholder and listens for `bodyAvailable` to re-render. The reconciler
24
+ * is responsible for actually fetching and emitting the event. */
25
+ export interface LocalMessage extends MessageEnvelope {
26
+ bodyHtml: string;
27
+ bodyText: string;
28
+ hasRemoteContent: boolean;
29
+ remoteAllowed: boolean;
30
+ attachments: Array<{
31
+ id: number;
32
+ filename: string;
33
+ mimeType: string;
34
+ size: number;
35
+ contentId: string;
36
+ }>;
37
+ cached: boolean;
38
+ deliveredTo: string;
39
+ returnPath: string;
40
+ listUnsubscribe: string;
41
+ listUnsubscribeMail: string;
42
+ listUnsubscribeHttp: string;
43
+ listUnsubscribeOneClick: boolean;
44
+ emlPath: string;
45
+ isFlagged: boolean;
46
+ }
47
+ export declare class LocalStore {
48
+ private db;
49
+ private bodyStore;
50
+ constructor(db: MailxDB, bodyStore: FileMessageStore);
51
+ /** DB-shape account list (id/name/email/lastSync). The richer
52
+ * AccountConfig (with imap/smtp/etc.) lives in accounts.jsonc and is
53
+ * loaded by mailx-settings, not the DB — that path stays in
54
+ * MailxService until step 3 of the local-first plan. */
55
+ getAccounts(): {
56
+ id: string;
57
+ name: string;
58
+ email: string;
59
+ lastSync: number;
60
+ }[];
61
+ getFolders(accountId: string): any[];
62
+ /** Single envelope by (account, uid, folder). Null when the row isn't
63
+ * in the DB — caller decides whether to show "deleted" or queue a
64
+ * server lookup via the reconciler. */
65
+ getMessageEnvelope(accountId: string, uid: number, folderId?: number): MessageEnvelope | null;
66
+ /** Paginated message list for a (account, folder, ...) query. */
67
+ getMessages(query: MessageQuery): PagedResult<MessageEnvelope>;
68
+ /** All-Inboxes view: union of every account's INBOX, paginated. */
69
+ getUnifiedInbox(page?: number, pageSize?: number): PagedResult<MessageEnvelope>;
70
+ /** Local FTS5 search. Server-scope search is the reconciler's job. */
71
+ searchMessages(query: string, page?: number, pageSize?: number, accountId?: string, folderId?: number): PagedResult<MessageEnvelope>;
72
+ /** Read a fully-parsed message (envelope + body + attachments) entirely
73
+ * from local state. Returns null when the envelope isn't known.
74
+ * Returns `{ ...envelope, cached: false }` when the envelope is known
75
+ * but the body file isn't on disk — UI shows a placeholder and the
76
+ * reconciler queues the fetch.
77
+ *
78
+ * `allowRemote=true` skips HTML sanitization. Used when the user has
79
+ * explicitly allowed remote content for this sender / domain. */
80
+ getMessage(accountId: string, uid: number, allowRemote: boolean, folderId?: number): Promise<LocalMessage | null>;
81
+ getCalendarEvents(accountId: string, fromMs: number, toMs: number): any[];
82
+ getTasks(accountId: string, includeCompleted?: boolean): any[];
83
+ searchContacts(query: string, limit?: number): any[];
84
+ listContacts(query: string, page?: number, pageSize?: number): any;
85
+ }
86
+ //# sourceMappingURL=local-store.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"local-store.d.ts","sourceRoot":"","sources":["local-store.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAIH,OAAO,KAAK,EAAE,OAAO,EAAE,gBAAgB,EAAE,MAAM,2BAA2B,CAAC;AAC3E,OAAO,KAAK,EACR,eAAe,EAAW,YAAY,EAAE,WAAW,EACtD,MAAM,2BAA2B,CAAC;AAuCnC;;;;mEAImE;AACnE,MAAM,WAAW,YAAa,SAAQ,eAAe;IACjD,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,EAAE,MAAM,CAAC;IACjB,gBAAgB,EAAE,OAAO,CAAC;IAC1B,aAAa,EAAE,OAAO,CAAC;IACvB,WAAW,EAAE,KAAK,CAAC;QAAE,EAAE,EAAE,MAAM,CAAC;QAAC,QAAQ,EAAE,MAAM,CAAC;QAAC,QAAQ,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAC;QAAC,SAAS,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IACxG,MAAM,EAAE,OAAO,CAAC;IAChB,WAAW,EAAE,MAAM,CAAC;IACpB,UAAU,EAAE,MAAM,CAAC;IACnB,eAAe,EAAE,MAAM,CAAC;IACxB,mBAAmB,EAAE,MAAM,CAAC;IAC5B,mBAAmB,EAAE,MAAM,CAAC;IAC5B,uBAAuB,EAAE,OAAO,CAAC;IACjC,OAAO,EAAE,MAAM,CAAC;IAChB,SAAS,EAAE,OAAO,CAAC;CACtB;AAED,qBAAa,UAAU;IAEf,OAAO,CAAC,EAAE;IACV,OAAO,CAAC,SAAS;gBADT,EAAE,EAAE,OAAO,EACX,SAAS,EAAE,gBAAgB;IAMvC;;;6DAGyD;IACzD,WAAW,IAAI;QAAE,EAAE,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAC;QAAC,QAAQ,EAAE,MAAM,CAAA;KAAE,EAAE;IAM9E,UAAU,CAAC,SAAS,EAAE,MAAM,GAAG,GAAG,EAAE;IAMpC;;4CAEwC;IACxC,kBAAkB,CAAC,SAAS,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,EAAE,QAAQ,CAAC,EAAE,MAAM,GAAG,eAAe,GAAG,IAAI;IAK7F,iEAAiE;IACjE,WAAW,CAAC,KAAK,EAAE,YAAY,GAAG,WAAW,CAAC,eAAe,CAAC;IAI9D,mEAAmE;IACnE,eAAe,CAAC,IAAI,SAAI,EAAE,QAAQ,SAAK,GAAG,WAAW,CAAC,eAAe,CAAC;IAItE,sEAAsE;IACtE,cAAc,CAAC,KAAK,EAAE,MAAM,EAAE,IAAI,SAAI,EAAE,QAAQ,SAAK,EAAE,SAAS,CAAC,EAAE,MAAM,EAAE,QAAQ,CAAC,EAAE,MAAM,GAAG,WAAW,CAAC,eAAe,CAAC;IAM3H;;;;;;;sEAOkE;IAC5D,UAAU,CAAC,SAAS,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,EAAE,WAAW,EAAE,OAAO,EAAE,QAAQ,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,GAAG,IAAI,CAAC;IAmJvH,iBAAiB,CAAC,SAAS,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,GAAG,EAAE;IAIzE,QAAQ,CAAC,SAAS,EAAE,MAAM,EAAE,gBAAgB,UAAQ,GAAG,GAAG,EAAE;IAI5D,cAAc,CAAC,KAAK,EAAE,MAAM,EAAE,KAAK,SAAK,GAAG,GAAG,EAAE;IAIhD,YAAY,CAAC,KAAK,EAAE,MAAM,EAAE,IAAI,SAAI,EAAE,QAAQ,SAAM,GAAG,GAAG;CAK7D"}