@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 +14 -2
- package/client/components/message-list.js +18 -5
- package/client/compose/compose.js +11 -0
- package/package.json +1 -1
- package/packages/mailx-api/index.js +3 -3
- package/packages/mailx-imap/index.d.ts +17 -2
- package/packages/mailx-imap/index.js +195 -89
- package/packages/mailx-service/index.d.ts +4 -1
- package/packages/mailx-service/index.js +14 -4
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
|
-
//
|
|
589
|
-
|
|
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
|
-
|
|
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
|
-
|
|
107
|
-
|
|
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
|
-
|
|
213
|
-
|
|
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
|
@@ -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,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
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
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
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
//
|
|
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
|
-
|
|
1381
|
-
|
|
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<
|
|
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);
|