@bobfrankston/mailx 1.0.324 → 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>
@@ -378,6 +378,15 @@ button.tb-menu-item { background: none; border: none; color: inherit; width: 100
378
378
  grid-template-columns: subgrid;
379
379
  align-content: start;
380
380
  scrollbar-gutter: stable;
381
+ /* Android WebView / Chromium: without an explicit touch-action, the
382
+ * browser waits on JS touch listeners (passive or not) before deciding
383
+ * to scroll. That delay makes the first row feel like it got tapped
384
+ * before the scroll actually starts. pan-y tells the compositor: treat
385
+ * any vertical drag as a scroll, don't wait on JS. overscroll-behavior
386
+ * contains the scroll so we don't chain into the outer page / pull-to-
387
+ * refresh when the user flicks at top/bottom. */
388
+ touch-action: pan-y;
389
+ overscroll-behavior-y: contain;
381
390
  }
382
391
 
383
392
  .ml-row {
@@ -387,6 +396,11 @@ button.tb-menu-item { background: none; border: none; color: inherit; width: 100
387
396
  padding: var(--gap-sm) var(--gap-sm);
388
397
  border-bottom: 1px solid color-mix(in oklch, var(--color-border) 50%, transparent);
389
398
  cursor: pointer;
399
+ /* Disable the 300ms double-tap-to-zoom delay on rows. Double-tap zoom
400
+ * makes no sense on a message row anyway; without this, Android often
401
+ * classifies a scroll-starting tap as a delayed-click because it's
402
+ * still in the double-tap wait window. */
403
+ touch-action: manipulation;
390
404
  font-size: var(--font-size-base);
391
405
  font-weight: 400;
392
406
  color: var(--color-text);
@@ -530,12 +544,39 @@ button.tb-menu-item { background: none; border: none; color: inherit; width: 100
530
544
  }
531
545
  .mailx-modal-wide {
532
546
  width: 80vw;
533
- max-width: 900px;
534
- 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;
535
554
  }
536
555
  .mailx-modal-title {
537
556
  font-size: var(--font-size-lg);
538
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
+ }
539
580
  }
540
581
  .mailx-modal-label {
541
582
  display: flex;
@@ -554,7 +595,8 @@ button.tb-menu-item { background: none; border: none; color: inherit; width: 100
554
595
  font-size: var(--font-size-base);
555
596
  }
556
597
  .mailx-modal-textarea {
557
- min-height: 50vh;
598
+ flex: 1;
599
+ min-height: 200px;
558
600
  resize: vertical;
559
601
  font-family: var(--font-mono);
560
602
  font-size: 13px;
@@ -580,6 +622,7 @@ button.tb-menu-item { background: none; border: none; color: inherit; width: 100
580
622
  display: grid;
581
623
  grid-template-columns: minmax(0, 1fr) minmax(240px, 360px);
582
624
  gap: var(--gap-md);
625
+ flex: 1;
583
626
  min-height: 0;
584
627
 
585
628
  &:has(.mailx-help-collapsed) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx",
3
- "version": "1.0.324",
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",
@@ -24,7 +24,7 @@
24
24
  "@bobfrankston/iflow-node": "^0.1.7",
25
25
  "@bobfrankston/miscinfo": "^1.0.9",
26
26
  "@bobfrankston/oauthsupport": "^1.0.24",
27
- "@bobfrankston/msger": "^0.1.343",
27
+ "@bobfrankston/msger": "^0.1.344",
28
28
  "@bobfrankston/mailx-host": "^0.1.3",
29
29
  "@capacitor/android": "^8.3.0",
30
30
  "@capacitor/cli": "^8.3.0",
@@ -88,7 +88,7 @@
88
88
  "@bobfrankston/iflow-node": "^0.1.7",
89
89
  "@bobfrankston/miscinfo": "^1.0.9",
90
90
  "@bobfrankston/oauthsupport": "^1.0.24",
91
- "@bobfrankston/msger": "^0.1.343",
91
+ "@bobfrankston/msger": "^0.1.344",
92
92
  "@bobfrankston/mailx-host": "^0.1.3",
93
93
  "@capacitor/android": "^8.3.0",
94
94
  "@capacitor/cli": "^8.3.0",
@@ -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) {
@@ -307,36 +307,43 @@ const PROVIDERS = {
307
307
  label: "Gmail",
308
308
  imap: { host: "imap.gmail.com", port: 993, tls: true, auth: "oauth2" },
309
309
  smtp: { host: "smtp.gmail.com", port: 587, tls: true, auth: "oauth2" },
310
+ spam: "SPAM", // Gmail labels, mailx tree shows as "SPAM"
310
311
  },
311
312
  "googlemail.com": {
312
313
  label: "Gmail",
313
314
  imap: { host: "imap.gmail.com", port: 993, tls: true, auth: "oauth2" },
314
315
  smtp: { host: "smtp.gmail.com", port: 587, tls: true, auth: "oauth2" },
316
+ spam: "SPAM",
315
317
  },
316
318
  "outlook.com": {
317
319
  label: "Outlook",
318
320
  imap: { host: "outlook.office365.com", port: 993, tls: true, auth: "oauth2" },
319
321
  smtp: { host: "smtp.office365.com", port: 587, tls: true, auth: "oauth2" },
322
+ spam: "Junk Email",
320
323
  },
321
324
  "hotmail.com": {
322
325
  label: "Hotmail",
323
326
  imap: { host: "outlook.office365.com", port: 993, tls: true, auth: "oauth2" },
324
327
  smtp: { host: "smtp.office365.com", port: 587, tls: true, auth: "oauth2" },
328
+ spam: "Junk Email",
325
329
  },
326
330
  "yahoo.com": {
327
331
  label: "Yahoo",
328
332
  imap: { host: "imap.mail.yahoo.com", port: 993, tls: true, auth: "password" },
329
333
  smtp: { host: "smtp.mail.yahoo.com", port: 587, tls: true, auth: "password" },
334
+ spam: "Bulk Mail",
330
335
  },
331
336
  "aol.com": {
332
337
  label: "AOL",
333
338
  imap: { host: "imap.aol.com", port: 993, tls: true, auth: "password" },
334
339
  smtp: { host: "smtp.aol.com", port: 587, tls: true, auth: "password" },
340
+ spam: "Bulk Mail",
335
341
  },
336
342
  "icloud.com": {
337
343
  label: "iCloud",
338
344
  imap: { host: "imap.mail.me.com", port: 993, tls: true, auth: "password" },
339
345
  smtp: { host: "smtp.mail.me.com", port: 587, tls: true, auth: "password" },
346
+ spam: "Junk",
340
347
  },
341
348
  };
342
349
  /** Fill in provider defaults for an account based on email domain */
@@ -372,6 +379,13 @@ function normalizeAccount(acct, globalName) {
372
379
  relayDomains: acct.relayDomains,
373
380
  deliveredToPrefix: acct.deliveredToPrefix,
374
381
  identityDomains: acct.identityDomains,
382
+ // Spam folder: explicit account config wins; otherwise fall back to
383
+ // the provider default (e.g. Gmail ships with built-in SPAM; Outlook
384
+ // with "Junk Email"). Before 2026-04-21 this field was dropped by
385
+ // normalizeAccount entirely — silent regression even for accounts
386
+ // that had it configured. `acct.spam` first so a user-set value on
387
+ // a recognized provider still overrides the default.
388
+ spam: acct.spam !== undefined ? acct.spam : provider?.spam,
375
389
  };
376
390
  }
377
391
  // ── Defaults ──
@@ -426,11 +440,23 @@ export function loadAccounts() {
426
440
  if (!accounts)
427
441
  accounts = readJsonc(localPath);
428
442
  if (accounts?.accounts || Array.isArray(accounts)) {
429
- // 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.
430
448
  if (sharedDir !== LOCAL_DIR && fs.existsSync(sharedPath)) {
431
449
  try {
432
- fs.mkdirSync(LOCAL_DIR, { recursive: true });
433
- 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
+ }
434
460
  }
435
461
  catch { /* ignore */ }
436
462
  }