@bobfrankston/rmfmail 1.0.679 → 1.0.681

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/client/app.js CHANGED
@@ -174,9 +174,13 @@ function updateBadge(count) {
174
174
  async function updateNewMessageCount() {
175
175
  try {
176
176
  const accounts = await getAccounts();
177
+ // Fan out folder queries in parallel — earlier code awaited each
178
+ // account's `getFolders` in series, so an N-account setup paid N
179
+ // back-to-back IPC round-trips on every count refresh (folderCountsChanged,
180
+ // sync events, IDLE updates).
181
+ const folderLists = await Promise.all(accounts.map((acct) => getFolders(acct.id).catch(() => [])));
177
182
  let totalUnread = 0;
178
- for (const acct of accounts) {
179
- const folders = await getFolders(acct.id);
183
+ for (const folders of folderLists) {
180
184
  const inbox = folders.find((f) => f.specialUse === "inbox");
181
185
  if (inbox)
182
186
  totalUnread += inbox.unreadCount || 0;
@@ -252,9 +256,10 @@ window.addEventListener("focus", stopTitleFlash);
252
256
  /** Call when user actively views messages — resets the badge */
253
257
  function markAsSeen() {
254
258
  getAccounts().then(async (accounts) => {
259
+ // Parallel folder fetch — see updateNewMessageCount for rationale.
260
+ const folderLists = await Promise.all(accounts.map((acct) => getFolders(acct.id).catch(() => [])));
255
261
  let total = 0;
256
- for (const acct of accounts) {
257
- const folders = await getFolders(acct.id);
262
+ for (const folders of folderLists) {
258
263
  const inbox = folders.find((f) => f.specialUse === "inbox");
259
264
  if (inbox)
260
265
  total += inbox.unreadCount || 0;
@@ -750,9 +755,29 @@ async function openCompose(mode) {
750
755
  console.warn(`[compose] ${mode} — no message selected`);
751
756
  return;
752
757
  }
753
- const accounts = await getAccounts();
754
- const accountId = current?.accountId || accounts[0]?.id || "";
758
+ // Parallel-load: kick off getAccounts AND open the iframe in the same
759
+ // tick. The iframe doesn't need the account list until after its editor
760
+ // bootstraps (200-500 ms for TinyMCE, less for Quill); by then the IPC
761
+ // round-trip has resolved. Earlier code awaited getAccounts FIRST,
762
+ // adding the IPC latency to the perceived Ctrl+N → editor-visible time.
763
+ // We post `compose-init-ready` to the iframe once init is in
764
+ // sessionStorage so compose.ts's IIFE can read synchronously without
765
+ // polling.
766
+ const accountsP = getAccounts();
755
767
  const msg = current?.message;
768
+ // Title bar text needs the subject from msg (no IPC dependency) — build
769
+ // it now so the iframe can be opened with the final title and avoid a
770
+ // flash of placeholder text.
771
+ const titlePrefix = mode === "reply" ? "Reply" :
772
+ mode === "replyAll" ? "Reply All" :
773
+ mode === "forward" ? "Forward" :
774
+ "Compose";
775
+ const titleSubject = mode === "new" ? "" : (msg?.subject || "");
776
+ const frame = showComposeOverlay(titleSubject ? `${titlePrefix}: ${titleSubject}` : titlePrefix);
777
+ // Now finish initialisation off the critical path — editor bootstrap
778
+ // inside the iframe runs concurrently with this await.
779
+ const accounts = await accountsP;
780
+ const accountId = current?.accountId || accounts[0]?.id || "";
756
781
  const rePrefix = /^(re|fwd?):\s*/i;
757
782
  const cleanSubject = msg ? msg.subject.replace(rePrefix, "") : "";
758
783
  const init = {
@@ -764,7 +789,7 @@ async function openCompose(mode) {
764
789
  bodyHtml: "",
765
790
  inReplyTo: "",
766
791
  references: [],
767
- accounts: accounts.map((a) => ({ id: a.id, name: a.name, email: a.email, signature: a.signature })),
792
+ accounts: accounts.map((a) => ({ id: a.id, name: a.name, email: a.email, signature: a.signature, sig: a.sig })),
768
793
  };
769
794
  // Auto-detect reply From: if the message was delivered to an identity address
770
795
  // (an alias on the account's domain, or the explicit `identityDomains` list
@@ -838,19 +863,16 @@ async function openCompose(mode) {
838
863
  init.bodyHtml = forwardBody(msg);
839
864
  init.fromAddress = detectReplyFrom();
840
865
  }
841
- // Store init data for compose window to pick up
866
+ // Store init data for compose window to pick up. sessionStorage is the
867
+ // canonical handoff path — same origin between parent and iframe; the
868
+ // compose IIFE reads it after the editor finishes booting. We also
869
+ // postMessage the iframe so it can short-circuit the listen-for-message
870
+ // wait if it's already past editor init.
842
871
  sessionStorage.setItem("composeInit", JSON.stringify(init));
843
- // Inline compose: load compose.html in an overlay iframe (same origin, same IPC)
844
- // Popup windows don't work in IPC mode (custom protocol doesn't propagate to child windows)
845
- // Title reflects mode + subject so the user can see what they're replying to
846
- // ("Re: Stars of STEM 2026" instead of just "Compose"). Forward shows the
847
- // forward target subject; new compose stays generic.
848
- const titlePrefix = mode === "reply" ? "Reply" :
849
- mode === "replyAll" ? "Reply All" :
850
- mode === "forward" ? "Forward" :
851
- "Compose";
852
- const titleSubject = mode === "new" ? "" : (msg?.subject || init.subject || "");
853
- showComposeOverlay(titleSubject ? `${titlePrefix}: ${titleSubject}` : titlePrefix);
872
+ try {
873
+ frame?.contentWindow?.postMessage({ type: "compose-init-ready" }, "*");
874
+ }
875
+ catch { /* */ }
854
876
  }
855
877
  function showComposeOverlay(title = "Compose") {
856
878
  const wrapper = document.createElement("div");
@@ -1000,6 +1022,7 @@ function showComposeOverlay(title = "Compose") {
1000
1022
  if (!isSmall)
1001
1023
  addComposeResizeHandles(wrapper, frame);
1002
1024
  document.body.appendChild(wrapper);
1025
+ return frame;
1003
1026
  }
1004
1027
  /** Drop a transparent full-viewport shield in front of every other element
1005
1028
  * so mousemove events stay in the document during a drag. Setting
@@ -1104,6 +1127,12 @@ function sanitizeQuotedBody(msg) {
1104
1127
  // future provider/path that didn't go through that pipeline.
1105
1128
  const isPlainText = !msg.bodyHtml;
1106
1129
  if (isPlainText) {
1130
+ // Full HTML escape. Leaving `>` unescaped was tempting for source
1131
+ // readability but breaks HTML in edge cases — TinyMCE's normalize-
1132
+ // on-paste re-interprets the input, and stray `>` near sequences
1133
+ // like `<!--` / `-->` / `<!` in plain-text bodies can be misread
1134
+ // by the parser. Per Bob 2026-05-12: "not just ugly, it breaks
1135
+ // the HTML." Trivial source-clutter is the lesser evil.
1107
1136
  const escaped = String(msg.bodyText || "")
1108
1137
  .replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
1109
1138
  return `<div style="white-space:pre-wrap;font-family:inherit;margin:0">${escaped}</div>`;
@@ -2640,7 +2669,12 @@ onWsEvent((event) => {
2640
2669
  * rather than a single line, escaping `<` so embedded angle brackets in a
2641
2670
  * signature/template don't get interpreted as tags. */
2642
2671
  async function openComposeFromMailto(m) {
2643
- const accounts = await getAccounts();
2672
+ // Open the iframe immediately and load accounts in parallel — same
2673
+ // pattern as openCompose. The mailto handler should never feel like
2674
+ // "waiting for the system" to a user who clicked a link.
2675
+ const accountsP = getAccounts();
2676
+ const frame = showComposeOverlay(m.subject ? `Compose: ${m.subject}` : "Compose");
2677
+ const accounts = await accountsP;
2644
2678
  const accountId = accounts[0]?.id || "";
2645
2679
  const escape = (s) => s.replace(/[&<>]/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;" }[c]));
2646
2680
  const bodyHtml = m.body
@@ -2656,10 +2690,13 @@ async function openComposeFromMailto(m) {
2656
2690
  bodyHtml,
2657
2691
  inReplyTo: m.inReplyTo,
2658
2692
  references: m.inReplyTo ? [m.inReplyTo] : [],
2659
- accounts: accounts.map((a) => ({ id: a.id, name: a.name, email: a.email, signature: a.signature })),
2693
+ accounts: accounts.map((a) => ({ id: a.id, name: a.name, email: a.email, signature: a.signature, sig: a.sig })),
2660
2694
  };
2661
2695
  sessionStorage.setItem("composeInit", JSON.stringify(init));
2662
- showComposeOverlay(m.subject ? `Compose: ${m.subject}` : "Compose");
2696
+ try {
2697
+ frame?.contentWindow?.postMessage({ type: "compose-init-ready" }, "*");
2698
+ }
2699
+ catch { /* */ }
2663
2700
  }
2664
2701
  // ── Keyboard shortcuts ──
2665
2702
  // Capture-phase pre-handler: intercept WebView accelerator keys that would
@@ -4389,8 +4426,11 @@ function renderOutboxStatus(s) {
4389
4426
  setInterval(async () => {
4390
4427
  try {
4391
4428
  const { getOutboxStatus, getDiagnostics } = await import("./lib/api-client.js");
4392
- renderOutboxStatus(await getOutboxStatus());
4393
- renderDiagnosticsBadge(await getDiagnostics());
4429
+ // Run in parallel — neither call depends on the other and each is a
4430
+ // separate IPC round-trip. Earlier code awaited them serially.
4431
+ const [outbox, diag] = await Promise.all([getOutboxStatus(), getDiagnostics()]);
4432
+ renderOutboxStatus(outbox);
4433
+ renderDiagnosticsBadge(diag);
4394
4434
  }
4395
4435
  catch { /* service unreachable */ }
4396
4436
  }, 15000);
@@ -4398,8 +4438,9 @@ setInterval(async () => {
4398
4438
  (async () => {
4399
4439
  try {
4400
4440
  const { getOutboxStatus, getDiagnostics } = await import("./lib/api-client.js");
4401
- renderOutboxStatus(await getOutboxStatus());
4402
- renderDiagnosticsBadge(await getDiagnostics());
4441
+ const [outbox, diag] = await Promise.all([getOutboxStatus(), getDiagnostics()]);
4442
+ renderOutboxStatus(outbox);
4443
+ renderDiagnosticsBadge(diag);
4403
4444
  }
4404
4445
  catch { /* */ }
4405
4446
  })();