@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
@@ -9,6 +9,7 @@ import type { ListMessage } from "../lib/message-state.js";
9
9
  import { showMessage as viewerShow, clearViewer as viewerClear } from "./message-viewer.js";
10
10
  import { showContextMenu, type MenuItem } from "./context-menu.js";
11
11
  import { pickFolder } from "./folder-picker.js";
12
+ import { foldText } from "../lib/fold-text.js";
12
13
  import { seenOf, flaggedOf, draftOf, setSeen, setFlagged } from "@bobfrankston/mailx-types";
13
14
 
14
15
  type MessageSelectHandler = (accountId: string, uid: number, folderId: number) => void;
@@ -43,7 +44,7 @@ let liveFilterText = "";
43
44
  * it (background reloads resume). Lower-cased once here so the per-row test
44
45
  * is a plain substring check. */
45
46
  export function setLiveFilter(query: string): void {
46
- liveFilterText = (query || "").trim().toLowerCase();
47
+ liveFilterText = foldText((query || "").trim());
47
48
  const body = document.getElementById("ml-body");
48
49
  if (body) applyLiveFilter(body);
49
50
  }
@@ -57,7 +58,7 @@ function applyLiveFilter(body: HTMLElement): void {
57
58
  return;
58
59
  }
59
60
  for (const row of body.querySelectorAll(".ml-row")) {
60
- const text = row.textContent?.toLowerCase() || "";
61
+ const text = foldText(row.textContent);
61
62
  (row as HTMLElement).classList.toggle("filter-hidden", !text.includes(liveFilterText));
62
63
  }
63
64
  }
@@ -13,6 +13,46 @@ import { showContextMenu } from "./context-menu.js";
13
13
  import { updateMessageFlags as stateUpdateFlags } from "../lib/message-state.js";
14
14
  import { setRowSeen } from "./message-list.js";
15
15
  import { setSeen, seenOf } from "@bobfrankston/mailx-types";
16
+ // ── "Copy image" bridge ──
17
+ // The preview iframe encodes the image and posts the bytes back; these track
18
+ // the outstanding request. Keyed by id because a user can hit Copy image on a
19
+ // second picture before the first encode finishes.
20
+ const pendingImageCopies = new Map();
21
+ let imageCopySeq = 0;
22
+ const IMAGE_COPY_TIMEOUT_MS = 8000;
23
+ window.addEventListener("message", (e) => {
24
+ const d = e.data;
25
+ if (!d || d.type !== "previewImageBytes")
26
+ return;
27
+ const resolve = pendingImageCopies.get(d.reqId);
28
+ if (!resolve)
29
+ return; // timed out already
30
+ pendingImageCopies.delete(d.reqId);
31
+ resolve({ blob: d.blob, error: d.error });
32
+ });
33
+ /** Ask the preview iframe to encode the image the user right-clicked. Resolves
34
+ * with a PNG blob, or an error string when the iframe can't read it (a remote
35
+ * image taints the canvas — caller falls back to the daemon). */
36
+ function requestImageFromPreview(sourceWindow, src) {
37
+ if (!sourceWindow)
38
+ return Promise.resolve({ error: "preview window is gone" });
39
+ const reqId = ++imageCopySeq;
40
+ return new Promise(resolve => {
41
+ const done = (r) => { clearTimeout(timer); resolve(r); };
42
+ const timer = setTimeout(() => {
43
+ pendingImageCopies.delete(reqId);
44
+ resolve({ error: "the preview did not answer in time" });
45
+ }, IMAGE_COPY_TIMEOUT_MS);
46
+ pendingImageCopies.set(reqId, done);
47
+ try {
48
+ sourceWindow.postMessage({ type: "previewCommand", cmd: "copyImage", reqId, src }, "*");
49
+ }
50
+ catch (err) {
51
+ pendingImageCopies.delete(reqId);
52
+ done({ error: err?.message || String(err) });
53
+ }
54
+ });
55
+ }
16
56
  /** Currently displayed message (for reply/forward) */
17
57
  let currentMessage = null;
18
58
  let currentAccountId = "";
@@ -189,25 +229,77 @@ export function showPreviewBodyMenu(absX, absY, selectedText, sourceWindow, link
189
229
  }
190
230
  })();
191
231
  items.push({ label: "Copy image", action: async () => {
192
- try {
193
- const resp = await fetch(imgSrc);
194
- const blob = await resp.blob();
195
- // Transcode to PNG via canvas WebView2's clipboard image
196
- // write is reliable for PNG; arbitrary source types (webp,
197
- // gif, jpeg) may not round-trip directly.
232
+ // Three ways in, because no single one covers every image a
233
+ // letter can carry (Bob 2026-08-11: right-click → copy just
234
+ // failed). In order of what works most often:
235
+ // 1. the preview iframe encodes its own already-decoded
236
+ // <img> the only path that works for inline cid:/data:
237
+ // images, and it costs no network at all;
238
+ // 2. the parent fetches the src — fine for data: and for
239
+ // hosts that send CORS headers;
240
+ // 3. the daemon fetches it — remote images are cross-origin
241
+ // with no CORS headers, so the WebView can never read
242
+ // their bytes, but Node has no such restriction.
243
+ const attempts = [];
244
+ const writePng = async (png) => {
245
+ await navigator.clipboard.write([new ClipboardItem({ "image/png": png })]);
246
+ };
247
+ // Transcode to PNG via canvas — WebView2's clipboard image
248
+ // write is reliable for PNG; arbitrary source types (webp,
249
+ // gif, jpeg) may not round-trip directly.
250
+ const toPng = async (blob) => {
251
+ if (blob.type === "image/png")
252
+ return blob;
198
253
  const bmp = await createImageBitmap(blob);
199
254
  const canvas = document.createElement("canvas");
200
255
  canvas.width = bmp.width;
201
256
  canvas.height = bmp.height;
202
257
  canvas.getContext("2d").drawImage(bmp, 0, 0);
203
- const png = await new Promise((res, rej) => canvas.toBlob(b => b ? res(b) : rej(new Error("toBlob failed")), "image/png"));
204
- await navigator.clipboard.write([new ClipboardItem({ "image/png": png })]);
258
+ return await new Promise((res, rej) => canvas.toBlob(b => b ? res(b) : rej(new Error("toBlob failed")), "image/png"));
259
+ };
260
+ // 1. Ask the preview to encode it.
261
+ const fromPreview = await requestImageFromPreview(sourceWindow, imgSrc);
262
+ if (fromPreview.blob) {
263
+ try {
264
+ await writePng(fromPreview.blob);
265
+ return;
266
+ }
267
+ catch (e) {
268
+ attempts.push(`clipboard write: ${e?.message || e}`);
269
+ }
270
+ }
271
+ else {
272
+ attempts.push(`preview: ${fromPreview.error}`);
273
+ }
274
+ // 2. Fetch it from here.
275
+ try {
276
+ const resp = await fetch(imgSrc);
277
+ if (!resp.ok)
278
+ throw new Error(`HTTP ${resp.status}`);
279
+ await writePng(await toPng(await resp.blob()));
280
+ return;
205
281
  }
206
282
  catch (e) {
207
- // cid: inline images can't be fetched from the parent
208
- // context — surface rather than fail silently.
209
- alert(`Couldn't copy image: ${e?.message || e}${imgSrc.startsWith("cid:") ? " (inline cid: image — try Save instead, or right-click → save in the message)" : ""}`);
283
+ attempts.push(`direct fetch: ${e?.message || e}`);
284
+ }
285
+ // 3. Let the daemon fetch it (remote images, no CORS).
286
+ if (/^https?:/i.test(imgSrc)) {
287
+ try {
288
+ const { fetchRemoteImage } = await import("../lib/api-client.js");
289
+ const r = await fetchRemoteImage(imgSrc);
290
+ if (!r?.base64)
291
+ throw new Error(r?.error || "no data returned");
292
+ const bytes = Uint8Array.from(atob(r.base64), c => c.charCodeAt(0));
293
+ await writePng(await toPng(new Blob([bytes], { type: r.contentType || "image/png" })));
294
+ return;
295
+ }
296
+ catch (e) {
297
+ attempts.push(`via rmfmail: ${e?.message || e}`);
298
+ }
210
299
  }
300
+ // Name what was tried — a bare "couldn't copy" leaves nothing
301
+ // to act on, and these three failure modes need different fixes.
302
+ alert(`Couldn't copy that image.\n\n${attempts.map(a => `• ${a}`).join("\n")}`);
211
303
  } }, { label: `Save image as "${guessImgName}"…`, action: () => {
212
304
  const a = document.createElement("a");
213
305
  a.href = imgSrc;
@@ -2372,6 +2464,9 @@ ${csp}
2372
2464
  // dismissers it sees. The parent's debounced 500 ms show-timer still
2373
2465
  // suppresses flicker.
2374
2466
  var lastHoveredHref = "";
2467
+ // The <img> under the most recent right-click, kept so a later
2468
+ // "copyImage" command can encode that exact element (see below).
2469
+ var lastContextImg = null;
2375
2470
  function postLinkHover(href, rect, mouse) {
2376
2471
  // Only post on transitions to avoid spamming the parent on every
2377
2472
  // mousemove inside a link.
@@ -2447,6 +2542,45 @@ ${csp}
2447
2542
  s.addRange(r);
2448
2543
  }
2449
2544
  } catch (_) {}
2545
+ } else if (d.cmd === "copyImage") {
2546
+ // Encode the right-clicked image HERE and hand the parent the
2547
+ // bytes. The parent can't fetch it itself: an inline image is a
2548
+ // multi-megabyte data: URI it would have to re-download through
2549
+ // the message channel, and a remote one is cross-origin with no
2550
+ // CORS headers, so a parent-side fetch rejects. This img is
2551
+ // already decoded here, so a canvas draw needs no network at all.
2552
+ // (A remote image DOES taint the canvas — toBlob then throws and
2553
+ // the parent falls back to fetching it through the daemon.)
2554
+ var target = lastContextImg;
2555
+ if (!target && d.src) {
2556
+ var all = document.getElementsByTagName("img");
2557
+ for (var i = 0; i < all.length; i++) {
2558
+ if (all[i].currentSrc === d.src || all[i].src === d.src) { target = all[i]; break; }
2559
+ }
2560
+ }
2561
+ if (!target) {
2562
+ window.parent.postMessage({ type: "previewImageBytes", reqId: d.reqId, error: "image element not found" }, "*");
2563
+ return;
2564
+ }
2565
+ try {
2566
+ var cv = document.createElement("canvas");
2567
+ cv.width = target.naturalWidth || target.width;
2568
+ cv.height = target.naturalHeight || target.height;
2569
+ if (!cv.width || !cv.height) throw new Error("image has no decoded size yet");
2570
+ cv.getContext("2d").drawImage(target, 0, 0);
2571
+ cv.toBlob(function (b) {
2572
+ if (!b) {
2573
+ window.parent.postMessage({ type: "previewImageBytes", reqId: d.reqId, error: "encode failed" }, "*");
2574
+ return;
2575
+ }
2576
+ window.parent.postMessage({ type: "previewImageBytes", reqId: d.reqId, blob: b }, "*");
2577
+ }, "image/png");
2578
+ } catch (err) {
2579
+ window.parent.postMessage({
2580
+ type: "previewImageBytes", reqId: d.reqId,
2581
+ error: (err && err.message) ? err.message : String(err)
2582
+ }, "*");
2583
+ }
2450
2584
  }
2451
2585
  });
2452
2586
 
@@ -2465,6 +2599,10 @@ ${csp}
2465
2599
  try { rect = e.target.getBoundingClientRect ? e.target.getBoundingClientRect() : rect; } catch (_) {}
2466
2600
  var a = e.target && e.target.closest ? e.target.closest("a[href]") : null;
2467
2601
  var img = e.target && e.target.closest ? e.target.closest("img") : null;
2602
+ // Remember the element itself, not just its src — "Copy image" encodes
2603
+ // this exact <img>, and matching by src would pick the wrong one when
2604
+ // a letter repeats the same image (spacers, logos, tracking pixels).
2605
+ lastContextImg = img;
2468
2606
  var sel = (window.getSelection && window.getSelection()) ? window.getSelection().toString() : "";
2469
2607
  window.parent.postMessage({
2470
2608
  type: "previewContextMenu",