@bobfrankston/rmfmail 1.0.545 → 1.0.546

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.
@@ -12,6 +12,8 @@ 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";
15
17
  import { loadSettings, saveSettings, loadAccounts, loadAccountsAsync, saveAccounts, initCloudConfig, loadAllowlist, saveAllowlist, loadAutocomplete, saveAutocomplete, loadKeys, saveKeys, ensureKeysSectionExists, getStorePath, getStorageInfo, getConfigDir } from "@bobfrankston/mailx-settings";
16
18
  import type { AccountConfig, Folder, AutocompleteRequest, AutocompleteResponse, AutocompleteSettings, AiTransformRequest, AiTransformResponse, ExtractedEvent } from "@bobfrankston/mailx-types";
17
19
  import { sanitizeHtml, encodeQuotedPrintable, htmlToPlainText } from "@bobfrankston/mailx-types";
@@ -27,71 +29,11 @@ import { simpleParser } from "mailparser";
27
29
  * Operates byte-wise so MIME boundaries / base64 / etc. are preserved.
28
30
  * The `utf-8` validity test rejects 0xC0–0xC1 / 0xF5–0xFF and continuation
29
31
  * 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
- }
32
+ // sniffAndFixCharset moved to ./charset.ts so LocalStore can use it
33
+ // without creating a circular dep with index.ts.
61
34
 
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;
69
-
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
- }
35
+ // parseListUnsubscribe moved to ./local-store.ts (only consumer is the
36
+ // body-read path, which is now part of LocalStore).
95
37
 
96
38
  // ── Email provider detection (MX-based) ──
97
39
 
@@ -177,10 +119,25 @@ export class MailxService {
177
119
  * showReminderPopup returns a "no host" reason. */
178
120
  setPopupFn(fn: PopupFn): void { this.popupFn = fn; }
179
121
 
122
+ /** Local-first read/write facade. Every UI IPC handler that touches the
123
+ * local DB or body store goes through this — no awaiting IMAP, no
124
+ * awaiting Gmail API, no awaiting SMTP. The reconciler (step 4) owns
125
+ * all server I/O. See docs/local-first-plan.md. */
126
+ private localStore: LocalStore;
127
+
128
+ /** Background body-fetch dedupe set: `${accountId}:${folderId}:${uid}`.
129
+ * When the UI requests a not-yet-cached message we kick off a single
130
+ * fire-and-forget IMAP fetch and emit `bodyAvailable` when it lands.
131
+ * Subsequent clicks on the same row before the fetch finishes are
132
+ * no-ops — they'll see `cached: true` once the event arrives. */
133
+ private bodyFetchInFlight = new Set<string>();
134
+
180
135
  constructor(
181
136
  private db: MailxDB,
182
137
  private imapManager: ImapManager,
183
138
  ) {
139
+ this.localStore = new LocalStore(db, imapManager.getBodyStore());
140
+
184
141
  // Invalidate account cache when accounts.jsonc changes on disk or GDrive.
185
142
  this.imapManager.on?.("configChanged", (filename: string) => {
186
143
  if (filename === "accounts.jsonc") this._accountsCache = null;
@@ -412,204 +369,78 @@ export class MailxService {
412
369
  // ── Messages ──
413
370
 
414
371
  getUnifiedInbox(page = 1, pageSize = 50): any {
415
- return this.db.getUnifiedInbox(page, pageSize);
372
+ return this.localStore.getUnifiedInbox(page, pageSize);
416
373
  }
417
374
 
418
375
  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 });
376
+ return this.localStore.getMessages({ accountId, folderId, page, pageSize, sort: sort as any, sortDir: sortDir as any, search, flaggedOnly });
420
377
  }
421
378
 
379
+ /** UI body read — local-first. Returns immediately from the local cache:
380
+ * `cached: true` with the full parsed body when on disk, or `cached: false`
381
+ * with envelope-only when not. In the cache-miss case we kick off a
382
+ * fire-and-forget IMAP fetch in the background and emit `bodyAvailable`
383
+ * when the body lands; the UI listens for that event and re-requests.
384
+ *
385
+ * No `await imap*` in the click → render path. The 60s body-fetch race
386
+ * and structured `bodyError` shape that lived here previously are gone —
387
+ * step 1 of the local-first refactor (docs/local-first-plan.md). */
422
388
  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");
389
+ const local = await this.localStore.getMessage(accountId, uid, allowRemote, folderId);
390
+ if (!local) throw new Error("Message not found");
425
391
 
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
- };
392
+ if (!local.cached) {
393
+ // Body not on disk. Trigger a single in-flight fetch and return
394
+ // envelope-only the UI will show its placeholder and re-request
395
+ // when it sees `bodyAvailable`.
396
+ this.kickOffBodyFetch(accountId, local.uid as any, local.folderId as any);
397
+ return local;
469
398
  }
470
399
 
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
- }
400
+ // Optional sender-reputation check. This is the one residual server
401
+ // touch in the read path (DNSBL lookup, ~500 ms). Opt-in via Settings;
402
+ // moves to the reconciler in step 4. See feedback_no_bandaids.md.
403
+ let reputation: ReputationResult | null = null;
404
+ const settings = (loadSettings() as any) || {};
405
+ const senderDomain = (local.from?.address || "").toLowerCase().split("@")[1] || "";
406
+ if (settings.checkDomainReputation && senderDomain && local.hasRemoteContent) {
407
+ reputation = await this.checkDomainReputation(senderDomain);
518
408
  }
519
409
 
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
- };
410
+ return { ...local, reputation };
411
+ }
541
412
 
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);
413
+ /** Fire-and-forget body fetch for a not-yet-cached message. Dedupes per
414
+ * (account, folder, uid) so rapid re-clicks don't stack IMAP fetches.
415
+ * Emits `bodyAvailable` on success (UI re-requests via getMessage) and
416
+ * `bodyFetchError` on failure (UI surfaces a banner without blocking). */
417
+ private kickOffBodyFetch(accountId: string, uid: number, folderId: number): void {
418
+ const key = `${accountId}:${folderId}:${uid}`;
419
+ if (this.bodyFetchInFlight.has(key)) return;
420
+ this.bodyFetchInFlight.add(key);
421
+
422
+ (async () => {
423
+ try {
424
+ const raw = await this.imapManager.fetchMessageBody(accountId, folderId, uid);
425
+ if (raw) {
426
+ this.imapManager.emit("bodyAvailable", { accountId, folderId, uid });
560
427
  }
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
- }
428
+ } catch (err: any) {
429
+ if (err?.isNotFound) {
430
+ try {
431
+ this.db.deleteMessage(accountId, uid);
432
+ this.db.recalcFolderCounts(folderId);
433
+ this.imapManager.emit("messageRemoved", { accountId, folderId, uid });
434
+ } catch { /* ignore */ }
435
+ return;
569
436
  }
437
+ const msg = err?.message || "body fetch failed";
438
+ const transient = /connection|Too many|UNAVAILABLE|rate|429|5\d\d|timeout|ENOTFOUND|ECONNRESET|ETIMEDOUT/i.test(msg);
439
+ this.imapManager.emit("bodyFetchError", { accountId, folderId, uid, error: msg, transient });
440
+ } finally {
441
+ this.bodyFetchInFlight.delete(key);
570
442
  }
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.
600
- let reputation: ReputationResult | null = null;
601
- const settings = (loadSettings() as any) || {};
602
- if (settings.checkDomainReputation && senderDomain && hasRemoteContent) {
603
- reputation = await this.checkDomainReputation(senderDomain);
604
- }
605
-
606
- return {
607
- ...envelope, bodyHtml, bodyText, hasRemoteContent, remoteAllowed: allowRemote,
608
- attachments, emlPath, deliveredTo, returnPath,
609
- listUnsubscribe, listUnsubscribeMail, listUnsubscribeHttp, listUnsubscribeOneClick,
610
- isFlagged,
611
- reputation,
612
- };
443
+ })();
613
444
  }
614
445
 
615
446
  /** RFC 8058 one-click unsubscribe: POST `List-Unsubscribe=One-Click` to the
@@ -1085,9 +916,9 @@ export class MailxService {
1085
916
  const sliced = items.slice((page - 1) * pageSize, page * pageSize);
1086
917
  return { items: sliced, total, page, pageSize };
1087
918
  } else if (scope === "current" && accountId && folderId) {
1088
- return this.db.searchMessages(q, page, pageSize, accountId, folderId);
919
+ return this.localStore.searchMessages(q, page, pageSize, accountId, folderId);
1089
920
  } else {
1090
- return this.db.searchMessages(q, page, pageSize);
921
+ return this.localStore.searchMessages(q, page, pageSize);
1091
922
  }
1092
923
  }
1093
924
 
@@ -2144,7 +1975,7 @@ export class MailxService {
2144
1975
  searchContacts(query: string): any[] {
2145
1976
  query = (query || "").trim();
2146
1977
  if (query.length < 1) return [];
2147
- return this.db.searchContacts(query);
1978
+ return this.localStore.searchContacts(query);
2148
1979
  }
2149
1980
 
2150
1981
  /** Q49: boolean hint for compose to auto-expand Cc when replying to this
@@ -2181,7 +2012,7 @@ export class MailxService {
2181
2012
 
2182
2013
  /** Address-book listing — paginated, filterable. */
2183
2014
  listContacts(query: string, page = 1, pageSize = 100): any {
2184
- return this.db.listContacts(query || "", page, pageSize);
2015
+ return this.localStore.listContacts(query || "", page, pageSize);
2185
2016
  }
2186
2017
 
2187
2018
  /** Upsert a contact from the address book UI (edit name). Two-way cache:
@@ -0,0 +1,81 @@
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
+ }
42
+ export declare class LocalStore {
43
+ private db;
44
+ private bodyStore;
45
+ constructor(db: MailxDB, bodyStore: FileMessageStore);
46
+ /** DB-shape account list (id/name/email/lastSync). The richer
47
+ * AccountConfig (with imap/smtp/etc.) lives in accounts.jsonc and is
48
+ * loaded by mailx-settings, not the DB — that path stays in
49
+ * MailxService until step 3 of the local-first plan. */
50
+ getAccounts(): {
51
+ id: string;
52
+ name: string;
53
+ email: string;
54
+ lastSync: number;
55
+ }[];
56
+ getFolders(accountId: string): any[];
57
+ /** Single envelope by (account, uid, folder). Null when the row isn't
58
+ * in the DB — caller decides whether to show "deleted" or queue a
59
+ * server lookup via the reconciler. */
60
+ getMessageEnvelope(accountId: string, uid: number, folderId?: number): MessageEnvelope | null;
61
+ /** Paginated message list for a (account, folder, ...) query. */
62
+ getMessages(query: MessageQuery): PagedResult<MessageEnvelope>;
63
+ /** All-Inboxes view: union of every account's INBOX, paginated. */
64
+ getUnifiedInbox(page?: number, pageSize?: number): PagedResult<MessageEnvelope>;
65
+ /** Local FTS5 search. Server-scope search is the reconciler's job. */
66
+ searchMessages(query: string, page?: number, pageSize?: number, accountId?: string, folderId?: number): PagedResult<MessageEnvelope>;
67
+ /** Read a fully-parsed message (envelope + body + attachments) entirely
68
+ * from local state. Returns null when the envelope isn't known.
69
+ * Returns `{ ...envelope, cached: false }` when the envelope is known
70
+ * but the body file isn't on disk — UI shows a placeholder and the
71
+ * reconciler queues the fetch.
72
+ *
73
+ * `allowRemote=true` skips HTML sanitization. Used when the user has
74
+ * explicitly allowed remote content for this sender / domain. */
75
+ getMessage(accountId: string, uid: number, allowRemote: boolean, folderId?: number): Promise<LocalMessage | null>;
76
+ getCalendarEvents(accountId: string, fromMs: number, toMs: number): any[];
77
+ getTasks(accountId: string, includeCompleted?: boolean): any[];
78
+ searchContacts(query: string, limit?: number): any[];
79
+ listContacts(query: string, page?: number, pageSize?: number): any;
80
+ }
81
+ //# 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;AAInC;;;;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;CAC3B;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;IA+EvH,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"}
@@ -0,0 +1,159 @@
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 { simpleParser } from "mailparser";
19
+ import { sanitizeHtml } from "@bobfrankston/mailx-types";
20
+ import { sniffAndFixCharset } from "./charset.js";
21
+ export class LocalStore {
22
+ db;
23
+ bodyStore;
24
+ constructor(db, bodyStore) {
25
+ this.db = db;
26
+ this.bodyStore = bodyStore;
27
+ }
28
+ // ── Account list (read-only here; mutations go through MailxService
29
+ // until the cloud-write path is part of the queue too) ──
30
+ /** DB-shape account list (id/name/email/lastSync). The richer
31
+ * AccountConfig (with imap/smtp/etc.) lives in accounts.jsonc and is
32
+ * loaded by mailx-settings, not the DB — that path stays in
33
+ * MailxService until step 3 of the local-first plan. */
34
+ getAccounts() {
35
+ return this.db.getAccounts();
36
+ }
37
+ // ── Folders ──
38
+ getFolders(accountId) {
39
+ return this.db.getFolders(accountId);
40
+ }
41
+ // ── Message envelopes ──
42
+ /** Single envelope by (account, uid, folder). Null when the row isn't
43
+ * in the DB — caller decides whether to show "deleted" or queue a
44
+ * server lookup via the reconciler. */
45
+ getMessageEnvelope(accountId, uid, folderId) {
46
+ const env = this.db.getMessageByUid(accountId, uid, folderId);
47
+ return env || null;
48
+ }
49
+ /** Paginated message list for a (account, folder, ...) query. */
50
+ getMessages(query) {
51
+ return this.db.getMessages(query);
52
+ }
53
+ /** All-Inboxes view: union of every account's INBOX, paginated. */
54
+ getUnifiedInbox(page = 1, pageSize = 50) {
55
+ return this.db.getUnifiedInbox(page, pageSize);
56
+ }
57
+ /** Local FTS5 search. Server-scope search is the reconciler's job. */
58
+ searchMessages(query, page = 1, pageSize = 50, accountId, folderId) {
59
+ return this.db.searchMessages(query, page, pageSize, accountId, folderId);
60
+ }
61
+ // ── Message body (read-from-disk only) ──
62
+ /** Read a fully-parsed message (envelope + body + attachments) entirely
63
+ * from local state. Returns null when the envelope isn't known.
64
+ * Returns `{ ...envelope, cached: false }` when the envelope is known
65
+ * but the body file isn't on disk — UI shows a placeholder and the
66
+ * reconciler queues the fetch.
67
+ *
68
+ * `allowRemote=true` skips HTML sanitization. Used when the user has
69
+ * explicitly allowed remote content for this sender / domain. */
70
+ async getMessage(accountId, uid, allowRemote, folderId) {
71
+ const envelope = this.db.getMessageByUid(accountId, uid, folderId);
72
+ if (!envelope)
73
+ return null;
74
+ // Resolve body path: prefer the row's own bodyPath, fall back to
75
+ // the historical lookup. Either may be empty (body never fetched).
76
+ let storedPath = envelope.bodyPath || "";
77
+ if (!storedPath)
78
+ storedPath = this.db.getMessageBodyPath(accountId, uid) || "";
79
+ const empty = {
80
+ ...envelope,
81
+ bodyHtml: "", bodyText: "",
82
+ hasRemoteContent: false, remoteAllowed: false,
83
+ attachments: [],
84
+ cached: false,
85
+ deliveredTo: "", returnPath: "", listUnsubscribe: "",
86
+ };
87
+ if (!storedPath)
88
+ return empty;
89
+ if (!await this.bodyStore.hasByPath(storedPath))
90
+ return empty;
91
+ let raw;
92
+ try {
93
+ raw = await this.bodyStore.readByPath(storedPath);
94
+ }
95
+ catch {
96
+ // File path is in DB but the file is missing — the prefetch
97
+ // dot is lying. Caller (UI) should mark the row as broken and
98
+ // the reconciler should re-fetch. Don't crash; return as if
99
+ // not cached.
100
+ return empty;
101
+ }
102
+ // Parse + sanitize. Same logic as today's MailxService.getMessage,
103
+ // but executed only when the body is local — never on a fresh
104
+ // server fetch.
105
+ const adjusted = sniffAndFixCharset(raw);
106
+ const parsed = await simpleParser(adjusted);
107
+ let bodyHtml = parsed.html || "";
108
+ const bodyText = parsed.text || "";
109
+ let hasRemoteContent = false;
110
+ let remoteAllowed = allowRemote;
111
+ const attachments = (parsed.attachments || []).map((a, i) => ({
112
+ id: i,
113
+ filename: a.filename || `attachment-${i}`,
114
+ mimeType: a.contentType || "application/octet-stream",
115
+ size: a.size || 0,
116
+ contentId: a.contentId || "",
117
+ }));
118
+ if (bodyHtml && !allowRemote) {
119
+ const result = sanitizeHtml(bodyHtml);
120
+ bodyHtml = result.html;
121
+ hasRemoteContent = result.hasRemoteContent;
122
+ }
123
+ // Header extraction — Delivered-To, Return-Path, List-Unsubscribe
124
+ // and friends. simpleParser exposes them via `headers`.
125
+ const hdr = (key) => {
126
+ const v = parsed.headers.get(key);
127
+ if (!v)
128
+ return "";
129
+ if (typeof v === "string")
130
+ return v;
131
+ return String(v?.value || "");
132
+ };
133
+ const deliveredTo = hdr("delivered-to");
134
+ const returnPath = hdr("return-path");
135
+ const listUnsubscribe = hdr("list-unsubscribe");
136
+ return {
137
+ ...envelope,
138
+ bodyHtml, bodyText,
139
+ hasRemoteContent, remoteAllowed,
140
+ attachments,
141
+ cached: true,
142
+ deliveredTo, returnPath, listUnsubscribe,
143
+ };
144
+ }
145
+ // ── Calendar / tasks / contacts (read paths) ──
146
+ getCalendarEvents(accountId, fromMs, toMs) {
147
+ return this.db.getCalendarEvents(accountId, fromMs, toMs);
148
+ }
149
+ getTasks(accountId, includeCompleted = false) {
150
+ return this.db.getTasks(accountId, includeCompleted);
151
+ }
152
+ searchContacts(query, limit = 10) {
153
+ return this.db.searchContacts(query, limit);
154
+ }
155
+ listContacts(query, page = 1, pageSize = 100) {
156
+ return this.db.listContacts(query, page, pageSize);
157
+ }
158
+ }
159
+ //# sourceMappingURL=local-store.js.map