@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.
Files changed (46) hide show
  1. package/bin/mailx.js +21 -1
  2. package/bin/mailx.js.map +1 -1
  3. package/bin/mailx.ts +20 -1
  4. package/client/app.js +51 -1
  5. package/client/app.js.map +1 -1
  6. package/client/app.ts +50 -1
  7. package/client/components/message-list.js +1 -3
  8. package/client/components/message-list.js.map +1 -1
  9. package/client/components/message-list.ts +1 -3
  10. package/client/components/message-viewer.js +65 -97
  11. package/client/components/message-viewer.js.map +1 -1
  12. package/client/components/message-viewer.ts +51 -90
  13. package/client/lib/api-client.js +5 -0
  14. package/client/lib/api-client.js.map +1 -1
  15. package/client/lib/api-client.ts +5 -1
  16. package/client/styles/components.css +0 -12
  17. package/package.json +1 -1
  18. package/packages/mailx-server/index.d.ts.map +1 -1
  19. package/packages/mailx-server/index.js +13 -0
  20. package/packages/mailx-server/index.js.map +1 -1
  21. package/packages/mailx-server/index.ts +14 -0
  22. package/packages/mailx-service/charset.d.ts +15 -0
  23. package/packages/mailx-service/charset.d.ts.map +1 -0
  24. package/packages/mailx-service/charset.js +61 -0
  25. package/packages/mailx-service/charset.js.map +1 -0
  26. package/packages/mailx-service/charset.ts +45 -0
  27. package/packages/mailx-service/index.d.ts +26 -0
  28. package/packages/mailx-service/index.d.ts.map +1 -1
  29. package/packages/mailx-service/index.js +84 -281
  30. package/packages/mailx-service/index.js.map +1 -1
  31. package/packages/mailx-service/index.ts +88 -266
  32. package/packages/mailx-service/local-store.d.ts +86 -0
  33. package/packages/mailx-service/local-store.d.ts.map +1 -0
  34. package/packages/mailx-service/local-store.js +259 -0
  35. package/packages/mailx-service/local-store.js.map +1 -0
  36. package/packages/mailx-service/local-store.ts +307 -0
  37. package/packages/mailx-service/reconciler.d.ts +62 -0
  38. package/packages/mailx-service/reconciler.d.ts.map +1 -0
  39. package/packages/mailx-service/reconciler.js +141 -0
  40. package/packages/mailx-service/reconciler.js.map +1 -0
  41. package/packages/mailx-service/reconciler.ts +151 -0
  42. package/packages/mailx-service/sync-queue.d.ts +79 -0
  43. package/packages/mailx-service/sync-queue.d.ts.map +1 -0
  44. package/packages/mailx-service/sync-queue.js +118 -0
  45. package/packages/mailx-service/sync-queue.js.map +1 -0
  46. 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 { sanitizeHtml, encodeQuotedPrintable, htmlToPlainText } from "@bobfrankston/mailx-types";
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
- function sniffAndFixCharset(raw) {
25
- const HEAD_LIMIT = 16384;
26
- const head = raw.subarray(0, Math.min(HEAD_LIMIT, raw.length)).toString("latin1");
27
- const re = /charset\s*=\s*"?(iso-8859-1|us-ascii|windows-1252|latin1)"?/gi;
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.db.getUnifiedInbox(page, pageSize);
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.db.getMessages({ accountId, folderId, page, pageSize, sort: sort, sortDir: sortDir, search, flaggedOnly });
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 envelope = this.db.getMessageByUid(accountId, uid, folderId);
441
- if (!envelope)
388
+ const local = await this.localStore.getMessage(accountId, uid, allowRemote, folderId);
389
+ if (!local)
442
390
  throw new Error("Message not found");
443
- let bodyHtml = "";
444
- let bodyText = "";
445
- let hasRemoteContent = false;
446
- let attachments = [];
447
- // The per-account ops queue inside ImapManager has its own per-task
448
- // timeout that destroys a wedged client and unblocks the queue. This
449
- // outer race is a safety net only — the underlying timeout in
450
- // withConnection should trigger first.
451
- const BODY_FETCH_TIMEOUT_MS = 60_000;
452
- let raw = null;
453
- try {
454
- raw = await Promise.race([
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
- if (settings.checkDomainReputation && senderDomain && hasRemoteContent) {
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
- ...envelope, bodyHtml, bodyText, hasRemoteContent, remoteAllowed: allowRemote,
621
- attachments, emlPath, deliveredTo, returnPath,
622
- listUnsubscribe, listUnsubscribeMail, listUnsubscribeHttp, listUnsubscribeOneClick,
623
- isFlagged,
624
- reputation,
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.db.searchMessages(q, page, pageSize, accountId, folderId);
928
+ return this.localStore.searchMessages(q, page, pageSize, accountId, folderId);
1139
929
  }
1140
930
  else {
1141
- return this.db.searchMessages(q, page, pageSize);
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.db.getCalendarEvents(acctId, fromMs, toMs);
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.db.getTasks(acctId, includeCompleted);
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
- const raw = await this.imapManager.fetchMessageBody(accountId, envelope.folderId, envelope.uid);
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. Fire-and-forget
2214
- // the ACK to the client is already on its way.
2215
- this.imapManager.saveDraft(accountId, raw, previousDraftUid, id).catch((e) => {
2216
- console.error(` [draft] background IMAP save failed for ${id}: ${e?.message || e}`);
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.db.searchContacts(query);
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.db.listContacts(query || "", page, pageSize);
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. */