Files
webmail-client/frontend/src/components/thread/MessageBody.tsx
T

126 lines
4.9 KiB
TypeScript
Raw Normal View History

2026-07-17 23:20:34 +02:00
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 }: { message: MessageEnvelope }) {
const t = useT();
const queryClient = useQueryClient();
const [allowed, setAllowed] = useState(false);
const frameRef = useRef<HTMLIFrameElement>(null);
const observerRef = useRef<ResizeObserver | null>(null);
const [height, setHeight] = useState(80);
const { data, isLoading, isError } = useQuery({
queryKey: ["message-html", message.id, allowed],
queryFn: async () => {
const resp = await fetch(`/api/messages/${message.id}/html`, { credentials: "include" });
if (!resp.ok) throw new Error("failed to load message");
return resp.text();
},
});
const resize = useCallback(() => {
2026-07-19 18:58:01 +02:00
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);
2026-07-17 23:20:34 +02:00
}, []);
2026-07-19 18:58:01 +02:00
// Hide the quoted original by default and drop a "•••" toggle right after the
// "On <date>, <sender> 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<HTMLElement>("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],
);
2026-07-17 23:20:34 +02:00
const onLoad = () => {
const doc = frameRef.current?.contentDocument;
if (!doc) return;
2026-07-19 18:58:01 +02:00
injectQuoteToggle(doc);
2026-07-17 23:20:34 +02:00
resize();
// 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] });
}
setAllowed(true);
};
return (
<div>
{message.has_remote_images && !allowed && (
<div className="flex flex-wrap items-center gap-3 border-b border-app-border bg-amber-100 px-3 py-1.5 text-xs text-amber-900 dark:bg-amber-900/30 dark:text-amber-200">
<span>{t("mail.imagesBlocked")}</span>
<button className="font-medium text-app-accent hover:underline" onClick={() => loadImages(false)}>
{t("mail.loadImages")}
</button>
<button className="font-medium text-app-accent hover:underline" onClick={() => loadImages(true)}>
{t("mail.alwaysFromSender")}
</button>
</div>
)}
{isLoading && <div className="p-3 text-sm text-app-faint">{t("mail.loadingThread")}</div>}
{isError && <div className="p-3 text-sm text-red-500">{t("mail.loadFailed")}</div>}
{data !== undefined && !isLoading && (
<iframe
ref={frameRef}
title={`message-${message.id}`}
sandbox="allow-same-origin"
srcDoc={data}
onLoad={onLoad}
style={{ height }}
className="w-full border-0 bg-white"
/>
)}
{message.has_attachments && <Attachments messageId={message.id} />}
</div>
);
}