@bobfrankston/mailx 1.0.216 → 1.0.218
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/components/message-list.js +9 -0
- package/client/compose/compose.css +16 -0
- package/client/compose/compose.html +1 -0
- package/client/compose/compose.js +87 -26
- package/client/compose/editor.js +75 -1
- package/client/lib/api-client.js +2 -2
- package/client/lib/mailxapi.js +2 -2
- package/client/styles/components.css +14 -0
- package/package.json +3 -3
- package/packages/mailx-api/index.js +3 -3
- package/packages/mailx-core/index.d.ts +1 -0
- package/packages/mailx-imap/index.d.ts +7 -3
- package/packages/mailx-imap/index.js +61 -26
- package/packages/mailx-service/index.d.ts +2 -2
- package/packages/mailx-service/index.js +4 -4
- package/packages/mailx-service/jsonrpc.js +1 -1
- package/packages/mailx-store/db.js +3 -1
- package/packages/mailx-types/index.d.ts +1 -0
|
@@ -335,6 +335,15 @@ function appendMessages(body, accountId, items) {
|
|
|
335
335
|
tag.title = msgAccountId;
|
|
336
336
|
from.prepend(tag);
|
|
337
337
|
}
|
|
338
|
+
// Search/cross-folder results carry folderName — show a tag so the user
|
|
339
|
+
// can tell which folder each hit lives in.
|
|
340
|
+
if (msg.folderName) {
|
|
341
|
+
const folderTag = document.createElement("span");
|
|
342
|
+
folderTag.className = "ml-folder-tag";
|
|
343
|
+
folderTag.textContent = msg.folderName;
|
|
344
|
+
folderTag.title = `In folder: ${msg.folderName}`;
|
|
345
|
+
from.prepend(folderTag);
|
|
346
|
+
}
|
|
338
347
|
const subject = document.createElement("span");
|
|
339
348
|
subject.className = "ml-subject";
|
|
340
349
|
subject.innerHTML = escapeHtml(msg.subject);
|
|
@@ -75,6 +75,22 @@ body {
|
|
|
75
75
|
background: var(--color-bg-hover);
|
|
76
76
|
}
|
|
77
77
|
|
|
78
|
+
.compose-status {
|
|
79
|
+
margin-left: auto;
|
|
80
|
+
align-self: center;
|
|
81
|
+
font-size: var(--font-size-xs);
|
|
82
|
+
color: var(--color-text-muted);
|
|
83
|
+
padding: 0 var(--gap-md);
|
|
84
|
+
white-space: nowrap;
|
|
85
|
+
overflow: hidden;
|
|
86
|
+
text-overflow: ellipsis;
|
|
87
|
+
max-width: 20em;
|
|
88
|
+
}
|
|
89
|
+
.compose-status.compose-status-error {
|
|
90
|
+
color: oklch(0.65 0.2 25);
|
|
91
|
+
font-weight: 500;
|
|
92
|
+
}
|
|
93
|
+
|
|
78
94
|
#btn-send {
|
|
79
95
|
background: var(--color-accent);
|
|
80
96
|
color: white;
|
|
@@ -38,6 +38,7 @@
|
|
|
38
38
|
<button class="tb-btn" id="btn-send">Send</button>
|
|
39
39
|
<button class="tb-btn" id="btn-attach">Attach</button>
|
|
40
40
|
<button class="tb-btn" id="btn-discard">Discard</button>
|
|
41
|
+
<span id="compose-status" class="compose-status"></span>
|
|
41
42
|
</div>
|
|
42
43
|
<div id="compose-editor"></div>
|
|
43
44
|
</body>
|
|
@@ -292,30 +292,23 @@ function applyInit(init) {
|
|
|
292
292
|
else
|
|
293
293
|
editor.focus();
|
|
294
294
|
}
|
|
295
|
-
// ──
|
|
296
|
-
const
|
|
297
|
-
|
|
298
|
-
sessionStorage.removeItem("composeInit");
|
|
299
|
-
applyInit(JSON.parse(stored));
|
|
300
|
-
}
|
|
301
|
-
else {
|
|
302
|
-
// New compose — focus To
|
|
303
|
-
toInput.focus();
|
|
304
|
-
}
|
|
305
|
-
// If From dropdown is empty (new compose without init, or init had no accounts), fetch from API
|
|
306
|
-
if (fromSelect.options.length === 0) {
|
|
307
|
-
getAccounts()
|
|
308
|
-
.then((accounts) => {
|
|
309
|
-
populateFromSelect(accounts);
|
|
310
|
-
})
|
|
311
|
-
.catch((e) => console.error("Failed to load accounts:", e));
|
|
312
|
-
}
|
|
313
|
-
// ── Auto-save drafts every 5 seconds ──
|
|
295
|
+
// ── Compose state (declared before init so the async IIFE can reference them) ──
|
|
296
|
+
const DRAFT_INPUT_DEBOUNCE_MS = 1500; // save ~1.5s after the last keystroke
|
|
297
|
+
const DRAFT_INTERVAL_MS = 5000; // safety-net interval save
|
|
314
298
|
let draftUid = null;
|
|
315
299
|
let draftId = null; // stable ID for dedup when APPENDUID unavailable
|
|
316
|
-
let draftTimer;
|
|
300
|
+
let draftTimer = null;
|
|
301
|
+
let draftDebounceTimer = null;
|
|
317
302
|
let lastDraftContent = "";
|
|
318
303
|
let draftSaving = false; // prevent concurrent saves
|
|
304
|
+
let draftSaveFailed = false; // surfaced in the compose status tag
|
|
305
|
+
function showDraftStatus(text, isError) {
|
|
306
|
+
const status = document.getElementById("compose-status");
|
|
307
|
+
if (!status)
|
|
308
|
+
return;
|
|
309
|
+
status.textContent = text;
|
|
310
|
+
status.classList.toggle("compose-status-error", isError);
|
|
311
|
+
}
|
|
319
312
|
async function saveDraft() {
|
|
320
313
|
if (draftSaving)
|
|
321
314
|
return; // previous save still in flight
|
|
@@ -341,13 +334,76 @@ async function saveDraft() {
|
|
|
341
334
|
draftUid = data.draftUid;
|
|
342
335
|
if (data?.draftId)
|
|
343
336
|
draftId = data.draftId;
|
|
337
|
+
if (draftSaveFailed) {
|
|
338
|
+
draftSaveFailed = false;
|
|
339
|
+
showDraftStatus("Draft saved", false);
|
|
340
|
+
}
|
|
341
|
+
else
|
|
342
|
+
showDraftStatus(`Draft saved ${new Date().toLocaleTimeString()}`, false);
|
|
343
|
+
}
|
|
344
|
+
catch (e) {
|
|
345
|
+
// Surface the error — silent failures are how drafts get lost on IMAP hiccups.
|
|
346
|
+
// The local editing/ checkpoint already exists server-side regardless.
|
|
347
|
+
console.error("[draft] save failed:", e);
|
|
348
|
+
draftSaveFailed = true;
|
|
349
|
+
showDraftStatus(`Draft save failed: ${e?.message || e}`, true);
|
|
350
|
+
// Clear lastDraftContent so the next tick retries the same content
|
|
351
|
+
lastDraftContent = "";
|
|
344
352
|
}
|
|
345
|
-
catch { /* ignore draft save errors */ }
|
|
346
353
|
finally {
|
|
347
354
|
draftSaving = false;
|
|
348
355
|
}
|
|
349
356
|
}
|
|
350
|
-
|
|
357
|
+
/** Schedule a debounced save on user input — fires ~1.5s after the last keystroke. */
|
|
358
|
+
function scheduleDraftSave() {
|
|
359
|
+
if (draftDebounceTimer)
|
|
360
|
+
clearTimeout(draftDebounceTimer);
|
|
361
|
+
draftDebounceTimer = setTimeout(() => { draftDebounceTimer = null; saveDraft(); }, DRAFT_INPUT_DEBOUNCE_MS);
|
|
362
|
+
}
|
|
363
|
+
// ── Initialize: always fetch real accounts from the API before applying init, then
|
|
364
|
+
// start the auto-save timer. Callers like message-viewer's Edit Draft pass
|
|
365
|
+
// init.accounts=[], so we can't trust what's in the init blob. ──
|
|
366
|
+
(async () => {
|
|
367
|
+
let accounts = [];
|
|
368
|
+
try {
|
|
369
|
+
accounts = await getAccounts();
|
|
370
|
+
}
|
|
371
|
+
catch (e) {
|
|
372
|
+
console.error("Failed to load accounts:", e);
|
|
373
|
+
}
|
|
374
|
+
const stored = sessionStorage.getItem("composeInit");
|
|
375
|
+
if (stored) {
|
|
376
|
+
sessionStorage.removeItem("composeInit");
|
|
377
|
+
const init = JSON.parse(stored);
|
|
378
|
+
if (!init.accounts || init.accounts.length === 0)
|
|
379
|
+
init.accounts = accounts;
|
|
380
|
+
applyInit(init);
|
|
381
|
+
}
|
|
382
|
+
else {
|
|
383
|
+
populateFromSelect(accounts);
|
|
384
|
+
toInput.focus();
|
|
385
|
+
}
|
|
386
|
+
// Wire debounced saves to input events — checkpoint ~1.5s after the last
|
|
387
|
+
// keystroke instead of waiting up to 5s for the interval tick.
|
|
388
|
+
toInput.addEventListener("input", scheduleDraftSave);
|
|
389
|
+
ccInput.addEventListener("input", scheduleDraftSave);
|
|
390
|
+
bccInput.addEventListener("input", scheduleDraftSave);
|
|
391
|
+
subjectInput.addEventListener("input", scheduleDraftSave);
|
|
392
|
+
editor.onContentChange(scheduleDraftSave);
|
|
393
|
+
// Safety-net interval: even with no user input, catch any edge cases.
|
|
394
|
+
draftTimer = setInterval(saveDraft, DRAFT_INTERVAL_MS);
|
|
395
|
+
// Flush the draft on window close so the last-typed content lands in
|
|
396
|
+
// editing/ even if the interval tick hasn't fired yet. navigator.sendBeacon
|
|
397
|
+
// is synchronous enough to survive unload; callNode IPC would be dropped.
|
|
398
|
+
window.addEventListener("beforeunload", () => {
|
|
399
|
+
if (draftDebounceTimer) {
|
|
400
|
+
clearTimeout(draftDebounceTimer);
|
|
401
|
+
draftDebounceTimer = null;
|
|
402
|
+
}
|
|
403
|
+
// fire-and-forget — can't await during unload
|
|
404
|
+
saveDraft();
|
|
405
|
+
});
|
|
406
|
+
})();
|
|
351
407
|
// ── Send ──
|
|
352
408
|
document.getElementById("btn-send")?.addEventListener("click", async () => {
|
|
353
409
|
const btn = document.getElementById("btn-send");
|
|
@@ -365,10 +421,15 @@ document.getElementById("btn-send")?.addEventListener("click", async () => {
|
|
|
365
421
|
};
|
|
366
422
|
try {
|
|
367
423
|
await sendMessage(body);
|
|
368
|
-
// Delete draft after successful send
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
424
|
+
// Delete draft after successful send — stop auto-save first so it can't
|
|
425
|
+
// append a fresh copy after we delete. Use the stored draftId as a fallback
|
|
426
|
+
// so any orphaned drafts with the same stable ID are cleaned up too.
|
|
427
|
+
if (draftTimer) {
|
|
428
|
+
clearInterval(draftTimer);
|
|
429
|
+
draftTimer = null;
|
|
430
|
+
}
|
|
431
|
+
if (draftUid || draftId) {
|
|
432
|
+
deleteDraft(getFromAccountId(), draftUid || 0, draftId || "").catch(() => { });
|
|
372
433
|
}
|
|
373
434
|
closeCompose();
|
|
374
435
|
}
|
package/client/compose/editor.js
CHANGED
|
@@ -3,6 +3,79 @@
|
|
|
3
3
|
* The compose window loads this module and calls createEditor() based on the user's setting.
|
|
4
4
|
*/
|
|
5
5
|
function createQuillEditor(container) {
|
|
6
|
+
// Extra keybindings for formatting that Quill doesn't wire up by default.
|
|
7
|
+
// Ctrl+K (insert link) is the one most users expect; we also add shortcuts
|
|
8
|
+
// for strikethrough, lists, indent, color, and clear-formatting.
|
|
9
|
+
const extraBindings = {
|
|
10
|
+
insertLink: {
|
|
11
|
+
key: "K", shortKey: true,
|
|
12
|
+
handler: function (range) {
|
|
13
|
+
if (!range)
|
|
14
|
+
return true;
|
|
15
|
+
const current = this.quill.getFormat(range).link || "";
|
|
16
|
+
const url = prompt("URL (leave blank to remove link):", current);
|
|
17
|
+
if (url === null)
|
|
18
|
+
return;
|
|
19
|
+
if (url === "")
|
|
20
|
+
this.quill.format("link", false);
|
|
21
|
+
else
|
|
22
|
+
this.quill.format("link", url);
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
removeLink: {
|
|
26
|
+
key: "K", shortKey: true, shiftKey: true,
|
|
27
|
+
handler: function () { this.quill.format("link", false); },
|
|
28
|
+
},
|
|
29
|
+
strike: {
|
|
30
|
+
key: "X", shortKey: true, shiftKey: true,
|
|
31
|
+
handler: function (range) {
|
|
32
|
+
if (!range)
|
|
33
|
+
return true;
|
|
34
|
+
const cur = this.quill.getFormat(range).strike;
|
|
35
|
+
this.quill.format("strike", !cur);
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
orderedList: {
|
|
39
|
+
key: "7", shortKey: true, shiftKey: true,
|
|
40
|
+
handler: function () { this.quill.format("list", "ordered"); },
|
|
41
|
+
},
|
|
42
|
+
bulletList: {
|
|
43
|
+
key: "8", shortKey: true, shiftKey: true,
|
|
44
|
+
handler: function () { this.quill.format("list", "bullet"); },
|
|
45
|
+
},
|
|
46
|
+
indent: {
|
|
47
|
+
key: "]", shortKey: true,
|
|
48
|
+
handler: function (range, context) {
|
|
49
|
+
this.quill.format("indent", (context.format.indent || 0) + 1);
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
outdent: {
|
|
53
|
+
key: "[", shortKey: true,
|
|
54
|
+
handler: function (range, context) {
|
|
55
|
+
this.quill.format("indent", Math.max(0, (context.format.indent || 0) - 1));
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
color: {
|
|
59
|
+
key: "C", shortKey: true, shiftKey: true,
|
|
60
|
+
handler: function (range) {
|
|
61
|
+
if (!range)
|
|
62
|
+
return true;
|
|
63
|
+
const current = this.quill.getFormat(range).color || "";
|
|
64
|
+
const color = prompt("Text color (name or #hex, blank to clear):", current);
|
|
65
|
+
if (color === null)
|
|
66
|
+
return;
|
|
67
|
+
this.quill.format("color", color || false);
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
clearFormat: {
|
|
71
|
+
key: "\\", shortKey: true,
|
|
72
|
+
handler: function (range) {
|
|
73
|
+
if (!range)
|
|
74
|
+
return true;
|
|
75
|
+
this.quill.removeFormat(range.index, range.length || 0);
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
};
|
|
6
79
|
const q = new Quill(container, {
|
|
7
80
|
theme: "snow",
|
|
8
81
|
placeholder: "Write your message...",
|
|
@@ -16,7 +89,8 @@ function createQuillEditor(container) {
|
|
|
16
89
|
[{ align: [] }],
|
|
17
90
|
["blockquote", "link", "image"],
|
|
18
91
|
["clean"]
|
|
19
|
-
]
|
|
92
|
+
],
|
|
93
|
+
keyboard: { bindings: extraBindings },
|
|
20
94
|
}
|
|
21
95
|
});
|
|
22
96
|
// Make toolbar buttons non-tabbable so Tab goes straight to editor body
|
package/client/lib/api-client.js
CHANGED
|
@@ -143,8 +143,8 @@ export function saveSettings(settings) {
|
|
|
143
143
|
export function repairAccounts() {
|
|
144
144
|
return ipc().repairAccounts?.();
|
|
145
145
|
}
|
|
146
|
-
export function deleteDraft(accountId, draftUid) {
|
|
147
|
-
return ipc().deleteDraft?.(accountId, draftUid);
|
|
146
|
+
export function deleteDraft(accountId, draftUid, draftId) {
|
|
147
|
+
return ipc().deleteDraft?.(accountId, draftUid, draftId);
|
|
148
148
|
}
|
|
149
149
|
export function setupAccount(name, email, password) {
|
|
150
150
|
return ipc().setupAccount?.(name, email, password);
|
package/client/lib/mailxapi.js
CHANGED
|
@@ -89,8 +89,8 @@
|
|
|
89
89
|
// Compose
|
|
90
90
|
sendMessage: function(msg) { return callNode("sendMessage", msg); },
|
|
91
91
|
saveDraft: function(params) { return callNode("saveDraft", params); },
|
|
92
|
-
deleteDraft: function(accountId, draftUid) {
|
|
93
|
-
return callNode("deleteDraft", { accountId: accountId, draftUid: draftUid });
|
|
92
|
+
deleteDraft: function(accountId, draftUid, draftId) {
|
|
93
|
+
return callNode("deleteDraft", { accountId: accountId, draftUid: draftUid, draftId: draftId });
|
|
94
94
|
},
|
|
95
95
|
|
|
96
96
|
// Search
|
|
@@ -385,6 +385,20 @@ button.tb-menu-item { background: none; border: none; color: inherit; width: 100
|
|
|
385
385
|
}
|
|
386
386
|
.ml-account-tag[title="gmail"] { background: #d93025; }
|
|
387
387
|
.ml-account-tag[title="bobma"] { background: #1a73e8; }
|
|
388
|
+
.ml-folder-tag {
|
|
389
|
+
display: inline-block;
|
|
390
|
+
padding: 0 6px;
|
|
391
|
+
font-size: 0.72rem;
|
|
392
|
+
font-weight: 500;
|
|
393
|
+
border-radius: 3px;
|
|
394
|
+
margin-right: 4px;
|
|
395
|
+
color: var(--color-text);
|
|
396
|
+
background: color-mix(in oklch, var(--color-accent) 18%, transparent);
|
|
397
|
+
vertical-align: baseline;
|
|
398
|
+
max-width: 10em;
|
|
399
|
+
overflow: hidden;
|
|
400
|
+
text-overflow: ellipsis;
|
|
401
|
+
}
|
|
388
402
|
.ml-subject {
|
|
389
403
|
overflow: hidden;
|
|
390
404
|
text-overflow: ellipsis;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bobfrankston/mailx",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.218",
|
|
4
4
|
"description": "Local-first email client with IMAP sync and standalone native app",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "bin/mailx.js",
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
"@bobfrankston/iflow-node": "^0.1.2",
|
|
25
25
|
"@bobfrankston/miscinfo": "^1.0.8",
|
|
26
26
|
"@bobfrankston/oauthsupport": "^1.0.22",
|
|
27
|
-
"@bobfrankston/msger": "^0.1.
|
|
27
|
+
"@bobfrankston/msger": "^0.1.280",
|
|
28
28
|
"@capacitor/android": "^8.3.0",
|
|
29
29
|
"@capacitor/cli": "^8.3.0",
|
|
30
30
|
"@capacitor/core": "^8.3.0",
|
|
@@ -78,7 +78,7 @@
|
|
|
78
78
|
"@bobfrankston/iflow-node": "^0.1.2",
|
|
79
79
|
"@bobfrankston/miscinfo": "^1.0.8",
|
|
80
80
|
"@bobfrankston/oauthsupport": "^1.0.22",
|
|
81
|
-
"@bobfrankston/msger": "^0.1.
|
|
81
|
+
"@bobfrankston/msger": "^0.1.280",
|
|
82
82
|
"@capacitor/android": "^8.3.0",
|
|
83
83
|
"@capacitor/cli": "^8.3.0",
|
|
84
84
|
"@capacitor/core": "^8.3.0",
|
|
@@ -274,7 +274,7 @@ export function createApiRouter(db, imapManager) {
|
|
|
274
274
|
try {
|
|
275
275
|
const { accountId, subject, bodyHtml, bodyText, to, cc, previousDraftUid, draftId } = req.body;
|
|
276
276
|
const result = await svc.saveDraft(accountId, subject, bodyHtml, bodyText, to, cc, previousDraftUid, draftId);
|
|
277
|
-
res.json({ ok: true, draftUid: result.
|
|
277
|
+
res.json({ ok: true, draftUid: result.draftUid, draftId: result.draftId });
|
|
278
278
|
}
|
|
279
279
|
catch (e) {
|
|
280
280
|
res.status(500).json({ error: e.message });
|
|
@@ -282,8 +282,8 @@ export function createApiRouter(db, imapManager) {
|
|
|
282
282
|
});
|
|
283
283
|
router.delete("/draft", async (req, res) => {
|
|
284
284
|
try {
|
|
285
|
-
if (req.body.accountId && req.body.draftUid)
|
|
286
|
-
await svc.deleteDraft(req.body.accountId, req.body.draftUid);
|
|
285
|
+
if (req.body.accountId && (req.body.draftUid || req.body.draftId))
|
|
286
|
+
await svc.deleteDraft(req.body.accountId, req.body.draftUid || 0, req.body.draftId);
|
|
287
287
|
res.json({ ok: true });
|
|
288
288
|
}
|
|
289
289
|
catch (e) {
|
|
@@ -169,8 +169,10 @@ export declare class ImapManager extends EventEmitter {
|
|
|
169
169
|
/** Save a draft to the Drafts folder via IMAP APPEND.
|
|
170
170
|
* Returns the UID of the saved draft (for replacing on next save). */
|
|
171
171
|
saveDraft(accountId: string, rawMessage: string | Buffer, previousDraftUid?: number, draftId?: string): Promise<number | null>;
|
|
172
|
-
/** Delete a draft after successful send
|
|
173
|
-
|
|
172
|
+
/** Delete a draft (or all drafts with a stable X-Mailx-Draft-ID) after successful send.
|
|
173
|
+
* Tries the specific UID first, then falls back to searchByHeader so orphaned copies
|
|
174
|
+
* from earlier failed autosaves are cleaned up at the same time. */
|
|
175
|
+
deleteDraft(accountId: string, draftUid: number, draftId?: string): Promise<void>;
|
|
174
176
|
/** Queue outgoing message locally — never fails, worker handles IMAP+SMTP */
|
|
175
177
|
queueOutgoingLocal(accountId: string, rawMessage: string): void;
|
|
176
178
|
/** Guard against concurrent processSendActions for the same account */
|
|
@@ -186,7 +188,9 @@ export declare class ImapManager extends EventEmitter {
|
|
|
186
188
|
private saveSendingCopy;
|
|
187
189
|
/** Queue a message for sending. Tries IMAP Outbox, falls back to local file. */
|
|
188
190
|
queueOutgoing(accountId: string, rawMessage: string | Buffer): Promise<void>;
|
|
189
|
-
/** Process local file queue — send from outbox/
|
|
191
|
+
/** Process local file queue — send from outbox/ only (IMAP-unreachable fallback).
|
|
192
|
+
* Do NOT scan sending/<acct>/queued/ — that was causing every sent message to be
|
|
193
|
+
* re-APPENDed to the IMAP Outbox on the next tick and delivered twice. */
|
|
190
194
|
private processLocalQueue;
|
|
191
195
|
/** Send a raw RFC 2822 message via SMTP for a given account */
|
|
192
196
|
private sendRawViaSMTP;
|
|
@@ -1558,24 +1558,32 @@ export class ImapManager extends EventEmitter {
|
|
|
1558
1558
|
}
|
|
1559
1559
|
const client = this.createClient(accountId);
|
|
1560
1560
|
try {
|
|
1561
|
-
// Delete previous draft —
|
|
1561
|
+
// Delete previous draft — try UID first (fast path), and ALWAYS also try
|
|
1562
|
+
// searchByHeader(X-Mailx-Draft-ID) as a safety net. Running both catches
|
|
1563
|
+
// orphans from a crash-mid-save or a UID delete that failed silently.
|
|
1562
1564
|
if (previousDraftUid) {
|
|
1563
1565
|
try {
|
|
1564
1566
|
await client.deleteMessageByUid(drafts.path, previousDraftUid);
|
|
1565
1567
|
}
|
|
1566
|
-
catch {
|
|
1568
|
+
catch (e) {
|
|
1569
|
+
console.error(` [drafts] Delete prev UID ${previousDraftUid} failed: ${e.message}`);
|
|
1570
|
+
}
|
|
1567
1571
|
}
|
|
1568
|
-
|
|
1569
|
-
// Search Drafts for our draft ID and delete it
|
|
1572
|
+
if (draftId) {
|
|
1570
1573
|
try {
|
|
1571
1574
|
const uids = await client.searchByHeader(drafts.path, "X-Mailx-Draft-ID", draftId);
|
|
1572
1575
|
for (const uid of uids) {
|
|
1573
|
-
|
|
1576
|
+
try {
|
|
1577
|
+
await client.deleteMessageByUid(drafts.path, uid);
|
|
1578
|
+
}
|
|
1579
|
+
catch { /* next */ }
|
|
1574
1580
|
}
|
|
1575
1581
|
if (uids.length > 0)
|
|
1576
|
-
console.log(` [drafts] Deleted ${uids.length}
|
|
1582
|
+
console.log(` [drafts] Deleted ${uids.length} stale draft(s) by ID ${draftId}`);
|
|
1583
|
+
}
|
|
1584
|
+
catch (e) {
|
|
1585
|
+
console.error(` [drafts] searchByHeader for ${draftId} failed: ${e.message}`);
|
|
1577
1586
|
}
|
|
1578
|
-
catch { /* search not supported or failed — tolerate duplicate */ }
|
|
1579
1587
|
}
|
|
1580
1588
|
// Append new draft
|
|
1581
1589
|
const result = await client.appendMessage(drafts.path, rawMessage, ["\\Draft", "\\Seen"]);
|
|
@@ -1590,18 +1598,42 @@ export class ImapManager extends EventEmitter {
|
|
|
1590
1598
|
catch { /* ignore */ }
|
|
1591
1599
|
}
|
|
1592
1600
|
}
|
|
1593
|
-
/** Delete a draft after successful send
|
|
1594
|
-
|
|
1601
|
+
/** Delete a draft (or all drafts with a stable X-Mailx-Draft-ID) after successful send.
|
|
1602
|
+
* Tries the specific UID first, then falls back to searchByHeader so orphaned copies
|
|
1603
|
+
* from earlier failed autosaves are cleaned up at the same time. */
|
|
1604
|
+
async deleteDraft(accountId, draftUid, draftId) {
|
|
1595
1605
|
const drafts = this.findFolder(accountId, "drafts");
|
|
1596
|
-
if (!drafts
|
|
1606
|
+
if (!drafts)
|
|
1607
|
+
return;
|
|
1608
|
+
if (!draftUid && !draftId)
|
|
1597
1609
|
return;
|
|
1598
1610
|
const client = this.createClient(accountId);
|
|
1599
1611
|
try {
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1612
|
+
if (draftUid) {
|
|
1613
|
+
try {
|
|
1614
|
+
await client.deleteMessageByUid(drafts.path, draftUid);
|
|
1615
|
+
console.log(` [drafts] Deleted draft UID ${draftUid}`);
|
|
1616
|
+
}
|
|
1617
|
+
catch (e) {
|
|
1618
|
+
console.error(` [drafts] Delete by UID ${draftUid} failed: ${e.message}`);
|
|
1619
|
+
}
|
|
1620
|
+
}
|
|
1621
|
+
if (draftId) {
|
|
1622
|
+
try {
|
|
1623
|
+
const uids = await client.searchByHeader(drafts.path, "X-Mailx-Draft-ID", draftId);
|
|
1624
|
+
for (const uid of uids) {
|
|
1625
|
+
try {
|
|
1626
|
+
await client.deleteMessageByUid(drafts.path, uid);
|
|
1627
|
+
}
|
|
1628
|
+
catch { /* next */ }
|
|
1629
|
+
}
|
|
1630
|
+
if (uids.length > 0)
|
|
1631
|
+
console.log(` [drafts] Deleted ${uids.length} draft(s) by ID ${draftId}`);
|
|
1632
|
+
}
|
|
1633
|
+
catch (e) {
|
|
1634
|
+
console.error(` [drafts] searchByHeader for ${draftId} failed: ${e.message}`);
|
|
1635
|
+
}
|
|
1636
|
+
}
|
|
1605
1637
|
}
|
|
1606
1638
|
finally {
|
|
1607
1639
|
try {
|
|
@@ -1709,8 +1741,13 @@ export class ImapManager extends EventEmitter {
|
|
|
1709
1741
|
}
|
|
1710
1742
|
/** Queue a message for sending. Tries IMAP Outbox, falls back to local file. */
|
|
1711
1743
|
async queueOutgoing(accountId, rawMessage) {
|
|
1712
|
-
//
|
|
1713
|
-
|
|
1744
|
+
// IMPORTANT: do NOT save a "debug copy" to sending/<acct>/queued/ here.
|
|
1745
|
+
// processLocalQueue also scans sending/<acct>/queued/, so writing there
|
|
1746
|
+
// on every send caused the same message to be re-APPENDed to the IMAP
|
|
1747
|
+
// Outbox on the next outbox tick — resulting in a duplicate send.
|
|
1748
|
+
// The only two legitimate queue locations are:
|
|
1749
|
+
// - IMAP Outbox (primary, populated by APPEND below)
|
|
1750
|
+
// - ~/.mailx/outbox/<acct>/*.ltr (fallback when IMAP is unreachable)
|
|
1714
1751
|
try {
|
|
1715
1752
|
const outboxPath = await this.ensureOutbox(accountId);
|
|
1716
1753
|
const client = this.createClient(accountId);
|
|
@@ -1734,7 +1771,7 @@ export class ImapManager extends EventEmitter {
|
|
|
1734
1771
|
catch (e) {
|
|
1735
1772
|
console.error(` [outbox] IMAP queue failed: ${e.message} — saving locally`);
|
|
1736
1773
|
}
|
|
1737
|
-
// Fallback: save to local file queue
|
|
1774
|
+
// Fallback: save to local file queue (processLocalQueue picks these up)
|
|
1738
1775
|
const localQueue = path.join(getConfigDir(), "outbox", accountId);
|
|
1739
1776
|
fs.mkdirSync(localQueue, { recursive: true });
|
|
1740
1777
|
const now = new Date();
|
|
@@ -1743,17 +1780,15 @@ export class ImapManager extends EventEmitter {
|
|
|
1743
1780
|
fs.writeFileSync(path.join(localQueue, filename), rawMessage);
|
|
1744
1781
|
console.log(` [outbox] Saved locally: ${filename}`);
|
|
1745
1782
|
}
|
|
1746
|
-
/** Process local file queue — send from outbox/
|
|
1783
|
+
/** Process local file queue — send from outbox/ only (IMAP-unreachable fallback).
|
|
1784
|
+
* Do NOT scan sending/<acct>/queued/ — that was causing every sent message to be
|
|
1785
|
+
* re-APPENDed to the IMAP Outbox on the next tick and delivered twice. */
|
|
1747
1786
|
async processLocalQueue(accountId) {
|
|
1748
|
-
// Collect files from both outbox/ (legacy .ltr) and sending/queued/ (drop-in)
|
|
1749
1787
|
const outboxDir = path.join(getConfigDir(), "outbox", accountId);
|
|
1750
|
-
const queuedDir = path.join(getConfigDir(), "sending", accountId, "queued");
|
|
1751
1788
|
const filesToSend = [];
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
for (const file of fs.readdirSync(dir).filter(f => f.endsWith(".ltr") || f.endsWith(".eml"))) {
|
|
1756
|
-
filesToSend.push({ dir, file });
|
|
1789
|
+
if (fs.existsSync(outboxDir)) {
|
|
1790
|
+
for (const file of fs.readdirSync(outboxDir).filter(f => f.endsWith(".ltr") || f.endsWith(".eml"))) {
|
|
1791
|
+
filesToSend.push({ dir: outboxDir, file });
|
|
1757
1792
|
}
|
|
1758
1793
|
}
|
|
1759
1794
|
if (filesToSend.length === 0)
|
|
@@ -48,10 +48,10 @@ export declare class MailxService {
|
|
|
48
48
|
filename: string;
|
|
49
49
|
}>;
|
|
50
50
|
saveDraft(accountId: string, subject: string, bodyHtml: string, bodyText: string, to?: string, cc?: string, previousDraftUid?: number, draftId?: string): Promise<{
|
|
51
|
-
|
|
51
|
+
draftUid: number | null;
|
|
52
52
|
draftId: string;
|
|
53
53
|
}>;
|
|
54
|
-
deleteDraft(accountId: string, draftUid: number): Promise<void>;
|
|
54
|
+
deleteDraft(accountId: string, draftUid: number, draftId?: string): Promise<void>;
|
|
55
55
|
searchContacts(query: string): any[];
|
|
56
56
|
syncGoogleContacts(): Promise<void>;
|
|
57
57
|
seedContacts(): number;
|
|
@@ -572,11 +572,11 @@ export class MailxService {
|
|
|
572
572
|
}
|
|
573
573
|
}
|
|
574
574
|
catch { /* ignore */ }
|
|
575
|
-
const
|
|
576
|
-
return {
|
|
575
|
+
const draftUid = await this.imapManager.saveDraft(accountId, raw, previousDraftUid, id);
|
|
576
|
+
return { draftUid, draftId: id };
|
|
577
577
|
}
|
|
578
|
-
async deleteDraft(accountId, draftUid) {
|
|
579
|
-
await this.imapManager.deleteDraft(accountId, draftUid);
|
|
578
|
+
async deleteDraft(accountId, draftUid, draftId) {
|
|
579
|
+
await this.imapManager.deleteDraft(accountId, draftUid, draftId);
|
|
580
580
|
}
|
|
581
581
|
// ── Contacts ──
|
|
582
582
|
searchContacts(query) {
|
|
@@ -76,7 +76,7 @@ async function dispatchAction(svc, action, p) {
|
|
|
76
76
|
case "saveDraft":
|
|
77
77
|
return svc.saveDraft(p.accountId, p.subject, p.bodyHtml, p.bodyText, p.to, p.cc, p.previousDraftUid, p.draftId);
|
|
78
78
|
case "deleteDraft":
|
|
79
|
-
await svc.deleteDraft(p.accountId, p.draftUid);
|
|
79
|
+
await svc.deleteDraft(p.accountId, p.draftUid, p.draftId);
|
|
80
80
|
return { ok: true };
|
|
81
81
|
// Sync
|
|
82
82
|
case "syncAll":
|
|
@@ -448,8 +448,9 @@ export class MailxDB {
|
|
|
448
448
|
}
|
|
449
449
|
const countRow = this.db.prepare(`SELECT COUNT(*) as cnt FROM messages m JOIN messages_fts fts ON m.id = fts.rowid WHERE messages_fts MATCH ?${scopeWhere}`).get(ftsQuery, ...scopeParams);
|
|
450
450
|
const total = countRow?.cnt || 0;
|
|
451
|
-
const rows = this.db.prepare(`SELECT m
|
|
451
|
+
const rows = this.db.prepare(`SELECT m.*, f.name AS folder_name FROM messages m
|
|
452
452
|
JOIN messages_fts fts ON m.id = fts.rowid
|
|
453
|
+
LEFT JOIN folders f ON f.id = m.folder_id AND f.account_id = m.account_id
|
|
453
454
|
WHERE messages_fts MATCH ?${scopeWhere}
|
|
454
455
|
ORDER BY m.date DESC
|
|
455
456
|
LIMIT ? OFFSET ?`).all(ftsQuery, ...scopeParams, pageSize, offset);
|
|
@@ -457,6 +458,7 @@ export class MailxDB {
|
|
|
457
458
|
id: r.id,
|
|
458
459
|
accountId: r.account_id,
|
|
459
460
|
folderId: r.folder_id,
|
|
461
|
+
folderName: r.folder_name || "",
|
|
460
462
|
uid: r.uid,
|
|
461
463
|
messageId: r.message_id || "",
|
|
462
464
|
inReplyTo: r.in_reply_to || "",
|
|
@@ -59,6 +59,7 @@ export interface MessageEnvelope {
|
|
|
59
59
|
id: number; /** Local store ID */
|
|
60
60
|
accountId: string;
|
|
61
61
|
folderId: number;
|
|
62
|
+
folderName?: string; /** Leaf folder name; populated by cross-folder search so the UI can tag each hit */
|
|
62
63
|
uid: number; /** IMAP UID */
|
|
63
64
|
messageId: string; /** RFC Message-ID header */
|
|
64
65
|
inReplyTo: string; /** For threading */
|