import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useCallback, useEffect, useRef, useState } from "react"; import { useT } from "../../i18n"; import { api } from "../../lib/api"; import type { MessageEnvelope } from "../../lib/types"; import { Attachments } from "./Attachments"; // Sanitized message HTML in a sandboxed iframe (SPEC §9.4, I-8). We use // sandbox="allow-same-origin" WITHOUT allow-scripts: scripts still never run // (the content is nh3-sanitized and script-less), but same-origin lets us (a) // measure the content to auto-size the frame — so the message shows in full and // only the thread panel scrolls — and (b) send the session cookie so inline // (cid:) images authenticate and load. export function MessageBody({ message, onMailto, }: { message: MessageEnvelope; onMailto?: (email: string) => void; }) { const t = useT(); const queryClient = useQueryClient(); // Seed from the server's trusted-sender flag so an already-approved sender // loads images and shows no "blocked" prompt on reopen. const [allowed, setAllowed] = useState(message.images_allowed); const frameRef = useRef(null); const observerRef = useRef(null); const onMailtoRef = useRef(onMailto); onMailtoRef.current = onMailto; const [height, setHeight] = useState(80); const { data, isLoading, isError } = useQuery({ queryKey: ["message-html", message.id, allowed], queryFn: async () => { // ?images=1 asks the server to render remote images for this one request // (the "Load images" one-off); "Always from this sender" persists separately. const url = `/api/messages/${message.id}/html${allowed ? "?images=1" : ""}`; const resp = await fetch(url, { credentials: "include" }); if (!resp.ok) throw new Error("failed to load message"); return resp.text(); }, }); const resize = useCallback(() => { const frame = frameRef.current; const doc = frame?.contentDocument; if (!frame || !doc?.documentElement) return; // Collapse first so scrollHeight reflects the content, not the current frame // height — otherwise the frame can grow but never shrink (e.g. hiding a quote). frame.style.height = "0px"; const h = doc.documentElement.scrollHeight; frame.style.height = `${h}px`; setHeight(h); }, []); // Hide the quoted original by default and drop a "•••" toggle right after the // "On , wrote:" attribution (inside the white body), so the reader // sees only the new content until they choose to reveal the quote. const injectQuoteToggle = useCallback( (doc: Document) => { if (doc.getElementById("__quote_toggle")) return; const quote = doc.querySelector("blockquote"); if (!quote?.parentNode) return; quote.style.display = "none"; const btn = doc.createElement("button"); btn.id = "__quote_toggle"; btn.type = "button"; btn.textContent = "•••"; btn.setAttribute( "style", "display:inline-block;margin:6px 0;padding:1px 9px;border-radius:8px;border:none;" + "background:#e5e7eb;color:#4b5563;cursor:pointer;font:inherit;font-size:15px;line-height:1.5;", ); btn.addEventListener("click", () => { quote.style.display = quote.style.display === "none" ? "" : "none"; resize(); }); quote.parentNode.insertBefore(btn, quote); }, [resize], ); // Links must NEVER navigate the message iframe (security: a click would replace // the message body, and could be used for clickjacking/phishing). The frame is // same-origin, so the parent intercepts clicks here: http/https open in a new // tab (noopener), mailto opens the composer, and every other scheme is blocked. const handleFrameClick = useCallback((e: Event) => { const el = e.target as Element | null; const anchor = el?.closest?.("a[href]") as HTMLAnchorElement | null; if (!anchor) return; const raw = (anchor.getAttribute("href") || "").trim(); e.preventDefault(); if (/^mailto:/i.test(raw)) { const email = decodeURIComponent(raw.replace(/^mailto:/i, "").split("?")[0]).trim(); if (email) onMailtoRef.current?.(email); } else if (/^https?:\/\//i.test(raw)) { window.open(anchor.href, "_blank", "noopener,noreferrer"); } // else: relative / cid: / javascript: / data: / tel: — blocked, no navigation. }, []); const onLoad = () => { const doc = frameRef.current?.contentDocument; if (!doc) return; injectQuoteToggle(doc); resize(); doc.addEventListener("click", handleFrameClick); // Re-measure as images finish loading (they change the content height). doc.querySelectorAll("img").forEach((img) => { if (!img.complete) img.addEventListener("load", resize, { once: true }); }); observerRef.current?.disconnect(); const ro = new ResizeObserver(resize); if (doc.body) ro.observe(doc.body); observerRef.current = ro; }; useEffect(() => () => observerRef.current?.disconnect(), []); const loadImages = async (persist: boolean) => { if (persist) { await api.allowRemoteImages(message.id); queryClient.invalidateQueries({ queryKey: ["message-html", message.id] }); // Refresh thread detail so the sender's images_allowed flag reflects the // new trust on reopen (suppresses the prompt next time). if (message.thread_id) { queryClient.invalidateQueries({ queryKey: ["thread", message.thread_id] }); } } setAllowed(true); }; return (
{message.has_remote_images && !allowed && (
{t("mail.imagesBlocked")}
)} {isLoading &&
{t("mail.loadingThread")}
} {isError &&
{t("mail.loadFailed")}
} {data !== undefined && !isLoading && (