Search (PG backend):
- '@' terms match address substrings (from/to/cc), so a partial email like
"dyrektor@wfdif" resolves and narrows predictably as you type
- index each address split into parts, so a bare domain/local word ("wfdif")
finds the address (indexed as one lexeme otherwise); dotted terms fall back to
an address substring
- rank sender/recipient name+address matches above body/subject-only ones
(addr_hit tier, then recency); cursor extended to keep keyset pagination correct
- scripts/reindex_address_parts.py backfills address tokens into existing rows
Inline images:
- extract attachments via a full MIME walk instead of shallow iter_attachments(),
which missed inline images nested in mixed>related>alternative (cid: rendered
broken); has_attachments counts only real (non-cid) attachments
- scripts/backfill_inline_attachments.py stores previously-missed inline images
Folder-name i18n (SPEC §12 M2):
- accent/punctuation-insensitive, multi-delimiter (/ \ .) special-use inference
across ~13 languages; curated names (no substring false positives)
Link security (reading panel):
- links never navigate the message iframe: a parent-side click interceptor opens
http/https in a new tab (noopener) and mailto in the composer (recipient
pre-filled); all other schemes blocked
Backend 292 tests green, ruff + mypy --strict clean; frontend tsc/eslint/vitest clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U72FZiF1W1pb8XxyWBTREA
164 lines
6.7 KiB
TypeScript
164 lines
6.7 KiB
TypeScript
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<HTMLIFrameElement>(null);
|
|
const observerRef = useRef<ResizeObserver | null>(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 <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],
|
|
);
|
|
|
|
// 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 (
|
|
<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>
|
|
);
|
|
}
|