@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.
- package/client/compose/compose.js +11 -0
- package/package.json +2 -2
- package/packages/mailx-api/index.js +3 -3
- package/packages/mailx-imap/index.d.ts +17 -2
- package/packages/mailx-imap/index.js +193 -99
- package/packages/mailx-server/index.js +42 -9
- package/packages/mailx-service/index.d.ts +4 -1
- package/packages/mailx-service/index.js +14 -4
|
@@ -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.
|
|
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.
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
456
|
-
const
|
|
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
|
-
|
|
489
|
-
|
|
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
|
|
744
|
+
* If message count changed, triggers inbox sync for that account. */
|
|
734
745
|
lastInboxCounts = new Map();
|
|
735
|
-
quickCheckRunning =
|
|
736
|
-
|
|
737
|
-
|
|
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.
|
|
751
|
+
if (this.reauthenticating.has(accountId))
|
|
752
|
+
return;
|
|
753
|
+
this.quickCheckRunning.add(accountId);
|
|
754
|
+
let client = null;
|
|
740
755
|
try {
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
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
|
-
|
|
778
|
-
|
|
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
|
-
//
|
|
785
|
-
//
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
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
|
-
|
|
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
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
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
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
//
|
|
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
|
-
|
|
1393
|
-
|
|
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
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
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<
|
|
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
|
-
`
|
|
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${
|
|
470
|
-
|
|
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);
|