@bobfrankston/mailx 1.0.327 → 1.0.336

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;
@@ -80,6 +80,45 @@ await loadEditorAssets(editorType);
80
80
  const container = document.getElementById("compose-editor");
81
81
  container.classList.add(editorType === "tiptap" ? "editor-tiptap" : "editor-quill");
82
82
  const editor = await createEditor(container, editorType);
83
+ // Ctrl+scroll / Ctrl+= / Ctrl+- / Ctrl+0 zoom for the compose editor body.
84
+ // Persists per-session in localStorage so zoom survives window pop/close cycles.
85
+ (() => {
86
+ const STORAGE_KEY = "mailx.compose.zoom";
87
+ const MIN = 0.5, MAX = 3, STEP = 0.1;
88
+ let zoom = parseFloat(localStorage.getItem(STORAGE_KEY) || "1") || 1;
89
+ const applyZoom = () => {
90
+ container.style.fontSize = `${zoom}em`;
91
+ localStorage.setItem(STORAGE_KEY, String(zoom));
92
+ };
93
+ applyZoom();
94
+ container.addEventListener("wheel", (e) => {
95
+ if (!e.ctrlKey)
96
+ return;
97
+ e.preventDefault();
98
+ const delta = e.deltaY < 0 ? STEP : -STEP;
99
+ zoom = Math.min(MAX, Math.max(MIN, Math.round((zoom + delta) * 10) / 10));
100
+ applyZoom();
101
+ }, { passive: false });
102
+ document.addEventListener("keydown", (e) => {
103
+ if (!(e.ctrlKey || e.metaKey))
104
+ return;
105
+ if (e.key === "=" || e.key === "+") {
106
+ zoom = Math.min(MAX, zoom + STEP);
107
+ applyZoom();
108
+ e.preventDefault();
109
+ }
110
+ else if (e.key === "-") {
111
+ zoom = Math.max(MIN, zoom - STEP);
112
+ applyZoom();
113
+ e.preventDefault();
114
+ }
115
+ else if (e.key === "0") {
116
+ zoom = 1;
117
+ applyZoom();
118
+ e.preventDefault();
119
+ }
120
+ });
121
+ })();
83
122
  // ── Populate from init data ──
84
123
  // From field is a free-text input with a <datalist> of known accounts. The
85
124
  // user can pick a preset or type an arbitrary "Name <addr@domain>" — no
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.336",
4
4
  "description": "Local-first email client with IMAP sync and standalone native app",
5
5
  "type": "module",
6
6
  "main": "bin/mailx.js",
@@ -39,7 +39,7 @@
39
39
  "@bobfrankston/tcp-transport": "^0.1.4",
40
40
  "@bobfrankston/node-tcp-transport": "^0.1.4",
41
41
  "@bobfrankston/smtp-direct": "^0.1.4",
42
- "@bobfrankston/mailx-sync": "^0.1.7"
42
+ "@bobfrankston/mailx-sync": "^0.1.8"
43
43
  },
44
44
  "devDependencies": {
45
45
  "@types/mailparser": "^3.4.6"
@@ -103,7 +103,7 @@
103
103
  "@bobfrankston/tcp-transport": "^0.1.4",
104
104
  "@bobfrankston/node-tcp-transport": "^0.1.4",
105
105
  "@bobfrankston/smtp-direct": "^0.1.4",
106
- "@bobfrankston/mailx-sync": "^0.1.7"
106
+ "@bobfrankston/mailx-sync": "^0.1.8"
107
107
  }
108
108
  }
109
109
  }
@@ -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 */
@@ -183,7 +187,11 @@ export declare class ImapManager extends EventEmitter {
183
187
  uid: number;
184
188
  folderId: number;
185
189
  }[]): Promise<void>;
186
- /** Bulk move messages — local-first, single IMAP connection for all */
190
+ /** Bulk move messages — queues the IMAP action only. The service layer
191
+ * (MailxService.moveMessages) owns the local DB mutation via
192
+ * updateMessageFolder; this method used to ALSO deleteMessage here,
193
+ * which wiped the row the service just updated — the message vanished
194
+ * on the next reconcile and "spam folder empty" was the symptom. */
187
195
  moveMessages(accountId: string, messages: {
188
196
  uid: number;
189
197
  folderId: number;
@@ -193,7 +201,8 @@ export declare class ImapManager extends EventEmitter {
193
201
  private debounceSyncActions;
194
202
  /** Move a message to Trash (delete) — local-first, queues IMAP sync */
195
203
  trashMessage(accountId: string, folderId: number, uid: number): Promise<void>;
196
- /** Move a message between folders — local-first, queues IMAP sync */
204
+ /** Move a message between folders — queues IMAP sync only. Service
205
+ * layer owns the local DB update (see MailxService.moveMessage). */
197
206
  moveMessage(accountId: string, uid: number, fromFolderId: number, toFolderId: number): Promise<void>;
198
207
  /** Move message across accounts using iflow's moveMessageToServer */
199
208
  moveMessageCrossAccount(fromAccountId: string, uid: number, fromFolderId: number, toAccountId: string, toFolderId: number): Promise<void>;
@@ -214,7 +223,13 @@ export declare class ImapManager extends EventEmitter {
214
223
  * Tries the specific UID first, then falls back to searchByHeader so orphaned copies
215
224
  * from earlier failed autosaves are cleaned up at the same time. */
216
225
  deleteDraft(accountId: string, draftUid: number, draftId?: string): Promise<void>;
217
- /** Queue outgoing message locally — never fails, worker handles IMAP+SMTP */
226
+ /** Queue outgoing message locally — never fails, worker handles IMAP+SMTP.
227
+ * Single path: write `~/.mailx/outbox/<acct>/*.ltr` synchronously, then
228
+ * kick processLocalQueue. The file IS the queue — durable across crashes,
229
+ * visible in the filesystem, consumed by the existing outbox worker that
230
+ * handles both IMAP-APPEND (non-Gmail) and direct SMTP (Gmail). The old
231
+ * sync_actions "send" branch was removed because it duplicated the same
232
+ * work and risked double-send when both paths fired on the same message. */
218
233
  queueOutgoingLocal(accountId: string, rawMessage: string): void;
219
234
  /** Guard against concurrent processSendActions for the same account */
220
235
  private sendingAccounts;
@@ -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
@@ -1985,27 +2052,18 @@ export class ImapManager extends EventEmitter {
1985
2052
  // Process all queued actions in one IMAP session
1986
2053
  this.debounceSyncActions(accountId);
1987
2054
  }
1988
- /** Bulk move messages — local-first, single IMAP connection for all */
2055
+ /** Bulk move messages — queues the IMAP action only. The service layer
2056
+ * (MailxService.moveMessages) owns the local DB mutation via
2057
+ * updateMessageFolder; this method used to ALSO deleteMessage here,
2058
+ * which wiped the row the service just updated — the message vanished
2059
+ * on the next reconcile and "spam folder empty" was the symptom. */
1989
2060
  async moveMessages(accountId, messages, targetFolderId) {
1990
2061
  if (messages.length === 0)
1991
2062
  return;
1992
- // Local first
1993
- for (const msg of messages) {
1994
- this.db.deleteMessage(accountId, msg.uid);
1995
- }
1996
- console.log(` Moved ${messages.length} messages locally (→ folder ${targetFolderId})`);
1997
- // Queue IMAP actions
1998
2063
  for (const msg of messages) {
1999
2064
  this.db.queueSyncAction(accountId, "move", msg.uid, msg.folderId, { targetFolderId });
2000
2065
  }
2001
- // Recalc folder counts (source folders + destination) so the tree
2002
- // badge updates immediately.
2003
- const sourceFolderIds = new Set(messages.map(m => m.folderId));
2004
- for (const fid of sourceFolderIds)
2005
- this.db.recalcFolderCounts(fid);
2006
- this.db.recalcFolderCounts(targetFolderId);
2007
- this.emit("folderCountsChanged", accountId, {});
2008
- // Process all queued actions in one IMAP session
2066
+ console.log(` [move] ${accountId}: queued IMAP MOVE for ${messages.length} message(s) folder ${targetFolderId}`);
2009
2067
  this.debounceSyncActions(accountId);
2010
2068
  }
2011
2069
  /** Debounced sync actions — batches rapid local changes into one IMAP operation */
@@ -2025,25 +2083,25 @@ export class ImapManager extends EventEmitter {
2025
2083
  // Local first — remove from DB immediately
2026
2084
  this.db.deleteMessage(accountId, uid);
2027
2085
  this.bodyStore.deleteMessage(accountId, folderId, uid).catch(() => { });
2028
- console.log(` Deleted message UID ${uid} locally`);
2029
- // Queue IMAP action
2086
+ // Queue IMAP action + log the resolution so "I deleted a message and
2087
+ // now it's in neither trash nor deleted" is diagnosable from the log.
2030
2088
  if (trash && trash.id !== folderId) {
2089
+ const trashFolder = this.db.getFolders(accountId).find(f => f.id === trash.id);
2031
2090
  this.db.queueSyncAction(accountId, "move", uid, folderId, { targetFolderId: trash.id });
2091
+ console.log(` [trash] ${accountId} UID ${uid}: queued MOVE to "${trashFolder?.path || trash.path}" (id=${trash.id}, specialUse=trash)`);
2032
2092
  }
2033
2093
  else {
2034
2094
  this.db.queueSyncAction(accountId, "delete", uid, folderId);
2095
+ console.log(` [trash] ${accountId} UID ${uid}: queued EXPUNGE in folder ${folderId} (already in trash or no trash configured)`);
2035
2096
  }
2036
2097
  // Debounced sync — batches multiple deletes into one IMAP session
2037
2098
  this.debounceSyncActions(accountId);
2038
2099
  }
2039
- /** Move a message between folders — local-first, queues IMAP sync */
2100
+ /** Move a message between folders — queues IMAP sync only. Service
2101
+ * layer owns the local DB update (see MailxService.moveMessage). */
2040
2102
  async moveMessage(accountId, uid, fromFolderId, toFolderId) {
2041
- // Local first
2042
- this.db.deleteMessage(accountId, uid);
2043
- console.log(` Moved UID ${uid} locally (folder ${fromFolderId} → ${toFolderId})`);
2044
- // Queue IMAP action
2045
2103
  this.db.queueSyncAction(accountId, "move", uid, fromFolderId, { targetFolderId: toFolderId });
2046
- // Debounced sync batches multiple moves into one IMAP session
2104
+ console.log(` [move] ${accountId}: queued IMAP MOVE UID ${uid} folder ${fromFolderId} ${toFolderId}`);
2047
2105
  this.debounceSyncActions(accountId);
2048
2106
  }
2049
2107
  /** Move message across accounts using iflow's moveMessageToServer */
@@ -2154,13 +2212,26 @@ export class ImapManager extends EventEmitter {
2154
2212
  break;
2155
2213
  case "move": {
2156
2214
  const target = folders.find(f => f.id === action.targetFolderId);
2157
- if (target) {
2158
- const msg = await client.fetchMessageByUid(folder.path, action.uid, { source: false });
2159
- if (msg) {
2160
- await client.moveMessage(msg, folder.path, target.path);
2161
- console.log(` [sync] Moved UID ${action.uid}: ${folder.path} → ${target.path}`);
2162
- }
2215
+ if (!target) {
2216
+ // Target folder gone treat as permanent failure so the
2217
+ // action doesn't loop forever. User must re-delete manually.
2218
+ console.error(` [sync] Move target folder ${action.targetFolderId} missing — dropping action UID ${action.uid}`);
2219
+ throw new Error(`move target folder ${action.targetFolderId} not found`);
2163
2220
  }
2221
+ const msg = await client.fetchMessageByUid(folder.path, action.uid, { source: false });
2222
+ if (!msg) {
2223
+ // Message no longer in source folder. Two real cases:
2224
+ // (a) another client already moved/deleted it — nothing to do,
2225
+ // just mark the action done.
2226
+ // (b) the server is lying (transient SELECT miss) — the retry
2227
+ // will pick it up. We can't tell these apart from one fetch,
2228
+ // so log loud and treat as (a) after the first failure; the
2229
+ // attempts counter handles (b) via the failSyncAction path.
2230
+ console.log(` [sync] Move UID ${action.uid} in ${folder.path}: message gone (attempt ${action.attempts + 1}); dropping action`);
2231
+ break;
2232
+ }
2233
+ await client.moveMessage(msg, folder.path, target.path);
2234
+ console.log(` [sync] Moved UID ${action.uid}: ${folder.path} → ${target.path}`);
2164
2235
  break;
2165
2236
  }
2166
2237
  case "flags":
@@ -2342,13 +2413,23 @@ export class ImapManager extends EventEmitter {
2342
2413
  catch { /* ignore */ }
2343
2414
  }
2344
2415
  }
2345
- /** Queue outgoing message locally — never fails, worker handles IMAP+SMTP */
2416
+ /** Queue outgoing message locally — never fails, worker handles IMAP+SMTP.
2417
+ * Single path: write `~/.mailx/outbox/<acct>/*.ltr` synchronously, then
2418
+ * kick processLocalQueue. The file IS the queue — durable across crashes,
2419
+ * visible in the filesystem, consumed by the existing outbox worker that
2420
+ * handles both IMAP-APPEND (non-Gmail) and direct SMTP (Gmail). The old
2421
+ * sync_actions "send" branch was removed because it duplicated the same
2422
+ * work and risked double-send when both paths fired on the same message. */
2346
2423
  queueOutgoingLocal(accountId, rawMessage) {
2347
- // Use folderId=0 and uid=Date.now() as placeholder — the worker will handle the real IMAP append
2348
- this.db.queueSyncAction(accountId, "send", Date.now(), 0, { rawMessage });
2349
- console.log(` [outbox] Queued locally for ${accountId}`);
2350
- // Try immediate processing
2351
- this.processSendActions(accountId).catch(() => { });
2424
+ const outboxDir = path.join(getConfigDir(), "outbox", accountId);
2425
+ fs.mkdirSync(outboxDir, { recursive: true });
2426
+ const now = new Date();
2427
+ const pad2 = (n) => String(n).padStart(2, "0");
2428
+ const filename = `${now.getFullYear()}${pad2(now.getMonth() + 1)}${pad2(now.getDate())}_${pad2(now.getHours())}${pad2(now.getMinutes())}${pad2(now.getSeconds())}-${String(Math.floor(Math.random() * 10000)).padStart(4, "0")}.ltr`;
2429
+ const filePath = path.join(outboxDir, filename);
2430
+ fs.writeFileSync(filePath, rawMessage);
2431
+ console.log(` [outbox] Queued ${filePath}`);
2432
+ this.processLocalQueue(accountId).catch((e) => console.error(` [outbox] processLocalQueue error: ${e?.message || e}`));
2352
2433
  }
2353
2434
  /** Guard against concurrent processSendActions for the same account */
2354
2435
  sendingAccounts = new Set();
@@ -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
  }