@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.
- package/bin/mailx.js +21 -1
- package/bin/mailx.js.map +1 -1
- package/bin/mailx.ts +20 -1
- package/client/app.js +51 -1
- package/client/app.js.map +1 -1
- package/client/app.ts +50 -1
- package/client/components/message-list.js +1 -3
- package/client/components/message-list.js.map +1 -1
- package/client/components/message-list.ts +1 -3
- package/client/components/message-viewer.js +65 -97
- package/client/components/message-viewer.js.map +1 -1
- package/client/components/message-viewer.ts +51 -90
- package/client/lib/api-client.js +5 -0
- package/client/lib/api-client.js.map +1 -1
- package/client/lib/api-client.ts +5 -1
- package/client/styles/components.css +0 -12
- package/package.json +1 -1
- package/packages/mailx-server/index.d.ts.map +1 -1
- package/packages/mailx-server/index.js +13 -0
- package/packages/mailx-server/index.js.map +1 -1
- package/packages/mailx-server/index.ts +14 -0
- 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 +26 -0
- package/packages/mailx-service/index.d.ts.map +1 -1
- package/packages/mailx-service/index.js +84 -281
- package/packages/mailx-service/index.js.map +1 -1
- package/packages/mailx-service/index.ts +88 -266
- package/packages/mailx-service/local-store.d.ts +86 -0
- package/packages/mailx-service/local-store.d.ts.map +1 -0
- package/packages/mailx-service/local-store.js +259 -0
- package/packages/mailx-service/local-store.js.map +1 -0
- package/packages/mailx-service/local-store.ts +307 -0
- package/packages/mailx-service/reconciler.d.ts +62 -0
- package/packages/mailx-service/reconciler.d.ts.map +1 -0
- package/packages/mailx-service/reconciler.js +141 -0
- package/packages/mailx-service/reconciler.js.map +1 -0
- package/packages/mailx-service/reconciler.ts +151 -0
- package/packages/mailx-service/sync-queue.d.ts +79 -0
- package/packages/mailx-service/sync-queue.d.ts.map +1 -0
- package/packages/mailx-service/sync-queue.js +118 -0
- package/packages/mailx-service/sync-queue.js.map +1 -0
- package/packages/mailx-service/sync-queue.ts +134 -0
|
@@ -0,0 +1,259 @@
|
|
|
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 { loadSettings, loadAllowlist } from "@bobfrankston/mailx-settings";
|
|
21
|
+
import { sniffAndFixCharset } from "./charset.js";
|
|
22
|
+
/** Parse `List-Unsubscribe` (RFC 2369) and `List-Unsubscribe-Post` (RFC 8058).
|
|
23
|
+
* mailparser only exposes ONE of mail/url even when both are present, so we
|
|
24
|
+
* also scan the raw header text for the full set of angle-bracketed URIs. */
|
|
25
|
+
function parseListUnsubscribe(headers) {
|
|
26
|
+
let mail = "";
|
|
27
|
+
let http = "";
|
|
28
|
+
let oneClick = false;
|
|
29
|
+
const raw = headers.get("list-unsubscribe");
|
|
30
|
+
const rawStr = typeof raw === "string" ? raw : (raw && typeof raw.text === "string" ? raw.text : "");
|
|
31
|
+
if (rawStr) {
|
|
32
|
+
const matches = rawStr.match(/<([^>]+)>/g) || [];
|
|
33
|
+
for (const m of matches) {
|
|
34
|
+
const url = m.slice(1, -1).trim();
|
|
35
|
+
if (!mail && /^mailto:/i.test(url))
|
|
36
|
+
mail = url;
|
|
37
|
+
else if (!http && /^https?:/i.test(url))
|
|
38
|
+
http = url;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
if (!mail && !http) {
|
|
42
|
+
const listHeaders = headers.get("list");
|
|
43
|
+
if (listHeaders?.unsubscribe) {
|
|
44
|
+
const unsub = listHeaders.unsubscribe;
|
|
45
|
+
if (unsub.url)
|
|
46
|
+
http = Array.isArray(unsub.url) ? unsub.url[0] : unsub.url;
|
|
47
|
+
if (unsub.mail)
|
|
48
|
+
mail = `mailto:${Array.isArray(unsub.mail) ? unsub.mail[0] : unsub.mail}`;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
const post = headers.get("list-unsubscribe-post");
|
|
52
|
+
const postStr = typeof post === "string" ? post : (post && typeof post.text === "string" ? post.text : "");
|
|
53
|
+
if (postStr && /one-?click/i.test(postStr))
|
|
54
|
+
oneClick = true;
|
|
55
|
+
return { listUnsubscribeMail: mail, listUnsubscribeHttp: http, listUnsubscribeOneClick: oneClick };
|
|
56
|
+
}
|
|
57
|
+
export class LocalStore {
|
|
58
|
+
db;
|
|
59
|
+
bodyStore;
|
|
60
|
+
constructor(db, bodyStore) {
|
|
61
|
+
this.db = db;
|
|
62
|
+
this.bodyStore = bodyStore;
|
|
63
|
+
}
|
|
64
|
+
// ── Account list (read-only here; mutations go through MailxService
|
|
65
|
+
// until the cloud-write path is part of the queue too) ──
|
|
66
|
+
/** DB-shape account list (id/name/email/lastSync). The richer
|
|
67
|
+
* AccountConfig (with imap/smtp/etc.) lives in accounts.jsonc and is
|
|
68
|
+
* loaded by mailx-settings, not the DB — that path stays in
|
|
69
|
+
* MailxService until step 3 of the local-first plan. */
|
|
70
|
+
getAccounts() {
|
|
71
|
+
return this.db.getAccounts();
|
|
72
|
+
}
|
|
73
|
+
// ── Folders ──
|
|
74
|
+
getFolders(accountId) {
|
|
75
|
+
return this.db.getFolders(accountId);
|
|
76
|
+
}
|
|
77
|
+
// ── Message envelopes ──
|
|
78
|
+
/** Single envelope by (account, uid, folder). Null when the row isn't
|
|
79
|
+
* in the DB — caller decides whether to show "deleted" or queue a
|
|
80
|
+
* server lookup via the reconciler. */
|
|
81
|
+
getMessageEnvelope(accountId, uid, folderId) {
|
|
82
|
+
const env = this.db.getMessageByUid(accountId, uid, folderId);
|
|
83
|
+
return env || null;
|
|
84
|
+
}
|
|
85
|
+
/** Paginated message list for a (account, folder, ...) query. */
|
|
86
|
+
getMessages(query) {
|
|
87
|
+
return this.db.getMessages(query);
|
|
88
|
+
}
|
|
89
|
+
/** All-Inboxes view: union of every account's INBOX, paginated. */
|
|
90
|
+
getUnifiedInbox(page = 1, pageSize = 50) {
|
|
91
|
+
return this.db.getUnifiedInbox(page, pageSize);
|
|
92
|
+
}
|
|
93
|
+
/** Local FTS5 search. Server-scope search is the reconciler's job. */
|
|
94
|
+
searchMessages(query, page = 1, pageSize = 50, accountId, folderId) {
|
|
95
|
+
return this.db.searchMessages(query, page, pageSize, accountId, folderId);
|
|
96
|
+
}
|
|
97
|
+
// ── Message body (read-from-disk only) ──
|
|
98
|
+
/** Read a fully-parsed message (envelope + body + attachments) entirely
|
|
99
|
+
* from local state. Returns null when the envelope isn't known.
|
|
100
|
+
* Returns `{ ...envelope, cached: false }` when the envelope is known
|
|
101
|
+
* but the body file isn't on disk — UI shows a placeholder and the
|
|
102
|
+
* reconciler queues the fetch.
|
|
103
|
+
*
|
|
104
|
+
* `allowRemote=true` skips HTML sanitization. Used when the user has
|
|
105
|
+
* explicitly allowed remote content for this sender / domain. */
|
|
106
|
+
async getMessage(accountId, uid, allowRemote, folderId) {
|
|
107
|
+
const envelope = this.db.getMessageByUid(accountId, uid, folderId);
|
|
108
|
+
if (!envelope)
|
|
109
|
+
return null;
|
|
110
|
+
const allowList = loadAllowlist();
|
|
111
|
+
const senderAddr = (envelope.from?.address || "").toLowerCase();
|
|
112
|
+
const senderDomain = senderAddr.split("@")[1] || "";
|
|
113
|
+
const toAddrs = (envelope.to || []).map((a) => (a.address || "").toLowerCase());
|
|
114
|
+
// Allowlist auto-allow: trusted sender / domain / recipient skips
|
|
115
|
+
// sanitization. Same rule as the legacy service implementation.
|
|
116
|
+
if (!allowRemote) {
|
|
117
|
+
const senders = (allowList.senders || []).map((s) => (s || "").toLowerCase());
|
|
118
|
+
const domains = (allowList.domains || []).map((d) => (d || "").toLowerCase());
|
|
119
|
+
const recipients = (allowList.recipients || []).map((r) => (r || "").toLowerCase());
|
|
120
|
+
if (senders.includes(senderAddr) ||
|
|
121
|
+
domains.includes(senderDomain) ||
|
|
122
|
+
toAddrs.some((a) => recipients.includes(a))) {
|
|
123
|
+
allowRemote = true;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
const isFlagged = !!((allowList.flaggedSenders || []).some((s) => (s || "").toLowerCase() === senderAddr) ||
|
|
127
|
+
(allowList.flaggedDomains || []).some((d) => (d || "").toLowerCase() === senderDomain));
|
|
128
|
+
// Resolve body path: prefer the row's own bodyPath, fall back to
|
|
129
|
+
// the historical lookup. Either may be empty (body never fetched).
|
|
130
|
+
let storedPath = envelope.bodyPath || "";
|
|
131
|
+
if (!storedPath)
|
|
132
|
+
storedPath = this.db.getMessageBodyPath(accountId, uid) || "";
|
|
133
|
+
const empty = {
|
|
134
|
+
...envelope,
|
|
135
|
+
bodyHtml: "", bodyText: "",
|
|
136
|
+
hasRemoteContent: false, remoteAllowed: allowRemote,
|
|
137
|
+
attachments: [],
|
|
138
|
+
cached: false,
|
|
139
|
+
deliveredTo: "", returnPath: "",
|
|
140
|
+
listUnsubscribe: "", listUnsubscribeMail: "", listUnsubscribeHttp: "", listUnsubscribeOneClick: false,
|
|
141
|
+
emlPath: "",
|
|
142
|
+
isFlagged,
|
|
143
|
+
};
|
|
144
|
+
if (!storedPath)
|
|
145
|
+
return empty;
|
|
146
|
+
if (!await this.bodyStore.hasByPath(storedPath))
|
|
147
|
+
return empty;
|
|
148
|
+
let raw;
|
|
149
|
+
try {
|
|
150
|
+
raw = await this.bodyStore.readByPath(storedPath);
|
|
151
|
+
}
|
|
152
|
+
catch {
|
|
153
|
+
// File path is in DB but the file is missing — the prefetch
|
|
154
|
+
// dot is lying. Caller (UI) should mark the row as broken and
|
|
155
|
+
// the reconciler should re-fetch. Don't crash; return as if
|
|
156
|
+
// not cached.
|
|
157
|
+
return empty;
|
|
158
|
+
}
|
|
159
|
+
// Parse + sanitize. Same logic as today's MailxService.getMessage,
|
|
160
|
+
// but executed only when the body is local — never on a fresh
|
|
161
|
+
// server fetch.
|
|
162
|
+
const adjusted = sniffAndFixCharset(raw);
|
|
163
|
+
const parsed = await simpleParser(adjusted);
|
|
164
|
+
let bodyHtml = parsed.html || "";
|
|
165
|
+
const bodyText = parsed.text || "";
|
|
166
|
+
let hasRemoteContent = false;
|
|
167
|
+
const attachments = (parsed.attachments || []).map((a, i) => ({
|
|
168
|
+
id: i,
|
|
169
|
+
filename: a.filename || `attachment-${i}`,
|
|
170
|
+
mimeType: a.contentType || "application/octet-stream",
|
|
171
|
+
size: a.size || 0,
|
|
172
|
+
contentId: a.contentId || "",
|
|
173
|
+
}));
|
|
174
|
+
if (bodyHtml && !allowRemote) {
|
|
175
|
+
const result = sanitizeHtml(bodyHtml);
|
|
176
|
+
bodyHtml = result.html;
|
|
177
|
+
hasRemoteContent = result.hasRemoteContent;
|
|
178
|
+
}
|
|
179
|
+
// Header extraction — Delivered-To with relay-domain unwrapping,
|
|
180
|
+
// Return-Path, List-Unsubscribe variants. The relay/prefix rules
|
|
181
|
+
// come from accounts.jsonc settings; cheap local read.
|
|
182
|
+
const settings = loadSettings() || {};
|
|
183
|
+
const acctConfig = (settings.accounts || []).find((a) => a.id === accountId) || {};
|
|
184
|
+
const relayDomains = acctConfig.relayDomains || [];
|
|
185
|
+
const prefixes = acctConfig.deliveredToPrefix || [];
|
|
186
|
+
let deliveredTo = "";
|
|
187
|
+
const rawDelivered = parsed.headers.get("delivered-to");
|
|
188
|
+
if (rawDelivered) {
|
|
189
|
+
const deliveredList = Array.isArray(rawDelivered) ? rawDelivered : [rawDelivered];
|
|
190
|
+
for (let i = deliveredList.length - 1; i >= 0; i--) {
|
|
191
|
+
const d = deliveredList[i];
|
|
192
|
+
const addr = typeof d === "string" ? d : d?.text || d?.address || String(d);
|
|
193
|
+
if (!relayDomains.some(rd => addr.includes(`@${rd}`))) {
|
|
194
|
+
deliveredTo = addr;
|
|
195
|
+
break;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
if (!deliveredTo && deliveredList.length > 0) {
|
|
199
|
+
const d = deliveredList[deliveredList.length - 1];
|
|
200
|
+
deliveredTo = typeof d === "string" ? d : d?.text || d?.address || String(d);
|
|
201
|
+
}
|
|
202
|
+
if (deliveredTo && prefixes.length > 0) {
|
|
203
|
+
const [local, domain] = deliveredTo.split("@");
|
|
204
|
+
for (const prefix of prefixes) {
|
|
205
|
+
if (local.startsWith(prefix)) {
|
|
206
|
+
deliveredTo = `${local.slice(prefix.length)}@${domain}`;
|
|
207
|
+
break;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
const hdr = (key) => {
|
|
213
|
+
let v = parsed.headers.get(key);
|
|
214
|
+
if (!v)
|
|
215
|
+
return "";
|
|
216
|
+
if (Array.isArray(v))
|
|
217
|
+
v = v[0];
|
|
218
|
+
if (typeof v === "string")
|
|
219
|
+
return v;
|
|
220
|
+
if (typeof v === "object" && v !== null) {
|
|
221
|
+
if ("text" in v)
|
|
222
|
+
return v.text || "";
|
|
223
|
+
if ("value" in v)
|
|
224
|
+
return String(v.value);
|
|
225
|
+
if ("address" in v)
|
|
226
|
+
return v.address || "";
|
|
227
|
+
}
|
|
228
|
+
return String(v);
|
|
229
|
+
};
|
|
230
|
+
const returnPath = hdr("return-path").replace(/[<>]/g, "");
|
|
231
|
+
const { listUnsubscribeMail, listUnsubscribeHttp, listUnsubscribeOneClick } = parseListUnsubscribe(parsed.headers);
|
|
232
|
+
const listUnsubscribe = listUnsubscribeHttp || listUnsubscribeMail;
|
|
233
|
+
return {
|
|
234
|
+
...envelope,
|
|
235
|
+
bodyHtml, bodyText,
|
|
236
|
+
hasRemoteContent, remoteAllowed: allowRemote,
|
|
237
|
+
attachments,
|
|
238
|
+
cached: true,
|
|
239
|
+
deliveredTo, returnPath,
|
|
240
|
+
listUnsubscribe, listUnsubscribeMail, listUnsubscribeHttp, listUnsubscribeOneClick,
|
|
241
|
+
emlPath: storedPath,
|
|
242
|
+
isFlagged,
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
// ── Calendar / tasks / contacts (read paths) ──
|
|
246
|
+
getCalendarEvents(accountId, fromMs, toMs) {
|
|
247
|
+
return this.db.getCalendarEvents(accountId, fromMs, toMs);
|
|
248
|
+
}
|
|
249
|
+
getTasks(accountId, includeCompleted = false) {
|
|
250
|
+
return this.db.getTasks(accountId, includeCompleted);
|
|
251
|
+
}
|
|
252
|
+
searchContacts(query, limit = 10) {
|
|
253
|
+
return this.db.searchContacts(query, limit);
|
|
254
|
+
}
|
|
255
|
+
listContacts(query, page = 1, pageSize = 100) {
|
|
256
|
+
return this.db.listContacts(query, page, pageSize);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
//# 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
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reconciler — single background loop that drains the SyncQueue, polls
|
|
3
|
+
* for server changes, and emits state events the UI listens for.
|
|
4
|
+
*
|
|
5
|
+
* Local-first contract: the Reconciler is the ONLY code path that touches
|
|
6
|
+
* IMAP / Gmail API on behalf of UI actions. UI handlers commit locally
|
|
7
|
+
* and enqueue; the Reconciler picks up and mirrors. Reads from the UI
|
|
8
|
+
* never await the Reconciler.
|
|
9
|
+
*
|
|
10
|
+
* What this owns today:
|
|
11
|
+
* - body-fetch lane: drains `SyncQueue.nextBodyFetch()`, calls
|
|
12
|
+
* `imapManager.fetchMessageBody`, emits `bodyAvailable` / `bodyFetchError`.
|
|
13
|
+
* This is the new piece; previously body fetches happened inline on
|
|
14
|
+
* every getMessage call.
|
|
15
|
+
* - sync-state pill events: emits `syncStateChanged` so the UI can show
|
|
16
|
+
* "Sync OK / Syncing N items".
|
|
17
|
+
*
|
|
18
|
+
* What it delegates (intentional — no duplication):
|
|
19
|
+
* - message-action drain: ImapManager already runs a 30s actionsInterval
|
|
20
|
+
* that processes both outbox (processSendActions) and sync_actions
|
|
21
|
+
* (processSyncActions) per account. Per plan decision #5, outbox stays
|
|
22
|
+
* under ImapManager; we don't fork that work.
|
|
23
|
+
* - poll loops (per-account quick check, full sync, prefetch, tombstone
|
|
24
|
+
* prune): all owned by ImapManager's startPeriodicSync. The Reconciler
|
|
25
|
+
* does NOT shadow them — back-pressure on those timers can be added
|
|
26
|
+
* later via a single `setBackPressure(level)` hook.
|
|
27
|
+
*
|
|
28
|
+
* Part of docs/local-first-plan.md (step 4).
|
|
29
|
+
*/
|
|
30
|
+
import type { ImapManager } from "@bobfrankston/mailx-imap";
|
|
31
|
+
import type { MailxDB } from "@bobfrankston/mailx-store";
|
|
32
|
+
import type { SyncQueue } from "./sync-queue.js";
|
|
33
|
+
export interface ReconcilerOptions {
|
|
34
|
+
/** Max concurrent body fetches across all accounts (in addition to
|
|
35
|
+
* per-account semaphore in ImapManager). Keeps the interactive lane
|
|
36
|
+
* responsive even when prefetch is hammering. */
|
|
37
|
+
bodyFetchConcurrency?: number;
|
|
38
|
+
/** Periodic emit of syncStateChanged — drives the UI status pill. */
|
|
39
|
+
statusEmitIntervalMs?: number;
|
|
40
|
+
}
|
|
41
|
+
export declare class Reconciler {
|
|
42
|
+
private db;
|
|
43
|
+
private imapManager;
|
|
44
|
+
private queue;
|
|
45
|
+
private opts;
|
|
46
|
+
private running;
|
|
47
|
+
private statusTimer;
|
|
48
|
+
private inFlightFetches;
|
|
49
|
+
constructor(db: MailxDB, imapManager: ImapManager, queue: SyncQueue, opts?: ReconcilerOptions);
|
|
50
|
+
start(): void;
|
|
51
|
+
stop(): void;
|
|
52
|
+
/** User-visible "sync now" — runs an immediate poll on all accounts
|
|
53
|
+
* (or just one) without waiting for the next periodic tick. */
|
|
54
|
+
syncNow(accountId?: string): void;
|
|
55
|
+
/** Drain the body-fetch lane up to `bodyFetchConcurrency`. Each
|
|
56
|
+
* fetch is fire-and-forget; on completion it kicks the pump again
|
|
57
|
+
* so a queue that grows during work stays drained. */
|
|
58
|
+
private pumpBodyFetches;
|
|
59
|
+
/** External nudge — call after enqueueing a high-priority body fetch. */
|
|
60
|
+
kick(): void;
|
|
61
|
+
}
|
|
62
|
+
//# sourceMappingURL=reconciler.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"reconciler.d.ts","sourceRoot":"","sources":["reconciler.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AAEH,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,0BAA0B,CAAC;AAC5D,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,2BAA2B,CAAC;AACzD,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,iBAAiB,CAAC;AAEjD,MAAM,WAAW,iBAAiB;IAC9B;;sDAEkD;IAClD,oBAAoB,CAAC,EAAE,MAAM,CAAC;IAC9B,qEAAqE;IACrE,oBAAoB,CAAC,EAAE,MAAM,CAAC;CACjC;AAOD,qBAAa,UAAU;IAOf,OAAO,CAAC,EAAE;IACV,OAAO,CAAC,WAAW;IACnB,OAAO,CAAC,KAAK;IARjB,OAAO,CAAC,IAAI,CAA8B;IAC1C,OAAO,CAAC,OAAO,CAAS;IACxB,OAAO,CAAC,WAAW,CAA+C;IAClE,OAAO,CAAC,eAAe,CAAK;gBAGhB,EAAE,EAAE,OAAO,EACX,WAAW,EAAE,WAAW,EACxB,KAAK,EAAE,SAAS,EACxB,IAAI,GAAE,iBAAsB;IAKhC,KAAK,IAAI,IAAI;IAeb,IAAI,IAAI,IAAI;IAKZ;oEACgE;IAChE,OAAO,CAAC,SAAS,CAAC,EAAE,MAAM,GAAG,IAAI;IAOjC;;2DAEuD;IACvD,OAAO,CAAC,eAAe;IAmDvB,yEAAyE;IACzE,IAAI,IAAI,IAAI;CAGf"}
|