@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.
@@ -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
- listEl.innerHTML = items.map((m, i) => `
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
- ${m.claimed ? `<span class="ob-badge ob-claimed" title="Sending now on this host">sending…</span>` : ""}
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" ${m.claimed ? "disabled title='Cannot cancel while in flight'" : ""}>Cancel</button>
77
+ <button type="button" class="ob-cancel">Cancel</button>
55
78
  </div>
56
- </div>`).join("");
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
- if (!confirm(`Drop this queued message?\n\nTo: ${m.to}\nSubject: ${m.subject}`))
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":"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"}
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
- listEl.innerHTML = items.map((m, i) => `
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
- ${m.claimed ? `<span class="ob-badge ob-claimed" title="Sending now on this host">sending…</span>` : ""}
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" ${m.claimed ? "disabled title='Cannot cancel while in flight'" : ""}>Cancel</button>
78
+ <button type="button" class="ob-cancel">Cancel</button>
58
79
  </div>
59
- </div>`).join("");
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
- if (!confirm(`Drop this queued message?\n\nTo: ${m.to}\nSubject: ${m.subject}`)) return;
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
- // Wait for the IPC round-trip before closing compose. The IPC is supposed
988
- // to be <100ms (Node validates + disk-writes synchronously inside send()
989
- // then returns). If it throws OR takes too long, the user keeps their
990
- // typed message and sees the error inline instead of losing it to a
991
- // fire-and-forget that never landed. Earlier fire-and-forget version lost
992
- // messages silently when anything upstream of disk write broke.
993
- const sendBtn = document.getElementById("btn-send");
994
- if (sendBtn) {
995
- sendBtn.disabled = true;
996
- sendBtn.textContent = "Sending…";
997
- }
998
- const statusEl = document.getElementById("compose-status");
999
- if (statusEl)
1000
- statusEl.textContent = "";
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
- // Parent-window relay for send. Empirical observation: Android (direct
1004
- // in-process SMTP) sends reliably; desktop (iframe parent.mailxapi
1005
- // msger Node service) has sendMessage IPCs failing to reach Node
1006
- // for reasons still unknown (iframe bridge behaves differently from the
1007
- // top frame in msger's WebView2 for this specific call). Meanwhile the
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
- const timer = setTimeout(() => {
1018
- window.removeEventListener("message", onMsg);
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
- resolve();
1028
- else
1029
- reject(new Error(ev.data.error || "unknown"));
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
- try {
1033
- parent.postMessage({ type: "mailx-compose-send", id: reqId, body }, "*");
1034
- logClientEvent("compose-send-ipc-invoked", { via: "parent-relay", reqId });
1035
- }
1036
- catch (e) {
1037
- clearTimeout(timer);
1038
- window.removeEventListener("message", onMsg);
1039
- reject(e);
1040
- }
1041
- });
1042
- Promise.resolve(ipcPromise)
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-rejected", { error: msg, ms: Date.now() - sendStart });
1071
- console.error(`[compose] Send IPC failed after ${Date.now() - sendStart}ms: ${msg}`);
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 = `Send failed: ${msg}`;
1078
- try {
1079
- parent.postMessage({ type: "mailx-send-error", message: msg, accountId: body.from }, "*");
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. */