@bobfrankston/mailx 1.0.133 → 1.0.135

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.
@@ -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.133",
3
+ "version": "1.0.135",
4
4
  "description": "Local-first email client with IMAP sync and standalone native app",
5
5
  "type": "module",
6
6
  "main": "bin/mailx.js",
@@ -20,7 +20,7 @@
20
20
  "postinstall": "node launcher/builder/postinstall.js"
21
21
  },
22
22
  "dependencies": {
23
- "@bobfrankston/iflow": "^1.0.50",
23
+ "@bobfrankston/iflow": "^1.0.51",
24
24
  "@bobfrankston/miscinfo": "^1.0.7",
25
25
  "@bobfrankston/oauthsupport": "^1.0.20",
26
26
  "@bobfrankston/rust-builder": "^0.1.3",
@@ -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,39 +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
- // 30s timeout prevents hanging on stale connections
870
- const msg = await Promise.race([
871
- client.fetchMessageByUid(folder.path, uid, { source: true }),
872
- new Promise((_, reject) => setTimeout(() => reject(new Error("Body fetch timeout (30s)")), 30000))
873
- ]);
874
- if (!msg?.source)
875
- return null;
876
- const raw = Buffer.from(msg.source, "utf-8");
877
- const bodyPath = await this.bodyStore.putMessage(accountId, folderId, uid, raw);
878
- // Update DB so body_path isn't null for on-demand fetches
879
- this.db.updateBodyPath(accountId, uid, bodyPath);
880
- 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);
881
904
  }
882
- catch (e) {
883
- console.error(` Body fetch error (${accountId}/${uid} attempt ${attempt + 1}): ${e.message}`);
884
- // Kill stale client so retry creates a fresh connection
885
- const stale = this.fetchClients.get(accountId);
886
- this.fetchClients.delete(accountId);
887
- if (stale) {
888
- try {
889
- await stale.logout();
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 */ }
890
929
  }
891
- catch { /* ignore */ }
930
+ if (attempt === 1)
931
+ return null;
892
932
  }
893
- if (attempt === 1)
894
- return null;
895
- // Retry with fresh client
896
933
  }
897
- }
898
- return null;
934
+ return null;
935
+ });
899
936
  }
900
937
  /** Get the body store for direct access */
901
938
  getBodyStore() {
@@ -1118,7 +1155,7 @@ export class ImapManager extends EventEmitter {
1118
1155
  }
1119
1156
  /** Save a draft to the Drafts folder via IMAP APPEND.
1120
1157
  * Returns the UID of the saved draft (for replacing on next save). */
1121
- async saveDraft(accountId, rawMessage, previousDraftUid) {
1158
+ async saveDraft(accountId, rawMessage, previousDraftUid, draftId) {
1122
1159
  const drafts = this.findFolder(accountId, "drafts");
1123
1160
  if (!drafts) {
1124
1161
  console.error(` [drafts] No Drafts folder found for ${accountId}`);
@@ -1126,17 +1163,29 @@ export class ImapManager extends EventEmitter {
1126
1163
  }
1127
1164
  const client = this.createClient(accountId);
1128
1165
  try {
1129
- // Delete previous draft if it exists
1166
+ // Delete previous draft — by UID if we have it, otherwise by X-Mailx-Draft-ID header
1130
1167
  if (previousDraftUid) {
1131
1168
  try {
1132
1169
  await client.deleteMessageByUid(drafts.path, previousDraftUid);
1133
1170
  }
1134
1171
  catch { /* previous draft may already be gone */ }
1135
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
+ }
1136
1185
  // Append new draft
1137
1186
  const result = await client.appendMessage(drafts.path, rawMessage, ["\\Draft", "\\Seen"]);
1138
- // imapflow append returns { destination, uid }
1139
- 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;
1140
1189
  return uid;
1141
1190
  }
1142
1191
  finally {
@@ -1174,8 +1223,21 @@ export class ImapManager extends EventEmitter {
1174
1223
  // Try immediate processing
1175
1224
  this.processSendActions(accountId).catch(() => { });
1176
1225
  }
1226
+ /** Guard against concurrent processSendActions for the same account */
1227
+ sendingAccounts = new Set();
1177
1228
  /** Process local send actions — APPEND to Outbox, which the outbox worker then sends */
1178
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) {
1179
1241
  const actions = this.db.getPendingSyncActions(accountId)
1180
1242
  .filter(a => a.action === "send");
1181
1243
  if (actions.length === 0)
@@ -1185,14 +1247,20 @@ export class ImapManager extends EventEmitter {
1185
1247
  this.db.completeSyncAction(action.id);
1186
1248
  continue;
1187
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
+ }
1188
1257
  try {
1189
1258
  await this.queueOutgoing(accountId, action.rawMessage);
1190
1259
  this.db.completeSyncAction(action.id);
1191
1260
  }
1192
1261
  catch (e) {
1193
- console.error(` [outbox] Local→IMAP failed: ${e.message}`);
1262
+ console.error(` [outbox] Local→IMAP failed (attempt ${action.attempts + 1}): ${e.message}`);
1194
1263
  this.db.failSyncAction(action.id, e.message);
1195
- // Don't give up — keep retrying sends
1196
1264
  }
1197
1265
  }
1198
1266
  }
@@ -1229,8 +1297,26 @@ export class ImapManager extends EventEmitter {
1229
1297
  outbox = this.findFolder(accountId, "outbox");
1230
1298
  return outbox?.path || "Outbox";
1231
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
+ }
1232
1316
  /** Queue a message for sending. Tries IMAP Outbox, falls back to local file. */
1233
1317
  async queueOutgoing(accountId, rawMessage) {
1318
+ // Always save a debug copy
1319
+ this.saveSendingCopy(accountId, rawMessage, "queued");
1234
1320
  try {
1235
1321
  const outboxPath = await this.ensureOutbox(accountId);
1236
1322
  const client = this.createClient(accountId);
@@ -1381,21 +1467,29 @@ export class ImapManager extends EventEmitter {
1381
1467
  }
1382
1468
  // Strip Bcc header from raw message before sending
1383
1469
  const rawToSend = msg.source.replace(/^Bcc:.*\r?\n/mi, "");
1470
+ // Save debug copy before sending
1471
+ this.saveSendingCopy(accountId, rawToSend, `sent-${uid}`);
1384
1472
  await transport.sendMail({
1385
1473
  raw: rawToSend,
1386
1474
  envelope: { from: sender, to: recipients },
1387
1475
  });
1388
1476
  console.log(` [outbox] Sent UID ${uid} → ${recipients.join(", ")}`);
1389
- // 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)
1390
1482
  const sentFolder = this.findFolder(accountId, "sent");
1391
1483
  if (sentFolder) {
1392
- await client.moveMessage(msg, outboxFolder.path, sentFolder.path);
1393
- 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
+ }
1394
1491
  this.syncFolder(accountId, outboxFolder.id).catch(() => { });
1395
1492
  }
1396
- else {
1397
- await client.deleteMessageByUid(outboxFolder.path, uid);
1398
- }
1399
1493
  }
1400
1494
  catch (e) {
1401
1495
  const errMsg = e.message || String(e);
@@ -234,10 +234,41 @@ async function start() {
234
234
  // Start HTTP server FIRST so UI is always reachable (even during IMAP startup)
235
235
  const externalAccess = process.argv.includes("--external");
236
236
  const hostname = externalAccess ? "0.0.0.0" : "127.0.0.1";
237
- server = createServer(app);
238
- wss = new WebSocketServer({ server });
239
- wireWebSocket();
240
- await new Promise((resolve) => server.listen(PORT, hostname, resolve));
237
+ // Retry listen with backoff — Windows CLOSE_WAIT zombies can hold the port for minutes after a crash
238
+ for (let attempt = 0; attempt < 30; attempt++) {
239
+ server = createServer(app);
240
+ // Prevent CLOSE_WAIT accumulation: short keepAlive timeout + connection close headers
241
+ server.keepAliveTimeout = 5000; // close idle keep-alive connections after 5s
242
+ server.headersTimeout = 10000; // kill connections with no headers after 10s
243
+ // Track connections for clean shutdown (prevents CLOSE_WAIT zombies on Windows)
244
+ server.on("connection", (conn) => {
245
+ openConnections.add(conn);
246
+ conn.on("close", () => openConnections.delete(conn));
247
+ });
248
+ // Suppress EADDRINUSE from bubbling to uncaughtException — we handle it here
249
+ server.on("error", () => { }); // will be replaced by listen handler below
250
+ wss = new WebSocketServer({ server });
251
+ wireWebSocket();
252
+ const listenResult = await new Promise((resolve) => {
253
+ server.removeAllListeners("error");
254
+ server.once("error", (e) => { resolve(e.code || e.message); });
255
+ server.listen({ port: PORT, host: hostname, exclusive: false }, () => {
256
+ server.removeAllListeners("error");
257
+ resolve("ok");
258
+ });
259
+ });
260
+ if (listenResult === "ok")
261
+ break;
262
+ if (listenResult === "EADDRINUSE" && attempt < 29) {
263
+ const wait = Math.min(2000 + attempt * 1000, 10000);
264
+ console.log(` Port ${PORT} in use (CLOSE_WAIT zombies?) — retry ${attempt + 1}/30 in ${wait / 1000}s...`);
265
+ server.close();
266
+ await new Promise(r => setTimeout(r, wait));
267
+ }
268
+ else {
269
+ throw new Error(`Cannot bind port ${PORT}: ${listenResult}`);
270
+ }
271
+ }
241
272
  console.log(`mailx server running on http://${hostname}:${PORT}`);
242
273
  // Seed contacts (fast — skips existing)
243
274
  const seeded = db.seedContactsFromMessages();
@@ -287,6 +318,8 @@ async function start() {
287
318
  imapManager.startOutboxWorker();
288
319
  }
289
320
  // ── Graceful Shutdown ──
321
+ /** Track all open connections so we can destroy them on shutdown (prevents CLOSE_WAIT zombies) */
322
+ const openConnections = new Set();
290
323
  async function shutdown() {
291
324
  console.log("\nShutting down...");
292
325
  const forceExit = setTimeout(() => { console.log("Force exit"); process.exit(1); }, 3000);
@@ -296,6 +329,11 @@ async function shutdown() {
296
329
  }
297
330
  catch { /* proceed */ }
298
331
  db.close();
332
+ // Destroy all open connections immediately — prevents CLOSE_WAIT zombies on Windows
333
+ for (const conn of openConnections) {
334
+ conn.destroy();
335
+ }
336
+ openConnections.clear();
299
337
  server?.close();
300
338
  clearTimeout(forceExit);
301
339
  process.exit(0);
@@ -308,11 +346,6 @@ process.on("unhandledRejection", (err) => {
308
346
  process.on("uncaughtException", (err) => {
309
347
  console.error("FATAL uncaught exception:", err.message);
310
348
  console.error(err.stack);
311
- // EADDRINUSE = another instance holds the port — exit so node --watch can retry
312
- if (err.code === "EADDRINUSE") {
313
- console.error("Port in use — exiting so node --watch can retry");
314
- process.exit(1);
315
- }
316
349
  // Other exceptions: stay alive, let node --watch handle file-change restarts
317
350
  });
318
351
  process.on("exit", (code) => {
@@ -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);