@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
|
@@ -8,8 +8,11 @@ import * as fs from "node:fs";
|
|
|
8
8
|
import * as path from "node:path";
|
|
9
9
|
const __dirname = import.meta.dirname;
|
|
10
10
|
import * as gsync from "./google-sync.js";
|
|
11
|
+
import { LocalStore } from "./local-store.js";
|
|
12
|
+
import { SyncQueue } from "./sync-queue.js";
|
|
13
|
+
import { Reconciler } from "./reconciler.js";
|
|
11
14
|
import { loadSettings, saveSettings, loadAccounts, loadAccountsAsync, saveAccounts, initCloudConfig, loadAllowlist, saveAllowlist, loadAutocomplete, saveAutocomplete, loadKeys, getStorageInfo, getConfigDir } from "@bobfrankston/mailx-settings";
|
|
12
|
-
import {
|
|
15
|
+
import { encodeQuotedPrintable, htmlToPlainText } from "@bobfrankston/mailx-types";
|
|
13
16
|
import { simpleParser } from "mailparser";
|
|
14
17
|
/** Detect mis-labeled charset and rewrite the part header to `utf-8` when
|
|
15
18
|
* the body bytes are actually valid UTF-8. PHPMailer-driven senders are
|
|
@@ -21,88 +24,10 @@ import { simpleParser } from "mailparser";
|
|
|
21
24
|
* Operates byte-wise so MIME boundaries / base64 / etc. are preserved.
|
|
22
25
|
* The `utf-8` validity test rejects 0xC0–0xC1 / 0xF5–0xFF and continuation
|
|
23
26
|
* bytes out of place, which would be common in genuine Latin-1 text. */
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
if (!re.test(head))
|
|
29
|
-
return raw;
|
|
30
|
-
if (!isValidUtf8(raw))
|
|
31
|
-
return raw;
|
|
32
|
-
const fixed = head.replace(/charset\s*=\s*"?(iso-8859-1|us-ascii|windows-1252|latin1)"?/gi, "charset=utf-8");
|
|
33
|
-
return Buffer.concat([Buffer.from(fixed, "latin1"), raw.subarray(head.length)]);
|
|
34
|
-
}
|
|
35
|
-
/** Strict UTF-8 validity check: rejects overlong forms, invalid start
|
|
36
|
-
* bytes, and dangling continuations. Used to confirm the body is really
|
|
37
|
-
* UTF-8 before overriding a Latin-1 declaration. */
|
|
38
|
-
function isValidUtf8(buf) {
|
|
39
|
-
let i = 0;
|
|
40
|
-
while (i < buf.length) {
|
|
41
|
-
const b = buf[i];
|
|
42
|
-
if (b < 0x80) {
|
|
43
|
-
i++;
|
|
44
|
-
continue;
|
|
45
|
-
}
|
|
46
|
-
let need;
|
|
47
|
-
if ((b & 0xE0) === 0xC0) {
|
|
48
|
-
if (b < 0xC2)
|
|
49
|
-
return false;
|
|
50
|
-
need = 1;
|
|
51
|
-
}
|
|
52
|
-
else if ((b & 0xF0) === 0xE0)
|
|
53
|
-
need = 2;
|
|
54
|
-
else if ((b & 0xF8) === 0xF0) {
|
|
55
|
-
if (b > 0xF4)
|
|
56
|
-
return false;
|
|
57
|
-
need = 3;
|
|
58
|
-
}
|
|
59
|
-
else
|
|
60
|
-
return false;
|
|
61
|
-
if (i + need >= buf.length)
|
|
62
|
-
return false;
|
|
63
|
-
for (let k = 1; k <= need; k++) {
|
|
64
|
-
if ((buf[i + k] & 0xC0) !== 0x80)
|
|
65
|
-
return false;
|
|
66
|
-
}
|
|
67
|
-
i += need + 1;
|
|
68
|
-
}
|
|
69
|
-
return true;
|
|
70
|
-
}
|
|
71
|
-
/** Parse `List-Unsubscribe` (RFC 2369) and `List-Unsubscribe-Post` (RFC 8058).
|
|
72
|
-
* mailparser only exposes ONE of mail/url even when both are present, so we
|
|
73
|
-
* also scan the raw header text for the full set of angle-bracketed URIs. */
|
|
74
|
-
function parseListUnsubscribe(headers) {
|
|
75
|
-
let mail = "";
|
|
76
|
-
let http = "";
|
|
77
|
-
let oneClick = false;
|
|
78
|
-
const raw = headers.get("list-unsubscribe");
|
|
79
|
-
const rawStr = typeof raw === "string" ? raw : (raw && typeof raw.text === "string" ? raw.text : "");
|
|
80
|
-
if (rawStr) {
|
|
81
|
-
const matches = rawStr.match(/<([^>]+)>/g) || [];
|
|
82
|
-
for (const m of matches) {
|
|
83
|
-
const url = m.slice(1, -1).trim();
|
|
84
|
-
if (!mail && /^mailto:/i.test(url))
|
|
85
|
-
mail = url;
|
|
86
|
-
else if (!http && /^https?:/i.test(url))
|
|
87
|
-
http = url;
|
|
88
|
-
}
|
|
89
|
-
}
|
|
90
|
-
if (!mail && !http) {
|
|
91
|
-
const listHeaders = headers.get("list");
|
|
92
|
-
if (listHeaders?.unsubscribe) {
|
|
93
|
-
const unsub = listHeaders.unsubscribe;
|
|
94
|
-
if (unsub.url)
|
|
95
|
-
http = Array.isArray(unsub.url) ? unsub.url[0] : unsub.url;
|
|
96
|
-
if (unsub.mail)
|
|
97
|
-
mail = `mailto:${Array.isArray(unsub.mail) ? unsub.mail[0] : unsub.mail}`;
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
const post = headers.get("list-unsubscribe-post");
|
|
101
|
-
const postStr = typeof post === "string" ? post : (post && typeof post.text === "string" ? post.text : "");
|
|
102
|
-
if (postStr && /one-?click/i.test(postStr))
|
|
103
|
-
oneClick = true;
|
|
104
|
-
return { listUnsubscribeMail: mail, listUnsubscribeHttp: http, listUnsubscribeOneClick: oneClick };
|
|
105
|
-
}
|
|
27
|
+
// sniffAndFixCharset moved to ./charset.ts so LocalStore can use it
|
|
28
|
+
// without creating a circular dep with index.ts.
|
|
29
|
+
// parseListUnsubscribe moved to ./local-store.ts (only consumer is the
|
|
30
|
+
// body-read path, which is now part of LocalStore).
|
|
106
31
|
// ── Email provider detection (MX-based) ──
|
|
107
32
|
const GOOGLE_DOMAINS = ["gmail.com", "googlemail.com"];
|
|
108
33
|
const MS_DOMAINS = ["outlook.com", "hotmail.com", "live.com"];
|
|
@@ -165,9 +90,23 @@ export class MailxService {
|
|
|
165
90
|
* bin/mailx.ts with mailx-host's showMessageBox. Without this,
|
|
166
91
|
* showReminderPopup returns a "no host" reason. */
|
|
167
92
|
setPopupFn(fn) { this.popupFn = fn; }
|
|
93
|
+
/** Local-first read/write facade. Every UI IPC handler that touches the
|
|
94
|
+
* local DB or body store goes through this — no awaiting IMAP, no
|
|
95
|
+
* awaiting Gmail API, no awaiting SMTP. See docs/local-first-plan.md. */
|
|
96
|
+
localStore;
|
|
97
|
+
/** Persistent (and in-memory body-fetch) queue. UI handlers commit
|
|
98
|
+
* locally, then enqueue a server-mirror task here. */
|
|
99
|
+
syncQueue;
|
|
100
|
+
/** Background loop: drains body-fetch tasks, retries failed message
|
|
101
|
+
* actions, emits sync-state events for the status pill. */
|
|
102
|
+
reconciler;
|
|
168
103
|
constructor(db, imapManager) {
|
|
169
104
|
this.db = db;
|
|
170
105
|
this.imapManager = imapManager;
|
|
106
|
+
this.localStore = new LocalStore(db, imapManager.getBodyStore());
|
|
107
|
+
this.syncQueue = new SyncQueue(db, imapManager);
|
|
108
|
+
this.reconciler = new Reconciler(db, imapManager, this.syncQueue);
|
|
109
|
+
this.reconciler.start();
|
|
171
110
|
// Invalidate account cache when accounts.jsonc changes on disk or GDrive.
|
|
172
111
|
this.imapManager.on?.("configChanged", (filename) => {
|
|
173
112
|
if (filename === "accounts.jsonc")
|
|
@@ -431,198 +370,49 @@ export class MailxService {
|
|
|
431
370
|
}
|
|
432
371
|
// ── Messages ──
|
|
433
372
|
getUnifiedInbox(page = 1, pageSize = 50) {
|
|
434
|
-
return this.
|
|
373
|
+
return this.localStore.getUnifiedInbox(page, pageSize);
|
|
435
374
|
}
|
|
436
375
|
getMessages(accountId, folderId, page = 1, pageSize = 50, sort = "date", sortDir = "desc", search, flaggedOnly = false) {
|
|
437
|
-
return this.
|
|
376
|
+
return this.localStore.getMessages({ accountId, folderId, page, pageSize, sort: sort, sortDir: sortDir, search, flaggedOnly });
|
|
438
377
|
}
|
|
378
|
+
/** UI body read — local-first. Returns immediately from the local cache:
|
|
379
|
+
* `cached: true` with the full parsed body when on disk, or `cached: false`
|
|
380
|
+
* with envelope-only when not. In the cache-miss case we kick off a
|
|
381
|
+
* fire-and-forget IMAP fetch in the background and emit `bodyAvailable`
|
|
382
|
+
* when the body lands; the UI listens for that event and re-requests.
|
|
383
|
+
*
|
|
384
|
+
* No `await imap*` in the click → render path. The 60s body-fetch race
|
|
385
|
+
* and structured `bodyError` shape that lived here previously are gone —
|
|
386
|
+
* step 1 of the local-first refactor (docs/local-first-plan.md). */
|
|
439
387
|
async getMessage(accountId, uid, allowRemote = false, folderId) {
|
|
440
|
-
const
|
|
441
|
-
if (!
|
|
388
|
+
const local = await this.localStore.getMessage(accountId, uid, allowRemote, folderId);
|
|
389
|
+
if (!local)
|
|
442
390
|
throw new Error("Message not found");
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
this.imapManager.fetchMessageBody(accountId, envelope.folderId, envelope.uid),
|
|
456
|
-
new Promise((_, reject) => setTimeout(() => reject(new Error("body fetch timed out — try again")), BODY_FETCH_TIMEOUT_MS)),
|
|
457
|
-
]);
|
|
458
|
-
}
|
|
459
|
-
catch (fetchErr) {
|
|
460
|
-
// Message was deleted from the server (another device, expunge, etc.) —
|
|
461
|
-
// drop the stale local row so the UI removes it instead of showing a
|
|
462
|
-
// confusing error. Throwing a tagged error lets the client react.
|
|
463
|
-
if (fetchErr?.isNotFound) {
|
|
464
|
-
try {
|
|
465
|
-
this.db.deleteMessage(accountId, envelope.uid);
|
|
466
|
-
this.db.recalcFolderCounts(envelope.folderId);
|
|
467
|
-
}
|
|
468
|
-
catch { /* ignore */ }
|
|
469
|
-
const err = new Error("Message was deleted from the server");
|
|
470
|
-
err.isNotFound = true;
|
|
471
|
-
throw err;
|
|
472
|
-
}
|
|
473
|
-
// Don't stuff the error text into bodyText — it bleeds into the
|
|
474
|
-
// viewer's main content area. Surface as a structured error field
|
|
475
|
-
// so the UI can render a banner with retry UX above the (empty)
|
|
476
|
-
// body. The caller keeps the envelope so the header still shows.
|
|
477
|
-
const rawErr = fetchErr.message || "connection failed";
|
|
478
|
-
const isTransient = /connection|Too many|UNAVAILABLE|rate|429|5\d\d|timeout|ENOTFOUND|ECONNRESET|ETIMEDOUT/i.test(rawErr);
|
|
479
|
-
return {
|
|
480
|
-
...envelope, bodyHtml: "", bodyText: "",
|
|
481
|
-
bodyError: rawErr,
|
|
482
|
-
bodyErrorTransient: isTransient,
|
|
483
|
-
hasRemoteContent: false, remoteAllowed: false, attachments: [], emlPath: "", deliveredTo: "", returnPath: "", listUnsubscribe: ""
|
|
484
|
-
};
|
|
485
|
-
}
|
|
486
|
-
if (!raw) {
|
|
487
|
-
// Same treatment as the thrown-error case: structured field, not body text.
|
|
488
|
-
return {
|
|
489
|
-
...envelope, bodyHtml: "", bodyText: "",
|
|
490
|
-
bodyError: "Message body not cached locally and the server fetch returned nothing.",
|
|
491
|
-
bodyErrorTransient: true,
|
|
492
|
-
hasRemoteContent: false, remoteAllowed: false, attachments: [], emlPath: "", deliveredTo: "", returnPath: "", listUnsubscribe: ""
|
|
493
|
-
};
|
|
494
|
-
}
|
|
495
|
-
else {
|
|
496
|
-
// Many senders (esp. PHPMailer-driven marketing) declare
|
|
497
|
-
// `charset=iso-8859-1` but emit UTF-8 bytes. simpleParser
|
|
498
|
-
// honors the declared charset and produces "â??" garbage for
|
|
499
|
-
// every non-ASCII codepoint (em-dash, smart quotes, …). If
|
|
500
|
-
// the raw body bytes are valid UTF-8, rewrite the charset
|
|
501
|
-
// header before parsing. We only override the obviously-
|
|
502
|
-
// wrong legacy declarations; explicit utf-8 / koi8 / etc.
|
|
503
|
-
// pass through.
|
|
504
|
-
const adjusted = sniffAndFixCharset(raw);
|
|
505
|
-
const parsed = await simpleParser(adjusted);
|
|
506
|
-
bodyHtml = parsed.html || "";
|
|
507
|
-
bodyText = parsed.text || "";
|
|
508
|
-
attachments = (parsed.attachments || []).map((a, i) => ({
|
|
509
|
-
id: i,
|
|
510
|
-
filename: a.filename || `attachment-${i}`,
|
|
511
|
-
mimeType: a.contentType || "application/octet-stream",
|
|
512
|
-
size: a.size || 0,
|
|
513
|
-
contentId: a.contentId || ""
|
|
514
|
-
}));
|
|
515
|
-
}
|
|
516
|
-
// Sanitize HTML
|
|
517
|
-
if (bodyHtml && !allowRemote) {
|
|
518
|
-
const allowList = loadAllowlist();
|
|
519
|
-
const senderAddr = envelope.from?.address || "";
|
|
520
|
-
const senderDomain = senderAddr.split("@")[1] || "";
|
|
521
|
-
const toAddrs = (envelope.to || []).map((a) => a.address);
|
|
522
|
-
const isAllowed = allowList.senders.includes(senderAddr) ||
|
|
523
|
-
allowList.domains.includes(senderDomain) ||
|
|
524
|
-
toAddrs.some((a) => allowList.recipients?.includes(a));
|
|
525
|
-
if (isAllowed) {
|
|
526
|
-
allowRemote = true;
|
|
527
|
-
}
|
|
528
|
-
else {
|
|
529
|
-
const result = sanitizeHtml(bodyHtml);
|
|
530
|
-
bodyHtml = result.html;
|
|
531
|
-
hasRemoteContent = result.hasRemoteContent;
|
|
532
|
-
}
|
|
533
|
-
}
|
|
534
|
-
// Extract headers
|
|
535
|
-
let deliveredTo = "";
|
|
536
|
-
let returnPath = "";
|
|
537
|
-
let listUnsubscribe = "";
|
|
538
|
-
let listUnsubscribeMail = "";
|
|
539
|
-
let listUnsubscribeHttp = "";
|
|
540
|
-
let listUnsubscribeOneClick = false;
|
|
541
|
-
if (raw) {
|
|
542
|
-
const parsed2 = await simpleParser(raw);
|
|
543
|
-
const hdr = (key) => {
|
|
544
|
-
let v = parsed2.headers.get(key);
|
|
545
|
-
if (!v)
|
|
546
|
-
return "";
|
|
547
|
-
if (Array.isArray(v))
|
|
548
|
-
v = v[0];
|
|
549
|
-
if (typeof v === "string")
|
|
550
|
-
return v;
|
|
551
|
-
if (typeof v === "object" && v !== null) {
|
|
552
|
-
if ("text" in v)
|
|
553
|
-
return v.text || "";
|
|
554
|
-
if ("value" in v)
|
|
555
|
-
return String(v.value);
|
|
556
|
-
if ("address" in v)
|
|
557
|
-
return v.address || "";
|
|
558
|
-
}
|
|
559
|
-
return String(v);
|
|
560
|
-
};
|
|
561
|
-
const msgSettings = loadSettings();
|
|
562
|
-
const acctConfig = msgSettings.accounts.find((a) => a.id === accountId);
|
|
563
|
-
const relayDomains = acctConfig?.relayDomains || [];
|
|
564
|
-
const prefixes = acctConfig?.deliveredToPrefix || [];
|
|
565
|
-
const rawDelivered = parsed2.headers.get("delivered-to");
|
|
566
|
-
if (rawDelivered) {
|
|
567
|
-
const deliveredList = Array.isArray(rawDelivered) ? rawDelivered : [rawDelivered];
|
|
568
|
-
for (let i = deliveredList.length - 1; i >= 0; i--) {
|
|
569
|
-
const d = deliveredList[i];
|
|
570
|
-
const addr = typeof d === "string" ? d : d?.text || d?.address || String(d);
|
|
571
|
-
if (!relayDomains.some(rd => addr.includes(`@${rd}`))) {
|
|
572
|
-
deliveredTo = addr;
|
|
573
|
-
break;
|
|
574
|
-
}
|
|
575
|
-
}
|
|
576
|
-
if (!deliveredTo && deliveredList.length > 0) {
|
|
577
|
-
const d = deliveredList[deliveredList.length - 1];
|
|
578
|
-
deliveredTo = typeof d === "string" ? d : d?.text || d?.address || String(d);
|
|
579
|
-
}
|
|
580
|
-
if (deliveredTo && prefixes.length > 0) {
|
|
581
|
-
const [local, domain] = deliveredTo.split("@");
|
|
582
|
-
for (const prefix of prefixes) {
|
|
583
|
-
if (local.startsWith(prefix)) {
|
|
584
|
-
deliveredTo = `${local.slice(prefix.length)}@${domain}`;
|
|
585
|
-
break;
|
|
586
|
-
}
|
|
587
|
-
}
|
|
588
|
-
}
|
|
589
|
-
}
|
|
590
|
-
returnPath = hdr("return-path").replace(/[<>]/g, "");
|
|
591
|
-
({ listUnsubscribeMail, listUnsubscribeHttp, listUnsubscribeOneClick } =
|
|
592
|
-
parseListUnsubscribe(parsed2.headers));
|
|
593
|
-
listUnsubscribe = listUnsubscribeHttp || listUnsubscribeMail;
|
|
594
|
-
}
|
|
595
|
-
// EML path: re-read the row after the fetch — `fetchMessageBody`
|
|
596
|
-
// writes the body to disk and updates `body_path` on success, but the
|
|
597
|
-
// `envelope` snapshot above pre-dates that write, so trusting it
|
|
598
|
-
// hides the Source button on every just-opened message.
|
|
599
|
-
const refreshed = this.db.getMessageByUid(accountId, uid, folderId);
|
|
600
|
-
const emlPath = refreshed?.bodyPath || envelope.bodyPath || "";
|
|
601
|
-
// Flag check — surfaced in the remote-content banner as a red
|
|
602
|
-
// warning when the sender's address or domain is on the user's
|
|
603
|
-
// flagged list. Cheap lookup; loadAllowlist is already cached.
|
|
604
|
-
const allowList = loadAllowlist();
|
|
605
|
-
const senderAddr = (envelope.from?.address || "").toLowerCase();
|
|
606
|
-
const senderDomain = senderAddr.split("@")[1] || "";
|
|
607
|
-
const isFlagged = !!((allowList.flaggedSenders || []).some((s) => (s || "").toLowerCase() === senderAddr) ||
|
|
608
|
-
(allowList.flaggedDomains || []).some((d) => (d || "").toLowerCase() === senderDomain));
|
|
609
|
-
// External reputation check — Spamhaus DBL + SURBL + URIBL in parallel.
|
|
610
|
-
// Off by default (privacy: the domain leaks to those DNSBLs and the
|
|
611
|
-
// user's local resolver). User opts in via Settings → "Check sender
|
|
612
|
-
// reputation". Each lookup is bounded at 500 ms; the whole check is
|
|
613
|
-
// bounded by the slowest, ~500 ms worst case.
|
|
391
|
+
if (!local.cached) {
|
|
392
|
+
// Body not on disk. Enqueue an interactive-lane fetch — the
|
|
393
|
+
// reconciler picks it up, emits `bodyAvailable` on success or
|
|
394
|
+
// `bodyFetchError` on failure. Return envelope-only; UI shows
|
|
395
|
+
// a placeholder and re-renders on the event.
|
|
396
|
+
this.syncQueue.enqueueBodyFetch(accountId, local.folderId, local.uid, "interactive");
|
|
397
|
+
this.reconciler.kick();
|
|
398
|
+
return local;
|
|
399
|
+
}
|
|
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.
|
|
614
403
|
let reputation = null;
|
|
615
404
|
const settings = loadSettings() || {};
|
|
616
|
-
|
|
405
|
+
const senderDomain = (local.from?.address || "").toLowerCase().split("@")[1] || "";
|
|
406
|
+
if (settings.checkDomainReputation && senderDomain && local.hasRemoteContent) {
|
|
617
407
|
reputation = await this.checkDomainReputation(senderDomain);
|
|
618
408
|
}
|
|
619
|
-
return {
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
409
|
+
return { ...local, reputation };
|
|
410
|
+
}
|
|
411
|
+
/** Diagnostic accessor — exposed for the sync-status pill / debug UI.
|
|
412
|
+
* Returns the current queue counts so the UI can render
|
|
413
|
+
* "Sync OK / Syncing N items" without polling per-account state. */
|
|
414
|
+
getSyncStatus() {
|
|
415
|
+
return this.syncQueue.pendingCount();
|
|
626
416
|
}
|
|
627
417
|
/** RFC 8058 one-click unsubscribe: POST `List-Unsubscribe=One-Click` to the
|
|
628
418
|
* HTTPS URL the message's List-Unsubscribe header advertised. Done server-
|
|
@@ -1135,10 +925,10 @@ export class MailxService {
|
|
|
1135
925
|
return { items: sliced, total, page, pageSize };
|
|
1136
926
|
}
|
|
1137
927
|
else if (scope === "current" && accountId && folderId) {
|
|
1138
|
-
return this.
|
|
928
|
+
return this.localStore.searchMessages(q, page, pageSize, accountId, folderId);
|
|
1139
929
|
}
|
|
1140
930
|
else {
|
|
1141
|
-
return this.
|
|
931
|
+
return this.localStore.searchMessages(q, page, pageSize);
|
|
1142
932
|
}
|
|
1143
933
|
}
|
|
1144
934
|
rebuildSearchIndex() {
|
|
@@ -1294,7 +1084,7 @@ export class MailxService {
|
|
|
1294
1084
|
.catch(e => this.handleGoogleRefreshError("calendar", e));
|
|
1295
1085
|
}
|
|
1296
1086
|
}
|
|
1297
|
-
return this.
|
|
1087
|
+
return this.localStore.getCalendarEvents(acctId, fromMs, toMs);
|
|
1298
1088
|
}
|
|
1299
1089
|
/** Returns true if the feature is currently in a quota-exceeded cooldown. */
|
|
1300
1090
|
inQuotaCooldown(feature) {
|
|
@@ -1449,7 +1239,7 @@ export class MailxService {
|
|
|
1449
1239
|
.catch(e => this.handleGoogleRefreshError("tasks", e));
|
|
1450
1240
|
}
|
|
1451
1241
|
}
|
|
1452
|
-
return this.
|
|
1242
|
+
return this.localStore.getTasks(acctId, includeCompleted);
|
|
1453
1243
|
}
|
|
1454
1244
|
async refreshTasks(accountId, includeCompleted) {
|
|
1455
1245
|
const tp = await this.primaryTokenProvider("tasks");
|
|
@@ -2146,7 +1936,24 @@ export class MailxService {
|
|
|
2146
1936
|
const envelope = this.db.getMessageByUid(accountId, uid, folderId);
|
|
2147
1937
|
if (!envelope)
|
|
2148
1938
|
throw new Error("Message not found");
|
|
2149
|
-
|
|
1939
|
+
// Prefer the on-disk body — when the user just opened the message,
|
|
1940
|
+
// LocalStore.getMessage either returned cached:true (body already on
|
|
1941
|
+
// disk) or kicked a background fetch that wrote it. Either way a
|
|
1942
|
+
// re-fetch here would be wasted IMAP work and risks racing.
|
|
1943
|
+
const bodyStore = this.imapManager.getBodyStore();
|
|
1944
|
+
let raw = null;
|
|
1945
|
+
const storedPath = envelope.bodyPath || this.db.getMessageBodyPath(accountId, uid) || "";
|
|
1946
|
+
if (storedPath && await bodyStore.hasByPath(storedPath)) {
|
|
1947
|
+
try {
|
|
1948
|
+
raw = await bodyStore.readByPath(storedPath);
|
|
1949
|
+
}
|
|
1950
|
+
catch {
|
|
1951
|
+
raw = null;
|
|
1952
|
+
}
|
|
1953
|
+
}
|
|
1954
|
+
if (!raw) {
|
|
1955
|
+
raw = await this.imapManager.fetchMessageBody(accountId, envelope.folderId, envelope.uid);
|
|
1956
|
+
}
|
|
2150
1957
|
if (!raw)
|
|
2151
1958
|
throw new Error("Message body not available");
|
|
2152
1959
|
const parsed = await simpleParser(raw);
|
|
@@ -2210,14 +2017,10 @@ export class MailxService {
|
|
|
2210
2017
|
}
|
|
2211
2018
|
}
|
|
2212
2019
|
catch { /* non-fatal — draft stays in memory at least */ }
|
|
2213
|
-
// Background reconcile to server Drafts folder
|
|
2214
|
-
//
|
|
2215
|
-
|
|
2216
|
-
|
|
2217
|
-
// Surface as an event so the UI can show a status-bar hint without
|
|
2218
|
-
// blocking the caller. Draft is preserved on disk regardless.
|
|
2219
|
-
this.emit?.("draftSaveDeferred", { accountId, draftId: id, error: String(e?.message || e) });
|
|
2220
|
-
});
|
|
2020
|
+
// Background reconcile to server Drafts folder via the SyncQueue
|
|
2021
|
+
// seam. Fire-and-forget; deferred failures surface as a `draftSaveDeferred`
|
|
2022
|
+
// event the UI can render in the status bar.
|
|
2023
|
+
this.syncQueue.enqueueDraftPush(accountId, raw, previousDraftUid, id);
|
|
2221
2024
|
return { draftUid: null, draftId: id };
|
|
2222
2025
|
}
|
|
2223
2026
|
async deleteDraft(accountId, draftUid, draftId) {
|
|
@@ -2228,7 +2031,7 @@ export class MailxService {
|
|
|
2228
2031
|
query = (query || "").trim();
|
|
2229
2032
|
if (query.length < 1)
|
|
2230
2033
|
return [];
|
|
2231
|
-
return this.
|
|
2034
|
+
return this.localStore.searchContacts(query);
|
|
2232
2035
|
}
|
|
2233
2036
|
/** Q49: boolean hint for compose to auto-expand Cc when replying to this
|
|
2234
2037
|
* address. True when at least one past sent message to the same recipient
|
|
@@ -2260,7 +2063,7 @@ export class MailxService {
|
|
|
2260
2063
|
}
|
|
2261
2064
|
/** Address-book listing — paginated, filterable. */
|
|
2262
2065
|
listContacts(query, page = 1, pageSize = 100) {
|
|
2263
|
-
return this.
|
|
2066
|
+
return this.localStore.listContacts(query || "", page, pageSize);
|
|
2264
2067
|
}
|
|
2265
2068
|
/** Upsert a contact from the address book UI (edit name). Two-way cache:
|
|
2266
2069
|
* commits locally, queues a Google People push. */
|