@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.
- package/bin/mailx.js +8 -1
- package/bin/mailx.js.map +1 -1
- package/bin/mailx.ts +7 -1
- package/package.json +1 -1
- package/packages/mailx-service/charset.d.ts +15 -0
- package/packages/mailx-service/charset.d.ts.map +1 -0
- package/packages/mailx-service/charset.js +61 -0
- package/packages/mailx-service/charset.js.map +1 -0
- package/packages/mailx-service/charset.ts +45 -0
- package/packages/mailx-service/index.d.ts.map +1 -1
- package/packages/mailx-service/index.js +3 -47
- package/packages/mailx-service/index.js.map +1 -1
- package/packages/mailx-service/index.ts +82 -251
- package/packages/mailx-service/local-store.d.ts +81 -0
- package/packages/mailx-service/local-store.d.ts.map +1 -0
- package/packages/mailx-service/local-store.js +159 -0
- package/packages/mailx-service/local-store.js.map +1 -0
- package/packages/mailx-service/local-store.ts +307 -0
|
@@ -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
|
+
}
|