@bobfrankston/rmfmail 1.2.263 → 1.2.270

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.
Files changed (51) hide show
  1. package/TODO.md +1 -0
  2. package/client/android-bootstrap.bundle.js +34 -1
  3. package/client/android-bootstrap.bundle.js.map +2 -2
  4. package/client/app.bundle.js +54 -108
  5. package/client/app.bundle.js.map +3 -3
  6. package/client/app.js +59 -116
  7. package/client/app.js.map +1 -1
  8. package/client/app.ts +64 -93
  9. package/client/components/message-list.js +18 -3
  10. package/client/components/message-list.js.map +1 -1
  11. package/client/components/message-list.ts +18 -3
  12. package/client/components/message-viewer.js +14 -4
  13. package/client/components/message-viewer.js.map +1 -1
  14. package/client/components/message-viewer.ts +13 -3
  15. package/client/compose/compose.bundle.js +17 -6
  16. package/client/compose/compose.bundle.js.map +2 -2
  17. package/client/compose/compose.js +13 -7
  18. package/client/compose/compose.js.map +1 -1
  19. package/client/compose/compose.ts +13 -5
  20. package/client/lib/rmf-tiny.js +15 -1
  21. package/client/package.json +1 -1
  22. package/client/styles/components.css +9 -0
  23. package/docs/thundermail-jmap.md +291 -0
  24. package/package.json +11 -11
  25. package/packages/mailx-imap/index.d.ts.map +1 -1
  26. package/packages/mailx-imap/index.js +17 -1
  27. package/packages/mailx-imap/index.js.map +1 -1
  28. package/packages/mailx-imap/index.ts +17 -1
  29. package/packages/mailx-imap/package-lock.json +2 -2
  30. package/packages/mailx-imap/package.json +1 -1
  31. package/packages/mailx-settings/docs/thundermail-jmap.md +291 -0
  32. package/packages/mailx-settings/index.d.ts +1 -0
  33. package/packages/mailx-settings/index.d.ts.map +1 -1
  34. package/packages/mailx-settings/index.js +1 -0
  35. package/packages/mailx-settings/index.js.map +1 -1
  36. package/packages/mailx-settings/index.ts +1 -0
  37. package/packages/mailx-settings/package.json +1 -1
  38. package/packages/mailx-store/package.json +1 -1
  39. package/packages/mailx-store-web/package.json +1 -1
  40. package/packages/mailx-store-web/web-settings.d.ts +1 -0
  41. package/packages/mailx-store-web/web-settings.d.ts.map +1 -1
  42. package/packages/mailx-store-web/web-settings.js +1 -0
  43. package/packages/mailx-store-web/web-settings.js.map +1 -1
  44. package/packages/mailx-store-web/web-settings.ts +1 -0
  45. package/packages/mailx-types/index.d.ts +59 -1
  46. package/packages/mailx-types/index.d.ts.map +1 -1
  47. package/packages/mailx-types/index.js +72 -0
  48. package/packages/mailx-types/index.js.map +1 -1
  49. package/packages/mailx-types/index.ts +80 -1
  50. package/packages/mailx-types/package.json +1 -1
  51. /package/packages/mailx-imap/{node_modules.npmglobalize-stash-88864 → node_modules.npmglobalize-stash-18100}/.package-lock.json +0 -0
package/client/app.ts CHANGED
@@ -1011,9 +1011,8 @@ async function openCompose(mode: ComposeMode, overrideMsg?: any, overrideAccount
1011
1011
  // bootstraps (200-500 ms for TinyMCE, less for Quill); by then the IPC
1012
1012
  // round-trip has resolved. Earlier code awaited getAccounts FIRST,
1013
1013
  // adding the IPC latency to the perceived Ctrl+N → editor-visible time.
1014
- // We post `compose-init-ready` to the iframe once init is in
1015
- // sessionStorage so compose.ts's IIFE can read synchronously without
1016
- // polling.
1014
+ // handOffComposeInit() below does the actual handoff (parent stash +
1015
+ // `compose-init` postMessage + sessionStorage + heartbeat).
1017
1016
  const accountsP = getAccounts();
1018
1017
  const msg = current?.message;
1019
1018
  // Title bar text needs the subject from msg (no IPC dependency) — build
@@ -1254,6 +1253,32 @@ async function openCompose(mode: ComposeMode, overrideMsg?: any, overrideAccount
1254
1253
  frame = showComposeOverlay(composeTitle);
1255
1254
  }
1256
1255
 
1256
+ handOffComposeInit(frame, init);
1257
+ }
1258
+
1259
+ /**
1260
+ * Hand a compose init payload to the compose iframe.
1261
+ *
1262
+ * THIS IS THE ONLY SUPPORTED WAY to seed a compose overlay — every entry
1263
+ * point (Ctrl+N, reply, mailto, Windows Share, Android share, edit-draft)
1264
+ * must go through it. It runs four handoffs at once because under WebView2's
1265
+ * custom-protocol host no single one is reliable:
1266
+ *
1267
+ * 1. `window.__mailxComposeInits[key]` — a parent-window stash the
1268
+ * same-origin iframe reads synchronously via window.parent. Most
1269
+ * reliable, and the only race-free one.
1270
+ * 2. `compose-init` postMessage carrying the payload.
1271
+ * 3. sessionStorage (the original mechanism).
1272
+ * 4. A 100 ms heartbeat for 3 s, plus a re-post on iframe `load`.
1273
+ *
1274
+ * The belt-and-braces is not paranoia: sessionStorage AND postMessage have
1275
+ * each failed in production (Bob 2026-05-24, replyAll opened with empty
1276
+ * fields), which is why the stash exists. The mailto / share / Android-share
1277
+ * / edit-draft paths were still on the old sessionStorage-only handoff and
1278
+ * so kept reproducing that same empty-compose bug — clicking a mailto link
1279
+ * in another app opened compose with nothing filled in (Bob 2026-08-19).
1280
+ */
1281
+ function handOffComposeInit(frame: HTMLIFrameElement | null | undefined, init: any): void {
1257
1282
  const initJson = JSON.stringify(init);
1258
1283
  try { sessionStorage.setItem("composeInit", initJson); }
1259
1284
  catch (e: any) { console.error("[compose] sessionStorage.setItem failed:", e?.message || e); }
@@ -2288,8 +2313,10 @@ document.getElementById("rail-inbox")?.addEventListener("click", () => {
2288
2313
  inbox?.click();
2289
2314
  });
2290
2315
  document.getElementById("rail-unified")?.addEventListener("click", () => {
2291
- const unified = document.querySelector('.folder-tree .all-inboxes') as HTMLElement | null
2292
- || document.getElementById("ft-all-inboxes");
2316
+ // The || document.getElementById("ft-all-inboxes") fallback was dropped
2317
+ // 2026-08-19: no HTML declares that id and nothing creates it, so the
2318
+ // querySelector was always the only live path.
2319
+ const unified = document.querySelector('.folder-tree .all-inboxes') as HTMLElement | null;
2293
2320
  unified?.click();
2294
2321
  });
2295
2322
  document.getElementById("rail-contacts")?.addEventListener("click", async () => {
@@ -2546,8 +2573,7 @@ document.addEventListener("mailx-share-intent", ((e: CustomEvent) => {
2546
2573
  if (Array.isArray(detail.attachments) && detail.attachments.length) {
2547
2574
  init.attachments = detail.attachments.filter((a: any) => a?.filename && a?.dataBase64);
2548
2575
  }
2549
- sessionStorage.setItem("composeInit", JSON.stringify(init));
2550
- showComposeOverlay(init.subject || "Compose");
2576
+ handOffComposeInit(showComposeOverlay(init.subject || "Compose"), init);
2551
2577
  }) as EventListener);
2552
2578
 
2553
2579
  // ── Search ──
@@ -2984,11 +3010,10 @@ window.addEventListener("message", (e) => {
2984
3010
  }
2985
3011
  return;
2986
3012
  }
2987
- if (e.data?.type === "openLink" && e.data.url) {
2988
- const api = (window as any).mailxapi;
2989
- if (api?.openExternal) api.openExternal(e.data.url);
2990
- else window.open(e.data.url, "_blank", "noopener,noreferrer");
2991
- }
3013
+ // (removed 2026-08-19, dead-code audit) An "openLink" postMessage
3014
+ // handler lived here. Nothing has posted that type since the preview
3015
+ // iframe moved to "linkClick" — it was unreachable. Link opening is
3016
+ // handled by the "linkClick" branch below.
2992
3017
  if (e.data?.type === "linkClick" && e.data.url) {
2993
3018
  const url = e.data.url;
2994
3019
  console.log(`[android-link] parent got linkClick → ${url}`);
@@ -3013,10 +3038,11 @@ window.addEventListener("message", (e) => {
3013
3038
  } catch { /* diagnostic only */ }
3014
3039
  return;
3015
3040
  }
3016
- if (e.data?.type === "previewToggleFullscreen") {
3017
- toggleFullscreenPreview();
3018
- return;
3019
- }
3041
+ // (removed 2026-08-19, dead-code audit) A "previewToggleFullscreen"
3042
+ // postMessage handler lived here, fed by an iframe-level dblclick that
3043
+ // was itself removed for hijacking word-selection. Nothing has posted
3044
+ // the type since; the chrome dblclick handler is the only entry into
3045
+ // fullscreen-preview.
3020
3046
  if (e.data?.type === "previewContextMenu") {
3021
3047
  // Translate iframe-relative coords to viewport. Find the iframe whose
3022
3048
  // contentWindow matches the source so we map correctly even when
@@ -3037,75 +3063,15 @@ window.addEventListener("message", (e) => {
3037
3063
  );
3038
3064
  return;
3039
3065
  }
3040
- if (e.data?.type === "linkContextMenu") {
3041
- // C29: right-click in body iframe Open / Save / Copy URL menu.
3042
- // Iframe's clientX/Y is relative to the iframe; translate to viewport.
3043
- let iframeRect: DOMRect | null = null;
3044
- for (const f of Array.from(document.querySelectorAll("iframe"))) {
3045
- if ((f as HTMLIFrameElement).contentWindow === e.source) { iframeRect = f.getBoundingClientRect(); break; }
3046
- }
3047
- const x = (iframeRect?.left || 0) + (e.data.x || 0);
3048
- const y = (iframeRect?.top || 0) + (e.data.y || 0);
3049
- const url: string = e.data.url || "";
3050
- // Find a sensible filename for the Save action.
3051
- const guessName = (() => {
3052
- try {
3053
- const u = new URL(url);
3054
- const last = u.pathname.split("/").pop() || "";
3055
- return last && last.includes(".") ? last : "";
3056
- } catch { return ""; }
3057
- })();
3058
- const items: { label: string; action: () => void }[] = [
3059
- { label: "Open in browser", action: () => {
3060
- window.open(url, "_blank", "noopener,noreferrer");
3061
- }},
3062
- { label: guessName ? `Save "${guessName}"…` : "Save link as…", action: () => {
3063
- // Trigger a download via anchor with download attr.
3064
- const a = document.createElement("a");
3065
- a.href = url;
3066
- if (guessName) a.download = guessName;
3067
- else a.download = "";
3068
- a.style.display = "none";
3069
- document.body.appendChild(a);
3070
- a.click();
3071
- setTimeout(() => a.remove(), 1000);
3072
- }},
3073
- { label: "Copy URL", action: async () => {
3074
- try { await navigator.clipboard.writeText(url); }
3075
- catch { prompt("URL:", url); }
3076
- }},
3077
- { label: "Copy link text", action: async () => {
3078
- try { await navigator.clipboard.writeText(e.data.text || url); }
3079
- catch { prompt("Text:", e.data.text || url); }
3080
- }},
3081
- ];
3082
- // Build a tiny inline menu (showContextMenu would do but it's in components/).
3083
- const menu = document.createElement("div");
3084
- menu.style.cssText = `position:fixed;z-index:2400;background:var(--color-bg);border:1px solid var(--color-border);border-radius:6px;box-shadow:0 4px 16px rgba(0,0,0,0.2);padding:4px 0;font-size:13px;min-width:180px;`;
3085
- menu.style.left = `${Math.min(x, window.innerWidth - 200)}px`;
3086
- menu.style.top = `${Math.min(y, window.innerHeight - 200)}px`;
3087
- // mousedown inside the menu must NOT reach the document-level
3088
- // dismiss handler — otherwise the menu is removed before click
3089
- // fires on the row and the action silently no-ops (user report
3090
- // 2026-04-24). Stop propagation at the menu root covers every row.
3091
- menu.addEventListener("mousedown", (ev) => ev.stopPropagation());
3092
- for (const it of items) {
3093
- const row = document.createElement("div");
3094
- row.textContent = it.label;
3095
- row.style.cssText = `padding:6px 12px;cursor:pointer;`;
3096
- row.addEventListener("mouseenter", () => row.style.background = "var(--color-bg-hover)");
3097
- row.addEventListener("mouseleave", () => row.style.background = "");
3098
- row.addEventListener("click", () => { menu.remove(); it.action(); });
3099
- menu.appendChild(row);
3100
- }
3101
- document.body.appendChild(menu);
3102
- const dismiss = () => { menu.remove(); document.removeEventListener("mousedown", dismiss); document.removeEventListener("keydown", dismiss); };
3103
- setTimeout(() => {
3104
- document.addEventListener("mousedown", dismiss);
3105
- document.addEventListener("keydown", dismiss);
3106
- }, 0);
3107
- return;
3108
- }
3066
+ // (removed 2026-08-19) A second, dead link context menu used to live
3067
+ // here a "linkContextMenu" postMessage handler labelled "Copy URL",
3068
+ // built when the preview iframe still posted that message type. Nothing
3069
+ // has posted it since the viewer moved to showBodyContextMenu(), so it
3070
+ // was unreachable code offering a different label ("Copy URL") for the
3071
+ // same action the live menu calls "Copy link URL" the kind of drift
3072
+ // Bob spotted 2026-08-19 ("other menus have both copy link and copy
3073
+ // url"). The live implementation is in components/message-viewer.ts;
3074
+ // keep link-menu changes there and nowhere else.
3109
3075
  if (e.data?.type === "mailx-send-error") {
3110
3076
  // Send failed AFTER compose closed (fire-and-forget model). Surface in
3111
3077
  // the status bar so the user sees something instead of the silence.
@@ -3781,8 +3747,7 @@ async function openComposeFromMailto(m: {
3781
3747
  references: m.inReplyTo ? [m.inReplyTo] : [],
3782
3748
  accounts: accounts.map((a: any) => ({ id: a.id, name: a.name, email: a.email, signature: a.signature, sig: a.sig })),
3783
3749
  };
3784
- sessionStorage.setItem("composeInit", JSON.stringify(init));
3785
- try { frame?.contentWindow?.postMessage({ type: "compose-init-ready" }, "*"); } catch { /* */ }
3750
+ handOffComposeInit(frame, init);
3786
3751
  }
3787
3752
 
3788
3753
  /** C46: open compose from a Windows Share sheet drop. Shared files become
@@ -3825,8 +3790,7 @@ async function openComposeFromShare(s: {
3825
3790
  attachments: s.attachments,
3826
3791
  accounts: accounts.map((a: any) => ({ id: a.id, name: a.name, email: a.email, signature: a.signature, sig: a.sig })),
3827
3792
  };
3828
- sessionStorage.setItem("composeInit", JSON.stringify(init));
3829
- try { frame?.contentWindow?.postMessage({ type: "compose-init-ready" }, "*"); } catch { /* */ }
3793
+ handOffComposeInit(frame, init);
3830
3794
  }
3831
3795
 
3832
3796
  // ── Keyboard shortcuts ──
@@ -4477,8 +4441,16 @@ document.addEventListener("keydown", (e) => {
4477
4441
  }
4478
4442
  const bd = document.getElementById("rail-menu-backdrop");
4479
4443
  if (bd) { bd.remove(); closed = true; }
4480
- const themeSub = document.getElementById("theme-submenu");
4481
- if (themeSub && !themeSub.hidden) { themeSub.hidden = true; closed = true; }
4444
+ // Was getElementById("theme-submenu") — an id nothing declares, so Escape
4445
+ // silently never closed the theme submenu (dead-code audit 2026-08-19).
4446
+ // The real element is #theme-submenu-popout; also reset the toggle's
4447
+ // aria-expanded so the button doesn't claim to be open.
4448
+ const themeSub = document.getElementById("theme-submenu-popout");
4449
+ if (themeSub && !themeSub.hidden) {
4450
+ themeSub.hidden = true;
4451
+ document.getElementById("theme-submenu-toggle")?.setAttribute("aria-expanded", "false");
4452
+ closed = true;
4453
+ }
4482
4454
  if (closed) {
4483
4455
  e.preventDefault();
4484
4456
  refreshToolbarOverlayShield();
@@ -6113,8 +6085,7 @@ document.addEventListener("mailx-popout-message", (async (e: any) => {
6113
6085
  }
6114
6086
  if (loaded.length) (init as any).attachments = loaded;
6115
6087
  }
6116
- sessionStorage.setItem("composeInit", JSON.stringify(init));
6117
- showComposeOverlay(msg.subject ? `Edit: ${msg.subject}` : "Edit draft");
6088
+ handOffComposeInit(showComposeOverlay(msg.subject ? `Edit: ${msg.subject}` : "Edit draft"), init);
6118
6089
  return;
6119
6090
  }
6120
6091
  // ONE read-only popout, not three. This handler used to build its own
@@ -8,7 +8,7 @@ import { showMessage as viewerShow, clearViewer as viewerClear } from "./message
8
8
  import { showContextMenu } from "./context-menu.js";
9
9
  import { pickFolder } from "./folder-picker.js";
10
10
  import { foldText } from "../lib/fold-text.js";
11
- import { seenOf, flaggedOf, draftOf, setSeen, setFlagged } from "@bobfrankston/mailx-types";
11
+ import { seenOf, flaggedOf, draftOf, setSeen, setFlagged, isJunkPreviewText, displayNameClaimsOtherAddress } from "@bobfrankston/mailx-types";
12
12
  let onMessageSelect;
13
13
  let currentAccountId;
14
14
  let currentFolderId;
@@ -1923,7 +1923,18 @@ class MessageRow {
1923
1923
  from.textContent = msg.to.map((a) => a.name || a.address).join(", ");
1924
1924
  }
1925
1925
  else {
1926
- from.textContent = msg.from.name || msg.from.address;
1926
+ // The row normally shows the display name alone — which is exactly
1927
+ // what a display-name spoof relies on ("security@paypal.com"
1928
+ // wired to anywhere). When the name asserts an address that
1929
+ // isn't the real one, show the real one too so the row can't
1930
+ // lie on its own (Bob 2026-08-19).
1931
+ const fromName = msg.from.name || msg.from.address;
1932
+ const fromClaim = displayNameClaimsOtherAddress(msg.from.name || "", msg.from.address || "");
1933
+ from.textContent = fromClaim ? `${fromName} — actually ${msg.from.address}` : fromName;
1934
+ if (fromClaim) {
1935
+ from.classList.add("ml-from-spoofed");
1936
+ from.title = `Display name claims ${fromClaim}, but the message is from ${msg.from.address}`;
1937
+ }
1927
1938
  }
1928
1939
  if (showAccountTag && accountId) {
1929
1940
  const tag = document.createElement("span");
@@ -1961,7 +1972,11 @@ class MessageRow {
1961
1972
  });
1962
1973
  subject.prepend(threadPill);
1963
1974
  }
1964
- if (msg.preview) {
1975
+ // isJunkPreviewText: rows indexed before extractPreview learned to skip
1976
+ // a text/plain part that is literally "undefined" (Bob 2026-08-19).
1977
+ // Suppressing it here means those rows read correctly straight away
1978
+ // instead of waiting for a -repair re-sync.
1979
+ if (msg.preview && !isJunkPreviewText(msg.preview)) {
1965
1980
  // Defensive cleanup for older rows whose preview was indexed
1966
1981
  // before extractPreview() stripped data: URIs. Quill-pasted
1967
1982
  // images can leave a `data:image/png;base64,…` blob in the