@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.ts CHANGED
@@ -171,9 +171,15 @@ function updateBadge(count: number): void {
171
171
  async function updateNewMessageCount(): Promise<void> {
172
172
  try {
173
173
  const accounts = await getAccounts();
174
+ // Fan out folder queries in parallel — earlier code awaited each
175
+ // account's `getFolders` in series, so an N-account setup paid N
176
+ // back-to-back IPC round-trips on every count refresh (folderCountsChanged,
177
+ // sync events, IDLE updates).
178
+ const folderLists = await Promise.all(
179
+ accounts.map((acct: any) => getFolders(acct.id).catch(() => [] as any[])),
180
+ );
174
181
  let totalUnread = 0;
175
- for (const acct of accounts) {
176
- const folders = await getFolders(acct.id);
182
+ for (const folders of folderLists) {
177
183
  const inbox = folders.find((f: any) => f.specialUse === "inbox");
178
184
  if (inbox) totalUnread += inbox.unreadCount || 0;
179
185
  }
@@ -242,9 +248,12 @@ window.addEventListener("focus", stopTitleFlash);
242
248
  /** Call when user actively views messages — resets the badge */
243
249
  function markAsSeen(): void {
244
250
  getAccounts().then(async (accounts: any[]) => {
251
+ // Parallel folder fetch — see updateNewMessageCount for rationale.
252
+ const folderLists = await Promise.all(
253
+ accounts.map((acct: any) => getFolders(acct.id).catch(() => [] as any[])),
254
+ );
245
255
  let total = 0;
246
- for (const acct of accounts) {
247
- const folders = await getFolders(acct.id);
256
+ for (const folders of folderLists) {
248
257
  const inbox = folders.find((f: any) => f.specialUse === "inbox");
249
258
  if (inbox) total += inbox.unreadCount || 0;
250
259
  }
@@ -718,9 +727,30 @@ async function openCompose(mode: ComposeMode): Promise<void> {
718
727
  console.warn(`[compose] ${mode} — no message selected`);
719
728
  return;
720
729
  }
721
- const accounts = await getAccounts();
722
- const accountId = current?.accountId || accounts[0]?.id || "";
730
+ // Parallel-load: kick off getAccounts AND open the iframe in the same
731
+ // tick. The iframe doesn't need the account list until after its editor
732
+ // bootstraps (200-500 ms for TinyMCE, less for Quill); by then the IPC
733
+ // round-trip has resolved. Earlier code awaited getAccounts FIRST,
734
+ // adding the IPC latency to the perceived Ctrl+N → editor-visible time.
735
+ // We post `compose-init-ready` to the iframe once init is in
736
+ // sessionStorage so compose.ts's IIFE can read synchronously without
737
+ // polling.
738
+ const accountsP = getAccounts();
723
739
  const msg = current?.message;
740
+ // Title bar text needs the subject from msg (no IPC dependency) — build
741
+ // it now so the iframe can be opened with the final title and avoid a
742
+ // flash of placeholder text.
743
+ const titlePrefix =
744
+ mode === "reply" ? "Reply" :
745
+ mode === "replyAll" ? "Reply All" :
746
+ mode === "forward" ? "Forward" :
747
+ "Compose";
748
+ const titleSubject = mode === "new" ? "" : (msg?.subject || "");
749
+ const frame = showComposeOverlay(titleSubject ? `${titlePrefix}: ${titleSubject}` : titlePrefix);
750
+ // Now finish initialisation off the critical path — editor bootstrap
751
+ // inside the iframe runs concurrently with this await.
752
+ const accounts = await accountsP;
753
+ const accountId = current?.accountId || accounts[0]?.id || "";
724
754
  const rePrefix = /^(re|fwd?):\s*/i;
725
755
  const cleanSubject = msg ? msg.subject.replace(rePrefix, "") : "";
726
756
 
@@ -733,7 +763,7 @@ async function openCompose(mode: ComposeMode): Promise<void> {
733
763
  bodyHtml: "",
734
764
  inReplyTo: "",
735
765
  references: [],
736
- accounts: accounts.map((a: any) => ({ id: a.id, name: a.name, email: a.email, signature: a.signature })),
766
+ accounts: accounts.map((a: any) => ({ id: a.id, name: a.name, email: a.email, signature: a.signature, sig: a.sig })),
737
767
  };
738
768
 
739
769
  // Auto-detect reply From: if the message was delivered to an identity address
@@ -806,23 +836,16 @@ async function openCompose(mode: ComposeMode): Promise<void> {
806
836
  }
807
837
 
808
838
 
809
- // Store init data for compose window to pick up
839
+ // Store init data for compose window to pick up. sessionStorage is the
840
+ // canonical handoff path — same origin between parent and iframe; the
841
+ // compose IIFE reads it after the editor finishes booting. We also
842
+ // postMessage the iframe so it can short-circuit the listen-for-message
843
+ // wait if it's already past editor init.
810
844
  sessionStorage.setItem("composeInit", JSON.stringify(init));
811
- // Inline compose: load compose.html in an overlay iframe (same origin, same IPC)
812
- // Popup windows don't work in IPC mode (custom protocol doesn't propagate to child windows)
813
- // Title reflects mode + subject so the user can see what they're replying to
814
- // ("Re: Stars of STEM 2026" instead of just "Compose"). Forward shows the
815
- // forward target subject; new compose stays generic.
816
- const titlePrefix =
817
- mode === "reply" ? "Reply" :
818
- mode === "replyAll" ? "Reply All" :
819
- mode === "forward" ? "Forward" :
820
- "Compose";
821
- const titleSubject = mode === "new" ? "" : (msg?.subject || init.subject || "");
822
- showComposeOverlay(titleSubject ? `${titlePrefix}: ${titleSubject}` : titlePrefix);
845
+ try { frame?.contentWindow?.postMessage({ type: "compose-init-ready" }, "*"); } catch { /* */ }
823
846
  }
824
847
 
825
- function showComposeOverlay(title = "Compose"): void {
848
+ function showComposeOverlay(title = "Compose"): HTMLIFrameElement {
826
849
  const wrapper = document.createElement("div");
827
850
  wrapper.className = "compose-overlay";
828
851
  // Full-screen on small/short screens, floating on larger
@@ -972,6 +995,7 @@ function showComposeOverlay(title = "Compose"): void {
972
995
  wrapper.appendChild(frame);
973
996
  if (!isSmall) addComposeResizeHandles(wrapper, frame);
974
997
  document.body.appendChild(wrapper);
998
+ return frame;
975
999
  }
976
1000
 
977
1001
  /** Drop a transparent full-viewport shield in front of every other element
@@ -1071,6 +1095,12 @@ function sanitizeQuotedBody(msg: any): string {
1071
1095
  // future provider/path that didn't go through that pipeline.
1072
1096
  const isPlainText = !msg.bodyHtml;
1073
1097
  if (isPlainText) {
1098
+ // Full HTML escape. Leaving `>` unescaped was tempting for source
1099
+ // readability but breaks HTML in edge cases — TinyMCE's normalize-
1100
+ // on-paste re-interprets the input, and stray `>` near sequences
1101
+ // like `<!--` / `-->` / `<!` in plain-text bodies can be misread
1102
+ // by the parser. Per Bob 2026-05-12: "not just ugly, it breaks
1103
+ // the HTML." Trivial source-clutter is the lesser evil.
1074
1104
  const escaped = String(msg.bodyText || "")
1075
1105
  .replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
1076
1106
  return `<div style="white-space:pre-wrap;font-family:inherit;margin:0">${escaped}</div>`;
@@ -2519,7 +2549,12 @@ async function openComposeFromMailto(m: {
2519
2549
  to: string[]; cc: string[]; bcc: string[];
2520
2550
  subject: string; body: string; inReplyTo: string;
2521
2551
  }): Promise<void> {
2522
- const accounts = await getAccounts();
2552
+ // Open the iframe immediately and load accounts in parallel — same
2553
+ // pattern as openCompose. The mailto handler should never feel like
2554
+ // "waiting for the system" to a user who clicked a link.
2555
+ const accountsP = getAccounts();
2556
+ const frame = showComposeOverlay(m.subject ? `Compose: ${m.subject}` : "Compose");
2557
+ const accounts = await accountsP;
2523
2558
  const accountId = accounts[0]?.id || "";
2524
2559
  const escape = (s: string) => s.replace(/[&<>]/g, c =>
2525
2560
  ({ "&": "&amp;", "<": "&lt;", ">": "&gt;" }[c]!));
@@ -2536,10 +2571,10 @@ async function openComposeFromMailto(m: {
2536
2571
  bodyHtml,
2537
2572
  inReplyTo: m.inReplyTo,
2538
2573
  references: m.inReplyTo ? [m.inReplyTo] : [],
2539
- accounts: accounts.map((a: any) => ({ id: a.id, name: a.name, email: a.email, signature: a.signature })),
2574
+ accounts: accounts.map((a: any) => ({ id: a.id, name: a.name, email: a.email, signature: a.signature, sig: a.sig })),
2540
2575
  };
2541
2576
  sessionStorage.setItem("composeInit", JSON.stringify(init));
2542
- showComposeOverlay(m.subject ? `Compose: ${m.subject}` : "Compose");
2577
+ try { frame?.contentWindow?.postMessage({ type: "compose-init-ready" }, "*"); } catch { /* */ }
2543
2578
  }
2544
2579
 
2545
2580
  // ── Keyboard shortcuts ──
@@ -4090,16 +4125,20 @@ function renderOutboxStatus(s: any): void {
4090
4125
  setInterval(async () => {
4091
4126
  try {
4092
4127
  const { getOutboxStatus, getDiagnostics } = await import("./lib/api-client.js");
4093
- renderOutboxStatus(await getOutboxStatus());
4094
- renderDiagnosticsBadge(await getDiagnostics());
4128
+ // Run in parallel — neither call depends on the other and each is a
4129
+ // separate IPC round-trip. Earlier code awaited them serially.
4130
+ const [outbox, diag] = await Promise.all([getOutboxStatus(), getDiagnostics()]);
4131
+ renderOutboxStatus(outbox);
4132
+ renderDiagnosticsBadge(diag);
4095
4133
  } catch { /* service unreachable */ }
4096
4134
  }, 15000);
4097
4135
  // First read on startup so the bar isn't blank.
4098
4136
  (async () => {
4099
4137
  try {
4100
4138
  const { getOutboxStatus, getDiagnostics } = await import("./lib/api-client.js");
4101
- renderOutboxStatus(await getOutboxStatus());
4102
- renderDiagnosticsBadge(await getDiagnostics());
4139
+ const [outbox, diag] = await Promise.all([getOutboxStatus(), getDiagnostics()]);
4140
+ renderOutboxStatus(outbox);
4141
+ renderDiagnosticsBadge(diag);
4103
4142
  } catch { /* */ }
4104
4143
  })();
4105
4144
 
@@ -596,6 +596,27 @@ async function createTinyMceEditor(container2, opts = {}) {
596
596
  // everything that the schema allows.
597
597
  paste_word_valid_elements: "@[style|class],-strong/b,-em/i,-u,-s,-sub,-sup,-strike,-p,-ol,-ul,-li,-h1,-h2,-h3,-h4,-h5,-h6,-blockquote,-table[border|cellpadding|cellspacing|width|height|class|style],-tr,-td[colspan|rowspan|width|height|class|style|valign|align|background|bgcolor],-th,-thead,-tbody,-tfoot,-pre,-br,-a[href|target|title],-img[src|alt|width|height|style|class]",
598
598
  paste_retain_style_properties: "color background background-color font-family font-size font-weight font-style text-decoration text-align padding padding-top padding-bottom padding-left padding-right margin margin-top margin-bottom margin-left margin-right border border-top border-bottom border-left border-right",
599
+ // Auto-link bare URLs in pasted content. TinyMCE's `autolink`
600
+ // plugin only fires on TYPED space/enter; URLs that arrive via
601
+ // clipboard (browser address bar, terminal copy) come in as
602
+ // plain text and stay un-linked. paste_preprocess runs on the
603
+ // HTML the paste plugin produced.
604
+ //
605
+ // CRITICAL: skip auto-link when the content already contains
606
+ // anchors. Naive regex over the whole content would wrap
607
+ // `<a href="X">X</a>` in ANOTHER anchor (nested anchors are
608
+ // invalid HTML and browsers split them, producing visible
609
+ // junk). For HTML pastes that already have linked URLs (the
610
+ // common case from a browser address bar or another mail
611
+ // client), TinyMCE preserves them — no auto-link pass needed.
612
+ // For plain-text pastes (no anchors present), wrap any bare
613
+ // http(s)://… runs. Trailing sentence punctuation is excluded
614
+ // from the URL.
615
+ paste_preprocess: (_plugin, args) => {
616
+ if (/<a[\s>]/i.test(args.content))
617
+ return;
618
+ args.content = args.content.replace(/(^|[\s(\[])((?:https?|ftp):\/\/[^\s<>"']+[^\s<>"'.,;:!?)\]])/gi, (_m, lead, url) => `${lead}<a href="${url}">${url}</a>`);
619
+ },
599
620
  content_style: "body { font-family: system-ui, sans-serif; font-size: 14px; }",
600
621
  init_instance_callback: (ed) => resolve(ed),
601
622
  setup: (ed) => {
@@ -2941,6 +2962,15 @@ document.addEventListener("contextmenu", () => {
2941
2962
 
2942
2963
  // client/compose/compose.ts
2943
2964
  logClientEvent("compose-module-loaded", { href: location.href, version: window.mailxVersion || "?" });
2965
+ var _composeT0 = performance.now();
2966
+ function _ctick(label) {
2967
+ const ms = (performance.now() - _composeT0).toFixed(0).padStart(5);
2968
+ try {
2969
+ logClientEvent(`compose-tick ${ms}ms ${label}`);
2970
+ } catch {
2971
+ }
2972
+ }
2973
+ _ctick("module body executing");
2944
2974
  function closeCompose() {
2945
2975
  logClientEvent("compose-close");
2946
2976
  try {
@@ -2979,8 +3009,8 @@ async function loadEditorAssets(type) {
2979
3009
  loadScript(`${cdn}/@tiptap/extension-placeholder@2/dist/index.umd.js`)
2980
3010
  ]);
2981
3011
  } else {
2982
- loadCSS("https://cdn.jsdelivr.net/npm/quill@2/dist/quill.snow.css");
2983
- await loadScript("https://cdn.jsdelivr.net/npm/quill@2/dist/quill.js");
3012
+ loadCSS("../lib/quill/quill.snow.css");
3013
+ await loadScript("../lib/quill/quill.js");
2984
3014
  }
2985
3015
  }
2986
3016
  var editorType = "quill";
@@ -3062,7 +3092,9 @@ async function tryEditor(type) {
3062
3092
  }
3063
3093
  }
3064
3094
  var activeEditorType = editorType;
3095
+ _ctick(`editor load start (${editorType})`);
3065
3096
  editor = await tryEditor(editorType);
3097
+ _ctick(`editor load end (${editorType}, ok=${!!editor})`);
3066
3098
  if (!editor) {
3067
3099
  const fallbackType = editorType === "quill" ? "tiptap" : "quill";
3068
3100
  logClientEvent("compose-editor-fallback-other", { from: editorType, to: fallbackType });
@@ -3647,13 +3679,41 @@ function scheduleDraftSave() {
3647
3679
  });
3648
3680
  }, DRAFT_INPUT_DEBOUNCE_MS);
3649
3681
  }
3682
+ var _parentInitReady = !!sessionStorage.getItem("composeInit");
3683
+ var _parentInitListeners = [];
3684
+ window.addEventListener("message", (e) => {
3685
+ if (e.data?.type !== "compose-init-ready") return;
3686
+ _parentInitReady = true;
3687
+ for (const fn of _parentInitListeners.splice(0)) fn();
3688
+ });
3689
+ function waitForParentInit(maxMs) {
3690
+ if (_parentInitReady) return Promise.resolve();
3691
+ return new Promise((resolve) => {
3692
+ const timer = setTimeout(() => {
3693
+ _parentInitReady = true;
3694
+ resolve();
3695
+ }, maxMs);
3696
+ _parentInitListeners.push(() => {
3697
+ clearTimeout(timer);
3698
+ resolve();
3699
+ });
3700
+ });
3701
+ }
3650
3702
  (async () => {
3703
+ _ctick("init IIFE start");
3704
+ if (!sessionStorage.getItem("composeInit")) {
3705
+ _ctick("waiting for parent init");
3706
+ await waitForParentInit(1500);
3707
+ _ctick("parent init received");
3708
+ }
3651
3709
  const stored = sessionStorage.getItem("composeInit");
3652
3710
  if (stored) {
3653
3711
  sessionStorage.removeItem("composeInit");
3654
3712
  const init = JSON.parse(stored);
3713
+ _ctick(`init parsed (mode=${init.mode}, bodyHtml=${init.bodyHtml?.length || 0} bytes)`);
3655
3714
  if (init.accounts && init.accounts.length > 0) {
3656
3715
  applyInit(init);
3716
+ _ctick("applyInit done \u2014 compose visible");
3657
3717
  getAccounts().then((fresh) => {
3658
3718
  if (Array.isArray(fresh) && fresh.length > 0) {
3659
3719
  init.accounts = fresh;