@bobfrankston/rmfmail 1.0.544 → 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.
@@ -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
@@ -0,0 +1 @@
1
+ {"version":3,"file":"local-store.js","sourceRoot":"","sources":["local-store.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,307 @@
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
+
19
+ import * as fs from "node:fs";
20
+ import { simpleParser } from "mailparser";
21
+ import type { MailxDB, FileMessageStore } from "@bobfrankston/mailx-store";
22
+ import type {
23
+ MessageEnvelope, Message, MessageQuery, PagedResult,
24
+ } from "@bobfrankston/mailx-types";
25
+ import { sanitizeHtml } from "@bobfrankston/mailx-types";
26
+ import { loadSettings, loadAllowlist } from "@bobfrankston/mailx-settings";
27
+ import { sniffAndFixCharset } from "./charset.js";
28
+
29
+ /** Parse `List-Unsubscribe` (RFC 2369) and `List-Unsubscribe-Post` (RFC 8058).
30
+ * mailparser only exposes ONE of mail/url even when both are present, so we
31
+ * also scan the raw header text for the full set of angle-bracketed URIs. */
32
+ function parseListUnsubscribe(headers: any): { listUnsubscribeMail: string; listUnsubscribeHttp: string; listUnsubscribeOneClick: boolean } {
33
+ let mail = "";
34
+ let http = "";
35
+ let oneClick = false;
36
+
37
+ const raw = headers.get("list-unsubscribe");
38
+ const rawStr = typeof raw === "string" ? raw : (raw && typeof (raw as any).text === "string" ? (raw as any).text : "");
39
+ if (rawStr) {
40
+ const matches = rawStr.match(/<([^>]+)>/g) || [];
41
+ for (const m of matches) {
42
+ const url = m.slice(1, -1).trim();
43
+ if (!mail && /^mailto:/i.test(url)) mail = url;
44
+ else if (!http && /^https?:/i.test(url)) http = url;
45
+ }
46
+ }
47
+ if (!mail && !http) {
48
+ const listHeaders = headers.get("list");
49
+ if (listHeaders?.unsubscribe) {
50
+ const unsub = listHeaders.unsubscribe;
51
+ if (unsub.url) http = Array.isArray(unsub.url) ? unsub.url[0] : unsub.url;
52
+ if (unsub.mail) mail = `mailto:${Array.isArray(unsub.mail) ? unsub.mail[0] : unsub.mail}`;
53
+ }
54
+ }
55
+
56
+ const post = headers.get("list-unsubscribe-post");
57
+ const postStr = typeof post === "string" ? post : (post && typeof (post as any).text === "string" ? (post as any).text : "");
58
+ if (postStr && /one-?click/i.test(postStr)) oneClick = true;
59
+
60
+ return { listUnsubscribeMail: mail, listUnsubscribeHttp: http, listUnsubscribeOneClick: oneClick };
61
+ }
62
+
63
+ /** What the UI gets back from a body read. Mirrors the historical
64
+ * `getMessage` shape so call-site migration is mechanical. `cached: false`
65
+ * means the body isn't on disk yet — the UI shows a "downloading…"
66
+ * placeholder and listens for `bodyAvailable` to re-render. The reconciler
67
+ * is responsible for actually fetching and emitting the event. */
68
+ export interface LocalMessage extends MessageEnvelope {
69
+ bodyHtml: string;
70
+ bodyText: string;
71
+ hasRemoteContent: boolean;
72
+ remoteAllowed: boolean;
73
+ attachments: Array<{ id: number; filename: string; mimeType: string; size: number; contentId: string }>;
74
+ cached: boolean; // false = body not on disk; UI shows downloading
75
+ deliveredTo: string;
76
+ returnPath: string;
77
+ listUnsubscribe: string;
78
+ listUnsubscribeMail: string;
79
+ listUnsubscribeHttp: string;
80
+ listUnsubscribeOneClick: boolean;
81
+ emlPath: string;
82
+ isFlagged: boolean;
83
+ }
84
+
85
+ export class LocalStore {
86
+ constructor(
87
+ private db: MailxDB,
88
+ private bodyStore: FileMessageStore,
89
+ ) {}
90
+
91
+ // ── Account list (read-only here; mutations go through MailxService
92
+ // until the cloud-write path is part of the queue too) ──
93
+
94
+ /** DB-shape account list (id/name/email/lastSync). The richer
95
+ * AccountConfig (with imap/smtp/etc.) lives in accounts.jsonc and is
96
+ * loaded by mailx-settings, not the DB — that path stays in
97
+ * MailxService until step 3 of the local-first plan. */
98
+ getAccounts(): { id: string; name: string; email: string; lastSync: number }[] {
99
+ return this.db.getAccounts();
100
+ }
101
+
102
+ // ── Folders ──
103
+
104
+ getFolders(accountId: string): any[] {
105
+ return this.db.getFolders(accountId);
106
+ }
107
+
108
+ // ── Message envelopes ──
109
+
110
+ /** Single envelope by (account, uid, folder). Null when the row isn't
111
+ * in the DB — caller decides whether to show "deleted" or queue a
112
+ * server lookup via the reconciler. */
113
+ getMessageEnvelope(accountId: string, uid: number, folderId?: number): MessageEnvelope | null {
114
+ const env = this.db.getMessageByUid(accountId, uid, folderId);
115
+ return env || null;
116
+ }
117
+
118
+ /** Paginated message list for a (account, folder, ...) query. */
119
+ getMessages(query: MessageQuery): PagedResult<MessageEnvelope> {
120
+ return this.db.getMessages(query);
121
+ }
122
+
123
+ /** All-Inboxes view: union of every account's INBOX, paginated. */
124
+ getUnifiedInbox(page = 1, pageSize = 50): PagedResult<MessageEnvelope> {
125
+ return this.db.getUnifiedInbox(page, pageSize);
126
+ }
127
+
128
+ /** Local FTS5 search. Server-scope search is the reconciler's job. */
129
+ searchMessages(query: string, page = 1, pageSize = 50, accountId?: string, folderId?: number): PagedResult<MessageEnvelope> {
130
+ return this.db.searchMessages(query, page, pageSize, accountId, folderId);
131
+ }
132
+
133
+ // ── Message body (read-from-disk only) ──
134
+
135
+ /** Read a fully-parsed message (envelope + body + attachments) entirely
136
+ * from local state. Returns null when the envelope isn't known.
137
+ * Returns `{ ...envelope, cached: false }` when the envelope is known
138
+ * but the body file isn't on disk — UI shows a placeholder and the
139
+ * reconciler queues the fetch.
140
+ *
141
+ * `allowRemote=true` skips HTML sanitization. Used when the user has
142
+ * explicitly allowed remote content for this sender / domain. */
143
+ async getMessage(accountId: string, uid: number, allowRemote: boolean, folderId?: number): Promise<LocalMessage | null> {
144
+ const envelope: any = this.db.getMessageByUid(accountId, uid, folderId);
145
+ if (!envelope) return null;
146
+
147
+ const allowList = loadAllowlist() as any;
148
+ const senderAddr = (envelope.from?.address || "").toLowerCase();
149
+ const senderDomain = senderAddr.split("@")[1] || "";
150
+ const toAddrs = (envelope.to || []).map((a: any) => (a.address || "").toLowerCase());
151
+
152
+ // Allowlist auto-allow: trusted sender / domain / recipient skips
153
+ // sanitization. Same rule as the legacy service implementation.
154
+ if (!allowRemote) {
155
+ const senders = (allowList.senders || []).map((s: string) => (s || "").toLowerCase());
156
+ const domains = (allowList.domains || []).map((d: string) => (d || "").toLowerCase());
157
+ const recipients = (allowList.recipients || []).map((r: string) => (r || "").toLowerCase());
158
+ if (senders.includes(senderAddr) ||
159
+ domains.includes(senderDomain) ||
160
+ toAddrs.some((a: string) => recipients.includes(a))) {
161
+ allowRemote = true;
162
+ }
163
+ }
164
+
165
+ const isFlagged = !!(
166
+ (allowList.flaggedSenders || []).some((s: string) => (s || "").toLowerCase() === senderAddr) ||
167
+ (allowList.flaggedDomains || []).some((d: string) => (d || "").toLowerCase() === senderDomain)
168
+ );
169
+
170
+ // Resolve body path: prefer the row's own bodyPath, fall back to
171
+ // the historical lookup. Either may be empty (body never fetched).
172
+ let storedPath = envelope.bodyPath || "";
173
+ if (!storedPath) storedPath = this.db.getMessageBodyPath(accountId, uid) || "";
174
+
175
+ const empty: LocalMessage = {
176
+ ...envelope,
177
+ bodyHtml: "", bodyText: "",
178
+ hasRemoteContent: false, remoteAllowed: allowRemote,
179
+ attachments: [],
180
+ cached: false,
181
+ deliveredTo: "", returnPath: "",
182
+ listUnsubscribe: "", listUnsubscribeMail: "", listUnsubscribeHttp: "", listUnsubscribeOneClick: false,
183
+ emlPath: "",
184
+ isFlagged,
185
+ };
186
+
187
+ if (!storedPath) return empty;
188
+ if (!await this.bodyStore.hasByPath(storedPath)) return empty;
189
+
190
+ let raw: Buffer;
191
+ try {
192
+ raw = await this.bodyStore.readByPath(storedPath);
193
+ } catch {
194
+ // File path is in DB but the file is missing — the prefetch
195
+ // dot is lying. Caller (UI) should mark the row as broken and
196
+ // the reconciler should re-fetch. Don't crash; return as if
197
+ // not cached.
198
+ return empty;
199
+ }
200
+
201
+ // Parse + sanitize. Same logic as today's MailxService.getMessage,
202
+ // but executed only when the body is local — never on a fresh
203
+ // server fetch.
204
+ const adjusted = sniffAndFixCharset(raw);
205
+ const parsed = await simpleParser(adjusted);
206
+ let bodyHtml = parsed.html || "";
207
+ const bodyText = parsed.text || "";
208
+ let hasRemoteContent = false;
209
+ const attachments = (parsed.attachments || []).map((a, i) => ({
210
+ id: i,
211
+ filename: a.filename || `attachment-${i}`,
212
+ mimeType: a.contentType || "application/octet-stream",
213
+ size: a.size || 0,
214
+ contentId: a.contentId || "",
215
+ }));
216
+
217
+ if (bodyHtml && !allowRemote) {
218
+ const result = sanitizeHtml(bodyHtml);
219
+ bodyHtml = result.html;
220
+ hasRemoteContent = result.hasRemoteContent;
221
+ }
222
+
223
+ // Header extraction — Delivered-To with relay-domain unwrapping,
224
+ // Return-Path, List-Unsubscribe variants. The relay/prefix rules
225
+ // come from accounts.jsonc settings; cheap local read.
226
+ const settings = (loadSettings() as any) || {};
227
+ const acctConfig = (settings.accounts || []).find((a: any) => a.id === accountId) || {};
228
+ const relayDomains: string[] = acctConfig.relayDomains || [];
229
+ const prefixes: string[] = acctConfig.deliveredToPrefix || [];
230
+
231
+ let deliveredTo = "";
232
+ const rawDelivered = parsed.headers.get("delivered-to");
233
+ if (rawDelivered) {
234
+ const deliveredList = Array.isArray(rawDelivered) ? rawDelivered : [rawDelivered];
235
+ for (let i = deliveredList.length - 1; i >= 0; i--) {
236
+ const d = deliveredList[i];
237
+ const addr = typeof d === "string" ? d : (d as any)?.text || (d as any)?.address || String(d);
238
+ if (!relayDomains.some(rd => addr.includes(`@${rd}`))) {
239
+ deliveredTo = addr;
240
+ break;
241
+ }
242
+ }
243
+ if (!deliveredTo && deliveredList.length > 0) {
244
+ const d = deliveredList[deliveredList.length - 1];
245
+ deliveredTo = typeof d === "string" ? d : (d as any)?.text || (d as any)?.address || String(d);
246
+ }
247
+ if (deliveredTo && prefixes.length > 0) {
248
+ const [local, domain] = deliveredTo.split("@");
249
+ for (const prefix of prefixes) {
250
+ if (local.startsWith(prefix)) {
251
+ deliveredTo = `${local.slice(prefix.length)}@${domain}`;
252
+ break;
253
+ }
254
+ }
255
+ }
256
+ }
257
+
258
+ const hdr = (key: string): string => {
259
+ let v = parsed.headers.get(key);
260
+ if (!v) return "";
261
+ if (Array.isArray(v)) v = v[0];
262
+ if (typeof v === "string") return v;
263
+ if (typeof v === "object" && v !== null) {
264
+ if ("text" in v) return (v as any).text || "";
265
+ if ("value" in v) return String((v as any).value);
266
+ if ("address" in v) return (v as any).address || "";
267
+ }
268
+ return String(v);
269
+ };
270
+ const returnPath = hdr("return-path").replace(/[<>]/g, "");
271
+ const { listUnsubscribeMail, listUnsubscribeHttp, listUnsubscribeOneClick } =
272
+ parseListUnsubscribe(parsed.headers);
273
+ const listUnsubscribe = listUnsubscribeHttp || listUnsubscribeMail;
274
+
275
+ return {
276
+ ...envelope,
277
+ bodyHtml, bodyText,
278
+ hasRemoteContent, remoteAllowed: allowRemote,
279
+ attachments,
280
+ cached: true,
281
+ deliveredTo, returnPath,
282
+ listUnsubscribe, listUnsubscribeMail, listUnsubscribeHttp, listUnsubscribeOneClick,
283
+ emlPath: storedPath,
284
+ isFlagged,
285
+ };
286
+ }
287
+
288
+ // ── Calendar / tasks / contacts (read paths) ──
289
+
290
+ getCalendarEvents(accountId: string, fromMs: number, toMs: number): any[] {
291
+ return this.db.getCalendarEvents(accountId, fromMs, toMs);
292
+ }
293
+
294
+ getTasks(accountId: string, includeCompleted = false): any[] {
295
+ return this.db.getTasks(accountId, includeCompleted);
296
+ }
297
+
298
+ searchContacts(query: string, limit = 10): any[] {
299
+ return this.db.searchContacts(query, limit);
300
+ }
301
+
302
+ listContacts(query: string, page = 1, pageSize = 100): any {
303
+ return this.db.listContacts(query, page, pageSize);
304
+ }
305
+
306
+ // Write paths and sync-queue enqueue methods come in step 3.
307
+ }