@bobfrankston/rmfmail 1.1.4 → 1.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/bin/build-bundles.mjs +14 -4
  2. package/bin/mailx.js +8 -3
  3. package/bin/mailx.js.map +1 -1
  4. package/bin/mailx.ts +8 -3
  5. package/client/android-bootstrap.bundle.js +2151 -2
  6. package/client/android-bootstrap.bundle.js.map +4 -4
  7. package/package.json +1 -1
  8. package/packages/mailx-api/index.d.ts +2 -2
  9. package/packages/mailx-api/index.d.ts.map +1 -1
  10. package/packages/mailx-api/index.js +2 -2
  11. package/packages/mailx-api/index.js.map +1 -1
  12. package/packages/mailx-api/index.ts +3 -3
  13. package/packages/mailx-core/index.d.ts.map +1 -1
  14. package/packages/mailx-core/index.js +3 -2
  15. package/packages/mailx-core/index.js.map +1 -1
  16. package/packages/mailx-core/index.ts +3 -2
  17. package/packages/mailx-imap/index.d.ts +13 -4
  18. package/packages/mailx-imap/index.d.ts.map +1 -1
  19. package/packages/mailx-imap/index.js +16 -8
  20. package/packages/mailx-imap/index.js.map +1 -1
  21. package/packages/mailx-imap/index.ts +15 -7
  22. package/packages/mailx-imap/package-lock.json +2 -2
  23. package/packages/mailx-imap/package.json +1 -1
  24. package/packages/mailx-server/index.d.ts.map +1 -1
  25. package/packages/mailx-server/index.js +4 -3
  26. package/packages/mailx-server/index.js.map +1 -1
  27. package/packages/mailx-server/index.ts +4 -3
  28. package/packages/mailx-service/db-worker.js +3 -4
  29. package/packages/mailx-service/db-worker.js.map +1 -1
  30. package/packages/mailx-service/db-worker.ts +5 -6
  31. package/packages/mailx-service/index.d.ts +20 -3
  32. package/packages/mailx-service/index.d.ts.map +1 -1
  33. package/packages/mailx-service/index.js +19 -17
  34. package/packages/mailx-service/index.js.map +1 -1
  35. package/packages/mailx-service/index.ts +18 -17
  36. package/packages/mailx-service/local-store.d.ts +7 -144
  37. package/packages/mailx-service/local-store.d.ts.map +1 -1
  38. package/packages/mailx-service/local-store.js +6 -511
  39. package/packages/mailx-service/local-store.js.map +1 -1
  40. package/packages/mailx-service/local-store.ts +7 -551
  41. package/packages/mailx-store/charset.d.ts +15 -0
  42. package/packages/mailx-store/charset.d.ts.map +1 -0
  43. package/packages/mailx-store/charset.js +61 -0
  44. package/packages/mailx-store/charset.js.map +1 -0
  45. package/packages/mailx-store/charset.ts +45 -0
  46. package/packages/mailx-store/index.d.ts +2 -0
  47. package/packages/mailx-store/index.d.ts.map +1 -1
  48. package/packages/mailx-store/index.js +2 -0
  49. package/packages/mailx-store/index.js.map +1 -1
  50. package/packages/mailx-store/index.ts +4 -0
  51. package/packages/mailx-store/package.json +1 -1
  52. package/packages/mailx-store/store.d.ts +169 -0
  53. package/packages/mailx-store/store.d.ts.map +1 -0
  54. package/packages/mailx-store/store.js +528 -0
  55. package/packages/mailx-store/store.js.map +1 -0
  56. package/packages/mailx-store/store.ts +567 -0
  57. /package/packages/mailx-imap/{node_modules.npmglobalize-stash-39436 → node_modules.npmglobalize-stash-11408}/.package-lock.json +0 -0
@@ -1,553 +1,9 @@
1
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.
2
+ * LocalStore — DEPRECATED. The Store moved to `@bobfrankston/mailx-store`
3
+ * (it's now the architectural nexus and lives in the package that owns
4
+ * the underlying DB + .eml file backend). Re-exported here only so any
5
+ * stale `./local-store.js` imports keep resolving while in-tree callers
6
+ * migrate to `import { Store } from "@bobfrankston/mailx-store"`.
17
7
  */
18
-
19
- import * as fs from "node:fs";
20
- import { parseSerial, storeBus } from "@bobfrankston/mailx-store";
21
- import type { MailxDB, FileMessageStore, StoreBus } 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
- // Parsed-body LRU. `simpleParser` is the dominant cost of getMessage on
87
- // a body that's already on disk — 100-500 ms for a typical Gmail message
88
- // (heavier multipart/alternative + embedded-image swizzling than plainer
89
- // IMAP mail). Stash the parsed result keyed by .eml path + mtime so
90
- // repeat views of the same message return in microseconds.
91
- //
92
- // Invalidation is implicit: when the underlying .eml is overwritten
93
- // (rare — bodies are immutable once cached, but reconcile can re-fetch
94
- // on UIDVALIDITY change), the new mtime won't match the cached key.
95
- // Capacity 200 entries × ~50 kB parsed envelope = ~10 MB ceiling.
96
- private static readonly PARSED_LRU_CAPACITY = 200;
97
- private parsedLru = new Map<string, LocalMessage>();
98
- private parsedLruGet(key: string): LocalMessage | undefined {
99
- const v = this.parsedLru.get(key);
100
- if (!v) return undefined;
101
- // Bump recency.
102
- this.parsedLru.delete(key);
103
- this.parsedLru.set(key, v);
104
- return v;
105
- }
106
- private parsedLruPut(key: string, msg: LocalMessage): void {
107
- this.parsedLru.delete(key);
108
- this.parsedLru.set(key, msg);
109
- while (this.parsedLru.size > LocalStore.PARSED_LRU_CAPACITY) {
110
- const oldest = this.parsedLru.keys().next().value;
111
- if (oldest === undefined) break;
112
- this.parsedLru.delete(oldest);
113
- }
114
- }
115
-
116
- // Allowlist + settings caches. Both files live on the GDrive-mounted
117
- // shared dir; their sync `readFileSync` calls in `loadAllowlist()` and
118
- // `loadSettings()` can stall for seconds per call. getMessage runs on
119
- // every preview click, so paying that twice each click made .eml
120
- // display "go to GDrive" even though the body itself is local. Cached
121
- // here; invalidated externally via invalidateConfigCaches() when the
122
- // parent service receives a `configChanged` event.
123
- private _allowlistCache: any | null = null;
124
- private _settingsCache: any | null = null;
125
- private getCachedAllowlist(): any {
126
- if (!this._allowlistCache) this._allowlistCache = loadAllowlist();
127
- return this._allowlistCache;
128
- }
129
- private getCachedSettings(): any {
130
- if (!this._settingsCache) this._settingsCache = loadSettings();
131
- return this._settingsCache;
132
- }
133
- invalidateConfigCaches(): void {
134
- this._allowlistCache = null;
135
- this._settingsCache = null;
136
- }
137
-
138
- constructor(
139
- private db: MailxDB,
140
- private bodyStore: FileMessageStore,
141
- /** Event bus for Store mutations. Defaults to the process-singleton
142
- * so cross-package subscribers (bin/mailx.ts forwarder → WebView,
143
- * in-process reconciler triggers) see all writes without explicit
144
- * wiring. Tests can pass a fresh StoreBus for isolation. */
145
- public readonly bus: StoreBus = storeBus,
146
- ) {}
147
-
148
- // ── Account list (read-only here; mutations go through MailxService
149
- // until the cloud-write path is part of the queue too) ──
150
-
151
- /** DB-shape account list (id/name/email/lastSync). The richer
152
- * AccountConfig (with imap/smtp/etc.) lives in accounts.jsonc and is
153
- * loaded by mailx-settings, not the DB — that path stays in
154
- * MailxService until step 3 of the local-first plan. */
155
- getAccounts(): { id: string; name: string; email: string; lastSync: number }[] {
156
- return this.db.getAccounts();
157
- }
158
-
159
- // ── Folders ──
160
-
161
- getFolders(accountId: string): any[] {
162
- return this.db.getFolders(accountId);
163
- }
164
-
165
- /** Look up a folder by RFC 6154 specialUse tag (`trash`, `drafts`, `sent`,
166
- * `junk`, etc.) for the given account. Falls back to a case-insensitive
167
- * path match for legacy rows where specialUse never got tagged.
168
- * Returns null when the account has no such folder configured. */
169
- findSpecialFolder(accountId: string, specialUse: string): { id: number; path: string } | null {
170
- const folders = this.db.getFolders(accountId);
171
- const f = folders.find(x =>
172
- x.specialUse === specialUse ||
173
- x.path.toLowerCase() === specialUse.toLowerCase()
174
- );
175
- return f ? { id: f.id, path: f.path } : null;
176
- }
177
-
178
- // ── Message envelopes ──
179
-
180
- /** Single envelope by (account, uid, folder). Null when the row isn't
181
- * in the DB — caller decides whether to show "deleted" or queue a
182
- * server lookup via the reconciler. */
183
- getMessageEnvelope(accountId: string, uid: number, folderId?: number): MessageEnvelope | null {
184
- const env = this.db.getMessageByUid(accountId, uid, folderId);
185
- return env || null;
186
- }
187
-
188
- /** Paginated message list for a (account, folder, ...) query. */
189
- getMessages(query: MessageQuery): PagedResult<MessageEnvelope> {
190
- return this.db.getMessages(query);
191
- }
192
-
193
- /** All-Inboxes view: union of every account's INBOX, paginated. */
194
- getUnifiedInbox(page = 1, pageSize = 50): PagedResult<MessageEnvelope> {
195
- return this.db.getUnifiedInbox(page, pageSize);
196
- }
197
-
198
- /** Local FTS5 search. Server-scope search is the reconciler's job. */
199
- searchMessages(query: string, page = 1, pageSize = 50, accountId?: string, folderId?: number, includeTrashSpam = false): PagedResult<MessageEnvelope> {
200
- return this.db.searchMessages(query, page, pageSize, accountId, folderId, includeTrashSpam);
201
- }
202
-
203
- // ── Message body (read-from-disk only) ──
204
-
205
- /** Read a fully-parsed message (envelope + body + attachments) entirely
206
- * from local state. Returns null when the envelope isn't known.
207
- * Returns `{ ...envelope, cached: false }` when the envelope is known
208
- * but the body file isn't on disk — UI shows a placeholder and the
209
- * reconciler queues the fetch.
210
- *
211
- * `allowRemote=true` skips HTML sanitization. Used when the user has
212
- * explicitly allowed remote content for this sender / domain. */
213
- async getMessage(accountId: string, uid: number, allowRemote: boolean, folderId?: number): Promise<LocalMessage | null> {
214
- const envelope: any = this.db.getMessageByUid(accountId, uid, folderId);
215
- if (!envelope) return null;
216
-
217
- const allowList = this.getCachedAllowlist() as any;
218
- const senderAddr = (envelope.from?.address || "").toLowerCase();
219
- const senderDomain = senderAddr.split("@")[1] || "";
220
- const toAddrs = (envelope.to || []).map((a: any) => (a.address || "").toLowerCase());
221
-
222
- // Allowlist auto-allow: trusted sender / domain / recipient skips
223
- // sanitization. Same rule as the legacy service implementation.
224
- if (!allowRemote) {
225
- const senders = (allowList.senders || []).map((s: string) => (s || "").toLowerCase());
226
- const domains = (allowList.domains || []).map((d: string) => (d || "").toLowerCase());
227
- const recipients = (allowList.recipients || []).map((r: string) => (r || "").toLowerCase());
228
- if (senders.includes(senderAddr) ||
229
- domains.includes(senderDomain) ||
230
- toAddrs.some((a: string) => recipients.includes(a))) {
231
- allowRemote = true;
232
- }
233
- }
234
-
235
- const isFlagged = !!(
236
- (allowList.flaggedSenders || []).some((s: string) => (s || "").toLowerCase() === senderAddr) ||
237
- (allowList.flaggedDomains || []).some((d: string) => (d || "").toLowerCase() === senderDomain)
238
- );
239
-
240
- // Resolve body path: prefer the row's own bodyPath, fall back to
241
- // the historical lookup. Either may be empty (body never fetched).
242
- let storedPath = envelope.bodyPath || "";
243
- if (!storedPath) storedPath = this.db.getMessageBodyPath(accountId, uid) || "";
244
-
245
- const empty: LocalMessage = {
246
- ...envelope,
247
- bodyHtml: "", bodyText: "",
248
- hasRemoteContent: false, remoteAllowed: allowRemote,
249
- attachments: [],
250
- cached: false,
251
- deliveredTo: "", returnPath: "",
252
- listUnsubscribe: "", listUnsubscribeMail: "", listUnsubscribeHttp: "", listUnsubscribeOneClick: false,
253
- emlPath: "",
254
- isFlagged,
255
- };
256
-
257
- if (!storedPath) return empty;
258
- if (!await this.bodyStore.hasByPath(storedPath)) return empty;
259
-
260
- // Parse-cache lookup. Key = path + mtime so a re-fetched body
261
- // (mtime changes) invalidates automatically. mtime stat is cheap
262
- // (sub-ms) compared to the parse it avoids (~100-500 ms).
263
- let mtimeMs = 0;
264
- try { mtimeMs = (await fs.promises.stat(storedPath)).mtimeMs; }
265
- catch { /* will fall through and fail at readByPath */ }
266
- const cacheKey = `${storedPath}|${mtimeMs}`;
267
- const cached = this.parsedLruGet(cacheKey);
268
- if (cached) {
269
- // Allowlist state can change between views even with the same
270
- // body cached; recompute the volatile fields and overlay.
271
- return { ...cached, remoteAllowed: allowRemote, isFlagged };
272
- }
273
-
274
- let raw: Buffer;
275
- try {
276
- raw = await this.bodyStore.readByPath(storedPath);
277
- } catch {
278
- // File path is in DB but the file is missing — the prefetch
279
- // dot is lying. Caller (UI) should mark the row as broken and
280
- // the reconciler should re-fetch. Don't crash; return as if
281
- // not cached.
282
- return empty;
283
- }
284
-
285
- // Parse + sanitize. Same logic as today's MailxService.getMessage,
286
- // but executed only when the body is local — never on a fresh
287
- // server fetch.
288
- const adjusted = sniffAndFixCharset(raw);
289
- // simpleParser blocks the event loop while it parses — visible cost
290
- // on >100 KB messages. Time it so the log makes the cost concrete:
291
- // when this number is high, that's why other IPC calls (the next
292
- // message click, the folder-count update tick) stall.
293
- const _parseT0 = Date.now();
294
- const parsed = await parseSerial(adjusted);
295
- const _parseMs = Date.now() - _parseT0;
296
- if (_parseMs > 50) {
297
- console.log(` [parse] simpleParser ${_parseMs}ms for ${(raw.length / 1024).toFixed(0)} KB (${storedPath})`);
298
- }
299
- let bodyHtml = parsed.html || "";
300
- const bodyText = parsed.text || "";
301
- // Backfill FTS body_text now that we've parsed the body. upsertMessage
302
- // only had the short `preview` snippet at index time; without this
303
- // backfill, searches miss any word that only appears deeper in the
304
- // body. Fire-and-forget — failures are non-fatal, never block the
305
- // user's preview render.
306
- if (bodyText) {
307
- try { this.db.updateFtsBodyByUid(accountId, envelope.folderId, uid, bodyText); }
308
- catch { /* */ }
309
- }
310
- let hasRemoteContent = false;
311
- // Filter out "spurious" attachments: mailing-list footers and signature
312
- // blocks frequently arrive as a separate text/plain MIME part with
313
- // Content-Disposition: inline and no filename. mailparser dutifully
314
- // surfaces them as attachments. Showing them as attachment chips
315
- // confuses the user (Bob 2026-05-13: spurious image-shaped chip on
316
- // a list message that had no real attachment). Rule: if the part is
317
- // text/* AND has no filename AND is dispositionally inline (or no
318
- // disposition at all), it's body content, not a real attachment.
319
- // Keep the original index even for filtered rows so getAttachment's
320
- // index lookup still works for the surviving ones.
321
- const attachments = (parsed.attachments || [])
322
- .map((a, i) => {
323
- const mime = (a.contentType || "application/octet-stream").toLowerCase();
324
- const disposition = (a.contentDisposition || "").toLowerCase();
325
- const isSpuriousTextPart = mime.startsWith("text/")
326
- && !a.filename
327
- && (disposition === "inline" || disposition === "");
328
- return { a, i, isSpuriousTextPart };
329
- })
330
- .filter(x => !x.isSpuriousTextPart)
331
- .map(x => ({
332
- id: x.i,
333
- filename: x.a.filename || `attachment-${x.i}`,
334
- mimeType: x.a.contentType || "application/octet-stream",
335
- size: x.a.size || 0,
336
- contentId: x.a.contentId || "",
337
- }));
338
-
339
- if (bodyHtml && !allowRemote) {
340
- const result = sanitizeHtml(bodyHtml);
341
- bodyHtml = result.html;
342
- hasRemoteContent = result.hasRemoteContent;
343
- }
344
-
345
- // Header extraction — Delivered-To, Return-Path, List-Unsubscribe.
346
- // mlproc preprocesses Delivered-To server-side, so the inner
347
- // address is already clean by the time mailx sees it. We take the
348
- // last entry of the chain (the final-delivery hop). The
349
- // `relayDomains` filtering layer was retired 2026-05-13: nothing
350
- // configures it in practice and the conditional made the code
351
- // harder to reason about than the one-liner that replaces it.
352
- let deliveredTo = "";
353
- const rawDelivered = parsed.headers.get("delivered-to");
354
- if (rawDelivered) {
355
- const deliveredList = Array.isArray(rawDelivered) ? rawDelivered : [rawDelivered];
356
- const d = deliveredList[deliveredList.length - 1];
357
- deliveredTo = typeof d === "string" ? d : (d as any)?.text || (d as any)?.address || String(d);
358
- }
359
-
360
- const hdr = (key: string): string => {
361
- let v = parsed.headers.get(key);
362
- if (!v) return "";
363
- if (Array.isArray(v)) v = v[0];
364
- if (typeof v === "string") return v;
365
- if (typeof v === "object" && v !== null) {
366
- if ("text" in v) return (v as any).text || "";
367
- if ("value" in v) return String((v as any).value);
368
- if ("address" in v) return (v as any).address || "";
369
- }
370
- return String(v);
371
- };
372
- const returnPath = hdr("return-path").replace(/[<>]/g, "");
373
- const { listUnsubscribeMail, listUnsubscribeHttp, listUnsubscribeOneClick } =
374
- parseListUnsubscribe(parsed.headers);
375
- const listUnsubscribe = listUnsubscribeHttp || listUnsubscribeMail;
376
-
377
- const result: LocalMessage = {
378
- ...envelope,
379
- bodyHtml, bodyText,
380
- hasRemoteContent, remoteAllowed: allowRemote,
381
- attachments,
382
- cached: true,
383
- deliveredTo, returnPath,
384
- listUnsubscribe, listUnsubscribeMail, listUnsubscribeHttp, listUnsubscribeOneClick,
385
- // body_path in the DB is stored relative to the body-store
386
- // basePath; resolve to absolute so the UI's "Source" button
387
- // can hand the path to an OS file open without re-deriving
388
- // basePath every time.
389
- emlPath: this.bodyStore.absolutePath(storedPath),
390
- isFlagged,
391
- };
392
- // Memoize the parsed result for subsequent views of the same UID.
393
- // `remoteAllowed` and `isFlagged` get re-overlaid on read so a flag
394
- // toggle or allowlist edit doesn't require a re-parse to take
395
- // effect (see the parsedLruGet path above).
396
- //
397
- // BUT don't poison the cache with an empty parse — a malformed .eml
398
- // or a fetch that left a stub file gives bodyHtml === "" AND
399
- // bodyText === ""; caching that means future views serve emptiness
400
- // until the daemon restarts. Let the next view try again; the
401
- // parse cost on a 9 kB .eml is ~20 ms, so re-parsing an oddity
402
- // is cheap.
403
- const hasContent = (bodyHtml && bodyHtml.length > 0) || (bodyText && bodyText.length > 0);
404
- if (mtimeMs > 0 && hasContent) this.parsedLruPut(cacheKey, result);
405
- return result;
406
- }
407
-
408
- // ── Calendar / tasks / contacts (read paths) ──
409
-
410
- getCalendarEvents(accountId: string, fromMs: number, toMs: number): any[] {
411
- return this.db.getCalendarEvents(accountId, fromMs, toMs);
412
- }
413
-
414
- getTasks(accountId: string, includeCompleted = false): any[] {
415
- return this.db.getTasks(accountId, includeCompleted);
416
- }
417
-
418
- searchContacts(query: string, limit = 10): any[] {
419
- return this.db.searchContacts(query, limit);
420
- }
421
-
422
- listContacts(query: string, page = 1, pageSize = 100): any {
423
- return this.db.listContacts(query, page, pageSize);
424
- }
425
-
426
- // ── Write paths (local-only; mirror to server is queued separately) ──
427
-
428
- /** Update a message's flag set. Local DB write completes synchronously;
429
- * the server-mirror enqueue is the caller's responsibility (typically
430
- * via SyncQueue.enqueueFlag) so callers that don't want a server push
431
- * — pure-local UI state like "pin in pane" — can skip it.
432
- *
433
- * Publishes:
434
- * `message:<uuid>` { kind: "flagsChanged" }
435
- * `folder:<id>` (auto fan-out)
436
- */
437
- updateFlags(accountId: string, uid: number, folderId: number, flags: string[]): void {
438
- this.db.updateMessageFlags(accountId, uid, flags);
439
- const env: any = this.db.getMessageByUid(accountId, uid, folderId);
440
- const msgUuid: string | undefined = env?.uuid;
441
- if (msgUuid) {
442
- this.bus.publish({
443
- topic: `message:${msgUuid}`,
444
- kind: "flagsChanged",
445
- accountId, folderId, uid, msgUuid, flags,
446
- });
447
- }
448
- }
449
-
450
- /** Move a message between folders in the same account. Adds a tombstone
451
- * on the Message-ID so the next sync doesn't re-import the pre-move row
452
- * in the source folder before the server-side MOVE completes; tombstone
453
- * is cleared on terminal IMAP failure (see processSyncActions).
454
- *
455
- * Returns true if a local row existed and was moved, false otherwise.
456
- *
457
- * Publishes:
458
- * `message:<uuid>` { kind: "messageMoved", folderId: source, targetFolderId }
459
- * `folder:<source>` and `folder:<target>` (auto fan-out + explicit count)
460
- */
461
- moveMessage(accountId: string, uid: number, fromFolderId: number, targetFolderId: number): boolean {
462
- const env: any = this.db.getMessageByUid(accountId, uid, fromFolderId);
463
- if (!env) return false;
464
- if (env.messageId) this.db.addTombstone(accountId, env.messageId, env.subject || "");
465
- const moved = this.db.moveMessageLocal(accountId, uid, fromFolderId, targetFolderId);
466
- if (!moved) return false;
467
- this.db.recalcFolderCounts(fromFolderId);
468
- this.db.recalcFolderCounts(targetFolderId);
469
- const msgUuid: string | undefined = env?.uuid;
470
- if (msgUuid) {
471
- this.bus.publish({
472
- topic: `message:${msgUuid}`,
473
- kind: "messageMoved",
474
- accountId, folderId: fromFolderId, targetFolderId, uid, msgUuid,
475
- });
476
- }
477
- // Folder count change isn't tied to a specific message uuid — publish
478
- // to both folder topics directly. (The fan-out only covers the source
479
- // via the message event's folderId; target needs its own publish.)
480
- this.bus.publish({
481
- topic: `folder:${targetFolderId}`,
482
- kind: "folderCountsChanged",
483
- accountId, folderId: targetFolderId,
484
- });
485
- return true;
486
- }
487
-
488
- /** Trash a message. If a trash folder is configured and the message is
489
- * not already in it, this is a move-to-trash. If the message is already
490
- * in trash (or no trash exists), it's a hard delete + body unlink.
491
- *
492
- * Returns "moved-to-trash" or "expunged" so the caller knows whether
493
- * to enqueue an IMAP MOVE or a DELETE+EXPUNGE on the queue.
494
- */
495
- trashMessage(accountId: string, uid: number, folderId: number, trashFolderId: number | null): "moved-to-trash" | "expunged" {
496
- const env: any = this.db.getMessageByUid(accountId, uid, folderId);
497
- if (env?.messageId) this.db.addTombstone(accountId, env.messageId, env.subject || "");
498
- const msgUuid: string | undefined = env?.uuid;
499
- if (trashFolderId != null && trashFolderId !== folderId) {
500
- this.db.moveMessageLocal(accountId, uid, folderId, trashFolderId);
501
- this.db.recalcFolderCounts(folderId);
502
- this.db.recalcFolderCounts(trashFolderId);
503
- if (msgUuid) {
504
- this.bus.publish({
505
- topic: `message:${msgUuid}`,
506
- kind: "messageMoved",
507
- accountId, folderId, targetFolderId: trashFolderId, uid, msgUuid,
508
- });
509
- }
510
- this.bus.publish({
511
- topic: `folder:${trashFolderId}`,
512
- kind: "folderCountsChanged",
513
- accountId, folderId: trashFolderId,
514
- });
515
- return "moved-to-trash";
516
- }
517
- this.db.deleteMessage(accountId, uid, "user-initiated trash (already in trash → expunge)", "LocalStore.trashMessage");
518
- this.db.recalcFolderCounts(folderId);
519
- if (msgUuid) {
520
- this.bus.publish({
521
- topic: `message:${msgUuid}`,
522
- kind: "messageRemoved",
523
- accountId, folderId, uid, msgUuid,
524
- });
525
- }
526
- return "expunged";
527
- }
528
-
529
- /** Restore a message from trash back to its original folder. Local-only;
530
- * caller handles the queue (cancel-pending-MOVE vs queue-counter-MOVE).
531
- * Returns true if a local row was moved. */
532
- undeleteMessage(accountId: string, uid: number, trashFolderId: number, originalFolderId: number): boolean {
533
- const moved = this.db.moveMessageLocal(accountId, uid, trashFolderId, originalFolderId);
534
- if (!moved) return false;
535
- this.db.recalcFolderCounts(trashFolderId);
536
- this.db.recalcFolderCounts(originalFolderId);
537
- const env: any = this.db.getMessageByUid(accountId, uid, originalFolderId);
538
- const msgUuid: string | undefined = env?.uuid;
539
- if (msgUuid) {
540
- this.bus.publish({
541
- topic: `message:${msgUuid}`,
542
- kind: "messageMoved",
543
- accountId, folderId: trashFolderId, targetFolderId: originalFolderId, uid, msgUuid,
544
- });
545
- }
546
- this.bus.publish({
547
- topic: `folder:${originalFolderId}`,
548
- kind: "folderCountsChanged",
549
- accountId, folderId: originalFolderId,
550
- });
551
- return true;
552
- }
553
- }
8
+ export { Store as LocalStore } from "@bobfrankston/mailx-store";
9
+ export type { StoreMessage as LocalMessage } from "@bobfrankston/mailx-store";
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Charset normalization for incoming email bodies.
3
+ *
4
+ * Many senders (esp. PHPMailer-driven marketing) declare
5
+ * `charset=iso-8859-1` but emit UTF-8 bytes. simpleParser honors the
6
+ * declared charset and produces "â??" garbage for every non-ASCII
7
+ * codepoint (em-dash, smart quotes, …). When the raw body bytes are
8
+ * valid UTF-8, rewrite the charset header before parsing. We only
9
+ * override the obviously-wrong legacy declarations; explicit utf-8 /
10
+ * koi8 / etc. pass through.
11
+ */
12
+ /** Returns either the original buffer (no change needed) or a copy with
13
+ * the leading charset declaration rewritten to utf-8. */
14
+ export declare function sniffAndFixCharset(raw: Buffer): Buffer;
15
+ //# sourceMappingURL=charset.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"charset.d.ts","sourceRoot":"","sources":["charset.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAEH;0DAC0D;AAC1D,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAQtD"}
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Charset normalization for incoming email bodies.
3
+ *
4
+ * Many senders (esp. PHPMailer-driven marketing) declare
5
+ * `charset=iso-8859-1` but emit UTF-8 bytes. simpleParser honors the
6
+ * declared charset and produces "â??" garbage for every non-ASCII
7
+ * codepoint (em-dash, smart quotes, …). When the raw body bytes are
8
+ * valid UTF-8, rewrite the charset header before parsing. We only
9
+ * override the obviously-wrong legacy declarations; explicit utf-8 /
10
+ * koi8 / etc. pass through.
11
+ */
12
+ /** Returns either the original buffer (no change needed) or a copy with
13
+ * the leading charset declaration rewritten to utf-8. */
14
+ export function sniffAndFixCharset(raw) {
15
+ const HEAD_LIMIT = 16384;
16
+ const head = raw.subarray(0, Math.min(HEAD_LIMIT, raw.length)).toString("latin1");
17
+ const re = /charset\s*=\s*"?(iso-8859-1|us-ascii|windows-1252|latin1)"?/gi;
18
+ if (!re.test(head))
19
+ return raw;
20
+ if (!isValidUtf8(raw))
21
+ return raw;
22
+ const fixed = head.replace(/charset\s*=\s*"?(iso-8859-1|us-ascii|windows-1252|latin1)"?/gi, "charset=utf-8");
23
+ return Buffer.concat([Buffer.from(fixed, "latin1"), raw.subarray(head.length)]);
24
+ }
25
+ /** Strict UTF-8 validity check: rejects overlong forms, invalid start
26
+ * bytes, and dangling continuations. Used to confirm the body is really
27
+ * UTF-8 before overriding a Latin-1 declaration. */
28
+ function isValidUtf8(buf) {
29
+ let i = 0;
30
+ while (i < buf.length) {
31
+ const b = buf[i];
32
+ if (b < 0x80) {
33
+ i++;
34
+ continue;
35
+ }
36
+ let need;
37
+ if ((b & 0xE0) === 0xC0) {
38
+ if (b < 0xC2)
39
+ return false;
40
+ need = 1;
41
+ }
42
+ else if ((b & 0xF0) === 0xE0)
43
+ need = 2;
44
+ else if ((b & 0xF8) === 0xF0) {
45
+ if (b > 0xF4)
46
+ return false;
47
+ need = 3;
48
+ }
49
+ else
50
+ return false;
51
+ if (i + need >= buf.length)
52
+ return false;
53
+ for (let k = 1; k <= need; k++) {
54
+ if ((buf[i + k] & 0xC0) !== 0x80)
55
+ return false;
56
+ }
57
+ i += need + 1;
58
+ }
59
+ return true;
60
+ }
61
+ //# sourceMappingURL=charset.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"charset.js","sourceRoot":"","sources":["charset.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAEH;0DAC0D;AAC1D,MAAM,UAAU,kBAAkB,CAAC,GAAW;IAC1C,MAAM,UAAU,GAAG,KAAK,CAAC;IACzB,MAAM,IAAI,GAAG,GAAG,CAAC,QAAQ,CAAC,CAAC,EAAE,IAAI,CAAC,GAAG,CAAC,UAAU,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC,CAAC,QAAQ,CAAC,QAAQ,CAAC,CAAC;IAClF,MAAM,EAAE,GAAG,+DAA+D,CAAC;IAC3E,IAAI,CAAC,EAAE,CAAC,IAAI,CAAC,IAAI,CAAC;QAAE,OAAO,GAAG,CAAC;IAC/B,IAAI,CAAC,WAAW,CAAC,GAAG,CAAC;QAAE,OAAO,GAAG,CAAC;IAClC,MAAM,KAAK,GAAG,IAAI,CAAC,OAAO,CAAC,+DAA+D,EAAE,eAAe,CAAC,CAAC;IAC7G,OAAO,MAAM,CAAC,MAAM,CAAC,CAAC,MAAM,CAAC,IAAI,CAAC,KAAK,EAAE,QAAQ,CAAC,EAAE,GAAG,CAAC,QAAQ,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC;AACpF,CAAC;AAED;;qDAEqD;AACrD,SAAS,WAAW,CAAC,GAAW;IAC5B,IAAI,CAAC,GAAG,CAAC,CAAC;IACV,OAAO,CAAC,GAAG,GAAG,CAAC,MAAM,EAAE,CAAC;QACpB,MAAM,CAAC,GAAG,GAAG,CAAC,CAAC,CAAC,CAAC;QACjB,IAAI,CAAC,GAAG,IAAI,EAAE,CAAC;YAAC,CAAC,EAAE,CAAC;YAAC,SAAS;QAAC,CAAC;QAChC,IAAI,IAAY,CAAC;QACjB,IAAI,CAAC,CAAC,GAAG,IAAI,CAAC,KAAK,IAAI,EAAE,CAAC;YAAC,IAAI,CAAC,GAAG,IAAI;gBAAE,OAAO,KAAK,CAAC;YAAC,IAAI,GAAG,CAAC,CAAC;QAAC,CAAC;aAC7D,IAAI,CAAC,CAAC,GAAG,IAAI,CAAC,KAAK,IAAI;YAAE,IAAI,GAAG,CAAC,CAAC;aAClC,IAAI,CAAC,CAAC,GAAG,IAAI,CAAC,KAAK,IAAI,EAAE,CAAC;YAAC,IAAI,CAAC,GAAG,IAAI;gBAAE,OAAO,KAAK,CAAC;YAAC,IAAI,GAAG,CAAC,CAAC;QAAC,CAAC;;YAClE,OAAO,KAAK,CAAC;QAClB,IAAI,CAAC,GAAG,IAAI,IAAI,GAAG,CAAC,MAAM;YAAE,OAAO,KAAK,CAAC;QACzC,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,IAAI,IAAI,EAAE,CAAC,EAAE,EAAE,CAAC;YAC7B,IAAI,CAAC,GAAG,CAAC,CAAC,GAAG,CAAC,CAAC,GAAG,IAAI,CAAC,KAAK,IAAI;gBAAE,OAAO,KAAK,CAAC;QACnD,CAAC;QACD,CAAC,IAAI,IAAI,GAAG,CAAC,CAAC;IAClB,CAAC;IACD,OAAO,IAAI,CAAC;AAChB,CAAC"}