@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
|
@@ -12,6 +12,10 @@ 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";
|
|
17
|
+
import { SyncQueue } from "./sync-queue.js";
|
|
18
|
+
import { Reconciler } from "./reconciler.js";
|
|
15
19
|
import { loadSettings, saveSettings, loadAccounts, loadAccountsAsync, saveAccounts, initCloudConfig, loadAllowlist, saveAllowlist, loadAutocomplete, saveAutocomplete, loadKeys, saveKeys, ensureKeysSectionExists, getStorePath, getStorageInfo, getConfigDir } from "@bobfrankston/mailx-settings";
|
|
16
20
|
import type { AccountConfig, Folder, AutocompleteRequest, AutocompleteResponse, AutocompleteSettings, AiTransformRequest, AiTransformResponse, ExtractedEvent } from "@bobfrankston/mailx-types";
|
|
17
21
|
import { sanitizeHtml, encodeQuotedPrintable, htmlToPlainText } from "@bobfrankston/mailx-types";
|
|
@@ -27,71 +31,11 @@ import { simpleParser } from "mailparser";
|
|
|
27
31
|
* Operates byte-wise so MIME boundaries / base64 / etc. are preserved.
|
|
28
32
|
* The `utf-8` validity test rejects 0xC0–0xC1 / 0xF5–0xFF and continuation
|
|
29
33
|
* 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
|
-
}
|
|
61
|
-
|
|
62
|
-
/** Parse `List-Unsubscribe` (RFC 2369) and `List-Unsubscribe-Post` (RFC 8058).
|
|
63
|
-
* mailparser only exposes ONE of mail/url even when both are present, so we
|
|
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;
|
|
34
|
+
// sniffAndFixCharset moved to ./charset.ts so LocalStore can use it
|
|
35
|
+
// without creating a circular dep with index.ts.
|
|
69
36
|
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
}
|
|
37
|
+
// parseListUnsubscribe moved to ./local-store.ts (only consumer is the
|
|
38
|
+
// body-read path, which is now part of LocalStore).
|
|
95
39
|
|
|
96
40
|
// ── Email provider detection (MX-based) ──
|
|
97
41
|
|
|
@@ -177,10 +121,28 @@ export class MailxService {
|
|
|
177
121
|
* showReminderPopup returns a "no host" reason. */
|
|
178
122
|
setPopupFn(fn: PopupFn): void { this.popupFn = fn; }
|
|
179
123
|
|
|
124
|
+
/** Local-first read/write facade. Every UI IPC handler that touches the
|
|
125
|
+
* local DB or body store goes through this — no awaiting IMAP, no
|
|
126
|
+
* awaiting Gmail API, no awaiting SMTP. See docs/local-first-plan.md. */
|
|
127
|
+
private localStore: LocalStore;
|
|
128
|
+
|
|
129
|
+
/** Persistent (and in-memory body-fetch) queue. UI handlers commit
|
|
130
|
+
* locally, then enqueue a server-mirror task here. */
|
|
131
|
+
private syncQueue: SyncQueue;
|
|
132
|
+
|
|
133
|
+
/** Background loop: drains body-fetch tasks, retries failed message
|
|
134
|
+
* actions, emits sync-state events for the status pill. */
|
|
135
|
+
private reconciler: Reconciler;
|
|
136
|
+
|
|
180
137
|
constructor(
|
|
181
138
|
private db: MailxDB,
|
|
182
139
|
private imapManager: ImapManager,
|
|
183
140
|
) {
|
|
141
|
+
this.localStore = new LocalStore(db, imapManager.getBodyStore());
|
|
142
|
+
this.syncQueue = new SyncQueue(db, imapManager);
|
|
143
|
+
this.reconciler = new Reconciler(db, imapManager, this.syncQueue);
|
|
144
|
+
this.reconciler.start();
|
|
145
|
+
|
|
184
146
|
// Invalidate account cache when accounts.jsonc changes on disk or GDrive.
|
|
185
147
|
this.imapManager.on?.("configChanged", (filename: string) => {
|
|
186
148
|
if (filename === "accounts.jsonc") this._accountsCache = null;
|
|
@@ -412,204 +374,54 @@ export class MailxService {
|
|
|
412
374
|
// ── Messages ──
|
|
413
375
|
|
|
414
376
|
getUnifiedInbox(page = 1, pageSize = 50): any {
|
|
415
|
-
return this.
|
|
377
|
+
return this.localStore.getUnifiedInbox(page, pageSize);
|
|
416
378
|
}
|
|
417
379
|
|
|
418
380
|
getMessages(accountId: string, folderId: number, page = 1, pageSize = 50, sort = "date", sortDir = "desc", search?: string, flaggedOnly = false): any {
|
|
419
|
-
return this.
|
|
381
|
+
return this.localStore.getMessages({ accountId, folderId, page, pageSize, sort: sort as any, sortDir: sortDir as any, search, flaggedOnly });
|
|
420
382
|
}
|
|
421
383
|
|
|
384
|
+
/** UI body read — local-first. Returns immediately from the local cache:
|
|
385
|
+
* `cached: true` with the full parsed body when on disk, or `cached: false`
|
|
386
|
+
* with envelope-only when not. In the cache-miss case we kick off a
|
|
387
|
+
* fire-and-forget IMAP fetch in the background and emit `bodyAvailable`
|
|
388
|
+
* when the body lands; the UI listens for that event and re-requests.
|
|
389
|
+
*
|
|
390
|
+
* No `await imap*` in the click → render path. The 60s body-fetch race
|
|
391
|
+
* and structured `bodyError` shape that lived here previously are gone —
|
|
392
|
+
* step 1 of the local-first refactor (docs/local-first-plan.md). */
|
|
422
393
|
async getMessage(accountId: string, uid: number, allowRemote = false, folderId?: number): Promise<any> {
|
|
423
|
-
const
|
|
424
|
-
if (!
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
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
|
-
};
|
|
469
|
-
}
|
|
470
|
-
|
|
471
|
-
if (!raw) {
|
|
472
|
-
// Same treatment as the thrown-error case: structured field, not body text.
|
|
473
|
-
return {
|
|
474
|
-
...envelope, bodyHtml: "", bodyText: "",
|
|
475
|
-
bodyError: "Message body not cached locally and the server fetch returned nothing.",
|
|
476
|
-
bodyErrorTransient: true,
|
|
477
|
-
hasRemoteContent: false, remoteAllowed: false, attachments: [], emlPath: "", deliveredTo: "", returnPath: "", listUnsubscribe: ""
|
|
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
|
-
}
|
|
518
|
-
}
|
|
519
|
-
|
|
520
|
-
// Extract headers
|
|
521
|
-
let deliveredTo = "";
|
|
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
|
-
};
|
|
541
|
-
|
|
542
|
-
const msgSettings = loadSettings();
|
|
543
|
-
const acctConfig = msgSettings.accounts.find((a: any) => a.id === accountId);
|
|
544
|
-
const relayDomains: string[] = acctConfig?.relayDomains || [];
|
|
545
|
-
const prefixes: string[] = acctConfig?.deliveredToPrefix || [];
|
|
546
|
-
const rawDelivered = parsed2.headers.get("delivered-to");
|
|
547
|
-
if (rawDelivered) {
|
|
548
|
-
const deliveredList = Array.isArray(rawDelivered) ? rawDelivered : [rawDelivered];
|
|
549
|
-
for (let i = deliveredList.length - 1; i >= 0; i--) {
|
|
550
|
-
const d = deliveredList[i];
|
|
551
|
-
const addr = typeof d === "string" ? d : (d as any)?.text || (d as any)?.address || String(d);
|
|
552
|
-
if (!relayDomains.some(rd => addr.includes(`@${rd}`))) {
|
|
553
|
-
deliveredTo = addr;
|
|
554
|
-
break;
|
|
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);
|
|
560
|
-
}
|
|
561
|
-
if (deliveredTo && prefixes.length > 0) {
|
|
562
|
-
const [local, domain] = deliveredTo.split("@");
|
|
563
|
-
for (const prefix of prefixes) {
|
|
564
|
-
if (local.startsWith(prefix)) {
|
|
565
|
-
deliveredTo = `${local.slice(prefix.length)}@${domain}`;
|
|
566
|
-
break;
|
|
567
|
-
}
|
|
568
|
-
}
|
|
569
|
-
}
|
|
570
|
-
}
|
|
571
|
-
returnPath = hdr("return-path").replace(/[<>]/g, "");
|
|
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.
|
|
394
|
+
const local = await this.localStore.getMessage(accountId, uid, allowRemote, folderId);
|
|
395
|
+
if (!local) throw new Error("Message not found");
|
|
396
|
+
|
|
397
|
+
if (!local.cached) {
|
|
398
|
+
// Body not on disk. Enqueue an interactive-lane fetch — the
|
|
399
|
+
// reconciler picks it up, emits `bodyAvailable` on success or
|
|
400
|
+
// `bodyFetchError` on failure. Return envelope-only; UI shows
|
|
401
|
+
// a placeholder and re-renders on the event.
|
|
402
|
+
this.syncQueue.enqueueBodyFetch(accountId, local.folderId as any, local.uid as any, "interactive");
|
|
403
|
+
this.reconciler.kick();
|
|
404
|
+
return local;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// Optional sender-reputation check. This is the one residual server
|
|
408
|
+
// touch in the read path (DNSBL lookup, ~500 ms). Opt-in via Settings;
|
|
409
|
+
// moves to the reconciler in step 4. See feedback_no_bandaids.md.
|
|
600
410
|
let reputation: ReputationResult | null = null;
|
|
601
411
|
const settings = (loadSettings() as any) || {};
|
|
602
|
-
|
|
412
|
+
const senderDomain = (local.from?.address || "").toLowerCase().split("@")[1] || "";
|
|
413
|
+
if (settings.checkDomainReputation && senderDomain && local.hasRemoteContent) {
|
|
603
414
|
reputation = await this.checkDomainReputation(senderDomain);
|
|
604
415
|
}
|
|
605
416
|
|
|
606
|
-
return {
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
417
|
+
return { ...local, reputation };
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/** Diagnostic accessor — exposed for the sync-status pill / debug UI.
|
|
421
|
+
* Returns the current queue counts so the UI can render
|
|
422
|
+
* "Sync OK / Syncing N items" without polling per-account state. */
|
|
423
|
+
getSyncStatus(): { messageActions: number; bodyFetches: number } {
|
|
424
|
+
return this.syncQueue.pendingCount();
|
|
613
425
|
}
|
|
614
426
|
|
|
615
427
|
/** RFC 8058 one-click unsubscribe: POST `List-Unsubscribe=One-Click` to the
|
|
@@ -1085,9 +897,9 @@ export class MailxService {
|
|
|
1085
897
|
const sliced = items.slice((page - 1) * pageSize, page * pageSize);
|
|
1086
898
|
return { items: sliced, total, page, pageSize };
|
|
1087
899
|
} else if (scope === "current" && accountId && folderId) {
|
|
1088
|
-
return this.
|
|
900
|
+
return this.localStore.searchMessages(q, page, pageSize, accountId, folderId);
|
|
1089
901
|
} else {
|
|
1090
|
-
return this.
|
|
902
|
+
return this.localStore.searchMessages(q, page, pageSize);
|
|
1091
903
|
}
|
|
1092
904
|
}
|
|
1093
905
|
|
|
@@ -1248,7 +1060,7 @@ export class MailxService {
|
|
|
1248
1060
|
.catch(e => this.handleGoogleRefreshError("calendar", e));
|
|
1249
1061
|
}
|
|
1250
1062
|
}
|
|
1251
|
-
return this.
|
|
1063
|
+
return this.localStore.getCalendarEvents(acctId, fromMs, toMs);
|
|
1252
1064
|
}
|
|
1253
1065
|
|
|
1254
1066
|
/** Returns true if the feature is currently in a quota-exceeded cooldown. */
|
|
@@ -1405,7 +1217,7 @@ export class MailxService {
|
|
|
1405
1217
|
.catch(e => this.handleGoogleRefreshError("tasks", e));
|
|
1406
1218
|
}
|
|
1407
1219
|
}
|
|
1408
|
-
return this.
|
|
1220
|
+
return this.localStore.getTasks(acctId, includeCompleted);
|
|
1409
1221
|
}
|
|
1410
1222
|
|
|
1411
1223
|
private async refreshTasks(accountId: string, includeCompleted: boolean): Promise<boolean> {
|
|
@@ -2056,8 +1868,22 @@ export class MailxService {
|
|
|
2056
1868
|
async getAttachment(accountId: string, uid: number, attachmentId: number, folderId?: number): Promise<{ content: Buffer; contentType: string; filename: string }> {
|
|
2057
1869
|
const envelope = this.db.getMessageByUid(accountId, uid, folderId);
|
|
2058
1870
|
if (!envelope) throw new Error("Message not found");
|
|
2059
|
-
|
|
1871
|
+
|
|
1872
|
+
// Prefer the on-disk body — when the user just opened the message,
|
|
1873
|
+
// LocalStore.getMessage either returned cached:true (body already on
|
|
1874
|
+
// disk) or kicked a background fetch that wrote it. Either way a
|
|
1875
|
+
// re-fetch here would be wasted IMAP work and risks racing.
|
|
1876
|
+
const bodyStore = this.imapManager.getBodyStore();
|
|
1877
|
+
let raw: Buffer | null = null;
|
|
1878
|
+
const storedPath = envelope.bodyPath || this.db.getMessageBodyPath(accountId, uid) || "";
|
|
1879
|
+
if (storedPath && await bodyStore.hasByPath(storedPath)) {
|
|
1880
|
+
try { raw = await bodyStore.readByPath(storedPath); } catch { raw = null; }
|
|
1881
|
+
}
|
|
1882
|
+
if (!raw) {
|
|
1883
|
+
raw = await this.imapManager.fetchMessageBody(accountId, envelope.folderId, envelope.uid);
|
|
1884
|
+
}
|
|
2060
1885
|
if (!raw) throw new Error("Message body not available");
|
|
1886
|
+
|
|
2061
1887
|
const parsed = await simpleParser(raw);
|
|
2062
1888
|
const att = parsed.attachments?.[attachmentId];
|
|
2063
1889
|
if (!att) throw new Error("Attachment not found");
|
|
@@ -2123,14 +1949,10 @@ export class MailxService {
|
|
|
2123
1949
|
}
|
|
2124
1950
|
} catch { /* non-fatal — draft stays in memory at least */ }
|
|
2125
1951
|
|
|
2126
|
-
// Background reconcile to server Drafts folder
|
|
2127
|
-
//
|
|
2128
|
-
|
|
2129
|
-
|
|
2130
|
-
// Surface as an event so the UI can show a status-bar hint without
|
|
2131
|
-
// blocking the caller. Draft is preserved on disk regardless.
|
|
2132
|
-
(this as any).emit?.("draftSaveDeferred", { accountId, draftId: id, error: String(e?.message || e) });
|
|
2133
|
-
});
|
|
1952
|
+
// Background reconcile to server Drafts folder via the SyncQueue
|
|
1953
|
+
// seam. Fire-and-forget; deferred failures surface as a `draftSaveDeferred`
|
|
1954
|
+
// event the UI can render in the status bar.
|
|
1955
|
+
this.syncQueue.enqueueDraftPush(accountId, raw, previousDraftUid, id);
|
|
2134
1956
|
|
|
2135
1957
|
return { draftUid: null, draftId: id };
|
|
2136
1958
|
}
|
|
@@ -2144,7 +1966,7 @@ export class MailxService {
|
|
|
2144
1966
|
searchContacts(query: string): any[] {
|
|
2145
1967
|
query = (query || "").trim();
|
|
2146
1968
|
if (query.length < 1) return [];
|
|
2147
|
-
return this.
|
|
1969
|
+
return this.localStore.searchContacts(query);
|
|
2148
1970
|
}
|
|
2149
1971
|
|
|
2150
1972
|
/** Q49: boolean hint for compose to auto-expand Cc when replying to this
|
|
@@ -2181,7 +2003,7 @@ export class MailxService {
|
|
|
2181
2003
|
|
|
2182
2004
|
/** Address-book listing — paginated, filterable. */
|
|
2183
2005
|
listContacts(query: string, page = 1, pageSize = 100): any {
|
|
2184
|
-
return this.
|
|
2006
|
+
return this.localStore.listContacts(query || "", page, pageSize);
|
|
2185
2007
|
}
|
|
2186
2008
|
|
|
2187
2009
|
/** Upsert a contact from the address book UI (edit name). Two-way cache:
|
|
@@ -0,0 +1,86 @@
|
|
|
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
|
+
listUnsubscribeMail: string;
|
|
42
|
+
listUnsubscribeHttp: string;
|
|
43
|
+
listUnsubscribeOneClick: boolean;
|
|
44
|
+
emlPath: string;
|
|
45
|
+
isFlagged: boolean;
|
|
46
|
+
}
|
|
47
|
+
export declare class LocalStore {
|
|
48
|
+
private db;
|
|
49
|
+
private bodyStore;
|
|
50
|
+
constructor(db: MailxDB, bodyStore: FileMessageStore);
|
|
51
|
+
/** DB-shape account list (id/name/email/lastSync). The richer
|
|
52
|
+
* AccountConfig (with imap/smtp/etc.) lives in accounts.jsonc and is
|
|
53
|
+
* loaded by mailx-settings, not the DB — that path stays in
|
|
54
|
+
* MailxService until step 3 of the local-first plan. */
|
|
55
|
+
getAccounts(): {
|
|
56
|
+
id: string;
|
|
57
|
+
name: string;
|
|
58
|
+
email: string;
|
|
59
|
+
lastSync: number;
|
|
60
|
+
}[];
|
|
61
|
+
getFolders(accountId: string): any[];
|
|
62
|
+
/** Single envelope by (account, uid, folder). Null when the row isn't
|
|
63
|
+
* in the DB — caller decides whether to show "deleted" or queue a
|
|
64
|
+
* server lookup via the reconciler. */
|
|
65
|
+
getMessageEnvelope(accountId: string, uid: number, folderId?: number): MessageEnvelope | null;
|
|
66
|
+
/** Paginated message list for a (account, folder, ...) query. */
|
|
67
|
+
getMessages(query: MessageQuery): PagedResult<MessageEnvelope>;
|
|
68
|
+
/** All-Inboxes view: union of every account's INBOX, paginated. */
|
|
69
|
+
getUnifiedInbox(page?: number, pageSize?: number): PagedResult<MessageEnvelope>;
|
|
70
|
+
/** Local FTS5 search. Server-scope search is the reconciler's job. */
|
|
71
|
+
searchMessages(query: string, page?: number, pageSize?: number, accountId?: string, folderId?: number): PagedResult<MessageEnvelope>;
|
|
72
|
+
/** Read a fully-parsed message (envelope + body + attachments) entirely
|
|
73
|
+
* from local state. Returns null when the envelope isn't known.
|
|
74
|
+
* Returns `{ ...envelope, cached: false }` when the envelope is known
|
|
75
|
+
* but the body file isn't on disk — UI shows a placeholder and the
|
|
76
|
+
* reconciler queues the fetch.
|
|
77
|
+
*
|
|
78
|
+
* `allowRemote=true` skips HTML sanitization. Used when the user has
|
|
79
|
+
* explicitly allowed remote content for this sender / domain. */
|
|
80
|
+
getMessage(accountId: string, uid: number, allowRemote: boolean, folderId?: number): Promise<LocalMessage | null>;
|
|
81
|
+
getCalendarEvents(accountId: string, fromMs: number, toMs: number): any[];
|
|
82
|
+
getTasks(accountId: string, includeCompleted?: boolean): any[];
|
|
83
|
+
searchContacts(query: string, limit?: number): any[];
|
|
84
|
+
listContacts(query: string, page?: number, pageSize?: number): any;
|
|
85
|
+
}
|
|
86
|
+
//# 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;AAuCnC;;;;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;IACxB,mBAAmB,EAAE,MAAM,CAAC;IAC5B,mBAAmB,EAAE,MAAM,CAAC;IAC5B,uBAAuB,EAAE,OAAO,CAAC;IACjC,OAAO,EAAE,MAAM,CAAC;IAChB,SAAS,EAAE,OAAO,CAAC;CACtB;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;IAmJvH,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"}
|