@bobfrankston/rmfmail 1.2.96 → 1.2.97

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.
@@ -9,7 +9,7 @@
9
9
  * drift impossible: there is exactly one path into this pane.
10
10
  */
11
11
 
12
- import { getMessage, updateFlags, allowRemoteContent, flagSenderOrDomain, getAttachment, openAttachment, addContact, listContacts, upsertContact, unsubscribeOneClick, addPreferredContact, onEvent, subscribeStore } from "../lib/api-client.js";
12
+ import { getMessage, updateFlags, allowRemoteContent, flagSenderOrDomain, getAttachment, openAttachment, getMessageSource, addContact, listContacts, upsertContact, unsubscribeOneClick, addPreferredContact, onEvent, subscribeStore } from "../lib/api-client.js";
13
13
  import { showContextMenu } from "./context-menu.js";
14
14
  import type { MenuItem } from "./context-menu.js";
15
15
  import type { ListMessage } from "../lib/message-state.js";
@@ -20,6 +20,9 @@ import { setSeen, seenOf } from "@bobfrankston/mailx-types";
20
20
  /** Currently displayed message (for reply/forward) */
21
21
  let currentMessage: any = null;
22
22
  let currentAccountId: string = "";
23
+ // Blob URLs created for attachment drag-out (revoked when the next message's
24
+ // chips render, so they don't accumulate across the session).
25
+ const dragOutUrls: string[] = [];
23
26
  let showMessageGeneration = 0; // Cancel stale fetches
24
27
  let retryCount = 0;
25
28
  /** Last envelope handed in by the list — used for envelope-first paint
@@ -220,6 +223,8 @@ export function showPreviewBodyMenu(absX: number, absY: number, selectedText: st
220
223
  { label: "Zoom out (−)", action: () => { for (const f of Array.from(document.querySelectorAll("iframe"))) { const d = (f as HTMLIFrameElement).contentDocument; if (d) { setZoom(previewZoom - ZOOM_STEP, d); break; } } } },
221
224
  { label: "", action: () => {}, separator: true },
222
225
  { label: "Print… (Ctrl+P)", action: () => printCurrentMessage() },
226
+ { label: "Save message (.eml)…", action: () => { void saveCurrentMessageAsEml(); } },
227
+ { label: "Save message as HTML…", action: () => { void saveCurrentMessageAsHtml(); } },
223
228
  );
224
229
  showContextMenu(absX, absY, items);
225
230
  }
@@ -1260,6 +1265,30 @@ export async function showMessage(accountId: string, uid: number, folderId?: num
1260
1265
  attEl.hidden = true;
1261
1266
  if (msg.attachments?.length) {
1262
1267
  attEl.hidden = false;
1268
+ // ── Drag-out prefetch cache ──
1269
+ // dataTransfer.setData ONLY works synchronously inside dragstart —
1270
+ // the drag data store closes at the first `await` — so the old
1271
+ // handler that fetched the attachment inside dragstart silently
1272
+ // set nothing, and drag-to-Explorer never worked (Bob 2026-07-02).
1273
+ // Prefetch every attachment's bytes in the background right after
1274
+ // the chips render; dragstart reads this cache synchronously.
1275
+ // Blob URLs from the previous message are revoked first.
1276
+ for (const u of dragOutUrls) { try { URL.revokeObjectURL(u); } catch { /* */ } }
1277
+ dragOutUrls.length = 0;
1278
+ const dragCache = new Map<number, { url: string; contentType: string }>();
1279
+ void (async () => {
1280
+ for (const a1 of msg.attachments) {
1281
+ if ((a1.size || 0) > 50 * 1024 * 1024) continue; // don't buffer >50MB for a maybe-drag
1282
+ try {
1283
+ const data = await getAttachment(accountId, uid, a1.id, msg.folderId);
1284
+ const bytes = Uint8Array.from(atob(data.content), c => c.charCodeAt(0));
1285
+ const blob = new Blob([bytes], { type: data.contentType || "application/octet-stream" });
1286
+ const url = URL.createObjectURL(blob);
1287
+ dragOutUrls.push(url);
1288
+ dragCache.set(a1.id, { url, contentType: data.contentType || "application/octet-stream" });
1289
+ } catch { /* drag-out just won't arm for this attachment */ }
1290
+ }
1291
+ })();
1263
1292
  // Save (download to disk) one attachment by index — distinct from
1264
1293
  // the click handler's "open with the OS app". Desktop (WebView2 =
1265
1294
  // Chromium) gets a real Save-As dialog via showSaveFilePicker;
@@ -1378,26 +1407,27 @@ export async function showMessage(accountId: string, uid: number, folderId?: num
1378
1407
  restoreChip();
1379
1408
  }
1380
1409
  });
1381
- // Drag the chip to an external target (Explorer / Finder / Files app)
1382
- // to drop the file there. Uses the Chromium `DownloadURL` dataTransfer
1383
- // format: "mime:filename:blob-url". We fetch the attachment first so
1384
- // the blob URL is valid by the time the drop lands.
1410
+ // Drag the chip to an external target (Explorer / Finder) to
1411
+ // drop the file there. Chromium `DownloadURL` dataTransfer
1412
+ // format: "mime:filename:blob-url". MUST be synchronous the
1413
+ // content comes from the prefetch cache above; Chromium stamps
1414
+ // the dropped file with the Mark-of-the-Web like any download.
1385
1415
  chip.draggable = true;
1386
- chip.addEventListener("dragstart", async (e) => {
1416
+ chip.addEventListener("dragstart", (e) => {
1387
1417
  if (!e.dataTransfer) return;
1388
- try {
1389
- const data = await getAttachment(accountId, uid, att.id, msg.folderId);
1390
- const bytes = Uint8Array.from(atob(data.content), c => c.charCodeAt(0));
1391
- const blob = new Blob([bytes], { type: data.contentType || "application/octet-stream" });
1392
- const url = URL.createObjectURL(blob);
1393
- // Sanitize filename: no path separators, no newlines.
1394
- const safeName = (att.filename || "attachment").replace(/[\r\n"\/\\]/g, "_");
1395
- const downloadUrl = `${data.contentType || "application/octet-stream"}:${safeName}:${url}`;
1396
- e.dataTransfer.setData("DownloadURL", downloadUrl);
1397
- e.dataTransfer.effectAllowed = "copy";
1398
- } catch (err: any) {
1399
- console.error(`Attachment drag-out failed: ${err.message || err}`);
1418
+ const cached = dragCache.get(att.id);
1419
+ if (!cached) {
1420
+ // Prefetch still in flight (or >50MB skip). Cancel the
1421
+ // drag rather than start one that can't carry data.
1422
+ e.preventDefault();
1423
+ const statusEl = document.getElementById("status-sync");
1424
+ if (statusEl) statusEl.textContent = "Attachment still loading — try dragging again in a moment.";
1425
+ return;
1400
1426
  }
1427
+ // Sanitize filename: no path separators, no newlines.
1428
+ const safeName = (att.filename || "attachment").replace(/[\r\n"\/\\]/g, "_");
1429
+ e.dataTransfer.setData("DownloadURL", `${cached.contentType}:${safeName}:${cached.url}`);
1430
+ e.dataTransfer.effectAllowed = "copy";
1401
1431
  });
1402
1432
  // Right-click → Open / Save / Save all. Click still opens; this
1403
1433
  // adds explicit save (download to disk) which the click path
@@ -1971,9 +2001,11 @@ export function popOutToWindow(): void {
1971
2001
  * document — a header block (From/To/Cc/Subject/Date) plus the message body
1972
2002
  * — renders it in an off-screen iframe, and fires the print dialog. There
1973
2003
  * was no way to print a letter before (Bob 2026-05-21). */
1974
- export function printCurrentMessage(): void {
1975
- if (!currentMessage) return;
1976
- const m = currentMessage;
2004
+ /** Full standalone HTML document for a message: printed-letter header block
2005
+ * (From/To/Cc/Subject/Date) + body. Shared by Print and Save-as-HTML so the
2006
+ * saved file reads exactly like the printout. */
2007
+ function buildPrintableDoc(m: any): string | null {
2008
+ if (!m) return null;
1977
2009
  const fmt = (a: { name?: string; address?: string }): string =>
1978
2010
  a?.name ? `${a.name} <${a.address || ""}>` : (a?.address || "");
1979
2011
  const esc = escapeHtmlLocal;
@@ -1991,11 +2023,16 @@ export function printCurrentMessage(): void {
1991
2023
  const bodyHtml = m.bodyHtml
1992
2024
  ? m.bodyHtml
1993
2025
  : `<pre style="white-space:pre-wrap;word-break:break-word;font-family:system-ui,sans-serif;">${esc(m.bodyText || "")}</pre>`;
1994
- const doc =
1995
- `<!DOCTYPE html><html><head><meta charset="utf-8">` +
2026
+ return `<!DOCTYPE html><html><head><meta charset="utf-8">` +
1996
2027
  `<title>${esc(m.subject || "Message")}</title>` +
1997
2028
  `<style>@media print{body{margin:0;}}body{margin:16px;}img{max-width:100%;}</style>` +
1998
2029
  `</head><body>${headerHtml}${bodyHtml}</body></html>`;
2030
+ }
2031
+
2032
+ export function printCurrentMessage(): void {
2033
+ if (!currentMessage) return;
2034
+ const doc = buildPrintableDoc(currentMessage);
2035
+ if (!doc) return;
1999
2036
 
2000
2037
  const iframe = document.createElement("iframe");
2001
2038
  iframe.style.cssText = "position:fixed;left:-9999px;width:1px;height:1px;border:0;";
@@ -2016,6 +2053,62 @@ export function printCurrentMessage(): void {
2016
2053
  document.body.appendChild(iframe);
2017
2054
  }
2018
2055
 
2056
+ /** Save a Blob to disk. Native Save-As dialog where the File System Access
2057
+ * API exists (WebView2/Chromium — which also stamps the file with the
2058
+ * Mark-of-the-Web, so Office/SmartScreen treat it as untrusted); falls back
2059
+ * to a programmatic <a download> elsewhere. */
2060
+ async function saveBlobAs(suggestedName: string, blob: Blob): Promise<void> {
2061
+ const picker = (window as any).showSaveFilePicker;
2062
+ if (typeof picker === "function") {
2063
+ let handle: any;
2064
+ try {
2065
+ handle = await picker({ suggestedName });
2066
+ } catch (e: any) {
2067
+ if (e?.name === "AbortError") return; // user cancelled
2068
+ throw e;
2069
+ }
2070
+ const writable = await handle.createWritable();
2071
+ await writable.write(blob);
2072
+ await writable.close();
2073
+ return;
2074
+ }
2075
+ const url = URL.createObjectURL(blob);
2076
+ const a = document.createElement("a");
2077
+ a.href = url; a.download = suggestedName; a.style.display = "none";
2078
+ document.body.appendChild(a); a.click();
2079
+ setTimeout(() => { a.remove(); URL.revokeObjectURL(url); }, 5000);
2080
+ }
2081
+
2082
+ /** Save the open message's raw RFC 822 source as a .eml file — the portable
2083
+ * mail format Outlook / Thunderbird / other clients open directly (an .eml
2084
+ * with multipart/related content is what .mht wraps; .eml additionally keeps
2085
+ * the real headers and attachments). */
2086
+ export async function saveCurrentMessageAsEml(): Promise<void> {
2087
+ if (!currentMessage) return;
2088
+ try {
2089
+ const src = await getMessageSource(currentAccountId, currentMessage.uid, currentMessage.folderId);
2090
+ const bytes = Uint8Array.from(atob(src.dataBase64), c => c.charCodeAt(0));
2091
+ await saveBlobAs(src.filename || "message.eml", new Blob([bytes], { type: "message/rfc822" }));
2092
+ } catch (e: any) {
2093
+ window.dispatchEvent(new CustomEvent("mailx-alert", { detail: { message: `Couldn't save message: ${e?.message || e}`, key: "message-save" } }));
2094
+ }
2095
+ }
2096
+
2097
+ /** Save the open message as a standalone HTML file — the same header block +
2098
+ * body document the Print path renders, so the saved file reads like the
2099
+ * printed letter. */
2100
+ export async function saveCurrentMessageAsHtml(): Promise<void> {
2101
+ if (!currentMessage) return;
2102
+ const doc = buildPrintableDoc(currentMessage);
2103
+ if (!doc) return;
2104
+ const base = (currentMessage.subject || "message").replace(/[<>:"/\\|?*\r\n]/g, "_").slice(0, 80).trim() || "message";
2105
+ try {
2106
+ await saveBlobAs(`${base}.html`, new Blob([doc], { type: "text/html" }));
2107
+ } catch (e: any) {
2108
+ window.dispatchEvent(new CustomEvent("mailx-alert", { detail: { message: `Couldn't save message: ${e?.message || e}`, key: "message-save" } }));
2109
+ }
2110
+ }
2111
+
2019
2112
  /** Build a floating overlay carrying a snapshot of the message: header
2020
2113
  * (subject, from, to, date) + sandboxed body iframe + attachment chips.
2021
2114
  * Reuses the compose-overlay drag/resize/close pattern. Independent of the
@@ -1052,6 +1052,7 @@ __export(api_client_exports, {
1052
1052
  getDiagnostics: () => getDiagnostics,
1053
1053
  getFolders: () => getFolders,
1054
1054
  getMessage: () => getMessage,
1055
+ getMessageSource: () => getMessageSource,
1055
1056
  getMessages: () => getMessages,
1056
1057
  getOutboxStatus: () => getOutboxStatus,
1057
1058
  getPrimaryAccount: () => getPrimaryAccount,
@@ -1614,6 +1615,9 @@ async function openAttachment(accountId, uid, attachmentId, folderId, filename)
1614
1615
  const fn = ipc().openAttachment;
1615
1616
  return fn ? fn(accountId, uid, attachmentId, folderId, filename) : void 0;
1616
1617
  }
1618
+ async function getMessageSource(accountId, uid, folderId) {
1619
+ return ipc().getMessageSource(accountId, uid, folderId);
1620
+ }
1617
1621
  async function getDeviceAccounts() {
1618
1622
  return ipc().getDeviceAccounts?.() ?? [];
1619
1623
  }