@bobfrankston/rmfmail 1.2.96 → 1.2.98
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.bundle.js +116 -20
- package/client/app.bundle.js.map +3 -3
- package/client/components/message-viewer.js +138 -26
- package/client/components/message-viewer.js.map +1 -1
- package/client/components/message-viewer.ts +122 -24
- package/client/compose/compose.bundle.js +4 -0
- package/client/compose/compose.bundle.js.map +2 -2
- package/client/lib/api-client.js +4 -0
- package/client/lib/api-client.js.map +1 -1
- package/client/lib/api-client.ts +5 -0
- package/client/lib/mailxapi.js +3 -0
- package/package.json +1 -1
- package/packages/mailx-service/index.d.ts +7 -0
- package/packages/mailx-service/index.d.ts.map +1 -1
- package/packages/mailx-service/index.js +40 -0
- package/packages/mailx-service/index.js.map +1 -1
- package/packages/mailx-service/index.ts +31 -0
- package/packages/mailx-service/jsonrpc.js +2 -0
- package/packages/mailx-service/jsonrpc.js.map +1 -1
- package/packages/mailx-service/jsonrpc.ts +2 -0
|
@@ -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
|
|
1382
|
-
//
|
|
1383
|
-
// format: "mime:filename:blob-url".
|
|
1384
|
-
//
|
|
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",
|
|
1416
|
+
chip.addEventListener("dragstart", (e) => {
|
|
1387
1417
|
if (!e.dataTransfer) return;
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
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
|
|
@@ -1862,7 +1892,12 @@ ${csp}
|
|
|
1862
1892
|
var k = (e.key || "").toLowerCase();
|
|
1863
1893
|
var isShortcut = e.ctrlKey && !e.altKey && !e.metaKey && (
|
|
1864
1894
|
k === "r" || k === "f" || k === "n" || k === "a" || k === "d" ||
|
|
1865
|
-
k === "z" || k === "y" || k === "k"
|
|
1895
|
+
k === "z" || k === "y" || k === "k" ||
|
|
1896
|
+
// Ctrl+P: without this suppress, WebView2's native print fires on
|
|
1897
|
+
// the iframe's default and prints the WHOLE APP window instead of
|
|
1898
|
+
// the letter (Bob 2026-07-02). The forwarded event reaches app.ts,
|
|
1899
|
+
// which runs printCurrentMessage (letter + header block).
|
|
1900
|
+
k === "p"
|
|
1866
1901
|
);
|
|
1867
1902
|
if (isShortcut) e.preventDefault();
|
|
1868
1903
|
window.parent.postMessage({
|
|
@@ -1971,9 +2006,11 @@ export function popOutToWindow(): void {
|
|
|
1971
2006
|
* document — a header block (From/To/Cc/Subject/Date) plus the message body
|
|
1972
2007
|
* — renders it in an off-screen iframe, and fires the print dialog. There
|
|
1973
2008
|
* was no way to print a letter before (Bob 2026-05-21). */
|
|
1974
|
-
|
|
1975
|
-
|
|
1976
|
-
|
|
2009
|
+
/** Full standalone HTML document for a message: printed-letter header block
|
|
2010
|
+
* (From/To/Cc/Subject/Date) + body. Shared by Print and Save-as-HTML so the
|
|
2011
|
+
* saved file reads exactly like the printout. */
|
|
2012
|
+
function buildPrintableDoc(m: any): string | null {
|
|
2013
|
+
if (!m) return null;
|
|
1977
2014
|
const fmt = (a: { name?: string; address?: string }): string =>
|
|
1978
2015
|
a?.name ? `${a.name} <${a.address || ""}>` : (a?.address || "");
|
|
1979
2016
|
const esc = escapeHtmlLocal;
|
|
@@ -1991,11 +2028,16 @@ export function printCurrentMessage(): void {
|
|
|
1991
2028
|
const bodyHtml = m.bodyHtml
|
|
1992
2029
|
? m.bodyHtml
|
|
1993
2030
|
: `<pre style="white-space:pre-wrap;word-break:break-word;font-family:system-ui,sans-serif;">${esc(m.bodyText || "")}</pre>`;
|
|
1994
|
-
|
|
1995
|
-
`<!DOCTYPE html><html><head><meta charset="utf-8">` +
|
|
2031
|
+
return `<!DOCTYPE html><html><head><meta charset="utf-8">` +
|
|
1996
2032
|
`<title>${esc(m.subject || "Message")}</title>` +
|
|
1997
2033
|
`<style>@media print{body{margin:0;}}body{margin:16px;}img{max-width:100%;}</style>` +
|
|
1998
2034
|
`</head><body>${headerHtml}${bodyHtml}</body></html>`;
|
|
2035
|
+
}
|
|
2036
|
+
|
|
2037
|
+
export function printCurrentMessage(): void {
|
|
2038
|
+
if (!currentMessage) return;
|
|
2039
|
+
const doc = buildPrintableDoc(currentMessage);
|
|
2040
|
+
if (!doc) return;
|
|
1999
2041
|
|
|
2000
2042
|
const iframe = document.createElement("iframe");
|
|
2001
2043
|
iframe.style.cssText = "position:fixed;left:-9999px;width:1px;height:1px;border:0;";
|
|
@@ -2016,6 +2058,62 @@ export function printCurrentMessage(): void {
|
|
|
2016
2058
|
document.body.appendChild(iframe);
|
|
2017
2059
|
}
|
|
2018
2060
|
|
|
2061
|
+
/** Save a Blob to disk. Native Save-As dialog where the File System Access
|
|
2062
|
+
* API exists (WebView2/Chromium — which also stamps the file with the
|
|
2063
|
+
* Mark-of-the-Web, so Office/SmartScreen treat it as untrusted); falls back
|
|
2064
|
+
* to a programmatic <a download> elsewhere. */
|
|
2065
|
+
async function saveBlobAs(suggestedName: string, blob: Blob): Promise<void> {
|
|
2066
|
+
const picker = (window as any).showSaveFilePicker;
|
|
2067
|
+
if (typeof picker === "function") {
|
|
2068
|
+
let handle: any;
|
|
2069
|
+
try {
|
|
2070
|
+
handle = await picker({ suggestedName });
|
|
2071
|
+
} catch (e: any) {
|
|
2072
|
+
if (e?.name === "AbortError") return; // user cancelled
|
|
2073
|
+
throw e;
|
|
2074
|
+
}
|
|
2075
|
+
const writable = await handle.createWritable();
|
|
2076
|
+
await writable.write(blob);
|
|
2077
|
+
await writable.close();
|
|
2078
|
+
return;
|
|
2079
|
+
}
|
|
2080
|
+
const url = URL.createObjectURL(blob);
|
|
2081
|
+
const a = document.createElement("a");
|
|
2082
|
+
a.href = url; a.download = suggestedName; a.style.display = "none";
|
|
2083
|
+
document.body.appendChild(a); a.click();
|
|
2084
|
+
setTimeout(() => { a.remove(); URL.revokeObjectURL(url); }, 5000);
|
|
2085
|
+
}
|
|
2086
|
+
|
|
2087
|
+
/** Save the open message's raw RFC 822 source as a .eml file — the portable
|
|
2088
|
+
* mail format Outlook / Thunderbird / other clients open directly (an .eml
|
|
2089
|
+
* with multipart/related content is what .mht wraps; .eml additionally keeps
|
|
2090
|
+
* the real headers and attachments). */
|
|
2091
|
+
export async function saveCurrentMessageAsEml(): Promise<void> {
|
|
2092
|
+
if (!currentMessage) return;
|
|
2093
|
+
try {
|
|
2094
|
+
const src = await getMessageSource(currentAccountId, currentMessage.uid, currentMessage.folderId);
|
|
2095
|
+
const bytes = Uint8Array.from(atob(src.dataBase64), c => c.charCodeAt(0));
|
|
2096
|
+
await saveBlobAs(src.filename || "message.eml", new Blob([bytes], { type: "message/rfc822" }));
|
|
2097
|
+
} catch (e: any) {
|
|
2098
|
+
window.dispatchEvent(new CustomEvent("mailx-alert", { detail: { message: `Couldn't save message: ${e?.message || e}`, key: "message-save" } }));
|
|
2099
|
+
}
|
|
2100
|
+
}
|
|
2101
|
+
|
|
2102
|
+
/** Save the open message as a standalone HTML file — the same header block +
|
|
2103
|
+
* body document the Print path renders, so the saved file reads like the
|
|
2104
|
+
* printed letter. */
|
|
2105
|
+
export async function saveCurrentMessageAsHtml(): Promise<void> {
|
|
2106
|
+
if (!currentMessage) return;
|
|
2107
|
+
const doc = buildPrintableDoc(currentMessage);
|
|
2108
|
+
if (!doc) return;
|
|
2109
|
+
const base = (currentMessage.subject || "message").replace(/[<>:"/\\|?*\r\n]/g, "_").slice(0, 80).trim() || "message";
|
|
2110
|
+
try {
|
|
2111
|
+
await saveBlobAs(`${base}.html`, new Blob([doc], { type: "text/html" }));
|
|
2112
|
+
} catch (e: any) {
|
|
2113
|
+
window.dispatchEvent(new CustomEvent("mailx-alert", { detail: { message: `Couldn't save message: ${e?.message || e}`, key: "message-save" } }));
|
|
2114
|
+
}
|
|
2115
|
+
}
|
|
2116
|
+
|
|
2019
2117
|
/** Build a floating overlay carrying a snapshot of the message: header
|
|
2020
2118
|
* (subject, from, to, date) + sandboxed body iframe + attachment chips.
|
|
2021
2119
|
* 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
|
}
|