@bobfrankston/rmfmail 1.2.248 → 1.2.251

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 (55) hide show
  1. package/bin/check-bridge-contract.mjs +9 -1
  2. package/client/app.bundle.js +222 -50
  3. package/client/app.bundle.js.map +4 -4
  4. package/client/app.js +23 -36
  5. package/client/app.js.map +1 -1
  6. package/client/app.ts +17 -36
  7. package/client/components/folder-picker.js +3 -2
  8. package/client/components/folder-picker.js.map +1 -1
  9. package/client/components/folder-picker.ts +3 -2
  10. package/client/components/folder-tree.js +90 -12
  11. package/client/components/folder-tree.js.map +1 -1
  12. package/client/components/folder-tree.ts +89 -12
  13. package/client/components/message-list.js +3 -2
  14. package/client/components/message-list.js.map +1 -1
  15. package/client/components/message-list.ts +3 -2
  16. package/client/components/message-viewer.js +149 -11
  17. package/client/components/message-viewer.js.map +1 -1
  18. package/client/components/message-viewer.ts +139 -11
  19. package/client/compose/compose.bundle.js +11 -0
  20. package/client/compose/compose.bundle.js.map +2 -2
  21. package/client/help/search-help.js +6 -1
  22. package/client/help/search-help.js.map +1 -1
  23. package/client/help/search-help.ts +6 -1
  24. package/client/lib/api-client.js +14 -0
  25. package/client/lib/api-client.js.map +1 -1
  26. package/client/lib/api-client.ts +9 -0
  27. package/client/lib/fold-text.js +18 -0
  28. package/client/lib/fold-text.js.map +1 -0
  29. package/client/lib/fold-text.ts +17 -0
  30. package/client/lib/mailxapi.js +12 -0
  31. package/client/styles/components.css +8 -3
  32. package/package.json +5 -5
  33. package/packages/mailx-imap/package-lock.json +2 -2
  34. package/packages/mailx-imap/package.json +1 -1
  35. package/packages/mailx-service/index.d.ts +16 -0
  36. package/packages/mailx-service/index.d.ts.map +1 -1
  37. package/packages/mailx-service/index.js +49 -1
  38. package/packages/mailx-service/index.js.map +1 -1
  39. package/packages/mailx-service/index.ts +48 -1
  40. package/packages/mailx-service/jsonrpc.js +2 -0
  41. package/packages/mailx-service/jsonrpc.js.map +1 -1
  42. package/packages/mailx-service/jsonrpc.ts +2 -0
  43. package/packages/mailx-service/package.json +1 -1
  44. package/packages/mailx-settings/package.json +1 -1
  45. package/packages/mailx-store/db.d.ts.map +1 -1
  46. package/packages/mailx-store/db.js +5 -2
  47. package/packages/mailx-store/db.js.map +1 -1
  48. package/packages/mailx-store/db.ts +5 -2
  49. package/packages/mailx-store/package.json +1 -1
  50. package/packages/mailx-store-web/package.json +1 -1
  51. package/packages/mailx-types/mailx-api.d.ts +7 -0
  52. package/packages/mailx-types/mailx-api.d.ts.map +1 -1
  53. package/packages/mailx-types/mailx-api.ts +4 -0
  54. package/packages/mailx-types/package.json +1 -1
  55. /package/packages/mailx-imap/{node_modules.npmglobalize-stash-63500 → node_modules.npmglobalize-stash-83100}/.package-lock.json +0 -0
@@ -17,6 +17,41 @@ import { updateMessageFlags as stateUpdateFlags } from "../lib/message-state.js"
17
17
  import { setRowSeen } from "./message-list.js";
18
18
  import { setSeen, seenOf } from "@bobfrankston/mailx-types";
19
19
 
20
+ // ── "Copy image" bridge ──
21
+ // The preview iframe encodes the image and posts the bytes back; these track
22
+ // the outstanding request. Keyed by id because a user can hit Copy image on a
23
+ // second picture before the first encode finishes.
24
+ const pendingImageCopies = new Map<number, (r: { blob?: Blob; error?: string }) => void>();
25
+ let imageCopySeq = 0;
26
+ const IMAGE_COPY_TIMEOUT_MS = 8000;
27
+
28
+ window.addEventListener("message", (e) => {
29
+ const d: any = e.data;
30
+ if (!d || d.type !== "previewImageBytes") return;
31
+ const resolve = pendingImageCopies.get(d.reqId);
32
+ if (!resolve) return; // timed out already
33
+ pendingImageCopies.delete(d.reqId);
34
+ resolve({ blob: d.blob, error: d.error });
35
+ });
36
+
37
+ /** Ask the preview iframe to encode the image the user right-clicked. Resolves
38
+ * with a PNG blob, or an error string when the iframe can't read it (a remote
39
+ * image taints the canvas — caller falls back to the daemon). */
40
+ function requestImageFromPreview(sourceWindow: Window | null, src: string): Promise<{ blob?: Blob; error?: string }> {
41
+ if (!sourceWindow) return Promise.resolve({ error: "preview window is gone" });
42
+ const reqId = ++imageCopySeq;
43
+ return new Promise(resolve => {
44
+ const done = (r: { blob?: Blob; error?: string }): void => { clearTimeout(timer); resolve(r); };
45
+ const timer = setTimeout(() => {
46
+ pendingImageCopies.delete(reqId);
47
+ resolve({ error: "the preview did not answer in time" });
48
+ }, IMAGE_COPY_TIMEOUT_MS);
49
+ pendingImageCopies.set(reqId, done);
50
+ try { sourceWindow.postMessage({ type: "previewCommand", cmd: "copyImage", reqId, src }, "*"); }
51
+ catch (err: any) { pendingImageCopies.delete(reqId); done({ error: err?.message || String(err) }); }
52
+ });
53
+ }
54
+
20
55
  /** Currently displayed message (for reply/forward) */
21
56
  let currentMessage: any = null;
22
57
  let currentAccountId: string = "";
@@ -165,24 +200,71 @@ export function showPreviewBodyMenu(absX: number, absY: number, selectedText: st
165
200
  })();
166
201
  items.push(
167
202
  { label: "Copy image", action: async () => {
168
- try {
169
- const resp = await fetch(imgSrc);
170
- const blob = await resp.blob();
171
- // Transcode to PNG via canvas WebView2's clipboard image
172
- // write is reliable for PNG; arbitrary source types (webp,
173
- // gif, jpeg) may not round-trip directly.
203
+ // Three ways in, because no single one covers every image a
204
+ // letter can carry (Bob 2026-08-11: right-click → copy just
205
+ // failed). In order of what works most often:
206
+ // 1. the preview iframe encodes its own already-decoded
207
+ // <img> the only path that works for inline cid:/data:
208
+ // images, and it costs no network at all;
209
+ // 2. the parent fetches the src — fine for data: and for
210
+ // hosts that send CORS headers;
211
+ // 3. the daemon fetches it — remote images are cross-origin
212
+ // with no CORS headers, so the WebView can never read
213
+ // their bytes, but Node has no such restriction.
214
+ const attempts: string[] = [];
215
+
216
+ const writePng = async (png: Blob): Promise<void> => {
217
+ await navigator.clipboard.write([new ClipboardItem({ "image/png": png })]);
218
+ };
219
+ // Transcode to PNG via canvas — WebView2's clipboard image
220
+ // write is reliable for PNG; arbitrary source types (webp,
221
+ // gif, jpeg) may not round-trip directly.
222
+ const toPng = async (blob: Blob): Promise<Blob> => {
223
+ if (blob.type === "image/png") return blob;
174
224
  const bmp = await createImageBitmap(blob);
175
225
  const canvas = document.createElement("canvas");
176
226
  canvas.width = bmp.width; canvas.height = bmp.height;
177
227
  canvas.getContext("2d")!.drawImage(bmp, 0, 0);
178
- const png: Blob = await new Promise((res, rej) =>
228
+ return await new Promise((res, rej) =>
179
229
  canvas.toBlob(b => b ? res(b) : rej(new Error("toBlob failed")), "image/png"));
180
- await navigator.clipboard.write([new ClipboardItem({ "image/png": png })]);
230
+ };
231
+
232
+ // 1. Ask the preview to encode it.
233
+ const fromPreview = await requestImageFromPreview(sourceWindow, imgSrc);
234
+ if (fromPreview.blob) {
235
+ try { await writePng(fromPreview.blob); return; }
236
+ catch (e: any) { attempts.push(`clipboard write: ${e?.message || e}`); }
237
+ } else {
238
+ attempts.push(`preview: ${fromPreview.error}`);
239
+ }
240
+
241
+ // 2. Fetch it from here.
242
+ try {
243
+ const resp = await fetch(imgSrc);
244
+ if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
245
+ await writePng(await toPng(await resp.blob()));
246
+ return;
181
247
  } catch (e: any) {
182
- // cid: inline images can't be fetched from the parent
183
- // context — surface rather than fail silently.
184
- alert(`Couldn't copy image: ${e?.message || e}${imgSrc.startsWith("cid:") ? " (inline cid: image — try Save instead, or right-click → save in the message)" : ""}`);
248
+ attempts.push(`direct fetch: ${e?.message || e}`);
185
249
  }
250
+
251
+ // 3. Let the daemon fetch it (remote images, no CORS).
252
+ if (/^https?:/i.test(imgSrc)) {
253
+ try {
254
+ const { fetchRemoteImage } = await import("../lib/api-client.js");
255
+ const r = await fetchRemoteImage(imgSrc);
256
+ if (!r?.base64) throw new Error(r?.error || "no data returned");
257
+ const bytes = Uint8Array.from(atob(r.base64), c => c.charCodeAt(0));
258
+ await writePng(await toPng(new Blob([bytes], { type: r.contentType || "image/png" })));
259
+ return;
260
+ } catch (e: any) {
261
+ attempts.push(`via rmfmail: ${e?.message || e}`);
262
+ }
263
+ }
264
+
265
+ // Name what was tried — a bare "couldn't copy" leaves nothing
266
+ // to act on, and these three failure modes need different fixes.
267
+ alert(`Couldn't copy that image.\n\n${attempts.map(a => `• ${a}`).join("\n")}`);
186
268
  } },
187
269
  { label: `Save image as "${guessImgName}"…`, action: () => {
188
270
  const a = document.createElement("a"); a.href = imgSrc;
@@ -2219,6 +2301,9 @@ ${csp}
2219
2301
  // dismissers it sees. The parent's debounced 500 ms show-timer still
2220
2302
  // suppresses flicker.
2221
2303
  var lastHoveredHref = "";
2304
+ // The <img> under the most recent right-click, kept so a later
2305
+ // "copyImage" command can encode that exact element (see below).
2306
+ var lastContextImg = null;
2222
2307
  function postLinkHover(href, rect, mouse) {
2223
2308
  // Only post on transitions to avoid spamming the parent on every
2224
2309
  // mousemove inside a link.
@@ -2294,6 +2379,45 @@ ${csp}
2294
2379
  s.addRange(r);
2295
2380
  }
2296
2381
  } catch (_) {}
2382
+ } else if (d.cmd === "copyImage") {
2383
+ // Encode the right-clicked image HERE and hand the parent the
2384
+ // bytes. The parent can't fetch it itself: an inline image is a
2385
+ // multi-megabyte data: URI it would have to re-download through
2386
+ // the message channel, and a remote one is cross-origin with no
2387
+ // CORS headers, so a parent-side fetch rejects. This img is
2388
+ // already decoded here, so a canvas draw needs no network at all.
2389
+ // (A remote image DOES taint the canvas — toBlob then throws and
2390
+ // the parent falls back to fetching it through the daemon.)
2391
+ var target = lastContextImg;
2392
+ if (!target && d.src) {
2393
+ var all = document.getElementsByTagName("img");
2394
+ for (var i = 0; i < all.length; i++) {
2395
+ if (all[i].currentSrc === d.src || all[i].src === d.src) { target = all[i]; break; }
2396
+ }
2397
+ }
2398
+ if (!target) {
2399
+ window.parent.postMessage({ type: "previewImageBytes", reqId: d.reqId, error: "image element not found" }, "*");
2400
+ return;
2401
+ }
2402
+ try {
2403
+ var cv = document.createElement("canvas");
2404
+ cv.width = target.naturalWidth || target.width;
2405
+ cv.height = target.naturalHeight || target.height;
2406
+ if (!cv.width || !cv.height) throw new Error("image has no decoded size yet");
2407
+ cv.getContext("2d").drawImage(target, 0, 0);
2408
+ cv.toBlob(function (b) {
2409
+ if (!b) {
2410
+ window.parent.postMessage({ type: "previewImageBytes", reqId: d.reqId, error: "encode failed" }, "*");
2411
+ return;
2412
+ }
2413
+ window.parent.postMessage({ type: "previewImageBytes", reqId: d.reqId, blob: b }, "*");
2414
+ }, "image/png");
2415
+ } catch (err) {
2416
+ window.parent.postMessage({
2417
+ type: "previewImageBytes", reqId: d.reqId,
2418
+ error: (err && err.message) ? err.message : String(err)
2419
+ }, "*");
2420
+ }
2297
2421
  }
2298
2422
  });
2299
2423
 
@@ -2312,6 +2436,10 @@ ${csp}
2312
2436
  try { rect = e.target.getBoundingClientRect ? e.target.getBoundingClientRect() : rect; } catch (_) {}
2313
2437
  var a = e.target && e.target.closest ? e.target.closest("a[href]") : null;
2314
2438
  var img = e.target && e.target.closest ? e.target.closest("img") : null;
2439
+ // Remember the element itself, not just its src — "Copy image" encodes
2440
+ // this exact <img>, and matching by src would pick the wrong one when
2441
+ // a letter repeats the same image (spacers, logos, tracking pixels).
2442
+ lastContextImg = img;
2315
2443
  var sel = (window.getSelection && window.getSelection()) ? window.getSelection().toString() : "";
2316
2444
  window.parent.postMessage({
2317
2445
  type: "previewContextMenu",
@@ -1049,6 +1049,7 @@ __export(api_client_exports, {
1049
1049
  drainStoreSync: () => drainStoreSync,
1050
1050
  emptyFolder: () => emptyFolder,
1051
1051
  fetchImageAsDataUri: () => fetchImageAsDataUri,
1052
+ fetchRemoteImage: () => fetchRemoteImage,
1052
1053
  flagSenderOrDomain: () => flagSenderOrDomain,
1053
1054
  formatJsonc: () => formatJsonc,
1054
1055
  getAccounts: () => getAccounts,
@@ -1611,6 +1612,16 @@ function readConfigHelp(name) {
1611
1612
  function unsubscribeOneClick(url) {
1612
1613
  return ipc().unsubscribeOneClick?.(url);
1613
1614
  }
1615
+ async function fetchRemoteImage(url) {
1616
+ const fn = ipc().fetchRemoteImage;
1617
+ if (!fn)
1618
+ return { error: "this host can't fetch remote images" };
1619
+ try {
1620
+ return await fn(url);
1621
+ } catch (e) {
1622
+ return { error: e?.message || String(e) };
1623
+ }
1624
+ }
1614
1625
  function openInWord(editId, html, popoutId = "") {
1615
1626
  return ipc().openInWord?.(editId, html, popoutId) ?? Promise.resolve({ ok: false, path: "", opener: "none" });
1616
1627
  }