@bobfrankston/rmfmail 1.0.592 → 1.0.597
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/TODO.md +5 -0
- package/bin/mailx.js +13 -7
- package/bin/mailx.js.map +1 -1
- package/bin/mailx.ts +22 -13
- package/client/components/folder-tree.js +15 -16
- package/client/components/folder-tree.js.map +1 -1
- package/client/components/folder-tree.ts +15 -16
- package/client/components/message-list.js +30 -0
- package/client/components/message-list.js.map +1 -1
- package/client/components/message-list.ts +24 -0
- package/client/components/outbox-view.js +32 -5
- package/client/components/outbox-view.js.map +1 -1
- package/client/components/outbox-view.ts +30 -5
- package/client/compose/compose.js +100 -81
- package/client/compose/compose.js.map +1 -1
- package/client/compose/compose.ts +90 -70
- package/package.json +3 -3
- package/packages/mailx-imap/index.d.ts +18 -0
- package/packages/mailx-imap/index.d.ts.map +1 -1
- package/packages/mailx-imap/index.js +64 -3
- package/packages/mailx-imap/index.js.map +1 -1
- package/packages/mailx-imap/index.ts +62 -3
- package/packages/mailx-imap/package-lock.json +2 -2
- package/packages/mailx-imap/package.json +1 -1
- package/packages/mailx-settings/index.d.ts.map +1 -1
- package/packages/mailx-settings/index.js +9 -2
- package/packages/mailx-settings/index.js.map +1 -1
- package/packages/mailx-settings/index.ts +9 -2
- package/packages/mailx-settings/package.json +1 -1
|
@@ -39,6 +39,17 @@ let touchWasScroll = false;
|
|
|
39
39
|
let currentSort = "date";
|
|
40
40
|
let currentSortDir: "asc" | "desc" = "desc";
|
|
41
41
|
|
|
42
|
+
/** Generation counter — incremented on every load* call (loadMessages,
|
|
43
|
+
* loadUnifiedInbox, loadSearchResults). Each load captures the current
|
|
44
|
+
* value at the top, then checks before rendering — if the captured gen
|
|
45
|
+
* no longer matches `loadGen`, the user has switched to a different
|
|
46
|
+
* view in the meantime and this stale response should be silently
|
|
47
|
+
* dropped instead of overwriting the new view. Bob 2026-05-08:
|
|
48
|
+
* rapid-fire folder clicks were producing "list shows folder X but
|
|
49
|
+
* preview shows folder Y" because folder X's getMessages eventually
|
|
50
|
+
* resolved AFTER folder Y's render and clobbered it. */
|
|
51
|
+
let loadGen = 0;
|
|
52
|
+
|
|
42
53
|
/** Single source of truth for "which row is focused" in the list.
|
|
43
54
|
*
|
|
44
55
|
* Each rendered row is a `MessageRow` instance owning its DOM element,
|
|
@@ -482,6 +493,7 @@ export function clearSearchMode(): void {
|
|
|
482
493
|
|
|
483
494
|
/** Load unified inbox (all accounts) */
|
|
484
495
|
export async function loadUnifiedInbox(autoSelect = true): Promise<void> {
|
|
496
|
+
const myGen = ++loadGen;
|
|
485
497
|
unifiedMode = true;
|
|
486
498
|
searchMode = false;
|
|
487
499
|
currentSpecialUse = "";
|
|
@@ -503,6 +515,7 @@ export async function loadUnifiedInbox(autoSelect = true): Promise<void> {
|
|
|
503
515
|
|
|
504
516
|
try {
|
|
505
517
|
const result = await apiGetUnifiedInbox(1);
|
|
518
|
+
if (myGen !== loadGen) return; // user moved on; drop stale response
|
|
506
519
|
totalMessages = result.total;
|
|
507
520
|
|
|
508
521
|
if (result.items.length === 0) {
|
|
@@ -522,6 +535,7 @@ export async function loadUnifiedInbox(autoSelect = true): Promise<void> {
|
|
|
522
535
|
}
|
|
523
536
|
} catch (e: any) {
|
|
524
537
|
if (e.name === "AbortError") return;
|
|
538
|
+
if (myGen !== loadGen) return;
|
|
525
539
|
body.innerHTML = `<div class="ml-empty">Error: ${e.message}</div>`;
|
|
526
540
|
}
|
|
527
541
|
}
|
|
@@ -531,6 +545,7 @@ export async function loadUnifiedInbox(autoSelect = true): Promise<void> {
|
|
|
531
545
|
* per-folder search ALWAYS returns matches in that folder, including when
|
|
532
546
|
* the folder itself is trash/junk. */
|
|
533
547
|
export async function loadSearchResults(query: string, scope = "all", accountId = "", folderId = 0, includeTrashSpam = false): Promise<void> {
|
|
548
|
+
const myGen = ++loadGen;
|
|
534
549
|
// Capture the pre-search mode on the first transition only — repeated
|
|
535
550
|
// searches (typing more characters) shouldn't overwrite it with the
|
|
536
551
|
// intermediate `unifiedMode = false` state.
|
|
@@ -562,6 +577,7 @@ export async function loadSearchResults(query: string, scope = "all", accountId
|
|
|
562
577
|
const source = scope === "current" && accountId
|
|
563
578
|
? await apiGetMessages(accountId, folderId, 1, 10000)
|
|
564
579
|
: await apiGetUnifiedInbox(1, 10000);
|
|
580
|
+
if (myGen !== loadGen) return;
|
|
565
581
|
const matches = source.items.filter((m: any) =>
|
|
566
582
|
regex.test(m.subject || "") || regex.test(m.from?.name || "") || regex.test(m.from?.address || "") || regex.test(m.preview || "")
|
|
567
583
|
);
|
|
@@ -575,6 +591,7 @@ export async function loadSearchResults(query: string, scope = "all", accountId
|
|
|
575
591
|
}
|
|
576
592
|
|
|
577
593
|
const result = await searchMessages(query, 1, 50, scope, accountId, folderId, includeTrashSpam);
|
|
594
|
+
if (myGen !== loadGen) return;
|
|
578
595
|
totalMessages = result.total;
|
|
579
596
|
|
|
580
597
|
if (result.items.length === 0) {
|
|
@@ -595,6 +612,7 @@ export async function loadSearchResults(query: string, scope = "all", accountId
|
|
|
595
612
|
}
|
|
596
613
|
|
|
597
614
|
export async function loadMessages(accountId: string, folderId: number, page = 1, specialUse = "", autoSelect = true): Promise<void> {
|
|
615
|
+
const myGen = ++loadGen;
|
|
598
616
|
searchMode = false;
|
|
599
617
|
unifiedMode = false;
|
|
600
618
|
// Folder switch clears any in-progress multi-select — carrying a "3
|
|
@@ -634,6 +652,10 @@ export async function loadMessages(accountId: string, folderId: number, page = 1
|
|
|
634
652
|
try {
|
|
635
653
|
const flaggedOnly = document.getElementById("ml-body")?.classList.contains("flagged-only") || false;
|
|
636
654
|
const result = await apiGetMessages(accountId, folderId, 1, 50, flaggedOnly, currentSort, currentSortDir);
|
|
655
|
+
// Stale-response guard: a newer load* fired while we were
|
|
656
|
+
// awaiting; the new view already painted. Drop this result
|
|
657
|
+
// silently rather than overwriting.
|
|
658
|
+
if (myGen !== loadGen) return;
|
|
637
659
|
totalMessages = result.total;
|
|
638
660
|
updateSortIndicators();
|
|
639
661
|
|
|
@@ -650,12 +672,14 @@ export async function loadMessages(accountId: string, folderId: number, page = 1
|
|
|
650
672
|
selectFirst(body);
|
|
651
673
|
} else {
|
|
652
674
|
requestAnimationFrame(() => {
|
|
675
|
+
if (myGen !== loadGen) return;
|
|
653
676
|
body.scrollTop = savedScroll;
|
|
654
677
|
restoreSelection(body, savedUid);
|
|
655
678
|
});
|
|
656
679
|
}
|
|
657
680
|
} catch (e: any) {
|
|
658
681
|
if (e.name === "AbortError") return;
|
|
682
|
+
if (myGen !== loadGen) return; // user moved on; suppress the error message
|
|
659
683
|
body.innerHTML = `<div class="ml-empty">Error: ${e.message}</div>`;
|
|
660
684
|
}
|
|
661
685
|
}
|
|
@@ -34,13 +34,36 @@ export async function openOutboxView() {
|
|
|
34
34
|
return;
|
|
35
35
|
}
|
|
36
36
|
const fmtDate = (ms) => new Date(ms).toLocaleString();
|
|
37
|
-
|
|
37
|
+
const ageStr = (ms) => {
|
|
38
|
+
const sec = Math.max(0, Math.round((Date.now() - ms) / 1000));
|
|
39
|
+
if (sec < 60)
|
|
40
|
+
return `${sec}s`;
|
|
41
|
+
if (sec < 3600)
|
|
42
|
+
return `${Math.round(sec / 60)}m`;
|
|
43
|
+
return `${Math.round(sec / 3600)}h`;
|
|
44
|
+
};
|
|
45
|
+
listEl.innerHTML = items.map((m, i) => {
|
|
46
|
+
// A claim that's "young" (under 60 s) is presumed actively
|
|
47
|
+
// sending. Older claims are stuck — server hung mid-SMTP,
|
|
48
|
+
// worker crashed, PID recycled. Cancel must always be
|
|
49
|
+
// available either way: the user knows their intent. A
|
|
50
|
+
// brief in-flight cancel may cross the SMTP-send window
|
|
51
|
+
// (rare; <60s of risk), in which case the X-Mailx-Retry +
|
|
52
|
+
// Message-ID dedup on the receiving end catches the dup.
|
|
53
|
+
const claimAge = m.claimed ? ageStr(m.createdAt) : "";
|
|
54
|
+
const stuckClaim = m.claimed && (Date.now() - m.createdAt) > 60_000;
|
|
55
|
+
const claimBadge = !m.claimed
|
|
56
|
+
? ""
|
|
57
|
+
: stuckClaim
|
|
58
|
+
? `<span class="ob-badge ob-stuck" title="Claim is older than 60 s — likely stuck. Cancel will drop it.">stuck (${claimAge})</span>`
|
|
59
|
+
: `<span class="ob-badge ob-claimed" title="Sending now on this host">sending… (${claimAge})</span>`;
|
|
60
|
+
return `
|
|
38
61
|
<div class="ob-row ob-pink" data-idx="${i}">
|
|
39
62
|
<div class="ob-row-hdr">
|
|
40
63
|
<span class="ob-acct">${escapeHtml(m.accountId)}</span>
|
|
41
64
|
<span class="ob-subject">${escapeHtml(m.subject || "(no subject)")}</span>
|
|
42
65
|
<span class="ob-created">${fmtDate(m.createdAt)}</span>
|
|
43
|
-
${
|
|
66
|
+
${claimBadge}
|
|
44
67
|
${m.attempts > 0 ? `<span class="ob-badge ob-retry" title="Retry attempts made so far">retry ×${m.attempts}</span>` : ""}
|
|
45
68
|
</div>
|
|
46
69
|
<div class="ob-row-meta">
|
|
@@ -51,13 +74,17 @@ export async function openOutboxView() {
|
|
|
51
74
|
</div>
|
|
52
75
|
<div class="ob-row-path">${escapeHtml(m.path)}</div>
|
|
53
76
|
<div class="ob-row-actions">
|
|
54
|
-
<button type="button" class="ob-cancel"
|
|
77
|
+
<button type="button" class="ob-cancel">Cancel</button>
|
|
55
78
|
</div>
|
|
56
|
-
</div
|
|
79
|
+
</div>`;
|
|
80
|
+
}).join("");
|
|
57
81
|
listEl.querySelectorAll(".ob-row").forEach((row, idx) => {
|
|
58
82
|
row.querySelector(".ob-cancel").addEventListener("click", async () => {
|
|
59
83
|
const m = items[idx];
|
|
60
|
-
|
|
84
|
+
const warning = m.claimed
|
|
85
|
+
? `This message is currently in flight to the SMTP server.\nIf SMTP already finished, the recipient may have received it.\nMessage-ID dedup catches the duplicate side; cancel is still safe.\n\nDrop anyway?\n\nTo: ${m.to}\nSubject: ${m.subject}`
|
|
86
|
+
: `Drop this queued message?\n\nTo: ${m.to}\nSubject: ${m.subject}`;
|
|
87
|
+
if (!confirm(warning))
|
|
61
88
|
return;
|
|
62
89
|
try {
|
|
63
90
|
await cancelQueuedOutgoing(m.path);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"outbox-view.js","sourceRoot":"","sources":["outbox-view.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AACH,OAAO,EAAE,kBAAkB,EAAE,oBAAoB,EAAE,MAAM,sBAAsB,CAAC;AAEhF,IAAI,MAAM,GAAG,KAAK,CAAC;AAEnB,MAAM,CAAC,KAAK,UAAU,cAAc;IAChC,IAAI,MAAM;QAAE,OAAO;IACnB,MAAM,GAAG,IAAI,CAAC;IAEd,MAAM,QAAQ,GAAG,QAAQ,CAAC,aAAa,CAAC,KAAK,CAAC,CAAC;IAC/C,QAAQ,CAAC,SAAS,GAAG,sBAAsB,CAAC;IAC5C,MAAM,KAAK,GAAG,QAAQ,CAAC,aAAa,CAAC,KAAK,CAAC,CAAC;IAC5C,KAAK,CAAC,SAAS,GAAG,8BAA8B,CAAC;IACjD,KAAK,CAAC,SAAS,GAAG;;;;;;;;;;;eAWP,CAAC;IACZ,QAAQ,CAAC,WAAW,CAAC,KAAK,CAAC,CAAC;IAC5B,QAAQ,CAAC,IAAI,CAAC,WAAW,CAAC,QAAQ,CAAC,CAAC;IACpC,MAAM,MAAM,GAAG,KAAK,CAAC,aAAa,CAAc,UAAU,CAAE,CAAC;IAE7D,MAAM,UAAU,GAAG,CAAC,KAAY,EAAE,EAAE;QAChC,IAAI,KAAK,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;YACrB,MAAM,CAAC,SAAS,GAAG,mEAAmE,CAAC;YACvF,OAAO;QACX,CAAC;QACD,MAAM,OAAO,GAAG,CAAC,EAAU,EAAE,EAAE,CAAC,IAAI,IAAI,CAAC,EAAE,CAAC,CAAC,cAAc,EAAE,CAAC;QAC9D,MAAM,CAAC,
|
|
1
|
+
{"version":3,"file":"outbox-view.js","sourceRoot":"","sources":["outbox-view.ts"],"names":[],"mappings":"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"}
|
|
@@ -37,13 +37,34 @@ export async function openOutboxView(): Promise<void> {
|
|
|
37
37
|
return;
|
|
38
38
|
}
|
|
39
39
|
const fmtDate = (ms: number) => new Date(ms).toLocaleString();
|
|
40
|
-
|
|
40
|
+
const ageStr = (ms: number): string => {
|
|
41
|
+
const sec = Math.max(0, Math.round((Date.now() - ms) / 1000));
|
|
42
|
+
if (sec < 60) return `${sec}s`;
|
|
43
|
+
if (sec < 3600) return `${Math.round(sec / 60)}m`;
|
|
44
|
+
return `${Math.round(sec / 3600)}h`;
|
|
45
|
+
};
|
|
46
|
+
listEl.innerHTML = items.map((m, i) => {
|
|
47
|
+
// A claim that's "young" (under 60 s) is presumed actively
|
|
48
|
+
// sending. Older claims are stuck — server hung mid-SMTP,
|
|
49
|
+
// worker crashed, PID recycled. Cancel must always be
|
|
50
|
+
// available either way: the user knows their intent. A
|
|
51
|
+
// brief in-flight cancel may cross the SMTP-send window
|
|
52
|
+
// (rare; <60s of risk), in which case the X-Mailx-Retry +
|
|
53
|
+
// Message-ID dedup on the receiving end catches the dup.
|
|
54
|
+
const claimAge = m.claimed ? ageStr(m.createdAt) : "";
|
|
55
|
+
const stuckClaim = m.claimed && (Date.now() - m.createdAt) > 60_000;
|
|
56
|
+
const claimBadge = !m.claimed
|
|
57
|
+
? ""
|
|
58
|
+
: stuckClaim
|
|
59
|
+
? `<span class="ob-badge ob-stuck" title="Claim is older than 60 s — likely stuck. Cancel will drop it.">stuck (${claimAge})</span>`
|
|
60
|
+
: `<span class="ob-badge ob-claimed" title="Sending now on this host">sending… (${claimAge})</span>`;
|
|
61
|
+
return `
|
|
41
62
|
<div class="ob-row ob-pink" data-idx="${i}">
|
|
42
63
|
<div class="ob-row-hdr">
|
|
43
64
|
<span class="ob-acct">${escapeHtml(m.accountId)}</span>
|
|
44
65
|
<span class="ob-subject">${escapeHtml(m.subject || "(no subject)")}</span>
|
|
45
66
|
<span class="ob-created">${fmtDate(m.createdAt)}</span>
|
|
46
|
-
${
|
|
67
|
+
${claimBadge}
|
|
47
68
|
${m.attempts > 0 ? `<span class="ob-badge ob-retry" title="Retry attempts made so far">retry ×${m.attempts}</span>` : ""}
|
|
48
69
|
</div>
|
|
49
70
|
<div class="ob-row-meta">
|
|
@@ -54,13 +75,17 @@ export async function openOutboxView(): Promise<void> {
|
|
|
54
75
|
</div>
|
|
55
76
|
<div class="ob-row-path">${escapeHtml(m.path)}</div>
|
|
56
77
|
<div class="ob-row-actions">
|
|
57
|
-
<button type="button" class="ob-cancel"
|
|
78
|
+
<button type="button" class="ob-cancel">Cancel</button>
|
|
58
79
|
</div>
|
|
59
|
-
</div
|
|
80
|
+
</div>`;
|
|
81
|
+
}).join("");
|
|
60
82
|
listEl.querySelectorAll<HTMLElement>(".ob-row").forEach((row, idx) => {
|
|
61
83
|
row.querySelector<HTMLButtonElement>(".ob-cancel")!.addEventListener("click", async () => {
|
|
62
84
|
const m = items[idx];
|
|
63
|
-
|
|
85
|
+
const warning = m.claimed
|
|
86
|
+
? `This message is currently in flight to the SMTP server.\nIf SMTP already finished, the recipient may have received it.\nMessage-ID dedup catches the duplicate side; cancel is still safe.\n\nDrop anyway?\n\nTo: ${m.to}\nSubject: ${m.subject}`
|
|
87
|
+
: `Drop this queued message?\n\nTo: ${m.to}\nSubject: ${m.subject}`;
|
|
88
|
+
if (!confirm(warning)) return;
|
|
64
89
|
try {
|
|
65
90
|
await cancelQueuedOutgoing(m.path);
|
|
66
91
|
await reload();
|
|
@@ -983,103 +983,122 @@ document.getElementById("btn-send")?.addEventListener("click", async () => {
|
|
|
983
983
|
alert("Please add at least one To recipient.");
|
|
984
984
|
return;
|
|
985
985
|
}
|
|
986
|
+
// Local-first send: validate fast in the client, then fire-and-forget
|
|
987
|
+
// the IPC and close compose IMMEDIATELY. The body is already snapshotted
|
|
988
|
+
// into `body` above; nothing the user types after this point can affect
|
|
989
|
+
// what gets sent.
|
|
990
|
+
//
|
|
991
|
+
// Earlier "wait for IPC ack before closing" version produced a real,
|
|
992
|
+
// user-reported bug: clicking Send → typing for a few hundred ms while
|
|
993
|
+
// the IPC was in flight → IPC returns → compose closes mid-keystroke
|
|
994
|
+
// and the user's last edits go nowhere. The fix is structural: send
|
|
995
|
+
// is committed by the client validation + the snapshot, NOT by the
|
|
996
|
+
// server's reply. The server-side write is synchronous-on-disk inside
|
|
997
|
+
// service.send() (queueOutgoingLocal), and any failure surfaces as a
|
|
998
|
+
// top-level banner via the parent's `mailx-send-error` postMessage.
|
|
999
|
+
//
|
|
1000
|
+
// Client-side regex validation up front so a typo'd address bounces
|
|
1001
|
+
// back inline (compose stays open) instead of disappearing into a
|
|
1002
|
+
// toast after compose has closed. Same pattern Outlook/Thunderbird use.
|
|
1003
|
+
const emailRe = /^[^\s<>@]+@[^\s<>@]+\.[^\s<>@]+$/;
|
|
1004
|
+
const badAddress = (list) => {
|
|
1005
|
+
if (!list)
|
|
1006
|
+
return null;
|
|
1007
|
+
for (const a of list) {
|
|
1008
|
+
const addr = (a?.address || "").trim();
|
|
1009
|
+
if (!addr)
|
|
1010
|
+
continue; // empty fragments are dropped
|
|
1011
|
+
if (!emailRe.test(addr))
|
|
1012
|
+
return addr;
|
|
1013
|
+
}
|
|
1014
|
+
return null;
|
|
1015
|
+
};
|
|
1016
|
+
const bad = badAddress(body.to) || badAddress(body.cc) || badAddress(body.bcc);
|
|
1017
|
+
if (bad) {
|
|
1018
|
+
logClientEvent("compose-send-rejected-bad-addr", { addr: bad });
|
|
1019
|
+
const statusEl = document.getElementById("compose-status");
|
|
1020
|
+
if (statusEl)
|
|
1021
|
+
statusEl.textContent = `Invalid address: "${bad}"`;
|
|
1022
|
+
else
|
|
1023
|
+
alert(`Invalid address: "${bad}"`);
|
|
1024
|
+
return;
|
|
1025
|
+
}
|
|
986
1026
|
console.log(`[compose] Send clicked: from=${body.from} to=${JSON.stringify(body.to)} subject="${body.subject}" attachments=${body.attachments.length}`);
|
|
987
|
-
//
|
|
988
|
-
//
|
|
989
|
-
//
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1027
|
+
// Stop autosave NOW — the body we're sending is the body we're sending,
|
|
1028
|
+
// and we don't want a stray autosave to write a stale "still typing"
|
|
1029
|
+
// copy back to the Drafts folder while SMTP is in flight.
|
|
1030
|
+
if (draftTimer) {
|
|
1031
|
+
clearInterval(draftTimer);
|
|
1032
|
+
draftTimer = null;
|
|
1033
|
+
}
|
|
1034
|
+
// Record From-address history before close. Only manual values worth
|
|
1035
|
+
// keeping — skip anything that exactly matches a known account.
|
|
1036
|
+
try {
|
|
1037
|
+
const raw = fromInput.value.trim();
|
|
1038
|
+
const known = knownAccounts.some(a => formatAccountFrom(a) === raw);
|
|
1039
|
+
if (raw && !known && /@.+\./.test(raw))
|
|
1040
|
+
recordFromHistory(raw);
|
|
1041
|
+
}
|
|
1042
|
+
catch { /* */ }
|
|
1043
|
+
if (draftUid || draftId) {
|
|
1044
|
+
deleteDraft(getFromAccountId(), draftUid || 0, draftId || "").catch(() => { });
|
|
1045
|
+
}
|
|
1001
1046
|
const sendStart = Date.now();
|
|
1002
1047
|
logClientEvent("compose-send-pre-ipc");
|
|
1003
|
-
//
|
|
1004
|
-
//
|
|
1005
|
-
//
|
|
1006
|
-
//
|
|
1007
|
-
|
|
1008
|
-
// parent window's bridge is proven — getAccounts / getOutboxStatus run
|
|
1009
|
-
// through it every few seconds with no failures.
|
|
1010
|
-
//
|
|
1011
|
-
// Fix: the iframe doesn't touch the bridge at all for send. It posts
|
|
1012
|
-
// a request to the parent, and the parent calls the real sendMessage
|
|
1013
|
-
// from its own frame. Parent posts the result back. This bypasses
|
|
1014
|
-
// whatever is wrong with iframe-scoped IPC.
|
|
1015
|
-
const ipcPromise = new Promise((resolve, reject) => {
|
|
1048
|
+
// Fire the parent-relay IPC and don't await it. Parent will postMessage
|
|
1049
|
+
// a `mailx-send-error` to the parent window on failure; the app handles
|
|
1050
|
+
// that with a top-level banner. On success the outbox-status pill picks
|
|
1051
|
+
// up the queued message via the daemon's outboxStatus event.
|
|
1052
|
+
try {
|
|
1016
1053
|
const reqId = `send-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
reject(new Error("parent-relay send timeout (120s)"));
|
|
1020
|
-
}, 120000);
|
|
1054
|
+
// Result listener: log only. Compose is already closed by the time
|
|
1055
|
+
// any of these fire.
|
|
1021
1056
|
const onMsg = (ev) => {
|
|
1022
1057
|
if (!ev.data || ev.data.type !== "mailx-compose-send-result" || ev.data.id !== reqId)
|
|
1023
1058
|
return;
|
|
1024
|
-
clearTimeout(timer);
|
|
1025
1059
|
window.removeEventListener("message", onMsg);
|
|
1026
|
-
if (ev.data.ok)
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1060
|
+
if (ev.data.ok) {
|
|
1061
|
+
logClientEvent("compose-send-ipc-resolved", { ms: Date.now() - sendStart });
|
|
1062
|
+
}
|
|
1063
|
+
else {
|
|
1064
|
+
const msg = ev.data.error || "unknown";
|
|
1065
|
+
logClientEvent("compose-send-ipc-rejected", { error: msg, ms: Date.now() - sendStart });
|
|
1066
|
+
// Bubble the error up so the parent can show a banner. The
|
|
1067
|
+
// dropped-into-outbox path (queueOutgoingLocal) catches most
|
|
1068
|
+
// failures locally, so this branch fires only when the
|
|
1069
|
+
// *queue write itself* failed — rare, but the user must see
|
|
1070
|
+
// it because the message would otherwise be lost silently.
|
|
1071
|
+
try {
|
|
1072
|
+
parent.postMessage({ type: "mailx-send-error", message: msg, accountId: body.from }, "*");
|
|
1073
|
+
}
|
|
1074
|
+
catch { /* */ }
|
|
1075
|
+
}
|
|
1030
1076
|
};
|
|
1031
1077
|
window.addEventListener("message", onMsg);
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
.then(() => {
|
|
1044
|
-
logClientEvent("compose-send-ipc-resolved", { ms: Date.now() - sendStart });
|
|
1045
|
-
console.log(`[compose] Send IPC returned OK in ${Date.now() - sendStart}ms`);
|
|
1046
|
-
// Record From-address history on successful send. Only manual
|
|
1047
|
-
// values worth keeping — skip anything that exactly matches a
|
|
1048
|
-
// known account (already in the dropdown), and skip obviously
|
|
1049
|
-
// invalid inputs. Populated dropdown surfaces this next time.
|
|
1050
|
-
try {
|
|
1051
|
-
const raw = fromInput.value.trim();
|
|
1052
|
-
const known = knownAccounts.some(a => formatAccountFrom(a) === raw);
|
|
1053
|
-
if (raw && !known && /@.+\./.test(raw))
|
|
1054
|
-
recordFromHistory(raw);
|
|
1055
|
-
}
|
|
1056
|
-
catch { /* */ }
|
|
1057
|
-
// Stop autosave only after ACK — if send threw we want the draft
|
|
1058
|
-
// autosave to keep the message safe.
|
|
1059
|
-
if (draftTimer) {
|
|
1060
|
-
clearInterval(draftTimer);
|
|
1061
|
-
draftTimer = null;
|
|
1062
|
-
}
|
|
1063
|
-
if (draftUid || draftId) {
|
|
1064
|
-
deleteDraft(getFromAccountId(), draftUid || 0, draftId || "").catch(() => { });
|
|
1065
|
-
}
|
|
1066
|
-
closeCompose();
|
|
1067
|
-
})
|
|
1068
|
-
.catch((e) => {
|
|
1078
|
+
// Safety: if parent never replies (msger pipe broken), prune the
|
|
1079
|
+
// listener after 120s so we don't leak it. No retry here — the
|
|
1080
|
+
// daemon's outbox worker is the retry path.
|
|
1081
|
+
setTimeout(() => window.removeEventListener("message", onMsg), 120000);
|
|
1082
|
+
parent.postMessage({ type: "mailx-compose-send", id: reqId, body }, "*");
|
|
1083
|
+
logClientEvent("compose-send-ipc-invoked", { via: "parent-relay", reqId });
|
|
1084
|
+
}
|
|
1085
|
+
catch (e) {
|
|
1086
|
+
// postMessage itself threw — bridge totally dead. This is the only
|
|
1087
|
+
// path where we keep compose open, since we couldn't even hand the
|
|
1088
|
+
// body off.
|
|
1069
1089
|
const msg = e?.message || String(e);
|
|
1070
|
-
logClientEvent("compose-send-ipc-
|
|
1071
|
-
|
|
1090
|
+
logClientEvent("compose-send-ipc-throw", { error: msg });
|
|
1091
|
+
const sendBtn = document.getElementById("btn-send");
|
|
1072
1092
|
if (sendBtn) {
|
|
1073
1093
|
sendBtn.disabled = false;
|
|
1074
1094
|
sendBtn.textContent = "Send";
|
|
1075
1095
|
}
|
|
1096
|
+
const statusEl = document.getElementById("compose-status");
|
|
1076
1097
|
if (statusEl)
|
|
1077
|
-
statusEl.textContent = `
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
catch { /* */ }
|
|
1082
|
-
});
|
|
1098
|
+
statusEl.textContent = `Bridge error: ${msg}`;
|
|
1099
|
+
return;
|
|
1100
|
+
}
|
|
1101
|
+
closeCompose();
|
|
1083
1102
|
});
|
|
1084
1103
|
// ── Close handling ──
|
|
1085
1104
|
/** True if the compose has anything worth asking about. */
|