src/mail/MessageContent.tsx
import { useContext, useEffect, useId, useRef, useState, type ReactNode } from "react";
import { imageSource, type Mail, type MailImage, type MailAttachment } from "../../shared/types";
import { privateMailLink } from "../../shared/mail-content";
import { redactPrivateText } from "../privacy";
import { PrivacyContext, Redact } from "../ui/Privacy";
import { FormattedMail } from "./FormattedMail";
export function MessageBody({ text }: { text: string }) {
const { on, names } = useContext(PrivacyContext);
const parts: ReactNode[] = [];
let last = 0;
for (const match of text.matchAll(/\bhttps?:\/\/[^\s<>"'“”‘’]+/gi)) {
if (match.index > 0 && /[\w:/]/.test(text[match.index - 1])) continue;
let url = match[0];
// Keep balanced URL delimiters, but leave prose wrappers outside the link.
let parentheses = url.split(")").length - url.split("(").length;
let brackets = url.split("]").length - url.split("[").length;
while (
/[.,;:!?]$/.test(url) ||
(url.endsWith(")") && parentheses > 0) ||
(url.endsWith("]") && brackets > 0)
) {
if (url.endsWith(")")) parentheses--;
else if (url.endsWith("]")) brackets--;
url = url.slice(0, -1);
}
try {
const parsed = new URL(url);
if (!parsed.hostname || /\\|%(?![\da-f]{2})/i.test(url)) continue;
} catch {
continue;
}
parts.push(<Redact key={`text-${match.index}`} text={text.slice(last, match.index)} />);
const privateLink = on && privateMailLink(url, (value) => redactPrivateText(value, names));
parts.push(privateLink ? (
<span key={match.index}>Private link hidden</span>
) : (
<a key={match.index} href={url} target="_blank" rel="noopener noreferrer" referrerPolicy="no-referrer">{url}</a>
));
last = match.index + url.length;
}
parts.push(<Redact key="tail" text={text.slice(last)} />);
return <>{parts}</>;
}
export function MessageText({ text }: { text: string }) {
return <>{text.replace(/\r\n?/g, "\n").split(/\n(?:[ \t\u00a0]*\n)+/)
.filter((paragraph) => paragraph.trim())
.map((paragraph, index) => <p key={index}><MessageBody text={paragraph} /></p>)}</>;
}
function ReaderImage({ image, index }: { image: MailImage; index: number }) {
const { on, names } = useContext(PrivacyContext);
const [status, setStatus] = useState("loading");
// Provider metadata may end mid-token at its 500-unit limit.
const description = on && image.alt.length >= 500 ? `Image ${index + 1}` : image.alt;
const privateSource = on && privateMailLink(image.src, (value) => redactPrivateText(value, names));
return <figure>
{!privateSource && status !== "error" && <img
src={image.src}
alt={on ? redactPrivateText(description, names) : description}
referrerPolicy="no-referrer"
decoding="async"
onLoad={() => setStatus("loaded")}
onError={() => setStatus("error")}
/>}
<figcaption>
{privateSource ? "Image unavailable while personal information is hidden." :
status === "loading" ? `Loading image ${index + 1}…` :
status === "error" ? `Image ${index + 1} could not be loaded.` :
<Redact text={description || `Image ${index + 1}`} />}
</figcaption>
</figure>;
}
export type ContentResult = { html: string | null; images?: MailImage[]; attachments?: MailAttachment[] };
type ImageProps = {
mail: Pick<Mail, "id" | "images"> & Partial<Pick<Mail, "html" | "body" | "attachments">>;
loadImages: (id: string) => Promise<{ images: MailImage[] }>;
loadContent?: (id: string) => Promise<ContentResult>;
body?: boolean;
};
export function MessageImages(props: ImageProps) {
return <ImageConsent key={props.mail.id} {...props} />;
}
export function ReaderContent(props: ImageProps & { loadContent: (id: string) => Promise<ContentResult> }) {
return <ImageConsent key={props.mail.id} {...props} body />;
}
function ImageConsent({ mail, loadImages, loadContent, body }: ImageProps) {
const { on: privacy, names } = useContext(PrivacyContext);
const [status, setStatus] = useState("blocked");
const [consentInPrivacy, setConsentInPrivacy] = useState(false);
const [images, setImages] = useState<MailImage[]>([]);
const [content, setContent] = useState<ContentResult>({ html: mail.html ?? null, images: mail.images, attachments: mail.attachments });
const [contentStatus, setContentStatus] = useState((mail.html === undefined || mail.attachments === undefined) && loadContent ? "loading" : "ready");
const active = useRef(true);
const imageRequest = useRef(0);
const region = useId();
useEffect(() => {
active.current = true;
return () => { active.current = false; imageRequest.current++; };
}, []);
useEffect(() => {
setConsentInPrivacy(false);
if (privacy) { imageRequest.current++; setStatus("blocked"); setImages([]); }
}, [privacy]);
const recoverContent = async () => {
if (!loadContent) return;
setContentStatus("loading");
try {
const result = await loadContent(mail.id);
if (active.current) { setContent(result); setContentStatus("ready"); }
} catch {
if (active.current) setContentStatus("error");
}
};
useEffect(() => {
if ((mail.html === undefined || mail.attachments === undefined) && loadContent) void recoverContent();
}, []);
const show = async () => {
const request = ++imageRequest.current;
setConsentInPrivacy(privacy);
setStatus("loading");
try {
const result = content.images ?? mail.images ?? (await loadImages(mail.id)).images;
if (!active.current || request !== imageRequest.current) return;
setImages(result.flatMap((image) => {
const src = imageSource(image.src);
return src ? [{ src, alt: image.alt }] : [];
}));
setStatus("shown");
} catch {
if (active.current && request === imageRequest.current) setStatus("error");
}
};
const htmlKnown = mail.html !== undefined || Boolean(loadContent && contentStatus === "ready");
// Empty extracted metadata cannot rule out images still present in formatted HTML.
const noImages = contentStatus === "ready" && status !== "error" && htmlKnown &&
(content.images ?? mail.images)?.length === 0 && !/<img\b/i.test(content.html || "");
const shown = status === "shown" && (!privacy || consentInPrivacy);
const gallery = shown && (images.length ? images.map((image, index) =>
<ReaderImage key={image.src} image={image} index={index} />) :
<p role="status">No supported remote images in this email. Embedded images are not shown.</p>);
const textBody = <MessageText text={mail.body || "This email has no plain-text body."} />;
return <section className="message-images" aria-label="Email content">
{!noImages && <div className="image-controls">
<button className="button" type="button"
aria-expanded={shown} aria-controls={region}
disabled={status === "loading"}
onClick={() => {
if (shown) { imageRequest.current++; setStatus("blocked"); }
else void show();
}}>
{status === "loading" ? "Loading images…" : shown ? "Hide images" :
status === "error" ? "Try images again" : "Show images"}
</button>
<span>Remote images can tell the sender you opened this email.
{privacy && " Image pixels may contain personal information."}</span>
</div>}
{body && contentStatus === "loading" && <p role="status">Loading formatted email…</p>}
{body && contentStatus === "error" && <p role="status">Formatted email is unavailable. The text is shown below. <button type="button" className="link" onClick={() => void recoverContent()}>Try again</button></p>}
{body && content.attachments?.length !== 0 && <section aria-label="Attachments"><h2>Attachments</h2>{(content.attachments || []).length ? (content.attachments || []).map((file, index) => <p key={file.id}><Redact text={file.filename || `Attachment ${index + 1}`} /> ({file.size} bytes) {file.unavailable ? "Unavailable within the import limit" : <a href={`/api/messages/${encodeURIComponent(mail.id)}/attachments/${encodeURIComponent(file.id)}`} download>Download attachment {index + 1}</a>}</p>) : <p>{contentStatus === "ready" ? "No cached attachment metadata." : "Attachment metadata is not yet available."}</p>}</section>}
<div id={region}>
{status === "error" && <p role="status">Images could not be loaded. The email may be unavailable or archived.</p>}
{body && <div className="message-body">
{content.html ? <FormattedMail html={content.html} showImages={shown} privacy={privacy} privateNames={names}
renderText={(text, segments) => <Redact text={text} segments={segments} />} redactText={(text) => redactPrivateText(text, names)}
fallback={<>{textBody}{gallery}</>} /> : textBody}
</div>}
{(!body || !content.html) && gallery}
</div>
</section>;
}