@bobfrankston/mailx 1.0.327 → 1.0.333

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 CHANGED
@@ -964,7 +964,13 @@ async function main() {
964
964
  // version-mismatch auto-upgrade actually transparent to the user.
965
965
  writeInstanceFile(process.pid);
966
966
  const __cleanupInstance = () => {
967
- clearInstanceFile();
967
+ // Only clear if WE are still the registered instance. Prevents the
968
+ // restart-daemon sequence (clear → spawn → new daemon writes its
969
+ // own entry → we exit) from deleting the replacement's claim on
970
+ // the way out.
971
+ const inst = readInstanceFile();
972
+ if (inst && inst.pid === process.pid)
973
+ clearInstanceFile();
968
974
  try {
969
975
  handle.close();
970
976
  }
@@ -1004,20 +1010,26 @@ async function main() {
1004
1010
  handle.send({ _cbid: req._cbid, result: { ok: true } });
1005
1011
  return;
1006
1012
  }
1007
- // Restart the daemon in-place without npm install. Spawn a fresh
1008
- // detached child running `mailx`, then gracefully shut this process
1009
- // down. The new daemon's version-mismatch / startup flow (see top
1010
- // of bin/mailx.ts) will either take over instantly (version same)
1011
- // or auto-upgrade through the instance-file cascade. Used when the
1012
- // user edits accounts.jsonc and needs the change to take effect
1013
- // without a terminal round-trip.
1013
+ // Restart the daemon in-place without npm install. Subtle: the new
1014
+ // mailx's startup-time instance check sees the instance.json we
1015
+ // wrote and bails with "already running" if versions match
1016
+ // skipping the new process entirely. Clear the instance file
1017
+ // FIRST so the replacement can claim the slot, THEN spawn, THEN
1018
+ // gracefully shut this process down. The exit handler guards
1019
+ // against clobbering the replacement's entry (see __cleanupInstance
1020
+ // below — only clears if instance.json's PID still matches ours).
1014
1021
  if (req._action === "restartDaemon") {
1015
1022
  handle.send({ _cbid: req._cbid, ok: true, status: "restarting" });
1016
1023
  try {
1024
+ clearInstanceFile();
1017
1025
  const { spawn: spawnChild } = await import("child_process");
1018
1026
  const child = spawnChild("mailx", [], { detached: true, stdio: "ignore", shell: true });
1019
1027
  child.unref();
1020
1028
  console.log(" [restart] Spawned fresh daemon; shutting down current");
1029
+ // Give the spawn a moment to take hold before we start
1030
+ // tearing things down — otherwise IMAP disconnects could
1031
+ // race with the new process's startup handshake.
1032
+ await new Promise(r => setTimeout(r, 800));
1021
1033
  }
1022
1034
  catch (e) {
1023
1035
  console.error(` [restart] Spawn failed: ${e.message}`);
@@ -150,6 +150,7 @@
150
150
  <button class="tb-btn" id="btn-delete" title="Delete">🗑</button>
151
151
  <button class="tb-btn" id="btn-spam" title="Mark as spam" hidden>⚠</button>
152
152
  <button class="tb-btn" id="btn-flag" title="Flag">⚑</button>
153
+ <button class="tb-btn" id="btn-mark-unread" title="Mark unread">◉</button>
153
154
  <span style="flex:1"></span>
154
155
  <button class="mv-action mv-action-primary" id="mv-edit-draft" hidden>Edit & Send</button>
155
156
  <a class="mv-unsubscribe" id="mv-unsubscribe" hidden>Unsubscribe</a>
package/client/app.js CHANGED
@@ -175,7 +175,11 @@ alertDismiss?.addEventListener("click", hideAlert);
175
175
  function showRestartForConfigBanner() {
176
176
  if (!alertBanner || !alertText)
177
177
  return;
178
- alertText.textContent = "accounts.jsonc changed restart to apply.";
178
+ // Timestamp in the banner so repeated / spurious fires are visually
179
+ // distinguishable (and the user can see when the change actually
180
+ // happened, useful for debugging false triggers).
181
+ const ts = new Date().toLocaleTimeString([], { hour12: false });
182
+ alertText.textContent = `[${ts}] accounts.jsonc changed — restart to apply.`;
179
183
  alertBanner.hidden = false;
180
184
  alertBanner.dataset.key = "config-restart";
181
185
  // Avoid duplicate buttons across repeat changes.
@@ -899,6 +903,25 @@ async function refreshSpamButtonVisibility() {
899
903
  document.addEventListener("mailx-message-shown", refreshSpamButtonVisibility);
900
904
  document.addEventListener("mailx-folder-changed", refreshSpamButtonVisibility);
901
905
  document.getElementById("btn-compose")?.addEventListener("click", () => openCompose("new"));
906
+ document.getElementById("btn-mark-unread")?.addEventListener("click", () => {
907
+ // Toggle \Seen on the currently-selected message. Mirrors the R
908
+ // keyboard shortcut and the right-click "Mark unread" menu item, but
909
+ // as a visible toolbar button so users discover the behavior.
910
+ const sel = messageState.getSelected();
911
+ if (!sel)
912
+ return;
913
+ const isSeen = sel.flags.includes("\\Seen");
914
+ const newFlags = isSeen
915
+ ? sel.flags.filter((f) => f !== "\\Seen")
916
+ : [...sel.flags, "\\Seen"];
917
+ updateFlags(sel.accountId, sel.uid, newFlags).then(() => {
918
+ sel.flags = newFlags;
919
+ messageState.updateMessageFlags(sel.accountId, sel.uid, newFlags);
920
+ const row = document.querySelector(`.ml-row[data-uid="${sel.uid}"][data-account-id="${sel.accountId}"]`);
921
+ if (row)
922
+ row.classList.toggle("unread", !newFlags.includes("\\Seen"));
923
+ }).catch(() => { });
924
+ });
902
925
  document.getElementById("btn-reply")?.addEventListener("click", () => openCompose("reply"));
903
926
  document.getElementById("btn-reply-all")?.addEventListener("click", () => openCompose("replyAll"));
904
927
  document.getElementById("btn-forward")?.addEventListener("click", () => openCompose("forward"));
@@ -1589,7 +1612,10 @@ async function openJsoncEditor(initialFile) {
1589
1612
  const panel = document.createElement("div");
1590
1613
  panel.className = "mailx-modal mailx-modal-wide";
1591
1614
  panel.innerHTML = `
1592
- <div class="mailx-modal-title">Edit config file</div>
1615
+ <div class="mailx-modal-title">
1616
+ <span class="mailx-modal-title-text">Edit config file</span>
1617
+ <button type="button" class="mailx-modal-close" id="jsonc-close" title="Close (Esc)" aria-label="Close">&times;</button>
1618
+ </div>
1593
1619
  <label class="mailx-modal-label">File
1594
1620
  <select class="mailx-modal-input" id="jsonc-file">
1595
1621
  <option value="accounts.jsonc">accounts.jsonc — accounts (shared via Google Drive)</option>
@@ -1701,6 +1727,7 @@ async function openJsoncEditor(initialFile) {
1701
1727
  }
1702
1728
  };
1703
1729
  document.addEventListener("keydown", onKey, true);
1730
+ panel.querySelector("#jsonc-close").addEventListener("click", close);
1704
1731
  panel.querySelectorAll(".mailx-modal-btn").forEach(btn => {
1705
1732
  btn.addEventListener("click", async () => {
1706
1733
  const action = btn.dataset.action;
package/client/index.html CHANGED
@@ -130,6 +130,7 @@
130
130
  <button class="tb-btn" id="btn-delete" title="Delete (Del)">🗑</button>
131
131
  <button class="tb-btn" id="btn-spam" title="Mark as spam — move to configured spam folder" hidden>⚠</button>
132
132
  <button class="tb-btn" id="btn-flag" title="Flag">⚑</button>
133
+ <button class="tb-btn" id="btn-mark-unread" title="Mark unread (R)">◉</button>
133
134
  <span style="flex:1"></span>
134
135
  <button class="mv-action mv-action-primary" id="mv-edit-draft" hidden>Edit & Send</button>
135
136
  <a class="mv-unsubscribe" id="mv-unsubscribe" hidden>Unsubscribe</a>
@@ -544,12 +544,39 @@ button.tb-menu-item { background: none; border: none; color: inherit; width: 100
544
544
  }
545
545
  .mailx-modal-wide {
546
546
  width: 80vw;
547
- max-width: 900px;
548
- max-height: 85vh;
547
+ max-width: 95vw;
548
+ max-height: 95vh;
549
+ height: 85vh;
550
+ resize: both;
551
+ overflow: auto;
552
+ min-width: 480px;
553
+ min-height: 320px;
549
554
  }
550
555
  .mailx-modal-title {
551
556
  font-size: var(--font-size-lg);
552
557
  font-weight: 600;
558
+ display: flex;
559
+ align-items: center;
560
+ gap: var(--gap-sm);
561
+ }
562
+ .mailx-modal-title-text {
563
+ flex: 1;
564
+ }
565
+ .mailx-modal-close {
566
+ appearance: none;
567
+ background: transparent;
568
+ border: none;
569
+ color: var(--color-text-muted);
570
+ font-size: 20px;
571
+ line-height: 1;
572
+ cursor: pointer;
573
+ padding: 2px 8px;
574
+ border-radius: var(--radius-sm);
575
+
576
+ &:hover {
577
+ background: var(--color-bg-surface);
578
+ color: var(--color-text);
579
+ }
553
580
  }
554
581
  .mailx-modal-label {
555
582
  display: flex;
@@ -568,7 +595,8 @@ button.tb-menu-item { background: none; border: none; color: inherit; width: 100
568
595
  font-size: var(--font-size-base);
569
596
  }
570
597
  .mailx-modal-textarea {
571
- min-height: 50vh;
598
+ flex: 1;
599
+ min-height: 200px;
572
600
  resize: vertical;
573
601
  font-family: var(--font-mono);
574
602
  font-size: 13px;
@@ -594,6 +622,7 @@ button.tb-menu-item { background: none; border: none; color: inherit; width: 100
594
622
  display: grid;
595
623
  grid-template-columns: minmax(0, 1fr) minmax(240px, 360px);
596
624
  gap: var(--gap-md);
625
+ flex: 1;
597
626
  min-height: 0;
598
627
 
599
628
  &:has(.mailx-help-collapsed) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx",
3
- "version": "1.0.327",
3
+ "version": "1.0.333",
4
4
  "description": "Local-first email client with IMAP sync and standalone native app",
5
5
  "type": "module",
6
6
  "main": "bin/mailx.js",
@@ -53,6 +53,10 @@ export declare class ImapManager extends EventEmitter {
53
53
  deleteOnServer(accountId: string, folderPath: string, uid: number): Promise<void>;
54
54
  /** Search messages on the IMAP server — returns matching UIDs */
55
55
  searchOnServer(accountId: string, mailboxPath: string, criteria: any): Promise<number[]>;
56
+ /** Server-side search that also materializes any UIDs we don't yet have
57
+ * locally. Returns the full result after upsert, so the caller can
58
+ * render hits that fall outside the history window. */
59
+ searchAndFetchOnServer(accountId: string, folderId: number, mailboxPath: string, criteria: any): Promise<number[]>;
56
60
  /** Create a fresh IMAP client for an account (public access for API endpoints) */
57
61
  createPublicClient(accountId: string): any;
58
62
  /** Persistent operational connections — one per account, reused for all operations */
@@ -241,6 +241,41 @@ export class ImapManager extends EventEmitter {
241
241
  catch { /* ignore */ }
242
242
  }
243
243
  }
244
+ /** Server-side search that also materializes any UIDs we don't yet have
245
+ * locally. Returns the full result after upsert, so the caller can
246
+ * render hits that fall outside the history window. */
247
+ async searchAndFetchOnServer(accountId, folderId, mailboxPath, criteria) {
248
+ const client = this.createClient(accountId);
249
+ try {
250
+ const uids = await client.searchMessages(mailboxPath, criteria);
251
+ if (uids.length === 0)
252
+ return [];
253
+ const have = new Set(this.db.getUidsForFolder(accountId, folderId));
254
+ const missing = uids.filter(u => !have.has(u));
255
+ if (missing.length > 0) {
256
+ // Fetch in chunks so a large hit-set doesn't over-long a single command.
257
+ const folder = this.db.getFolders(accountId).find(f => f.id === folderId);
258
+ if (folder) {
259
+ const CHUNK = 500;
260
+ for (let i = 0; i < missing.length; i += CHUNK) {
261
+ const range = missing.slice(i, i + CHUNK).join(",");
262
+ const fetched = await client.fetchMessages(mailboxPath, range, { source: false });
263
+ if (fetched?.length) {
264
+ await this.storeMessages(accountId, folderId, folder, fetched, 0);
265
+ }
266
+ }
267
+ this.db.recalcFolderCounts(folderId);
268
+ }
269
+ }
270
+ return uids;
271
+ }
272
+ finally {
273
+ try {
274
+ await client.logout();
275
+ }
276
+ catch { /* ignore */ }
277
+ }
278
+ }
244
279
  /** Create a fresh IMAP client for an account (public access for API endpoints) */
245
280
  createPublicClient(accountId) {
246
281
  return this.createClient(accountId);
@@ -694,16 +729,11 @@ export class ImapManager extends EventEmitter {
694
729
  let messages;
695
730
  const firstSync = highestUid === 0;
696
731
  const historyDays = getHistoryDays(accountId);
697
- // IMAP: historyDays=0 (unlimited) is dangerous "SEARCH SINCE 1970"
698
- // asks the server to enumerate every message, which Dovecot times out
699
- // on (300s). Cap at 90 days for IMAP. Gmail API handles 0 fine via
700
- // pagination. The first-sync cap (30) applies when starting from
701
- // scratch so the UI isn't empty for minutes.
702
- const isGmail = this.isGmailAccount(accountId);
703
- const MAX_IMAP_DAYS = 90;
732
+ // historyDays=0 means "all". On first sync we still cap at 30 days
733
+ // so the UI isn't empty for minutes while SEARCH SINCE 1970 runs
734
+ // through a years-old mailbox. Once we have any local messages, the
735
+ // backfill below extends the window in 90-day chunks per sync cycle.
704
736
  let effectiveDays = historyDays;
705
- if (historyDays === 0 && !isGmail)
706
- effectiveDays = MAX_IMAP_DAYS;
707
737
  if (effectiveDays === 0 && firstSync)
708
738
  effectiveDays = 30;
709
739
  const startDate = effectiveDays > 0
@@ -747,15 +777,25 @@ export class ImapManager extends EventEmitter {
747
777
  console.error(` ${folder.path}: gap detection failed: ${e.message}`);
748
778
  }
749
779
  }
750
- // Backfill: if historyDays extends further back than our oldest message, fetch the gap
780
+ // Backfill: if the history window reaches further back than our
781
+ // oldest local message, fetch the gap. Chunk 90 days per sync
782
+ // cycle so historyDays=0 catches up incrementally instead of
783
+ // asking Dovecot for SEARCH SINCE 1970 in one go.
751
784
  const oldestDate = this.db.getOldestDate(accountId, folderId);
752
785
  if (oldestDate > 0 && startDate.getTime() < oldestDate) {
753
- const existingUids = new Set(this.db.getUidsForFolder(accountId, folderId));
754
- const backfill = await client.fetchMessageByDate(folder.path, startDate, new Date(oldestDate), { source: false });
755
- const newBackfill = backfill.filter((m) => !existingUids.has(m.uid));
756
- if (newBackfill.length > 0) {
757
- console.log(` ${folder.path}: backfilling ${newBackfill.length} older messages`);
758
- messages.push(...newBackfill);
786
+ try {
787
+ const CHUNK_MS = 90 * 86400000;
788
+ const chunkStart = new Date(Math.max(startDate.getTime(), oldestDate - CHUNK_MS));
789
+ const existingUids = new Set(this.db.getUidsForFolder(accountId, folderId));
790
+ const backfill = await client.fetchMessageByDate(folder.path, chunkStart, new Date(oldestDate), { source: false });
791
+ const newBackfill = backfill.filter((m) => !existingUids.has(m.uid));
792
+ if (newBackfill.length > 0) {
793
+ console.log(` ${folder.path}: backfilling ${newBackfill.length} older messages (${chunkStart.toISOString().slice(0, 10)} → ${new Date(oldestDate).toISOString().slice(0, 10)})`);
794
+ messages.push(...newBackfill);
795
+ }
796
+ }
797
+ catch (e) {
798
+ console.error(` ${folder.path}: backfill failed: ${e.message}`);
759
799
  }
760
800
  }
761
801
  }
@@ -1120,9 +1160,13 @@ export class ImapManager extends EventEmitter {
1120
1160
  // Incremental: fetch messages since last known UID.
1121
1161
  // Gmail "UIDs" are hashed (not chronological), so fetchSince
1122
1162
  // returns messages in hash order — they can be from ANY date.
1123
- // Filter by the history window so years-old messages don't
1124
- // suddenly flood the inbox on every incremental sync.
1125
- messages = await api.fetchSince(folder.path, highestUid, { source: false });
1163
+ // Pass the date window so the provider can page the whole range
1164
+ // (otherwise Gmail's default 200-id cap truncates high-volume
1165
+ // inboxes to ~10 days regardless of historyDays).
1166
+ const fetchOpts = { source: false };
1167
+ if (effectiveDays > 0)
1168
+ fetchOpts.since = startDate;
1169
+ messages = await api.fetchSince(folder.path, highestUid, fetchOpts);
1126
1170
  if (effectiveDays > 0) {
1127
1171
  const cutoff = startDate.getTime();
1128
1172
  const before = messages.length;
@@ -1131,6 +1175,29 @@ export class ImapManager extends EventEmitter {
1131
1175
  console.log(` [api] ${accountId}/${folder.path}: filtered ${before - messages.length} messages older than ${effectiveDays}d`);
1132
1176
  }
1133
1177
  }
1178
+ // Backfill: if the history window reaches further back than our
1179
+ // oldest local message, fetch the gap. Mirrors the IMAP path —
1180
+ // otherwise a user who started with historyDays=30 and later
1181
+ // sets it to 0 (or 365) never actually sees older mail. Cap
1182
+ // each sync cycle at 90 days so unlimited-history accounts
1183
+ // catch up incrementally instead of paging the whole mailbox.
1184
+ const oldestDate = this.db.getOldestDate(accountId, folder.id);
1185
+ if (oldestDate > 0 && startDate.getTime() < oldestDate) {
1186
+ try {
1187
+ const CHUNK_MS = 90 * 86400000;
1188
+ const chunkStart = new Date(Math.max(startDate.getTime(), oldestDate - CHUNK_MS));
1189
+ const existingUids = new Set(this.db.getUidsForFolder(accountId, folder.id));
1190
+ const backfill = await api.fetchByDate(folder.path, chunkStart, new Date(oldestDate), { source: false });
1191
+ const newBackfill = backfill.filter(m => !existingUids.has(m.uid));
1192
+ if (newBackfill.length > 0) {
1193
+ console.log(` [api] ${accountId}/${folder.path}: backfilling ${newBackfill.length} older messages (${chunkStart.toISOString().slice(0, 10)} → ${new Date(oldestDate).toISOString().slice(0, 10)})`);
1194
+ messages.push(...newBackfill);
1195
+ }
1196
+ }
1197
+ catch (e) {
1198
+ console.error(` [api] ${accountId}/${folder.path}: backfill failed: ${e.message}`);
1199
+ }
1200
+ }
1134
1201
  }
1135
1202
  else {
1136
1203
  // First sync: fetch by date range
@@ -9,7 +9,10 @@ import type { Folder, AutocompleteRequest, AutocompleteResponse, AutocompleteSet
9
9
  export declare class MailxService {
10
10
  private db;
11
11
  private imapManager;
12
+ private _accountsCache;
12
13
  constructor(db: MailxDB, imapManager: ImapManager);
14
+ /** Return accounts from cache — load once, reuse until configChanged. */
15
+ private getCachedAccounts;
13
16
  getAccounts(): any[];
14
17
  getFolders(accountId: string): Folder[];
15
18
  getUnifiedInbox(page?: number, pageSize?: number): any;
@@ -74,17 +74,32 @@ async function detectEmailProvider(domain) {
74
74
  export class MailxService {
75
75
  db;
76
76
  imapManager;
77
+ // Cached accounts — loadSettings() reads from the cloud-mounted
78
+ // accounts.jsonc, which can stall on a flaky GDrive File Stream.
79
+ // Refresh on configChanged (fs.watch) so edits still land.
80
+ _accountsCache = null;
77
81
  constructor(db, imapManager) {
78
82
  this.db = db;
79
83
  this.imapManager = imapManager;
84
+ // Invalidate account cache when accounts.jsonc changes on disk or GDrive.
85
+ this.imapManager.on?.("configChanged", (filename) => {
86
+ if (filename === "accounts.jsonc")
87
+ this._accountsCache = null;
88
+ });
89
+ }
90
+ /** Return accounts from cache — load once, reuse until configChanged. */
91
+ getCachedAccounts() {
92
+ if (!this._accountsCache)
93
+ this._accountsCache = loadAccounts();
94
+ return this._accountsCache;
80
95
  }
81
96
  // ── Accounts ──
82
97
  getAccounts() {
83
98
  const dbAccounts = this.db.getAccounts();
84
- const settings = loadSettings();
99
+ const cfgs = this.getCachedAccounts();
85
100
  // Order by settings (accounts.jsonc is the source of truth for order)
86
101
  const ordered = [];
87
- for (const cfg of settings.accounts) {
102
+ for (const cfg of cfgs) {
88
103
  const a = dbAccounts.find(d => d.id === cfg.id);
89
104
  if (a)
90
105
  ordered.push({ ...a, label: cfg.label, defaultSend: cfg.defaultSend || false, identityDomains: cfg.identityDomains || [], spam: cfg.spam || "" });
@@ -270,11 +285,37 @@ export class MailxService {
270
285
  async unsubscribeOneClick(url) {
271
286
  if (!/^https:\/\//i.test(url))
272
287
  throw new Error("one-click unsubscribe requires an https URL");
273
- const resp = await fetch(url, {
288
+ // RFC 8058 POST with List-Unsubscribe=One-Click body. A User-Agent
289
+ // header appeases servers that reject anonymous clients as "malformed".
290
+ const headers = {
291
+ "Content-Type": "application/x-www-form-urlencoded",
292
+ "User-Agent": "mailx/1.0 (https://github.com/BobFrankston/mailx)",
293
+ };
294
+ let resp = await fetch(url, {
274
295
  method: "POST",
275
- headers: { "Content-Type": "application/x-www-form-urlencoded" },
296
+ headers,
276
297
  body: "List-Unsubscribe=One-Click",
298
+ redirect: "follow",
277
299
  });
300
+ // Some mailers advertise List-Unsubscribe-Post but their endpoint
301
+ // actually only handles GET (older RFC 2369 style). Fall back once
302
+ // on 4xx so the user doesn't have to open the URL manually.
303
+ if (!resp.ok && resp.status >= 400 && resp.status < 500) {
304
+ const body = await resp.text().catch(() => "");
305
+ console.log(` [unsub] POST ${url} → ${resp.status} ${resp.statusText}; body: ${body.slice(0, 200)}`);
306
+ try {
307
+ const fallback = await fetch(url, { method: "GET", headers, redirect: "follow" });
308
+ if (fallback.ok) {
309
+ return { ok: true, status: fallback.status, statusText: `${fallback.statusText} (via GET)` };
310
+ }
311
+ const fbody = await fallback.text().catch(() => "");
312
+ console.log(` [unsub] GET ${url} → ${fallback.status} ${fallback.statusText}; body: ${fbody.slice(0, 200)}`);
313
+ // Surface the server's own error so the UI shows the real reason.
314
+ return { ok: false, status: fallback.status, statusText: (fbody.trim().split("\n")[0] || fallback.statusText).slice(0, 200) };
315
+ }
316
+ catch { /* fall through to POST error */ }
317
+ return { ok: false, status: resp.status, statusText: (body.trim().split("\n")[0] || resp.statusText).slice(0, 200) };
318
+ }
278
319
  return { ok: resp.ok, status: resp.status, statusText: resp.statusText };
279
320
  }
280
321
  async updateFlags(accountId, uid, flags) {
@@ -301,11 +342,8 @@ export class MailxService {
301
342
  async search(q, page = 1, pageSize = 50, scope = "all", accountId, folderId) {
302
343
  if (!q.trim())
303
344
  return { items: [], total: 0, page, pageSize };
304
- if (scope === "server" && accountId) {
305
- const folders = this.db.getFolders(accountId);
306
- const folder = folderId ? folders.find(f => f.id === folderId) : folders.find(f => f.specialUse === "inbox");
307
- if (!folder)
308
- return { items: [], total: 0, page, pageSize };
345
+ if (scope === "server") {
346
+ // Parse qualifiers once; SEARCH runs per folder.
309
347
  const criteria = {};
310
348
  const fromMatch = q.match(/from:(\S+)/i);
311
349
  const toMatch = q.match(/to:(\S+)/i);
@@ -319,11 +357,41 @@ export class MailxService {
319
357
  criteria.subject = subjectMatch[1].trim();
320
358
  if (bodyText)
321
359
  criteria.body = bodyText;
322
- const uids = await this.imapManager.searchOnServer(accountId, folder.path, criteria);
323
- const items = uids.slice((page - 1) * pageSize, page * pageSize)
324
- .map(uid => this.db.getMessageByUid(accountId, uid, folderId))
325
- .filter(Boolean);
326
- return { items, total: uids.length, page, pageSize };
360
+ // Server search spans every selectable folder on every enabled
361
+ // account otherwise a message that got moved / was in Sent /
362
+ // only exists in an archive folder silently fails to turn up.
363
+ // Each folder runs as its own SEARCH; we dedupe by messageId.
364
+ const dbAccounts = accountId
365
+ ? [{ id: accountId }]
366
+ : this.db.getAccounts();
367
+ const seen = new Set();
368
+ const items = [];
369
+ let total = 0;
370
+ for (const acct of dbAccounts) {
371
+ const folders = this.db.getFolders(acct.id)
372
+ .filter((f) => !(f.flags || []).some((x) => /noselect/i.test(x)));
373
+ const results = await Promise.allSettled(folders.map(f => this.imapManager.searchAndFetchOnServer(acct.id, f.id, f.path, criteria)
374
+ .then(uids => ({ folderId: f.id, uids }))));
375
+ for (const r of results) {
376
+ if (r.status !== "fulfilled")
377
+ continue;
378
+ for (const uid of r.value.uids) {
379
+ const msg = this.db.getMessageByUid(acct.id, uid, r.value.folderId);
380
+ if (!msg)
381
+ continue;
382
+ const key = msg.messageId || `${acct.id}:${r.value.folderId}:${uid}`;
383
+ if (seen.has(key))
384
+ continue;
385
+ seen.add(key);
386
+ items.push(msg);
387
+ total++;
388
+ }
389
+ }
390
+ }
391
+ // Newest first, then paginate.
392
+ items.sort((a, b) => (b.date?.getTime?.() || 0) - (a.date?.getTime?.() || 0));
393
+ const sliced = items.slice((page - 1) * pageSize, page * pageSize);
394
+ return { items: sliced, total, page, pageSize };
327
395
  }
328
396
  else if (scope === "current" && accountId && folderId) {
329
397
  return this.db.searchMessages(q, page, pageSize, accountId, folderId);
@@ -368,8 +436,17 @@ export class MailxService {
368
436
  }
369
437
  // ── Send ──
370
438
  async send(msg) {
371
- const settings = loadSettings();
372
- const account = settings.accounts.find(a => a.id === msg.from);
439
+ // Local-first: the critical path is validate → build raw → queue
440
+ // locally. Everything else (contacts recording, IMAP APPEND,
441
+ // SMTP) happens after the IPC ACK. Settings come from cache so
442
+ // a stalled GDrive mount doesn't block the send.
443
+ const accounts = this.getCachedAccounts();
444
+ let account = accounts.find(a => a.id === msg.from);
445
+ if (!account) {
446
+ // Cache miss — invalidate and try one authoritative read.
447
+ this._accountsCache = null;
448
+ account = this.getCachedAccounts().find(a => a.id === msg.from);
449
+ }
373
450
  if (!account)
374
451
  throw new Error(`Unknown account: ${msg.from}`);
375
452
  // Vet every recipient address — refuse to send if any field contains a
@@ -456,14 +533,23 @@ export class MailxService {
456
533
  }
457
534
  this.imapManager.queueOutgoingLocal(account.id, rawMessage);
458
535
  console.log(` Queued locally: ${msg.subject} via ${account.id} from ${fromHeader}`);
459
- for (const addr of msg.to)
460
- this.db.recordSentAddress(addr.name, addr.address);
461
- if (msg.cc)
462
- for (const addr of msg.cc)
463
- this.db.recordSentAddress(addr.name, addr.address);
464
- if (msg.bcc)
465
- for (const addr of msg.bcc)
466
- this.db.recordSentAddress(addr.name, addr.address);
536
+ // Contacts recording is off the critical path — deferred until after
537
+ // the IPC ACK so a slow DB write can't stall the send.
538
+ setImmediate(() => {
539
+ try {
540
+ for (const addr of msg.to)
541
+ this.db.recordSentAddress(addr.name, addr.address);
542
+ if (msg.cc)
543
+ for (const addr of msg.cc)
544
+ this.db.recordSentAddress(addr.name, addr.address);
545
+ if (msg.bcc)
546
+ for (const addr of msg.bcc)
547
+ this.db.recordSentAddress(addr.name, addr.address);
548
+ }
549
+ catch (e) {
550
+ console.error(` recordSentAddress failed: ${e?.message || e}`);
551
+ }
552
+ });
467
553
  }
468
554
  // ── Delete / Move / Undelete ──
469
555
  async deleteMessage(accountId, uid) {
@@ -440,11 +440,23 @@ export function loadAccounts() {
440
440
  if (!accounts)
441
441
  accounts = readJsonc(localPath);
442
442
  if (accounts?.accounts || Array.isArray(accounts)) {
443
- // Cache shared to local for offline fallback
443
+ // Cache shared to local for offline fallback — but ONLY if the
444
+ // content actually differs. Unconditionally writing on every load
445
+ // retriggers fs.watch on the local copy, which fires the config-
446
+ // changed banner and cloud-poll cycle even when nothing changed.
447
+ // Result: "accounts.jsonc changed" notification firing constantly.
444
448
  if (sharedDir !== LOCAL_DIR && fs.existsSync(sharedPath)) {
445
449
  try {
446
- fs.mkdirSync(LOCAL_DIR, { recursive: true });
447
- fs.writeFileSync(localPath, fs.readFileSync(sharedPath, "utf-8"));
450
+ const sharedContent = fs.readFileSync(sharedPath, "utf-8");
451
+ let localContent = "";
452
+ try {
453
+ localContent = fs.readFileSync(localPath, "utf-8");
454
+ }
455
+ catch { /* missing */ }
456
+ if (sharedContent !== localContent) {
457
+ fs.mkdirSync(LOCAL_DIR, { recursive: true });
458
+ fs.writeFileSync(localPath, sharedContent);
459
+ }
448
460
  }
449
461
  catch { /* ignore */ }
450
462
  }