@bobfrankston/mailx 1.0.132 → 1.0.134

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/client/app.js CHANGED
@@ -428,6 +428,7 @@ function doSearch(immediate = false) {
428
428
  // Track current folder for scoped search
429
429
  let currentAccountId = "";
430
430
  let currentFolderId = 0;
431
+ let reloadDebounceTimer = null;
431
432
  searchInput?.addEventListener("input", () => {
432
433
  clearTimeout(searchTimeout);
433
434
  searchTimeout = setTimeout(() => doSearch(false), 300);
@@ -585,8 +586,19 @@ onWsEvent((event) => {
585
586
  // Incremental count update — no DOM rebuild, no jitter
586
587
  updateFolderCounts();
587
588
  updateNewMessageCount();
588
- // Reload message list but keep current scroll position and selection
589
- reloadCurrentFolder();
589
+ // Only reload message list if the synced account is the one we're viewing
590
+ // (or unified inbox which shows all accounts). Debounce to avoid rapid reloads
591
+ // during first sync which emits per-batch.
592
+ const syncedAccount = event.accountId;
593
+ const viewingThis = !currentAccountId || currentAccountId === syncedAccount;
594
+ if (viewingThis) {
595
+ if (reloadDebounceTimer)
596
+ clearTimeout(reloadDebounceTimer);
597
+ reloadDebounceTimer = setTimeout(() => {
598
+ reloadDebounceTimer = null;
599
+ reloadCurrentFolder();
600
+ }, 500);
601
+ }
590
602
  // Sync finished — re-enable sync button
591
603
  const syncBtn = document.getElementById("btn-sync");
592
604
  if (syncBtn) {
@@ -94,7 +94,10 @@ export async function loadUnifiedInbox(autoSelect = true) {
94
94
  return;
95
95
  const savedScroll = !autoSelect ? body.scrollTop : 0;
96
96
  const savedUid = !autoSelect ? body.querySelector(".ml-row.selected")?.getAttribute("data-uid") : null;
97
- body.innerHTML = `<div class="ml-empty">Loading...</div>`;
97
+ // Only show loading indicator on fresh navigation, not reloads
98
+ if (autoSelect) {
99
+ body.innerHTML = `<div class="ml-empty">Loading...</div>`;
100
+ }
98
101
  try {
99
102
  const result = await getUnifiedInbox(1);
100
103
  totalMessages = result.total;
@@ -103,8 +106,13 @@ export async function loadUnifiedInbox(autoSelect = true) {
103
106
  clearViewer();
104
107
  return;
105
108
  }
106
- body.innerHTML = "";
107
- appendMessages(body, "", result.items);
109
+ // Build new rows into a fragment, then swap atomically (no flash)
110
+ const fragment = document.createDocumentFragment();
111
+ const tempDiv = document.createElement("div");
112
+ appendMessages(tempDiv, "", result.items);
113
+ while (tempDiv.firstChild)
114
+ fragment.appendChild(tempDiv.firstChild);
115
+ body.replaceChildren(fragment);
108
116
  if (autoSelect) {
109
117
  const firstRow = body.querySelector(".ml-row");
110
118
  if (firstRow)
@@ -209,8 +217,13 @@ export async function loadMessages(accountId, folderId, page = 1, specialUse = "
209
217
  clearViewer();
210
218
  return;
211
219
  }
212
- body.innerHTML = "";
213
- appendMessages(body, accountId, result.items);
220
+ // Build new rows into a fragment, then swap atomically (no flash)
221
+ const fragment = document.createDocumentFragment();
222
+ const tempDiv = document.createElement("div");
223
+ appendMessages(tempDiv, accountId, result.items);
224
+ while (tempDiv.firstChild)
225
+ fragment.appendChild(tempDiv.firstChild);
226
+ body.replaceChildren(fragment);
214
227
  if (autoSelect) {
215
228
  // Explicit folder navigation — select first message
216
229
  const firstRow = body.querySelector(".ml-row");
@@ -300,15 +300,20 @@ if (fromSelect.options.length === 0) {
300
300
  }
301
301
  // ── Auto-save drafts every 5 seconds ──
302
302
  let draftUid = null;
303
+ let draftId = null; // stable ID for dedup when APPENDUID unavailable
303
304
  let draftTimer;
304
305
  let lastDraftContent = "";
306
+ let draftSaving = false; // prevent concurrent saves
305
307
  async function saveDraft() {
308
+ if (draftSaving)
309
+ return; // previous save still in flight
306
310
  const content = editor.getHtml() + subjectInput.value + toInput.value;
307
311
  if (content === lastDraftContent)
308
312
  return; // no changes
309
313
  if (!editor.getText().trim() && !subjectInput.value && !toInput.value)
310
314
  return; // empty
311
315
  lastDraftContent = content;
316
+ draftSaving = true;
312
317
  try {
313
318
  const data = await fetch("/api/draft", {
314
319
  method: "POST",
@@ -321,12 +326,18 @@ async function saveDraft() {
321
326
  to: toInput.value,
322
327
  cc: ccInput.value,
323
328
  previousDraftUid: draftUid,
329
+ draftId: draftId,
324
330
  }),
325
331
  }).then(r => r.ok ? r.json() : null);
326
332
  if (data?.draftUid)
327
333
  draftUid = data.draftUid;
334
+ if (data?.draftId)
335
+ draftId = data.draftId;
328
336
  }
329
337
  catch { /* ignore draft save errors */ }
338
+ finally {
339
+ draftSaving = false;
340
+ }
330
341
  }
331
342
  draftTimer = setInterval(saveDraft, 5000);
332
343
  // ── Send ──
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx",
3
- "version": "1.0.132",
3
+ "version": "1.0.134",
4
4
  "description": "Local-first email client with IMAP sync and standalone native app",
5
5
  "type": "module",
6
6
  "main": "bin/mailx.js",
@@ -344,9 +344,9 @@ export function createApiRouter(db, imapManager) {
344
344
  // ── Drafts ──
345
345
  router.post("/draft", async (req, res) => {
346
346
  try {
347
- const { accountId, subject, bodyHtml, bodyText, to, cc, previousDraftUid } = req.body;
348
- const uid = await svc.saveDraft(accountId, subject, bodyHtml, bodyText, to, cc, previousDraftUid);
349
- res.json({ ok: true, draftUid: uid });
347
+ const { accountId, subject, bodyHtml, bodyText, to, cc, previousDraftUid, draftId } = req.body;
348
+ const result = await svc.saveDraft(accountId, subject, bodyHtml, bodyText, to, cc, previousDraftUid, draftId);
349
+ res.json({ ok: true, draftUid: result.uid, draftId: result.draftId });
350
350
  }
351
351
  catch (e) {
352
352
  res.status(500).json({ error: e.message });
@@ -71,18 +71,28 @@ export declare class ImapManager extends EventEmitter {
71
71
  /** Sync just INBOX for each account (fast check for new mail) */
72
72
  syncInbox(): Promise<void>;
73
73
  /** Quick inbox check — uses IMAP STATUS (single command, no mailbox open).
74
- * If message count changed, triggers a full inbox sync. */
74
+ * If message count changed, triggers inbox sync for that account. */
75
75
  private lastInboxCounts;
76
76
  private quickCheckRunning;
77
+ /** Check a single account's inbox */
78
+ quickInboxCheckAccount(accountId: string): Promise<void>;
79
+ /** Check all accounts (used by legacy callers) */
77
80
  quickInboxCheck(): Promise<void>;
78
81
  /** Start periodic sync */
79
82
  startPeriodicSync(intervalMinutes: number): void;
80
83
  /** Stop periodic sync */
81
84
  stopPeriodicSync(): void;
85
+ /** Check if an account is OAuth (Gmail/Outlook — generous connection limits) */
86
+ isOAuthAccount(accountId: string): boolean;
82
87
  /** Start IMAP IDLE watchers for INBOX on each account */
83
88
  startWatching(): Promise<void>;
84
89
  /** Stop all IDLE watchers */
85
90
  stopWatching(): Promise<void>;
91
+ /** Per-account fetch queue — serializes body fetches so only one IMAP command runs at a time.
92
+ * The persistent fetchClient can only handle one command at a time (IMAP protocol limitation). */
93
+ private fetchQueues;
94
+ /** Serialize body fetch operations per account — prevents concurrent IMAP commands on same connection */
95
+ private enqueueFetch;
86
96
  /** Get or create a persistent client for body fetching */
87
97
  private getFetchClient;
88
98
  /** Fetch a single message body on demand, caching in the store */
@@ -120,17 +130,22 @@ export declare class ImapManager extends EventEmitter {
120
130
  copyToSent(accountId: string, rawMessage: string | Buffer): Promise<void>;
121
131
  /** Save a draft to the Drafts folder via IMAP APPEND.
122
132
  * Returns the UID of the saved draft (for replacing on next save). */
123
- saveDraft(accountId: string, rawMessage: string | Buffer, previousDraftUid?: number): Promise<number | null>;
133
+ saveDraft(accountId: string, rawMessage: string | Buffer, previousDraftUid?: number, draftId?: string): Promise<number | null>;
124
134
  /** Delete a draft after successful send */
125
135
  deleteDraft(accountId: string, draftUid: number): Promise<void>;
126
136
  /** Queue outgoing message locally — never fails, worker handles IMAP+SMTP */
127
137
  queueOutgoingLocal(accountId: string, rawMessage: string): void;
138
+ /** Guard against concurrent processSendActions for the same account */
139
+ private sendingAccounts;
128
140
  /** Process local send actions — APPEND to Outbox, which the outbox worker then sends */
129
141
  private processSendActions;
142
+ private _processSendActions;
130
143
  private outboxInterval;
131
144
  private readonly hostname;
132
145
  /** Ensure Outbox folder exists, create if needed */
133
146
  private ensureOutbox;
147
+ /** Save a debug copy of outgoing mail to the sending directory */
148
+ private saveSendingCopy;
134
149
  /** Queue a message for sending. Tries IMAP Outbox, falls back to local file. */
135
150
  queueOutgoing(accountId: string, rawMessage: string | Buffer): Promise<void>;
136
151
  /** Process local file queue — move to IMAP Outbox when server is reachable */
@@ -214,13 +214,28 @@ export class ImapManager extends EventEmitter {
214
214
  // Wrap logout to auto-decrement connection counter (prevents leaks from missed trackLogout calls)
215
215
  const originalLogout = client.logout.bind(client);
216
216
  let loggedOut = false;
217
- client.logout = async () => {
218
- await originalLogout();
217
+ const doTrackLogout = () => {
219
218
  if (!loggedOut) {
220
219
  loggedOut = true;
221
220
  this.trackLogout(accountId);
222
221
  }
223
222
  };
223
+ client.logout = async () => {
224
+ await originalLogout();
225
+ doTrackLogout();
226
+ };
227
+ // Safety net: if client isn't logged out within 5 minutes, assume it leaked
228
+ const leakTimer = setTimeout(() => {
229
+ if (!loggedOut) {
230
+ console.warn(` [conn] ${accountId}: connection leaked (5min timeout) — forcing decrement`);
231
+ doTrackLogout();
232
+ }
233
+ }, 300000);
234
+ // Clear the timer if logout happens normally
235
+ const origDoTrack = doTrackLogout;
236
+ // Prevent timer from keeping process alive
237
+ if (leakTimer.unref)
238
+ leakTimer.unref();
224
239
  return client;
225
240
  }
226
241
  /** Track client logout for connection counting (called automatically by wrapped logout) */
@@ -452,12 +467,10 @@ export class ImapManager extends EventEmitter {
452
467
  this.emit("syncProgress", accountId, `sync:${folder.path}`, Math.round((batchEnd / messages.length) * 100));
453
468
  // On first sync, emit folderCountsChanged per batch so newest messages appear immediately
454
469
  if (firstSync && newCount > 0) {
455
- const total = newCount;
456
- const unread = this.db.getMessages({ accountId, folderId, page: 1, pageSize: total })
457
- .items.filter((m) => !m.flags.includes("\\Seen")).length;
458
- this.db.updateFolderCounts(folderId, total, unread);
470
+ this.db.recalcFolderCounts(folderId);
471
+ const folderInfo = this.db.getFolders(accountId).find(f => f.id === folderId);
459
472
  this.emit("folderCountsChanged", accountId, {
460
- [folderId]: { total, unread }
473
+ [folderId]: { total: folderInfo?.totalCount || 0, unread: folderInfo?.unreadCount || 0 }
461
474
  });
462
475
  }
463
476
  }
@@ -485,16 +498,14 @@ export class ImapManager extends EventEmitter {
485
498
  }
486
499
  }
487
500
  // Update folder counts from local DB (after deletions + additions)
488
- const result = this.db.getMessages({ accountId, folderId, page: 1, pageSize: 1 });
489
- const total = result.total;
490
- const localMsgs = this.db.getMessages({ accountId, folderId, page: 1, pageSize: result.total });
491
- const unread = localMsgs.items.filter((m) => !m.flags.includes("\\Seen")).length;
492
- this.db.updateFolderCounts(folderId, total, unread);
501
+ // Use recalcFolderCounts single SQL query instead of fetching all messages
502
+ this.db.recalcFolderCounts(folderId);
493
503
  this.emit("syncProgress", accountId, `sync:${folder.path}`, 100);
494
504
  // Notify client to refresh if anything changed
495
505
  if (newCount > 0 || deletedCount > 0) {
506
+ const updatedFolder = this.db.getFolders(accountId).find(f => f.id === folderId);
496
507
  this.emit("folderCountsChanged", accountId, {
497
- [folderId]: { total, unread }
508
+ [folderId]: { total: updatedFolder?.totalCount || 0, unread: updatedFolder?.unreadCount || 0 }
498
509
  });
499
510
  }
500
511
  this.db.updateLastSync(accountId, Date.now());
@@ -730,63 +741,69 @@ export class ImapManager extends EventEmitter {
730
741
  }
731
742
  }
732
743
  /** Quick inbox check — uses IMAP STATUS (single command, no mailbox open).
733
- * If message count changed, triggers a full inbox sync. */
744
+ * If message count changed, triggers inbox sync for that account. */
734
745
  lastInboxCounts = new Map();
735
- quickCheckRunning = false;
736
- async quickInboxCheck() {
737
- if (this.quickCheckRunning || this.syncing || this.inboxSyncing)
746
+ quickCheckRunning = new Set(); // per-account guard
747
+ /** Check a single account's inbox */
748
+ async quickInboxCheckAccount(accountId) {
749
+ if (this.quickCheckRunning.has(accountId) || this.syncing || this.inboxSyncing)
738
750
  return;
739
- this.quickCheckRunning = true;
751
+ if (this.reauthenticating.has(accountId))
752
+ return;
753
+ this.quickCheckRunning.add(accountId);
754
+ let client = null;
740
755
  try {
741
- for (const [accountId] of this.configs) {
742
- if (this.reauthenticating.has(accountId))
743
- continue;
744
- let client = null;
756
+ const inbox = this.db.getFolders(accountId).find(f => f.specialUse === "inbox");
757
+ if (!inbox)
758
+ return;
759
+ client = this.createClient(accountId);
760
+ const count = await client.getMessagesCount("INBOX");
761
+ await client.logout();
762
+ client = null;
763
+ const prev = this.lastInboxCounts.get(accountId) ?? count;
764
+ this.lastInboxCounts.set(accountId, count);
765
+ if (count !== prev) {
766
+ console.log(` [check] ${accountId} INBOX: ${prev} → ${count}`);
767
+ client = this.createClient(accountId);
768
+ await this.syncFolder(accountId, inbox.id, client);
769
+ await client.logout();
770
+ client = null;
771
+ }
772
+ }
773
+ catch {
774
+ // Lightweight check — silently ignore errors
775
+ }
776
+ finally {
777
+ if (client) {
745
778
  try {
746
- const inbox = this.db.getFolders(accountId).find(f => f.specialUse === "inbox");
747
- if (!inbox)
748
- continue;
749
- client = this.createClient(accountId);
750
- const count = await client.getMessagesCount("INBOX");
751
779
  await client.logout();
752
- client = null;
753
- const prev = this.lastInboxCounts.get(accountId) ?? count;
754
- this.lastInboxCounts.set(accountId, count);
755
- if (count !== prev) {
756
- console.log(` [check] ${accountId} INBOX: ${prev} → ${count}`);
757
- client = this.createClient(accountId);
758
- await this.syncFolder(accountId, inbox.id, client);
759
- await client.logout();
760
- client = null;
761
- }
762
- }
763
- catch {
764
- // Lightweight check — silently ignore errors
765
- }
766
- finally {
767
- if (client) {
768
- try {
769
- await client.logout();
770
- }
771
- catch { /* ignore */ }
772
- this.trackLogout(accountId);
773
- }
774
780
  }
781
+ catch { /* ignore */ }
775
782
  }
783
+ this.quickCheckRunning.delete(accountId);
776
784
  }
777
- finally {
778
- this.quickCheckRunning = false;
785
+ }
786
+ /** Check all accounts (used by legacy callers) */
787
+ async quickInboxCheck() {
788
+ for (const [accountId] of this.configs) {
789
+ await this.quickInboxCheckAccount(accountId);
779
790
  }
780
791
  }
781
792
  /** Start periodic sync */
782
793
  startPeriodicSync(intervalMinutes) {
783
794
  this.stopPeriodicSync();
784
- // Quick inbox check every 10 seconds STATUS command is cheap but TCP setup isn't
785
- // Guards prevent overlapping with full sync or inbox sync
786
- const quickCheck = setInterval(() => {
787
- this.quickInboxCheck().catch(() => { });
788
- }, 10000);
789
- this.syncIntervals.set("quick", quickCheck);
795
+ // Per-account quick inbox check — adapts to server constraints:
796
+ // OAuth (Gmail/Outlook): every 15s generous connection limits
797
+ // Password (Dovecot etc): every 60s — conservative, 20-connection limit
798
+ // IDLE gives instant notification when working; STATUS is the fallback.
799
+ for (const [accountId] of this.configs) {
800
+ const interval = this.isOAuthAccount(accountId) ? 15000 : 60000;
801
+ const timer = setInterval(() => {
802
+ this.quickInboxCheckAccount(accountId).catch(() => { });
803
+ }, interval);
804
+ this.syncIntervals.set(`quick:${accountId}`, timer);
805
+ console.log(` [periodic] ${accountId}: STATUS check every ${interval / 1000}s (${this.isOAuthAccount(accountId) ? "OAuth" : "password"})`);
806
+ }
790
807
  // Sync actions (sends + flags/deletes/moves) every 30 seconds
791
808
  const actionsInterval = setInterval(async () => {
792
809
  for (const [accountId] of this.configs) {
@@ -811,6 +828,11 @@ export class ImapManager extends EventEmitter {
811
828
  }
812
829
  this.syncIntervals.clear();
813
830
  }
831
+ /** Check if an account is OAuth (Gmail/Outlook — generous connection limits) */
832
+ isOAuthAccount(accountId) {
833
+ const config = this.configs.get(accountId);
834
+ return !!config?.tokenProvider;
835
+ }
814
836
  /** Start IMAP IDLE watchers for INBOX on each account */
815
837
  async startWatching() {
816
838
  for (const [accountId] of this.configs) {
@@ -820,7 +842,8 @@ export class ImapManager extends EventEmitter {
820
842
  const watchClient = this.createClient(accountId);
821
843
  const stop = await watchClient.watchMailbox("INBOX", (newCount) => {
822
844
  console.log(` [idle] ${accountId}: ${newCount} new message(s)`);
823
- this.syncAll().catch(e => console.error(` [idle] sync error: ${e.message}`));
845
+ // Sync just INBOX for speed — full sync runs on the configured interval
846
+ this.syncInbox().catch(e => console.error(` [idle] sync error: ${e.message}`));
824
847
  });
825
848
  this.watchers.set(accountId, async () => {
826
849
  await stop();
@@ -843,6 +866,16 @@ export class ImapManager extends EventEmitter {
843
866
  }
844
867
  this.watchers.clear();
845
868
  }
869
+ /** Per-account fetch queue — serializes body fetches so only one IMAP command runs at a time.
870
+ * The persistent fetchClient can only handle one command at a time (IMAP protocol limitation). */
871
+ fetchQueues = new Map();
872
+ /** Serialize body fetch operations per account — prevents concurrent IMAP commands on same connection */
873
+ enqueueFetch(accountId, fn) {
874
+ const prev = this.fetchQueues.get(accountId) || Promise.resolve();
875
+ const next = prev.then(fn, fn); // run fn after previous completes (regardless of success/failure)
876
+ this.fetchQueues.set(accountId, next);
877
+ return next;
878
+ }
846
879
  /** Get or create a persistent client for body fetching */
847
880
  getFetchClient(accountId) {
848
881
  let client = this.fetchClients.get(accountId);
@@ -863,27 +896,43 @@ export class ImapManager extends EventEmitter {
863
896
  const folder = this.db.getFolders(accountId).find(f => f.id === folderId);
864
897
  if (!folder)
865
898
  return null;
866
- for (let attempt = 0; attempt < 2; attempt++) {
867
- try {
868
- const client = this.getFetchClient(accountId);
869
- const msg = await client.fetchMessageByUid(folder.path, uid, { source: true });
870
- if (!msg?.source)
871
- return null;
872
- const raw = Buffer.from(msg.source, "utf-8");
873
- const bodyPath = await this.bodyStore.putMessage(accountId, folderId, uid, raw);
874
- // Update DB so body_path isn't null for on-demand fetches
875
- this.db.updateBodyPath(accountId, uid, bodyPath);
876
- return raw;
899
+ // Serialize: only one body fetch per account at a time (IMAP can only handle one command)
900
+ return this.enqueueFetch(accountId, async () => {
901
+ // Re-check cache — may have been fetched while queued
902
+ if (await this.bodyStore.hasMessage(accountId, folderId, uid)) {
903
+ return this.bodyStore.getMessage(accountId, folderId, uid);
877
904
  }
878
- catch (e) {
879
- console.error(` Body fetch error (${accountId}/${uid}): ${e.message}`);
880
- this.fetchClients.delete(accountId);
881
- if (attempt === 1)
882
- return null;
883
- // Retry with fresh client
905
+ for (let attempt = 0; attempt < 2; attempt++) {
906
+ try {
907
+ const client = this.getFetchClient(accountId);
908
+ // 30s timeout — prevents hanging on stale connections
909
+ const msg = await Promise.race([
910
+ client.fetchMessageByUid(folder.path, uid, { source: true }),
911
+ new Promise((_, reject) => setTimeout(() => reject(new Error("Body fetch timeout (30s)")), 30000))
912
+ ]);
913
+ if (!msg?.source)
914
+ return null;
915
+ const raw = Buffer.from(msg.source, "utf-8");
916
+ const bodyPath = await this.bodyStore.putMessage(accountId, folderId, uid, raw);
917
+ this.db.updateBodyPath(accountId, uid, bodyPath);
918
+ return raw;
919
+ }
920
+ catch (e) {
921
+ console.error(` Body fetch error (${accountId}/${uid} attempt ${attempt + 1}): ${e.message}`);
922
+ const stale = this.fetchClients.get(accountId);
923
+ this.fetchClients.delete(accountId);
924
+ if (stale) {
925
+ try {
926
+ await stale.logout();
927
+ }
928
+ catch { /* ignore */ }
929
+ }
930
+ if (attempt === 1)
931
+ return null;
932
+ }
884
933
  }
885
- }
886
- return null;
934
+ return null;
935
+ });
887
936
  }
888
937
  /** Get the body store for direct access */
889
938
  getBodyStore() {
@@ -1106,7 +1155,7 @@ export class ImapManager extends EventEmitter {
1106
1155
  }
1107
1156
  /** Save a draft to the Drafts folder via IMAP APPEND.
1108
1157
  * Returns the UID of the saved draft (for replacing on next save). */
1109
- async saveDraft(accountId, rawMessage, previousDraftUid) {
1158
+ async saveDraft(accountId, rawMessage, previousDraftUid, draftId) {
1110
1159
  const drafts = this.findFolder(accountId, "drafts");
1111
1160
  if (!drafts) {
1112
1161
  console.error(` [drafts] No Drafts folder found for ${accountId}`);
@@ -1114,17 +1163,29 @@ export class ImapManager extends EventEmitter {
1114
1163
  }
1115
1164
  const client = this.createClient(accountId);
1116
1165
  try {
1117
- // Delete previous draft if it exists
1166
+ // Delete previous draft — by UID if we have it, otherwise by X-Mailx-Draft-ID header
1118
1167
  if (previousDraftUid) {
1119
1168
  try {
1120
1169
  await client.deleteMessageByUid(drafts.path, previousDraftUid);
1121
1170
  }
1122
1171
  catch { /* previous draft may already be gone */ }
1123
1172
  }
1173
+ else if (draftId) {
1174
+ // Search Drafts for our draft ID and delete it
1175
+ try {
1176
+ const uids = await client.searchByHeader(drafts.path, "X-Mailx-Draft-ID", draftId);
1177
+ for (const uid of uids) {
1178
+ await client.deleteMessageByUid(drafts.path, uid);
1179
+ }
1180
+ if (uids.length > 0)
1181
+ console.log(` [drafts] Deleted ${uids.length} previous draft(s) by ID ${draftId}`);
1182
+ }
1183
+ catch { /* search not supported or failed — tolerate duplicate */ }
1184
+ }
1124
1185
  // Append new draft
1125
1186
  const result = await client.appendMessage(drafts.path, rawMessage, ["\\Draft", "\\Seen"]);
1126
- // imapflow append returns { destination, uid }
1127
- const uid = result?.uid || null;
1187
+ // APPENDUID returns the UID directly; imapflow returns { destination, uid }
1188
+ const uid = typeof result === "number" ? result : result?.uid || null;
1128
1189
  return uid;
1129
1190
  }
1130
1191
  finally {
@@ -1162,8 +1223,21 @@ export class ImapManager extends EventEmitter {
1162
1223
  // Try immediate processing
1163
1224
  this.processSendActions(accountId).catch(() => { });
1164
1225
  }
1226
+ /** Guard against concurrent processSendActions for the same account */
1227
+ sendingAccounts = new Set();
1165
1228
  /** Process local send actions — APPEND to Outbox, which the outbox worker then sends */
1166
1229
  async processSendActions(accountId) {
1230
+ if (this.sendingAccounts.has(accountId))
1231
+ return; // already processing
1232
+ this.sendingAccounts.add(accountId);
1233
+ try {
1234
+ await this._processSendActions(accountId);
1235
+ }
1236
+ finally {
1237
+ this.sendingAccounts.delete(accountId);
1238
+ }
1239
+ }
1240
+ async _processSendActions(accountId) {
1167
1241
  const actions = this.db.getPendingSyncActions(accountId)
1168
1242
  .filter(a => a.action === "send");
1169
1243
  if (actions.length === 0)
@@ -1173,14 +1247,20 @@ export class ImapManager extends EventEmitter {
1173
1247
  this.db.completeSyncAction(action.id);
1174
1248
  continue;
1175
1249
  }
1250
+ // Abandon after 10 failed attempts — don't retry forever
1251
+ if (action.attempts >= 10) {
1252
+ console.error(` [outbox] Abandoning send action ${action.id} after ${action.attempts} attempts: ${action.rawMessage?.substring(0, 100)}`);
1253
+ this.db.completeSyncAction(action.id);
1254
+ this.emit("accountError", accountId, `Send permanently failed after ${action.attempts} attempts`, "Message removed from queue", false);
1255
+ continue;
1256
+ }
1176
1257
  try {
1177
1258
  await this.queueOutgoing(accountId, action.rawMessage);
1178
1259
  this.db.completeSyncAction(action.id);
1179
1260
  }
1180
1261
  catch (e) {
1181
- console.error(` [outbox] Local→IMAP failed: ${e.message}`);
1262
+ console.error(` [outbox] Local→IMAP failed (attempt ${action.attempts + 1}): ${e.message}`);
1182
1263
  this.db.failSyncAction(action.id, e.message);
1183
- // Don't give up — keep retrying sends
1184
1264
  }
1185
1265
  }
1186
1266
  }
@@ -1217,8 +1297,26 @@ export class ImapManager extends EventEmitter {
1217
1297
  outbox = this.findFolder(accountId, "outbox");
1218
1298
  return outbox?.path || "Outbox";
1219
1299
  }
1300
+ /** Save a debug copy of outgoing mail to the sending directory */
1301
+ saveSendingCopy(accountId, rawMessage, label) {
1302
+ try {
1303
+ const sendingDir = path.join(import.meta.dirname, "..", "..", "sending", accountId);
1304
+ fs.mkdirSync(sendingDir, { recursive: true });
1305
+ const now = new Date();
1306
+ const pad2 = (n) => String(n).padStart(2, "0");
1307
+ const ts = `${now.getFullYear()}${pad2(now.getMonth() + 1)}${pad2(now.getDate())}_${pad2(now.getHours())}${pad2(now.getMinutes())}${pad2(now.getSeconds())}`;
1308
+ const filename = `${ts}-${label}.eml`;
1309
+ fs.writeFileSync(path.join(sendingDir, filename), rawMessage);
1310
+ console.log(` [sending] Saved debug copy: ${filename}`);
1311
+ }
1312
+ catch (e) {
1313
+ console.error(` [sending] Failed to save debug copy: ${e.message}`);
1314
+ }
1315
+ }
1220
1316
  /** Queue a message for sending. Tries IMAP Outbox, falls back to local file. */
1221
1317
  async queueOutgoing(accountId, rawMessage) {
1318
+ // Always save a debug copy
1319
+ this.saveSendingCopy(accountId, rawMessage, "queued");
1222
1320
  try {
1223
1321
  const outboxPath = await this.ensureOutbox(accountId);
1224
1322
  const client = this.createClient(accountId);
@@ -1369,21 +1467,29 @@ export class ImapManager extends EventEmitter {
1369
1467
  }
1370
1468
  // Strip Bcc header from raw message before sending
1371
1469
  const rawToSend = msg.source.replace(/^Bcc:.*\r?\n/mi, "");
1470
+ // Save debug copy before sending
1471
+ this.saveSendingCopy(accountId, rawToSend, `sent-${uid}`);
1372
1472
  await transport.sendMail({
1373
1473
  raw: rawToSend,
1374
1474
  envelope: { from: sender, to: recipients },
1375
1475
  });
1376
1476
  console.log(` [outbox] Sent UID ${uid} → ${recipients.join(", ")}`);
1377
- // Move to Sent and sync both folders so UI updates
1477
+ // Delete from Outbox FIRST to prevent double-send if move-to-Sent fails.
1478
+ // The message is already sent via SMTP — worst case we lose the Sent copy,
1479
+ // which is better than sending the message twice.
1480
+ await client.deleteMessageByUid(outboxFolder.path, uid);
1481
+ // Copy to Sent folder (best-effort — message is already sent)
1378
1482
  const sentFolder = this.findFolder(accountId, "sent");
1379
1483
  if (sentFolder) {
1380
- await client.moveMessage(msg, outboxFolder.path, sentFolder.path);
1381
- this.syncFolder(accountId, sentFolder.id).catch(() => { });
1484
+ try {
1485
+ await client.appendMessage(sentFolder.path, msg.source, ["\\Seen"]);
1486
+ this.syncFolder(accountId, sentFolder.id).catch(() => { });
1487
+ }
1488
+ catch (sentErr) {
1489
+ console.error(` [outbox] Failed to copy to Sent: ${sentErr.message} — message was sent successfully`);
1490
+ }
1382
1491
  this.syncFolder(accountId, outboxFolder.id).catch(() => { });
1383
1492
  }
1384
- else {
1385
- await client.deleteMessageByUid(outboxFolder.path, uid);
1386
- }
1387
1493
  }
1388
1494
  catch (e) {
1389
1495
  const errMsg = e.message || String(e);
@@ -47,7 +47,10 @@ export declare class MailxService {
47
47
  contentType: string;
48
48
  filename: string;
49
49
  }>;
50
- saveDraft(accountId: string, subject: string, bodyHtml: string, bodyText: string, to?: string, cc?: string, previousDraftUid?: number): Promise<number | null>;
50
+ saveDraft(accountId: string, subject: string, bodyHtml: string, bodyText: string, to?: string, cc?: string, previousDraftUid?: number, draftId?: string): Promise<{
51
+ uid: number | null;
52
+ draftId: string;
53
+ }>;
51
54
  deleteDraft(accountId: string, draftUid: number): Promise<void>;
52
55
  searchContacts(query: string): any[];
53
56
  syncGoogleContacts(): Promise<void>;
@@ -285,10 +285,14 @@ export class MailxService {
285
285
  const bcc = msg.bcc?.map((a) => a.name ? `${a.name} <${a.address}>` : a.address).join(", ");
286
286
  const body = msg.bodyHtml || msg.bodyText || "";
287
287
  const bodyBase64 = Buffer.from(body, "utf-8").toString("base64").replace(/(.{76})/g, "$1\r\n");
288
+ // Generate a unique Message-ID (required for threading, dedup, and RFC compliance)
289
+ const domain = account.email.split("@")[1] || "mailx.local";
290
+ const messageId = `<${Date.now()}.${Math.random().toString(36).slice(2)}@${domain}>`;
288
291
  const headers = [
289
292
  `From: ${fromHeader}`, `To: ${to}`,
290
293
  cc ? `Cc: ${cc}` : null, bcc ? `Bcc: ${bcc}` : null,
291
294
  `Subject: ${msg.subject}`, `Date: ${new Date().toUTCString()}`,
295
+ `Message-ID: ${messageId}`,
292
296
  msg.inReplyTo ? `In-Reply-To: ${msg.inReplyTo}` : null,
293
297
  msg.references?.length ? `References: ${msg.references.join(" ")}` : null,
294
298
  `MIME-Version: 1.0`, `Content-Type: text/html; charset=UTF-8`, `Content-Transfer-Encoding: base64`,
@@ -455,19 +459,25 @@ export class MailxService {
455
459
  };
456
460
  }
457
461
  // ── Drafts ──
458
- async saveDraft(accountId, subject, bodyHtml, bodyText, to, cc, previousDraftUid) {
462
+ async saveDraft(accountId, subject, bodyHtml, bodyText, to, cc, previousDraftUid, draftId) {
459
463
  const settings = loadSettings();
460
464
  const account = settings.accounts.find(a => a.id === accountId);
461
465
  if (!account)
462
466
  throw new Error(`Unknown account: ${accountId}`);
467
+ // Generate or reuse a stable draft ID for dedup
468
+ const id = draftId || `mailx-draft-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
469
+ const body = bodyHtml || bodyText || "";
470
+ const bodyBase64 = Buffer.from(body, "utf-8").toString("base64").replace(/(.{76})/g, "$1\r\n");
463
471
  const headers = [
464
472
  `From: ${account.name} <${account.email}>`,
465
473
  to ? `To: ${to}` : null, cc ? `Cc: ${cc}` : null,
466
474
  `Subject: ${subject || "(no subject)"}`, `Date: ${new Date().toUTCString()}`,
467
- `MIME-Version: 1.0`, `Content-Type: text/html; charset=UTF-8`,
475
+ `X-Mailx-Draft-ID: ${id}`,
476
+ `MIME-Version: 1.0`, `Content-Type: text/html; charset=UTF-8`, `Content-Transfer-Encoding: base64`,
468
477
  ].filter(h => h !== null).join("\r\n");
469
- const raw = `${headers}\r\n\r\n${bodyHtml || bodyText || ""}`;
470
- return this.imapManager.saveDraft(accountId, raw, previousDraftUid);
478
+ const raw = `${headers}\r\n\r\n${bodyBase64}`;
479
+ const uid = await this.imapManager.saveDraft(accountId, raw, previousDraftUid, id);
480
+ return { uid, draftId: id };
471
481
  }
472
482
  async deleteDraft(accountId, draftUid) {
473
483
  await this.imapManager.deleteDraft(accountId, draftUid);