@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
|
@@ -12,6 +12,8 @@ import { MailxDB } from "@bobfrankston/mailx-store";
|
|
|
12
12
|
const __dirname = import.meta.dirname;
|
|
13
13
|
import { ImapManager } from "@bobfrankston/mailx-imap";
|
|
14
14
|
import * as gsync from "./google-sync.js";
|
|
15
|
+
import { sniffAndFixCharset } from "./charset.js";
|
|
16
|
+
import { LocalStore } from "./local-store.js";
|
|
15
17
|
import { loadSettings, saveSettings, loadAccounts, loadAccountsAsync, saveAccounts, initCloudConfig, loadAllowlist, saveAllowlist, loadAutocomplete, saveAutocomplete, loadKeys, saveKeys, ensureKeysSectionExists, getStorePath, getStorageInfo, getConfigDir } from "@bobfrankston/mailx-settings";
|
|
16
18
|
import type { AccountConfig, Folder, AutocompleteRequest, AutocompleteResponse, AutocompleteSettings, AiTransformRequest, AiTransformResponse, ExtractedEvent } from "@bobfrankston/mailx-types";
|
|
17
19
|
import { sanitizeHtml, encodeQuotedPrintable, htmlToPlainText } from "@bobfrankston/mailx-types";
|
|
@@ -27,71 +29,11 @@ import { simpleParser } from "mailparser";
|
|
|
27
29
|
* Operates byte-wise so MIME boundaries / base64 / etc. are preserved.
|
|
28
30
|
* The `utf-8` validity test rejects 0xC0–0xC1 / 0xF5–0xFF and continuation
|
|
29
31
|
* bytes out of place, which would be common in genuine Latin-1 text. */
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
const head = raw.subarray(0, Math.min(HEAD_LIMIT, raw.length)).toString("latin1");
|
|
33
|
-
const re = /charset\s*=\s*"?(iso-8859-1|us-ascii|windows-1252|latin1)"?/gi;
|
|
34
|
-
if (!re.test(head)) return raw;
|
|
35
|
-
if (!isValidUtf8(raw)) return raw;
|
|
36
|
-
const fixed = head.replace(/charset\s*=\s*"?(iso-8859-1|us-ascii|windows-1252|latin1)"?/gi, "charset=utf-8");
|
|
37
|
-
return Buffer.concat([Buffer.from(fixed, "latin1"), raw.subarray(head.length)]);
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
/** Strict UTF-8 validity check: rejects overlong forms, invalid start
|
|
41
|
-
* bytes, and dangling continuations. Used to confirm the body is really
|
|
42
|
-
* UTF-8 before overriding a Latin-1 declaration. */
|
|
43
|
-
function isValidUtf8(buf: Buffer): boolean {
|
|
44
|
-
let i = 0;
|
|
45
|
-
while (i < buf.length) {
|
|
46
|
-
const b = buf[i];
|
|
47
|
-
if (b < 0x80) { i++; continue; }
|
|
48
|
-
let need: number;
|
|
49
|
-
if ((b & 0xE0) === 0xC0) { if (b < 0xC2) return false; need = 1; }
|
|
50
|
-
else if ((b & 0xF0) === 0xE0) need = 2;
|
|
51
|
-
else if ((b & 0xF8) === 0xF0) { if (b > 0xF4) return false; need = 3; }
|
|
52
|
-
else return false;
|
|
53
|
-
if (i + need >= buf.length) return false;
|
|
54
|
-
for (let k = 1; k <= need; k++) {
|
|
55
|
-
if ((buf[i + k] & 0xC0) !== 0x80) return false;
|
|
56
|
-
}
|
|
57
|
-
i += need + 1;
|
|
58
|
-
}
|
|
59
|
-
return true;
|
|
60
|
-
}
|
|
32
|
+
// sniffAndFixCharset moved to ./charset.ts so LocalStore can use it
|
|
33
|
+
// without creating a circular dep with index.ts.
|
|
61
34
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
* also scan the raw header text for the full set of angle-bracketed URIs. */
|
|
65
|
-
function parseListUnsubscribe(headers: any): { listUnsubscribeMail: string; listUnsubscribeHttp: string; listUnsubscribeOneClick: boolean } {
|
|
66
|
-
let mail = "";
|
|
67
|
-
let http = "";
|
|
68
|
-
let oneClick = false;
|
|
69
|
-
|
|
70
|
-
const raw = headers.get("list-unsubscribe");
|
|
71
|
-
const rawStr = typeof raw === "string" ? raw : (raw && typeof (raw as any).text === "string" ? (raw as any).text : "");
|
|
72
|
-
if (rawStr) {
|
|
73
|
-
const matches = rawStr.match(/<([^>]+)>/g) || [];
|
|
74
|
-
for (const m of matches) {
|
|
75
|
-
const url = m.slice(1, -1).trim();
|
|
76
|
-
if (!mail && /^mailto:/i.test(url)) mail = url;
|
|
77
|
-
else if (!http && /^https?:/i.test(url)) http = url;
|
|
78
|
-
}
|
|
79
|
-
}
|
|
80
|
-
if (!mail && !http) {
|
|
81
|
-
const listHeaders = headers.get("list");
|
|
82
|
-
if (listHeaders?.unsubscribe) {
|
|
83
|
-
const unsub = listHeaders.unsubscribe;
|
|
84
|
-
if (unsub.url) http = Array.isArray(unsub.url) ? unsub.url[0] : unsub.url;
|
|
85
|
-
if (unsub.mail) mail = `mailto:${Array.isArray(unsub.mail) ? unsub.mail[0] : unsub.mail}`;
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
const post = headers.get("list-unsubscribe-post");
|
|
90
|
-
const postStr = typeof post === "string" ? post : (post && typeof (post as any).text === "string" ? (post as any).text : "");
|
|
91
|
-
if (postStr && /one-?click/i.test(postStr)) oneClick = true;
|
|
92
|
-
|
|
93
|
-
return { listUnsubscribeMail: mail, listUnsubscribeHttp: http, listUnsubscribeOneClick: oneClick };
|
|
94
|
-
}
|
|
35
|
+
// parseListUnsubscribe moved to ./local-store.ts (only consumer is the
|
|
36
|
+
// body-read path, which is now part of LocalStore).
|
|
95
37
|
|
|
96
38
|
// ── Email provider detection (MX-based) ──
|
|
97
39
|
|
|
@@ -177,10 +119,25 @@ export class MailxService {
|
|
|
177
119
|
* showReminderPopup returns a "no host" reason. */
|
|
178
120
|
setPopupFn(fn: PopupFn): void { this.popupFn = fn; }
|
|
179
121
|
|
|
122
|
+
/** Local-first read/write facade. Every UI IPC handler that touches the
|
|
123
|
+
* local DB or body store goes through this — no awaiting IMAP, no
|
|
124
|
+
* awaiting Gmail API, no awaiting SMTP. The reconciler (step 4) owns
|
|
125
|
+
* all server I/O. See docs/local-first-plan.md. */
|
|
126
|
+
private localStore: LocalStore;
|
|
127
|
+
|
|
128
|
+
/** Background body-fetch dedupe set: `${accountId}:${folderId}:${uid}`.
|
|
129
|
+
* When the UI requests a not-yet-cached message we kick off a single
|
|
130
|
+
* fire-and-forget IMAP fetch and emit `bodyAvailable` when it lands.
|
|
131
|
+
* Subsequent clicks on the same row before the fetch finishes are
|
|
132
|
+
* no-ops — they'll see `cached: true` once the event arrives. */
|
|
133
|
+
private bodyFetchInFlight = new Set<string>();
|
|
134
|
+
|
|
180
135
|
constructor(
|
|
181
136
|
private db: MailxDB,
|
|
182
137
|
private imapManager: ImapManager,
|
|
183
138
|
) {
|
|
139
|
+
this.localStore = new LocalStore(db, imapManager.getBodyStore());
|
|
140
|
+
|
|
184
141
|
// Invalidate account cache when accounts.jsonc changes on disk or GDrive.
|
|
185
142
|
this.imapManager.on?.("configChanged", (filename: string) => {
|
|
186
143
|
if (filename === "accounts.jsonc") this._accountsCache = null;
|
|
@@ -412,204 +369,78 @@ export class MailxService {
|
|
|
412
369
|
// ── Messages ──
|
|
413
370
|
|
|
414
371
|
getUnifiedInbox(page = 1, pageSize = 50): any {
|
|
415
|
-
return this.
|
|
372
|
+
return this.localStore.getUnifiedInbox(page, pageSize);
|
|
416
373
|
}
|
|
417
374
|
|
|
418
375
|
getMessages(accountId: string, folderId: number, page = 1, pageSize = 50, sort = "date", sortDir = "desc", search?: string, flaggedOnly = false): any {
|
|
419
|
-
return this.
|
|
376
|
+
return this.localStore.getMessages({ accountId, folderId, page, pageSize, sort: sort as any, sortDir: sortDir as any, search, flaggedOnly });
|
|
420
377
|
}
|
|
421
378
|
|
|
379
|
+
/** UI body read — local-first. Returns immediately from the local cache:
|
|
380
|
+
* `cached: true` with the full parsed body when on disk, or `cached: false`
|
|
381
|
+
* with envelope-only when not. In the cache-miss case we kick off a
|
|
382
|
+
* fire-and-forget IMAP fetch in the background and emit `bodyAvailable`
|
|
383
|
+
* when the body lands; the UI listens for that event and re-requests.
|
|
384
|
+
*
|
|
385
|
+
* No `await imap*` in the click → render path. The 60s body-fetch race
|
|
386
|
+
* and structured `bodyError` shape that lived here previously are gone —
|
|
387
|
+
* step 1 of the local-first refactor (docs/local-first-plan.md). */
|
|
422
388
|
async getMessage(accountId: string, uid: number, allowRemote = false, folderId?: number): Promise<any> {
|
|
423
|
-
const
|
|
424
|
-
if (!
|
|
389
|
+
const local = await this.localStore.getMessage(accountId, uid, allowRemote, folderId);
|
|
390
|
+
if (!local) throw new Error("Message not found");
|
|
425
391
|
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
// timeout that destroys a wedged client and unblocks the queue. This
|
|
433
|
-
// outer race is a safety net only — the underlying timeout in
|
|
434
|
-
// withConnection should trigger first.
|
|
435
|
-
const BODY_FETCH_TIMEOUT_MS = 60_000;
|
|
436
|
-
let raw: Buffer | null = null;
|
|
437
|
-
try {
|
|
438
|
-
raw = await Promise.race([
|
|
439
|
-
this.imapManager.fetchMessageBody(accountId, envelope.folderId, envelope.uid),
|
|
440
|
-
new Promise<never>((_, reject) =>
|
|
441
|
-
setTimeout(() => reject(new Error("body fetch timed out — try again")), BODY_FETCH_TIMEOUT_MS)
|
|
442
|
-
),
|
|
443
|
-
]);
|
|
444
|
-
} catch (fetchErr: any) {
|
|
445
|
-
// Message was deleted from the server (another device, expunge, etc.) —
|
|
446
|
-
// drop the stale local row so the UI removes it instead of showing a
|
|
447
|
-
// confusing error. Throwing a tagged error lets the client react.
|
|
448
|
-
if ((fetchErr as any)?.isNotFound) {
|
|
449
|
-
try {
|
|
450
|
-
this.db.deleteMessage(accountId, envelope.uid);
|
|
451
|
-
this.db.recalcFolderCounts(envelope.folderId);
|
|
452
|
-
} catch { /* ignore */ }
|
|
453
|
-
const err = new Error("Message was deleted from the server");
|
|
454
|
-
(err as any).isNotFound = true;
|
|
455
|
-
throw err;
|
|
456
|
-
}
|
|
457
|
-
// Don't stuff the error text into bodyText — it bleeds into the
|
|
458
|
-
// viewer's main content area. Surface as a structured error field
|
|
459
|
-
// so the UI can render a banner with retry UX above the (empty)
|
|
460
|
-
// body. The caller keeps the envelope so the header still shows.
|
|
461
|
-
const rawErr = fetchErr.message || "connection failed";
|
|
462
|
-
const isTransient = /connection|Too many|UNAVAILABLE|rate|429|5\d\d|timeout|ENOTFOUND|ECONNRESET|ETIMEDOUT/i.test(rawErr);
|
|
463
|
-
return {
|
|
464
|
-
...envelope, bodyHtml: "", bodyText: "",
|
|
465
|
-
bodyError: rawErr,
|
|
466
|
-
bodyErrorTransient: isTransient,
|
|
467
|
-
hasRemoteContent: false, remoteAllowed: false, attachments: [], emlPath: "", deliveredTo: "", returnPath: "", listUnsubscribe: ""
|
|
468
|
-
};
|
|
392
|
+
if (!local.cached) {
|
|
393
|
+
// Body not on disk. Trigger a single in-flight fetch and return
|
|
394
|
+
// envelope-only — the UI will show its placeholder and re-request
|
|
395
|
+
// when it sees `bodyAvailable`.
|
|
396
|
+
this.kickOffBodyFetch(accountId, local.uid as any, local.folderId as any);
|
|
397
|
+
return local;
|
|
469
398
|
}
|
|
470
399
|
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
} else {
|
|
480
|
-
// Many senders (esp. PHPMailer-driven marketing) declare
|
|
481
|
-
// `charset=iso-8859-1` but emit UTF-8 bytes. simpleParser
|
|
482
|
-
// honors the declared charset and produces "â??" garbage for
|
|
483
|
-
// every non-ASCII codepoint (em-dash, smart quotes, …). If
|
|
484
|
-
// the raw body bytes are valid UTF-8, rewrite the charset
|
|
485
|
-
// header before parsing. We only override the obviously-
|
|
486
|
-
// wrong legacy declarations; explicit utf-8 / koi8 / etc.
|
|
487
|
-
// pass through.
|
|
488
|
-
const adjusted = sniffAndFixCharset(raw);
|
|
489
|
-
const parsed = await simpleParser(adjusted);
|
|
490
|
-
bodyHtml = parsed.html || "";
|
|
491
|
-
bodyText = parsed.text || "";
|
|
492
|
-
attachments = (parsed.attachments || []).map((a, i) => ({
|
|
493
|
-
id: i,
|
|
494
|
-
filename: a.filename || `attachment-${i}`,
|
|
495
|
-
mimeType: a.contentType || "application/octet-stream",
|
|
496
|
-
size: a.size || 0,
|
|
497
|
-
contentId: a.contentId || ""
|
|
498
|
-
}));
|
|
499
|
-
}
|
|
500
|
-
|
|
501
|
-
// Sanitize HTML
|
|
502
|
-
if (bodyHtml && !allowRemote) {
|
|
503
|
-
const allowList = loadAllowlist();
|
|
504
|
-
const senderAddr = envelope.from?.address || "";
|
|
505
|
-
const senderDomain = senderAddr.split("@")[1] || "";
|
|
506
|
-
const toAddrs = (envelope.to || []).map((a: any) => a.address);
|
|
507
|
-
const isAllowed = allowList.senders.includes(senderAddr) ||
|
|
508
|
-
allowList.domains.includes(senderDomain) ||
|
|
509
|
-
toAddrs.some((a: string) => allowList.recipients?.includes(a));
|
|
510
|
-
|
|
511
|
-
if (isAllowed) {
|
|
512
|
-
allowRemote = true;
|
|
513
|
-
} else {
|
|
514
|
-
const result = sanitizeHtml(bodyHtml);
|
|
515
|
-
bodyHtml = result.html;
|
|
516
|
-
hasRemoteContent = result.hasRemoteContent;
|
|
517
|
-
}
|
|
400
|
+
// Optional sender-reputation check. This is the one residual server
|
|
401
|
+
// touch in the read path (DNSBL lookup, ~500 ms). Opt-in via Settings;
|
|
402
|
+
// moves to the reconciler in step 4. See feedback_no_bandaids.md.
|
|
403
|
+
let reputation: ReputationResult | null = null;
|
|
404
|
+
const settings = (loadSettings() as any) || {};
|
|
405
|
+
const senderDomain = (local.from?.address || "").toLowerCase().split("@")[1] || "";
|
|
406
|
+
if (settings.checkDomainReputation && senderDomain && local.hasRemoteContent) {
|
|
407
|
+
reputation = await this.checkDomainReputation(senderDomain);
|
|
518
408
|
}
|
|
519
409
|
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
let returnPath = "";
|
|
523
|
-
let listUnsubscribe = "";
|
|
524
|
-
let listUnsubscribeMail = "";
|
|
525
|
-
let listUnsubscribeHttp = "";
|
|
526
|
-
let listUnsubscribeOneClick = false;
|
|
527
|
-
if (raw) {
|
|
528
|
-
const parsed2 = await simpleParser(raw);
|
|
529
|
-
const hdr = (key: string): string => {
|
|
530
|
-
let v = parsed2.headers.get(key);
|
|
531
|
-
if (!v) return "";
|
|
532
|
-
if (Array.isArray(v)) v = v[0];
|
|
533
|
-
if (typeof v === "string") return v;
|
|
534
|
-
if (typeof v === "object" && v !== null) {
|
|
535
|
-
if ("text" in v) return (v as any).text || "";
|
|
536
|
-
if ("value" in v) return String((v as any).value);
|
|
537
|
-
if ("address" in v) return (v as any).address || "";
|
|
538
|
-
}
|
|
539
|
-
return String(v);
|
|
540
|
-
};
|
|
410
|
+
return { ...local, reputation };
|
|
411
|
+
}
|
|
541
412
|
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
}
|
|
556
|
-
}
|
|
557
|
-
if (!deliveredTo && deliveredList.length > 0) {
|
|
558
|
-
const d = deliveredList[deliveredList.length - 1];
|
|
559
|
-
deliveredTo = typeof d === "string" ? d : (d as any)?.text || (d as any)?.address || String(d);
|
|
413
|
+
/** Fire-and-forget body fetch for a not-yet-cached message. Dedupes per
|
|
414
|
+
* (account, folder, uid) so rapid re-clicks don't stack IMAP fetches.
|
|
415
|
+
* Emits `bodyAvailable` on success (UI re-requests via getMessage) and
|
|
416
|
+
* `bodyFetchError` on failure (UI surfaces a banner without blocking). */
|
|
417
|
+
private kickOffBodyFetch(accountId: string, uid: number, folderId: number): void {
|
|
418
|
+
const key = `${accountId}:${folderId}:${uid}`;
|
|
419
|
+
if (this.bodyFetchInFlight.has(key)) return;
|
|
420
|
+
this.bodyFetchInFlight.add(key);
|
|
421
|
+
|
|
422
|
+
(async () => {
|
|
423
|
+
try {
|
|
424
|
+
const raw = await this.imapManager.fetchMessageBody(accountId, folderId, uid);
|
|
425
|
+
if (raw) {
|
|
426
|
+
this.imapManager.emit("bodyAvailable", { accountId, folderId, uid });
|
|
560
427
|
}
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
428
|
+
} catch (err: any) {
|
|
429
|
+
if (err?.isNotFound) {
|
|
430
|
+
try {
|
|
431
|
+
this.db.deleteMessage(accountId, uid);
|
|
432
|
+
this.db.recalcFolderCounts(folderId);
|
|
433
|
+
this.imapManager.emit("messageRemoved", { accountId, folderId, uid });
|
|
434
|
+
} catch { /* ignore */ }
|
|
435
|
+
return;
|
|
569
436
|
}
|
|
437
|
+
const msg = err?.message || "body fetch failed";
|
|
438
|
+
const transient = /connection|Too many|UNAVAILABLE|rate|429|5\d\d|timeout|ENOTFOUND|ECONNRESET|ETIMEDOUT/i.test(msg);
|
|
439
|
+
this.imapManager.emit("bodyFetchError", { accountId, folderId, uid, error: msg, transient });
|
|
440
|
+
} finally {
|
|
441
|
+
this.bodyFetchInFlight.delete(key);
|
|
570
442
|
}
|
|
571
|
-
|
|
572
|
-
({ listUnsubscribeMail, listUnsubscribeHttp, listUnsubscribeOneClick } =
|
|
573
|
-
parseListUnsubscribe(parsed2.headers));
|
|
574
|
-
listUnsubscribe = listUnsubscribeHttp || listUnsubscribeMail;
|
|
575
|
-
}
|
|
576
|
-
|
|
577
|
-
// EML path: re-read the row after the fetch — `fetchMessageBody`
|
|
578
|
-
// writes the body to disk and updates `body_path` on success, but the
|
|
579
|
-
// `envelope` snapshot above pre-dates that write, so trusting it
|
|
580
|
-
// hides the Source button on every just-opened message.
|
|
581
|
-
const refreshed: any = this.db.getMessageByUid(accountId, uid, folderId);
|
|
582
|
-
const emlPath = refreshed?.bodyPath || envelope.bodyPath || "";
|
|
583
|
-
|
|
584
|
-
// Flag check — surfaced in the remote-content banner as a red
|
|
585
|
-
// warning when the sender's address or domain is on the user's
|
|
586
|
-
// flagged list. Cheap lookup; loadAllowlist is already cached.
|
|
587
|
-
const allowList = loadAllowlist() as any;
|
|
588
|
-
const senderAddr = (envelope.from?.address || "").toLowerCase();
|
|
589
|
-
const senderDomain = senderAddr.split("@")[1] || "";
|
|
590
|
-
const isFlagged = !!(
|
|
591
|
-
(allowList.flaggedSenders || []).some((s: string) => (s || "").toLowerCase() === senderAddr) ||
|
|
592
|
-
(allowList.flaggedDomains || []).some((d: string) => (d || "").toLowerCase() === senderDomain)
|
|
593
|
-
);
|
|
594
|
-
|
|
595
|
-
// External reputation check — Spamhaus DBL + SURBL + URIBL in parallel.
|
|
596
|
-
// Off by default (privacy: the domain leaks to those DNSBLs and the
|
|
597
|
-
// user's local resolver). User opts in via Settings → "Check sender
|
|
598
|
-
// reputation". Each lookup is bounded at 500 ms; the whole check is
|
|
599
|
-
// bounded by the slowest, ~500 ms worst case.
|
|
600
|
-
let reputation: ReputationResult | null = null;
|
|
601
|
-
const settings = (loadSettings() as any) || {};
|
|
602
|
-
if (settings.checkDomainReputation && senderDomain && hasRemoteContent) {
|
|
603
|
-
reputation = await this.checkDomainReputation(senderDomain);
|
|
604
|
-
}
|
|
605
|
-
|
|
606
|
-
return {
|
|
607
|
-
...envelope, bodyHtml, bodyText, hasRemoteContent, remoteAllowed: allowRemote,
|
|
608
|
-
attachments, emlPath, deliveredTo, returnPath,
|
|
609
|
-
listUnsubscribe, listUnsubscribeMail, listUnsubscribeHttp, listUnsubscribeOneClick,
|
|
610
|
-
isFlagged,
|
|
611
|
-
reputation,
|
|
612
|
-
};
|
|
443
|
+
})();
|
|
613
444
|
}
|
|
614
445
|
|
|
615
446
|
/** RFC 8058 one-click unsubscribe: POST `List-Unsubscribe=One-Click` to the
|
|
@@ -1085,9 +916,9 @@ export class MailxService {
|
|
|
1085
916
|
const sliced = items.slice((page - 1) * pageSize, page * pageSize);
|
|
1086
917
|
return { items: sliced, total, page, pageSize };
|
|
1087
918
|
} else if (scope === "current" && accountId && folderId) {
|
|
1088
|
-
return this.
|
|
919
|
+
return this.localStore.searchMessages(q, page, pageSize, accountId, folderId);
|
|
1089
920
|
} else {
|
|
1090
|
-
return this.
|
|
921
|
+
return this.localStore.searchMessages(q, page, pageSize);
|
|
1091
922
|
}
|
|
1092
923
|
}
|
|
1093
924
|
|
|
@@ -2144,7 +1975,7 @@ export class MailxService {
|
|
|
2144
1975
|
searchContacts(query: string): any[] {
|
|
2145
1976
|
query = (query || "").trim();
|
|
2146
1977
|
if (query.length < 1) return [];
|
|
2147
|
-
return this.
|
|
1978
|
+
return this.localStore.searchContacts(query);
|
|
2148
1979
|
}
|
|
2149
1980
|
|
|
2150
1981
|
/** Q49: boolean hint for compose to auto-expand Cc when replying to this
|
|
@@ -2181,7 +2012,7 @@ export class MailxService {
|
|
|
2181
2012
|
|
|
2182
2013
|
/** Address-book listing — paginated, filterable. */
|
|
2183
2014
|
listContacts(query: string, page = 1, pageSize = 100): any {
|
|
2184
|
-
return this.
|
|
2015
|
+
return this.localStore.listContacts(query || "", page, pageSize);
|
|
2185
2016
|
}
|
|
2186
2017
|
|
|
2187
2018
|
/** Upsert a contact from the address book UI (edit name). Two-way cache:
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LocalStore — the formal API surface for UI-bound reads and writes.
|
|
3
|
+
*
|
|
4
|
+
* Contract: every method here is local-only. No IMAP, no Gmail API, no
|
|
5
|
+
* SMTP, no DNS, no Drive API. Methods complete in microseconds (DB reads)
|
|
6
|
+
* or whatever a single local file IO takes (body reads). They never await
|
|
7
|
+
* network calls.
|
|
8
|
+
*
|
|
9
|
+
* UI-side IPC dispatch lands here. The reconciler / sync queue is a
|
|
10
|
+
* separate concern — it owns network IO and signals completed work via
|
|
11
|
+
* events that the UI listens for. The two never call each other directly.
|
|
12
|
+
*
|
|
13
|
+
* This file is part of the local-first refactor (docs/local-first-plan.md).
|
|
14
|
+
* Step 1 of that plan: introduce the facade, prove it compiles, route
|
|
15
|
+
* one IPC method through it as proof-of-pattern. Subsequent steps migrate
|
|
16
|
+
* remaining call sites and remove the server-fetch path from the UI.
|
|
17
|
+
*/
|
|
18
|
+
import type { MailxDB, FileMessageStore } from "@bobfrankston/mailx-store";
|
|
19
|
+
import type { MessageEnvelope, MessageQuery, PagedResult } from "@bobfrankston/mailx-types";
|
|
20
|
+
/** What the UI gets back from a body read. Mirrors the historical
|
|
21
|
+
* `getMessage` shape so call-site migration is mechanical. `cached: false`
|
|
22
|
+
* means the body isn't on disk yet — the UI shows a "downloading…"
|
|
23
|
+
* placeholder and listens for `bodyAvailable` to re-render. The reconciler
|
|
24
|
+
* is responsible for actually fetching and emitting the event. */
|
|
25
|
+
export interface LocalMessage extends MessageEnvelope {
|
|
26
|
+
bodyHtml: string;
|
|
27
|
+
bodyText: string;
|
|
28
|
+
hasRemoteContent: boolean;
|
|
29
|
+
remoteAllowed: boolean;
|
|
30
|
+
attachments: Array<{
|
|
31
|
+
id: number;
|
|
32
|
+
filename: string;
|
|
33
|
+
mimeType: string;
|
|
34
|
+
size: number;
|
|
35
|
+
contentId: string;
|
|
36
|
+
}>;
|
|
37
|
+
cached: boolean;
|
|
38
|
+
deliveredTo: string;
|
|
39
|
+
returnPath: string;
|
|
40
|
+
listUnsubscribe: string;
|
|
41
|
+
}
|
|
42
|
+
export declare class LocalStore {
|
|
43
|
+
private db;
|
|
44
|
+
private bodyStore;
|
|
45
|
+
constructor(db: MailxDB, bodyStore: FileMessageStore);
|
|
46
|
+
/** DB-shape account list (id/name/email/lastSync). The richer
|
|
47
|
+
* AccountConfig (with imap/smtp/etc.) lives in accounts.jsonc and is
|
|
48
|
+
* loaded by mailx-settings, not the DB — that path stays in
|
|
49
|
+
* MailxService until step 3 of the local-first plan. */
|
|
50
|
+
getAccounts(): {
|
|
51
|
+
id: string;
|
|
52
|
+
name: string;
|
|
53
|
+
email: string;
|
|
54
|
+
lastSync: number;
|
|
55
|
+
}[];
|
|
56
|
+
getFolders(accountId: string): any[];
|
|
57
|
+
/** Single envelope by (account, uid, folder). Null when the row isn't
|
|
58
|
+
* in the DB — caller decides whether to show "deleted" or queue a
|
|
59
|
+
* server lookup via the reconciler. */
|
|
60
|
+
getMessageEnvelope(accountId: string, uid: number, folderId?: number): MessageEnvelope | null;
|
|
61
|
+
/** Paginated message list for a (account, folder, ...) query. */
|
|
62
|
+
getMessages(query: MessageQuery): PagedResult<MessageEnvelope>;
|
|
63
|
+
/** All-Inboxes view: union of every account's INBOX, paginated. */
|
|
64
|
+
getUnifiedInbox(page?: number, pageSize?: number): PagedResult<MessageEnvelope>;
|
|
65
|
+
/** Local FTS5 search. Server-scope search is the reconciler's job. */
|
|
66
|
+
searchMessages(query: string, page?: number, pageSize?: number, accountId?: string, folderId?: number): PagedResult<MessageEnvelope>;
|
|
67
|
+
/** Read a fully-parsed message (envelope + body + attachments) entirely
|
|
68
|
+
* from local state. Returns null when the envelope isn't known.
|
|
69
|
+
* Returns `{ ...envelope, cached: false }` when the envelope is known
|
|
70
|
+
* but the body file isn't on disk — UI shows a placeholder and the
|
|
71
|
+
* reconciler queues the fetch.
|
|
72
|
+
*
|
|
73
|
+
* `allowRemote=true` skips HTML sanitization. Used when the user has
|
|
74
|
+
* explicitly allowed remote content for this sender / domain. */
|
|
75
|
+
getMessage(accountId: string, uid: number, allowRemote: boolean, folderId?: number): Promise<LocalMessage | null>;
|
|
76
|
+
getCalendarEvents(accountId: string, fromMs: number, toMs: number): any[];
|
|
77
|
+
getTasks(accountId: string, includeCompleted?: boolean): any[];
|
|
78
|
+
searchContacts(query: string, limit?: number): any[];
|
|
79
|
+
listContacts(query: string, page?: number, pageSize?: number): any;
|
|
80
|
+
}
|
|
81
|
+
//# sourceMappingURL=local-store.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"local-store.d.ts","sourceRoot":"","sources":["local-store.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAIH,OAAO,KAAK,EAAE,OAAO,EAAE,gBAAgB,EAAE,MAAM,2BAA2B,CAAC;AAC3E,OAAO,KAAK,EACR,eAAe,EAAW,YAAY,EAAE,WAAW,EACtD,MAAM,2BAA2B,CAAC;AAInC;;;;mEAImE;AACnE,MAAM,WAAW,YAAa,SAAQ,eAAe;IACjD,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,EAAE,MAAM,CAAC;IACjB,gBAAgB,EAAE,OAAO,CAAC;IAC1B,aAAa,EAAE,OAAO,CAAC;IACvB,WAAW,EAAE,KAAK,CAAC;QAAE,EAAE,EAAE,MAAM,CAAC;QAAC,QAAQ,EAAE,MAAM,CAAC;QAAC,QAAQ,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAC;QAAC,SAAS,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IACxG,MAAM,EAAE,OAAO,CAAC;IAChB,WAAW,EAAE,MAAM,CAAC;IACpB,UAAU,EAAE,MAAM,CAAC;IACnB,eAAe,EAAE,MAAM,CAAC;CAC3B;AAED,qBAAa,UAAU;IAEf,OAAO,CAAC,EAAE;IACV,OAAO,CAAC,SAAS;gBADT,EAAE,EAAE,OAAO,EACX,SAAS,EAAE,gBAAgB;IAMvC;;;6DAGyD;IACzD,WAAW,IAAI;QAAE,EAAE,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAC;QAAC,QAAQ,EAAE,MAAM,CAAA;KAAE,EAAE;IAM9E,UAAU,CAAC,SAAS,EAAE,MAAM,GAAG,GAAG,EAAE;IAMpC;;4CAEwC;IACxC,kBAAkB,CAAC,SAAS,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,EAAE,QAAQ,CAAC,EAAE,MAAM,GAAG,eAAe,GAAG,IAAI;IAK7F,iEAAiE;IACjE,WAAW,CAAC,KAAK,EAAE,YAAY,GAAG,WAAW,CAAC,eAAe,CAAC;IAI9D,mEAAmE;IACnE,eAAe,CAAC,IAAI,SAAI,EAAE,QAAQ,SAAK,GAAG,WAAW,CAAC,eAAe,CAAC;IAItE,sEAAsE;IACtE,cAAc,CAAC,KAAK,EAAE,MAAM,EAAE,IAAI,SAAI,EAAE,QAAQ,SAAK,EAAE,SAAS,CAAC,EAAE,MAAM,EAAE,QAAQ,CAAC,EAAE,MAAM,GAAG,WAAW,CAAC,eAAe,CAAC;IAM3H;;;;;;;sEAOkE;IAC5D,UAAU,CAAC,SAAS,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,EAAE,WAAW,EAAE,OAAO,EAAE,QAAQ,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,GAAG,IAAI,CAAC;IA+EvH,iBAAiB,CAAC,SAAS,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,GAAG,EAAE;IAIzE,QAAQ,CAAC,SAAS,EAAE,MAAM,EAAE,gBAAgB,UAAQ,GAAG,GAAG,EAAE;IAI5D,cAAc,CAAC,KAAK,EAAE,MAAM,EAAE,KAAK,SAAK,GAAG,GAAG,EAAE;IAIhD,YAAY,CAAC,KAAK,EAAE,MAAM,EAAE,IAAI,SAAI,EAAE,QAAQ,SAAM,GAAG,GAAG;CAK7D"}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LocalStore — the formal API surface for UI-bound reads and writes.
|
|
3
|
+
*
|
|
4
|
+
* Contract: every method here is local-only. No IMAP, no Gmail API, no
|
|
5
|
+
* SMTP, no DNS, no Drive API. Methods complete in microseconds (DB reads)
|
|
6
|
+
* or whatever a single local file IO takes (body reads). They never await
|
|
7
|
+
* network calls.
|
|
8
|
+
*
|
|
9
|
+
* UI-side IPC dispatch lands here. The reconciler / sync queue is a
|
|
10
|
+
* separate concern — it owns network IO and signals completed work via
|
|
11
|
+
* events that the UI listens for. The two never call each other directly.
|
|
12
|
+
*
|
|
13
|
+
* This file is part of the local-first refactor (docs/local-first-plan.md).
|
|
14
|
+
* Step 1 of that plan: introduce the facade, prove it compiles, route
|
|
15
|
+
* one IPC method through it as proof-of-pattern. Subsequent steps migrate
|
|
16
|
+
* remaining call sites and remove the server-fetch path from the UI.
|
|
17
|
+
*/
|
|
18
|
+
import { simpleParser } from "mailparser";
|
|
19
|
+
import { sanitizeHtml } from "@bobfrankston/mailx-types";
|
|
20
|
+
import { sniffAndFixCharset } from "./charset.js";
|
|
21
|
+
export class LocalStore {
|
|
22
|
+
db;
|
|
23
|
+
bodyStore;
|
|
24
|
+
constructor(db, bodyStore) {
|
|
25
|
+
this.db = db;
|
|
26
|
+
this.bodyStore = bodyStore;
|
|
27
|
+
}
|
|
28
|
+
// ── Account list (read-only here; mutations go through MailxService
|
|
29
|
+
// until the cloud-write path is part of the queue too) ──
|
|
30
|
+
/** DB-shape account list (id/name/email/lastSync). The richer
|
|
31
|
+
* AccountConfig (with imap/smtp/etc.) lives in accounts.jsonc and is
|
|
32
|
+
* loaded by mailx-settings, not the DB — that path stays in
|
|
33
|
+
* MailxService until step 3 of the local-first plan. */
|
|
34
|
+
getAccounts() {
|
|
35
|
+
return this.db.getAccounts();
|
|
36
|
+
}
|
|
37
|
+
// ── Folders ──
|
|
38
|
+
getFolders(accountId) {
|
|
39
|
+
return this.db.getFolders(accountId);
|
|
40
|
+
}
|
|
41
|
+
// ── Message envelopes ──
|
|
42
|
+
/** Single envelope by (account, uid, folder). Null when the row isn't
|
|
43
|
+
* in the DB — caller decides whether to show "deleted" or queue a
|
|
44
|
+
* server lookup via the reconciler. */
|
|
45
|
+
getMessageEnvelope(accountId, uid, folderId) {
|
|
46
|
+
const env = this.db.getMessageByUid(accountId, uid, folderId);
|
|
47
|
+
return env || null;
|
|
48
|
+
}
|
|
49
|
+
/** Paginated message list for a (account, folder, ...) query. */
|
|
50
|
+
getMessages(query) {
|
|
51
|
+
return this.db.getMessages(query);
|
|
52
|
+
}
|
|
53
|
+
/** All-Inboxes view: union of every account's INBOX, paginated. */
|
|
54
|
+
getUnifiedInbox(page = 1, pageSize = 50) {
|
|
55
|
+
return this.db.getUnifiedInbox(page, pageSize);
|
|
56
|
+
}
|
|
57
|
+
/** Local FTS5 search. Server-scope search is the reconciler's job. */
|
|
58
|
+
searchMessages(query, page = 1, pageSize = 50, accountId, folderId) {
|
|
59
|
+
return this.db.searchMessages(query, page, pageSize, accountId, folderId);
|
|
60
|
+
}
|
|
61
|
+
// ── Message body (read-from-disk only) ──
|
|
62
|
+
/** Read a fully-parsed message (envelope + body + attachments) entirely
|
|
63
|
+
* from local state. Returns null when the envelope isn't known.
|
|
64
|
+
* Returns `{ ...envelope, cached: false }` when the envelope is known
|
|
65
|
+
* but the body file isn't on disk — UI shows a placeholder and the
|
|
66
|
+
* reconciler queues the fetch.
|
|
67
|
+
*
|
|
68
|
+
* `allowRemote=true` skips HTML sanitization. Used when the user has
|
|
69
|
+
* explicitly allowed remote content for this sender / domain. */
|
|
70
|
+
async getMessage(accountId, uid, allowRemote, folderId) {
|
|
71
|
+
const envelope = this.db.getMessageByUid(accountId, uid, folderId);
|
|
72
|
+
if (!envelope)
|
|
73
|
+
return null;
|
|
74
|
+
// Resolve body path: prefer the row's own bodyPath, fall back to
|
|
75
|
+
// the historical lookup. Either may be empty (body never fetched).
|
|
76
|
+
let storedPath = envelope.bodyPath || "";
|
|
77
|
+
if (!storedPath)
|
|
78
|
+
storedPath = this.db.getMessageBodyPath(accountId, uid) || "";
|
|
79
|
+
const empty = {
|
|
80
|
+
...envelope,
|
|
81
|
+
bodyHtml: "", bodyText: "",
|
|
82
|
+
hasRemoteContent: false, remoteAllowed: false,
|
|
83
|
+
attachments: [],
|
|
84
|
+
cached: false,
|
|
85
|
+
deliveredTo: "", returnPath: "", listUnsubscribe: "",
|
|
86
|
+
};
|
|
87
|
+
if (!storedPath)
|
|
88
|
+
return empty;
|
|
89
|
+
if (!await this.bodyStore.hasByPath(storedPath))
|
|
90
|
+
return empty;
|
|
91
|
+
let raw;
|
|
92
|
+
try {
|
|
93
|
+
raw = await this.bodyStore.readByPath(storedPath);
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
// File path is in DB but the file is missing — the prefetch
|
|
97
|
+
// dot is lying. Caller (UI) should mark the row as broken and
|
|
98
|
+
// the reconciler should re-fetch. Don't crash; return as if
|
|
99
|
+
// not cached.
|
|
100
|
+
return empty;
|
|
101
|
+
}
|
|
102
|
+
// Parse + sanitize. Same logic as today's MailxService.getMessage,
|
|
103
|
+
// but executed only when the body is local — never on a fresh
|
|
104
|
+
// server fetch.
|
|
105
|
+
const adjusted = sniffAndFixCharset(raw);
|
|
106
|
+
const parsed = await simpleParser(adjusted);
|
|
107
|
+
let bodyHtml = parsed.html || "";
|
|
108
|
+
const bodyText = parsed.text || "";
|
|
109
|
+
let hasRemoteContent = false;
|
|
110
|
+
let remoteAllowed = allowRemote;
|
|
111
|
+
const attachments = (parsed.attachments || []).map((a, i) => ({
|
|
112
|
+
id: i,
|
|
113
|
+
filename: a.filename || `attachment-${i}`,
|
|
114
|
+
mimeType: a.contentType || "application/octet-stream",
|
|
115
|
+
size: a.size || 0,
|
|
116
|
+
contentId: a.contentId || "",
|
|
117
|
+
}));
|
|
118
|
+
if (bodyHtml && !allowRemote) {
|
|
119
|
+
const result = sanitizeHtml(bodyHtml);
|
|
120
|
+
bodyHtml = result.html;
|
|
121
|
+
hasRemoteContent = result.hasRemoteContent;
|
|
122
|
+
}
|
|
123
|
+
// Header extraction — Delivered-To, Return-Path, List-Unsubscribe
|
|
124
|
+
// and friends. simpleParser exposes them via `headers`.
|
|
125
|
+
const hdr = (key) => {
|
|
126
|
+
const v = parsed.headers.get(key);
|
|
127
|
+
if (!v)
|
|
128
|
+
return "";
|
|
129
|
+
if (typeof v === "string")
|
|
130
|
+
return v;
|
|
131
|
+
return String(v?.value || "");
|
|
132
|
+
};
|
|
133
|
+
const deliveredTo = hdr("delivered-to");
|
|
134
|
+
const returnPath = hdr("return-path");
|
|
135
|
+
const listUnsubscribe = hdr("list-unsubscribe");
|
|
136
|
+
return {
|
|
137
|
+
...envelope,
|
|
138
|
+
bodyHtml, bodyText,
|
|
139
|
+
hasRemoteContent, remoteAllowed,
|
|
140
|
+
attachments,
|
|
141
|
+
cached: true,
|
|
142
|
+
deliveredTo, returnPath, listUnsubscribe,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
// ── Calendar / tasks / contacts (read paths) ──
|
|
146
|
+
getCalendarEvents(accountId, fromMs, toMs) {
|
|
147
|
+
return this.db.getCalendarEvents(accountId, fromMs, toMs);
|
|
148
|
+
}
|
|
149
|
+
getTasks(accountId, includeCompleted = false) {
|
|
150
|
+
return this.db.getTasks(accountId, includeCompleted);
|
|
151
|
+
}
|
|
152
|
+
searchContacts(query, limit = 10) {
|
|
153
|
+
return this.db.searchContacts(query, limit);
|
|
154
|
+
}
|
|
155
|
+
listContacts(query, page = 1, pageSize = 100) {
|
|
156
|
+
return this.db.listContacts(query, page, pageSize);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
//# sourceMappingURL=local-store.js.map
|