@bobfrankston/mailx 1.0.217 → 1.0.219
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/.msger-window.json +1 -1
- package/client/app.js +26 -7
- package/client/compose/compose.css +16 -0
- package/client/compose/compose.html +1 -0
- package/client/compose/compose.js +119 -21
- package/client/compose/editor.js +75 -1
- package/package.json +5 -5
- package/packages/mailx-imap/index.d.ts +3 -1
- package/packages/mailx-imap/index.js +37 -12
- package/packages/mailx-store/file-store.d.ts +2 -0
- package/packages/mailx-store/file-store.js +4 -0
|
@@ -1 +1 @@
|
|
|
1
|
-
{"height":1344,"width":2151,"x":
|
|
1
|
+
{"height":1344,"width":2151,"x":194,"y":22}
|
package/client/app.js
CHANGED
|
@@ -371,30 +371,49 @@ function showComposeOverlay() {
|
|
|
371
371
|
closeBtn.addEventListener("mouseenter", () => closeBtn.style.color = "#c00");
|
|
372
372
|
closeBtn.addEventListener("mouseleave", () => closeBtn.style.color = "#666");
|
|
373
373
|
closeBtn.addEventListener("click", () => {
|
|
374
|
+
// compose.ts handles the prompt (Save/Discard/Cancel) and then calls
|
|
375
|
+
// window.close() which is redirected to wrapper.remove() at line below.
|
|
376
|
+
// If the user cancels the prompt, closeCompose() is never called and
|
|
377
|
+
// the wrapper stays. Don't force-remove on a timer — that defeats Cancel.
|
|
374
378
|
try {
|
|
375
379
|
const win = frame.contentWindow;
|
|
376
380
|
if (win)
|
|
377
381
|
win.dispatchEvent(new Event("compose-save-and-close"));
|
|
378
382
|
}
|
|
379
383
|
catch { /* */ }
|
|
380
|
-
setTimeout(() => wrapper.remove(), 500);
|
|
381
384
|
});
|
|
382
385
|
titleBar.appendChild(closeBtn);
|
|
383
|
-
// Drag to move
|
|
386
|
+
// Drag to move. While dragging we set pointer-events:none on the iframe
|
|
387
|
+
// so mouse events don't get swallowed by the inner document the moment
|
|
388
|
+
// the cursor crosses into the iframe region. Without that, drag only
|
|
389
|
+
// worked if you stayed on the title bar pixels, which is why it felt
|
|
390
|
+
// broken except at the lower-right (resize grip) corner.
|
|
384
391
|
let dragX = 0, dragY = 0;
|
|
385
392
|
titleBar.addEventListener("mousedown", (e) => {
|
|
386
393
|
if (e.target === closeBtn)
|
|
387
394
|
return;
|
|
388
395
|
e.preventDefault();
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
396
|
+
const rect = wrapper.getBoundingClientRect();
|
|
397
|
+
dragX = e.clientX - rect.left;
|
|
398
|
+
dragY = e.clientY - rect.top;
|
|
399
|
+
// Clamp movement to the viewport so the title bar stays grabbable.
|
|
400
|
+
const clamp = (val, min, max) => Math.max(min, Math.min(max, val));
|
|
401
|
+
frame.style.pointerEvents = "none";
|
|
402
|
+
document.body.style.userSelect = "none";
|
|
403
|
+
const onMove = (ev) => {
|
|
404
|
+
ev.preventDefault();
|
|
405
|
+
const w = wrapper.offsetWidth;
|
|
406
|
+
const h = wrapper.offsetHeight;
|
|
407
|
+
const left = clamp(ev.clientX - dragX, 0, window.innerWidth - 40);
|
|
408
|
+
const top = clamp(ev.clientY - dragY, 0, window.innerHeight - 40);
|
|
409
|
+
wrapper.style.left = `${left}px`;
|
|
410
|
+
wrapper.style.top = `${top}px`;
|
|
394
411
|
wrapper.style.bottom = "auto";
|
|
395
412
|
wrapper.style.right = "auto";
|
|
396
413
|
};
|
|
397
414
|
const onUp = () => {
|
|
415
|
+
frame.style.pointerEvents = "";
|
|
416
|
+
document.body.style.userSelect = "";
|
|
398
417
|
document.removeEventListener("mousemove", onMove);
|
|
399
418
|
document.removeEventListener("mouseup", onUp);
|
|
400
419
|
};
|
|
@@ -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>
|
|
@@ -293,11 +293,22 @@ function applyInit(init) {
|
|
|
293
293
|
editor.focus();
|
|
294
294
|
}
|
|
295
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
|
|
296
298
|
let draftUid = null;
|
|
297
299
|
let draftId = null; // stable ID for dedup when APPENDUID unavailable
|
|
298
300
|
let draftTimer = null;
|
|
301
|
+
let draftDebounceTimer = null;
|
|
299
302
|
let lastDraftContent = "";
|
|
300
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
|
+
}
|
|
301
312
|
async function saveDraft() {
|
|
302
313
|
if (draftSaving)
|
|
303
314
|
return; // previous save still in flight
|
|
@@ -323,12 +334,32 @@ async function saveDraft() {
|
|
|
323
334
|
draftUid = data.draftUid;
|
|
324
335
|
if (data?.draftId)
|
|
325
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 = "";
|
|
326
352
|
}
|
|
327
|
-
catch { /* ignore draft save errors */ }
|
|
328
353
|
finally {
|
|
329
354
|
draftSaving = false;
|
|
330
355
|
}
|
|
331
356
|
}
|
|
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
|
+
}
|
|
332
363
|
// ── Initialize: always fetch real accounts from the API before applying init, then
|
|
333
364
|
// start the auto-save timer. Callers like message-viewer's Edit Draft pass
|
|
334
365
|
// init.accounts=[], so we can't trust what's in the init blob. ──
|
|
@@ -352,9 +383,26 @@ async function saveDraft() {
|
|
|
352
383
|
populateFromSelect(accounts);
|
|
353
384
|
toInput.focus();
|
|
354
385
|
}
|
|
355
|
-
//
|
|
356
|
-
//
|
|
357
|
-
|
|
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
|
+
});
|
|
358
406
|
})();
|
|
359
407
|
// ── Send ──
|
|
360
408
|
document.getElementById("btn-send")?.addEventListener("click", async () => {
|
|
@@ -391,18 +439,65 @@ document.getElementById("btn-send")?.addEventListener("click", async () => {
|
|
|
391
439
|
alert(`Send failed: ${e.message}`);
|
|
392
440
|
}
|
|
393
441
|
});
|
|
394
|
-
// ──
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
442
|
+
// ── Close handling ──
|
|
443
|
+
/** True if the compose has anything worth asking about. */
|
|
444
|
+
function composeHasContent() {
|
|
445
|
+
return !!(editor.getText().trim() || toInput.value.trim() || ccInput.value.trim() || bccInput.value.trim() || subjectInput.value.trim());
|
|
446
|
+
}
|
|
447
|
+
/** Ask Save/Discard/Cancel. Returns "save" | "discard" | "cancel". */
|
|
448
|
+
function promptSaveOrDiscard() {
|
|
449
|
+
// Three-way prompt built from two native dialogs: OK(save) / Cancel → then
|
|
450
|
+
// for Cancel, ask "Really discard?" which becomes Discard / Cancel.
|
|
451
|
+
const saveIt = confirm("Save this message as a draft?\n\nOK = Save as draft\nCancel = continue...");
|
|
452
|
+
if (saveIt)
|
|
453
|
+
return "save";
|
|
454
|
+
const discard = confirm("Discard the message without saving?\n\nOK = Discard\nCancel = Keep editing");
|
|
455
|
+
if (discard)
|
|
456
|
+
return "discard";
|
|
457
|
+
return "cancel";
|
|
458
|
+
}
|
|
459
|
+
/** Handle any "close the compose" action (Discard button, Escape, X, window close). */
|
|
460
|
+
async function handleCloseRequest() {
|
|
461
|
+
if (!composeHasContent()) {
|
|
462
|
+
closeCompose();
|
|
463
|
+
return true;
|
|
399
464
|
}
|
|
400
|
-
|
|
465
|
+
const choice = promptSaveOrDiscard();
|
|
466
|
+
if (choice === "cancel")
|
|
467
|
+
return false;
|
|
468
|
+
// Stop auto-save so it can't race with our explicit save/discard.
|
|
469
|
+
if (draftDebounceTimer) {
|
|
470
|
+
clearTimeout(draftDebounceTimer);
|
|
471
|
+
draftDebounceTimer = null;
|
|
472
|
+
}
|
|
473
|
+
if (draftTimer) {
|
|
474
|
+
clearInterval(draftTimer);
|
|
475
|
+
draftTimer = null;
|
|
476
|
+
}
|
|
477
|
+
if (choice === "save") {
|
|
478
|
+
try {
|
|
479
|
+
await saveDraft();
|
|
480
|
+
}
|
|
481
|
+
catch { /* already logged */ }
|
|
482
|
+
}
|
|
483
|
+
else {
|
|
484
|
+
// Discard: if we have a tracked draft, delete it so the orphan doesn't stick around.
|
|
485
|
+
if (draftUid || draftId) {
|
|
486
|
+
try {
|
|
487
|
+
await deleteDraft(getFromAccountId(), draftUid || 0, draftId || "");
|
|
488
|
+
}
|
|
489
|
+
catch { /* ignore */ }
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
closeCompose();
|
|
493
|
+
return true;
|
|
494
|
+
}
|
|
495
|
+
document.getElementById("btn-discard")?.addEventListener("click", () => {
|
|
496
|
+
handleCloseRequest();
|
|
401
497
|
});
|
|
402
498
|
// ── Save and close (X button from parent) ──
|
|
403
|
-
window.addEventListener("compose-save-and-close",
|
|
404
|
-
|
|
405
|
-
closeCompose();
|
|
499
|
+
window.addEventListener("compose-save-and-close", () => {
|
|
500
|
+
handleCloseRequest();
|
|
406
501
|
});
|
|
407
502
|
// ── Keyboard shortcuts ──
|
|
408
503
|
document.addEventListener("keydown", (e) => {
|
|
@@ -410,17 +505,20 @@ document.addEventListener("keydown", (e) => {
|
|
|
410
505
|
document.getElementById("btn-send")?.click();
|
|
411
506
|
}
|
|
412
507
|
if (e.key === "Escape") {
|
|
413
|
-
document.getElementById("btn-discard")?.click();
|
|
414
|
-
}
|
|
415
|
-
// Ctrl+K = trigger address completion
|
|
416
|
-
if (e.ctrlKey && e.key === "k") {
|
|
417
508
|
e.preventDefault();
|
|
509
|
+
handleCloseRequest();
|
|
510
|
+
}
|
|
511
|
+
// Ctrl+K in an address field = trigger address completion.
|
|
512
|
+
// NOTE: Ctrl+K is ALSO the editor's "insert link" shortcut. Scope this handler
|
|
513
|
+
// strictly to the to/cc/bcc inputs so it doesn't shadow the editor binding when
|
|
514
|
+
// focus is in the body.
|
|
515
|
+
if (e.ctrlKey && (e.key === "k" || e.key === "K")) {
|
|
418
516
|
const active = document.activeElement;
|
|
419
517
|
const addressFields = [toInput, ccInput, bccInput];
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
518
|
+
if (addressFields.includes(active)) {
|
|
519
|
+
e.preventDefault();
|
|
520
|
+
active.dispatchEvent(new Event("input"));
|
|
521
|
+
}
|
|
424
522
|
}
|
|
425
523
|
});
|
|
426
524
|
//# sourceMappingURL=compose.js.map
|
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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bobfrankston/mailx",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.219",
|
|
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,11 +20,11 @@
|
|
|
20
20
|
"postinstall": "node bin/postinstall.js"
|
|
21
21
|
},
|
|
22
22
|
"dependencies": {
|
|
23
|
-
"@bobfrankston/iflow-direct": "^0.1.
|
|
23
|
+
"@bobfrankston/iflow-direct": "^0.1.8",
|
|
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.282",
|
|
28
28
|
"@capacitor/android": "^8.3.0",
|
|
29
29
|
"@capacitor/cli": "^8.3.0",
|
|
30
30
|
"@capacitor/core": "^8.3.0",
|
|
@@ -74,11 +74,11 @@
|
|
|
74
74
|
},
|
|
75
75
|
".transformedSnapshot": {
|
|
76
76
|
"dependencies": {
|
|
77
|
-
"@bobfrankston/iflow-direct": "^0.1.
|
|
77
|
+
"@bobfrankston/iflow-direct": "^0.1.8",
|
|
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.282",
|
|
82
82
|
"@capacitor/android": "^8.3.0",
|
|
83
83
|
"@capacitor/cli": "^8.3.0",
|
|
84
84
|
"@capacitor/core": "^8.3.0",
|
|
@@ -188,7 +188,9 @@ export declare class ImapManager extends EventEmitter {
|
|
|
188
188
|
private saveSendingCopy;
|
|
189
189
|
/** Queue a message for sending. Tries IMAP Outbox, falls back to local file. */
|
|
190
190
|
queueOutgoing(accountId: string, rawMessage: string | Buffer): Promise<void>;
|
|
191
|
-
/** 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. */
|
|
192
194
|
private processLocalQueue;
|
|
193
195
|
/** Send a raw RFC 2822 message via SMTP for a given account */
|
|
194
196
|
private sendRawViaSMTP;
|
|
@@ -1250,8 +1250,15 @@ export class ImapManager extends EventEmitter {
|
|
|
1250
1250
|
/** Fetch a single message body on demand, caching in the store.
|
|
1251
1251
|
* Uses its own fresh connection — never blocked by background prefetch. */
|
|
1252
1252
|
async fetchMessageBody(accountId, folderId, uid) {
|
|
1253
|
-
// Already cached?
|
|
1253
|
+
// Already cached? If the file is on disk but body_path wasn't written to
|
|
1254
|
+
// the DB (e.g. from an interrupted earlier run), the prefetch loop would
|
|
1255
|
+
// otherwise keep returning the same missing rows forever — once saw
|
|
1256
|
+
// "gmail: 17266796 bodies cached" in the logs, which is the counter
|
|
1257
|
+
// spinning on the same 100 rows.
|
|
1254
1258
|
if (await this.bodyStore.hasMessage(accountId, folderId, uid)) {
|
|
1259
|
+
const existingPath = this.bodyStore.getMessagePath?.(accountId, folderId, uid);
|
|
1260
|
+
if (existingPath)
|
|
1261
|
+
this.db.updateBodyPath(accountId, uid, existingPath);
|
|
1255
1262
|
return this.bodyStore.getMessage(accountId, folderId, uid);
|
|
1256
1263
|
}
|
|
1257
1264
|
if (!this.configs.has(accountId))
|
|
@@ -1359,6 +1366,14 @@ export class ImapManager extends EventEmitter {
|
|
|
1359
1366
|
this.db.queueSyncAction(accountId, "delete", msg.uid, msg.folderId);
|
|
1360
1367
|
}
|
|
1361
1368
|
}
|
|
1369
|
+
// Recalc folder counts so the tree badge updates immediately instead
|
|
1370
|
+
// of showing stale numbers until the next full sync.
|
|
1371
|
+
const sourceFolderIds = new Set(messages.map(m => m.folderId));
|
|
1372
|
+
for (const fid of sourceFolderIds)
|
|
1373
|
+
this.db.recalcFolderCounts(fid);
|
|
1374
|
+
if (trash)
|
|
1375
|
+
this.db.recalcFolderCounts(trash.id);
|
|
1376
|
+
this.emit("folderCountsChanged", accountId, {});
|
|
1362
1377
|
// Process all queued actions in one IMAP session
|
|
1363
1378
|
this.debounceSyncActions(accountId);
|
|
1364
1379
|
}
|
|
@@ -1375,6 +1390,13 @@ export class ImapManager extends EventEmitter {
|
|
|
1375
1390
|
for (const msg of messages) {
|
|
1376
1391
|
this.db.queueSyncAction(accountId, "move", msg.uid, msg.folderId, { targetFolderId });
|
|
1377
1392
|
}
|
|
1393
|
+
// Recalc folder counts (source folders + destination) so the tree
|
|
1394
|
+
// badge updates immediately.
|
|
1395
|
+
const sourceFolderIds = new Set(messages.map(m => m.folderId));
|
|
1396
|
+
for (const fid of sourceFolderIds)
|
|
1397
|
+
this.db.recalcFolderCounts(fid);
|
|
1398
|
+
this.db.recalcFolderCounts(targetFolderId);
|
|
1399
|
+
this.emit("folderCountsChanged", accountId, {});
|
|
1378
1400
|
// Process all queued actions in one IMAP session
|
|
1379
1401
|
this.debounceSyncActions(accountId);
|
|
1380
1402
|
}
|
|
@@ -1741,8 +1763,13 @@ export class ImapManager extends EventEmitter {
|
|
|
1741
1763
|
}
|
|
1742
1764
|
/** Queue a message for sending. Tries IMAP Outbox, falls back to local file. */
|
|
1743
1765
|
async queueOutgoing(accountId, rawMessage) {
|
|
1744
|
-
//
|
|
1745
|
-
|
|
1766
|
+
// IMPORTANT: do NOT save a "debug copy" to sending/<acct>/queued/ here.
|
|
1767
|
+
// processLocalQueue also scans sending/<acct>/queued/, so writing there
|
|
1768
|
+
// on every send caused the same message to be re-APPENDed to the IMAP
|
|
1769
|
+
// Outbox on the next outbox tick — resulting in a duplicate send.
|
|
1770
|
+
// The only two legitimate queue locations are:
|
|
1771
|
+
// - IMAP Outbox (primary, populated by APPEND below)
|
|
1772
|
+
// - ~/.mailx/outbox/<acct>/*.ltr (fallback when IMAP is unreachable)
|
|
1746
1773
|
try {
|
|
1747
1774
|
const outboxPath = await this.ensureOutbox(accountId);
|
|
1748
1775
|
const client = this.createClient(accountId);
|
|
@@ -1766,7 +1793,7 @@ export class ImapManager extends EventEmitter {
|
|
|
1766
1793
|
catch (e) {
|
|
1767
1794
|
console.error(` [outbox] IMAP queue failed: ${e.message} — saving locally`);
|
|
1768
1795
|
}
|
|
1769
|
-
// Fallback: save to local file queue
|
|
1796
|
+
// Fallback: save to local file queue (processLocalQueue picks these up)
|
|
1770
1797
|
const localQueue = path.join(getConfigDir(), "outbox", accountId);
|
|
1771
1798
|
fs.mkdirSync(localQueue, { recursive: true });
|
|
1772
1799
|
const now = new Date();
|
|
@@ -1775,17 +1802,15 @@ export class ImapManager extends EventEmitter {
|
|
|
1775
1802
|
fs.writeFileSync(path.join(localQueue, filename), rawMessage);
|
|
1776
1803
|
console.log(` [outbox] Saved locally: ${filename}`);
|
|
1777
1804
|
}
|
|
1778
|
-
/** Process local file queue — send from outbox/
|
|
1805
|
+
/** Process local file queue — send from outbox/ only (IMAP-unreachable fallback).
|
|
1806
|
+
* Do NOT scan sending/<acct>/queued/ — that was causing every sent message to be
|
|
1807
|
+
* re-APPENDed to the IMAP Outbox on the next tick and delivered twice. */
|
|
1779
1808
|
async processLocalQueue(accountId) {
|
|
1780
|
-
// Collect files from both outbox/ (legacy .ltr) and sending/queued/ (drop-in)
|
|
1781
1809
|
const outboxDir = path.join(getConfigDir(), "outbox", accountId);
|
|
1782
|
-
const queuedDir = path.join(getConfigDir(), "sending", accountId, "queued");
|
|
1783
1810
|
const filesToSend = [];
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
for (const file of fs.readdirSync(dir).filter(f => f.endsWith(".ltr") || f.endsWith(".eml"))) {
|
|
1788
|
-
filesToSend.push({ dir, file });
|
|
1811
|
+
if (fs.existsSync(outboxDir)) {
|
|
1812
|
+
for (const file of fs.readdirSync(outboxDir).filter(f => f.endsWith(".ltr") || f.endsWith(".eml"))) {
|
|
1813
|
+
filesToSend.push({ dir: outboxDir, file });
|
|
1789
1814
|
}
|
|
1790
1815
|
}
|
|
1791
1816
|
if (filesToSend.length === 0)
|
|
@@ -8,6 +8,8 @@ export declare class FileMessageStore implements MessageStore {
|
|
|
8
8
|
private basePath;
|
|
9
9
|
constructor(basePath: string);
|
|
10
10
|
private messagePath;
|
|
11
|
+
/** Public lookup of the on-disk path without touching the file. */
|
|
12
|
+
getMessagePath(accountId: string, folderId: number, uid: number): string;
|
|
11
13
|
putMessage(accountId: string, folderId: number, uid: number, raw: Buffer): Promise<string>;
|
|
12
14
|
getMessage(accountId: string, folderId: number, uid: number): Promise<Buffer>;
|
|
13
15
|
deleteMessage(accountId: string, folderId: number, uid: number): Promise<void>;
|
|
@@ -14,6 +14,10 @@ export class FileMessageStore {
|
|
|
14
14
|
messagePath(accountId, folderId, uid) {
|
|
15
15
|
return path.join(this.basePath, accountId, String(folderId), `${uid}.eml`);
|
|
16
16
|
}
|
|
17
|
+
/** Public lookup of the on-disk path without touching the file. */
|
|
18
|
+
getMessagePath(accountId, folderId, uid) {
|
|
19
|
+
return this.messagePath(accountId, folderId, uid);
|
|
20
|
+
}
|
|
17
21
|
async putMessage(accountId, folderId, uid, raw) {
|
|
18
22
|
const filePath = this.messagePath(accountId, folderId, uid);
|
|
19
23
|
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|