src/mail/FormattedMail.tsx
import { Fragment, createElement, useMemo, useState, type CSSProperties, type ReactNode } from "react";
import { imageSource } from "../../shared/types";
import { mailLink, mailStyle, parseMailContent, privateMailLink, type MailContentNode } from "../../shared/mail-content";
import { segmentPrivateParts, type PrivacySegment } from "../privacy";
type Props = {
html: string | null | undefined;
showImages: boolean;
renderText: (text: string, segments?: readonly PrivacySegment[]) => ReactNode;
redactText: (text: string) => string;
privacy: boolean;
privateNames?: readonly string[];
fallbackText?: string;
fallback?: ReactNode;
};
// Parent owns opening-specific consent and text redaction. Hooks: formatted-mail,
// formatted-mail-fallback, formatted-mail-image and formatted-mail-image-placeholder.
export function FormattedMail({ html, showImages, renderText, redactText, privacy, privateNames = [], fallbackText = "", fallback }: Props) {
const nodes = useMemo(() => typeof html === "string" ? parseMailContent(html) : null, [html]);
const textParts = nodes ? contentParts(nodes) : [];
const privateParts = privacy ? segmentPrivateParts(textParts, privateNames) : undefined;
let textIndex = 0;
const render = (node: MailContentNode, key: number): ReactNode => {
if (typeof node === "string") return <Fragment key={key}>{renderText(node, privateParts?.[textIndex++])}</Fragment>;
const { tag, attributes: a, children } = node;
const style: CSSProperties = {};
// Legacy email presentation attributes pass through the same CSS validators.
Object.assign(style, mailStyle([
a.bgcolor && `background-color:${a.bgcolor}`, a.color && `color:${a.color}`,
a.valign && `vertical-align:${a.valign}`,
a.width && `width:${/^\d+$/.test(a.width) ? a.width + "px" : a.width}`,
a.height && `height:${/^\d+$/.test(a.height) ? a.height + "px" : a.height}`,
].filter(Boolean).join(";")));
if (tag === "font" && /^[1-7]$/.test(a.size || "")) style.fontSize = [10, 13, 16, 18, 24, 32, 48][Number(a.size) - 1];
Object.assign(style, legacyAlign(tag, (a.align || "").toLowerCase()));
Object.assign(style, mailStyle(a.style || ""));
if (tag !== "img" && hasText(children)) for (const property of ["fontSize", "lineHeight", "height", "maxHeight"] as const) {
const value = style[property];
if (typeof value === "string" && (value === "0" || value.endsWith("px") && parseFloat(value) < 6))
delete style[property];
}
// Sender sizes are already bounded; scale absolute sizes once, including legacy fonts.
if (style.fontSize) style.fontSize = `calc(${typeof style.fontSize === "number" ? `${style.fontSize}px` : style.fontSize} * var(--text-scale, 1))`;
// Quotes take the app's own inset and bar, so nested replies keep their width.
if (tag === "blockquote") for (const property of Object.keys(style) as (keyof CSSProperties)[])
if (/^(margin|padding|border)/.test(property)) delete style[property];
delete style.color;
style.backgroundColor = "transparent";
if (style.borderColor) style.borderColor = "var(--line)";
for (const property of ["border", "borderTop", "borderRight", "borderBottom", "borderLeft"] as const) {
if (style[property]) style[property] = String(style[property]).replace(/^(\d+(?:\.\d+)?px (?:solid|dashed|dotted) ).+$/, "$1var(--line)");
}
if (tag === "img") {
// An empty box at the image's size keeps the sender layout; small icon
// cells would otherwise wrap the reason one letter per line.
if (!showImages) return <ImagePlaceholder key={key} style={style} reason="Image blocked. Use Show images to load it." />;
const src = imageSource(a.src || "");
if (privacy && (privateMailLink(a.src || "", redactText) || (src && privateMailLink(src, redactText))))
return <ImagePlaceholder key={key} style={style} reason="Image unavailable while personal information is hidden." />;
return src ? <FormattedImage key={`${key}:${src}`} src={src} alt={redactText(a.alt || "").slice(0, 500)} style={style} /> :
<ImagePlaceholder key={key} style={style} reason="Unsupported image." />;
}
const props: Record<string, unknown> = { key, style };
let element = tag === "font" ? "span" : tag === "center" ? "div" : tag;
if (tag === "a") {
const href = mailLink(a.href || "");
if (href && (!privacy || (!privateMailLink(a.href, redactText) && !privateMailLink(href, redactText)))) {
style.color = "var(--link)";
// Card-style links set no decoration; plain links keep an underline.
style.textDecoration ??= "underline";
Object.assign(props, { href, target: "_blank", rel: "noopener noreferrer", referrerPolicy: "no-referrer" });
}
else element = "span";
}
if (tag === "td" || tag === "th") {
for (const [attribute, property] of [["colspan", "colSpan"], ["rowspan", "rowSpan"]]) {
if (/^\d{1,2}$/.test(a[attribute] || "") && Number(a[attribute]) >= 1 && Number(a[attribute]) <= 20)
props[property] = Number(a[attribute]);
}
}
if (tag === "table") {
for (const [attribute, property] of [["cellpadding", "cellPadding"], ["cellspacing", "cellSpacing"], ["border", "border"]]) {
if (/^\d{1,2}$/.test(a[attribute] || "") && Number(a[attribute]) <= (attribute === "border" ? 4 : 24))
props[property] = Number(a[attribute]);
}
}
if (tag === "ol" && /^-?\d{1,4}$/.test(a.start || "")) props.start = Number(a.start);
const content = children.map(render);
// Consume the same block separator used by cross-leaf privacy matching.
if (blockTags.has(tag)) textIndex++;
if (["br", "hr", "col"].includes(tag)) return createElement(element, props);
return createElement(element, props, content);
};
const formatted = !!nodes && !!(textParts.join("").trim() || hasImage(nodes));
return <div className={formatted ? "formatted-mail" : "formatted-mail formatted-mail-fallback"}
style={{ maxWidth: "100%", minWidth: 0, overflow: "auto", overflowWrap: "anywhere", contain: "layout paint", isolation: "isolate", whiteSpace: formatted ? "normal" : "pre-wrap" }}>
{formatted ? nodes.map(render) : fallback ?? renderText(fallbackText)}
</div>;
}
// Holds a blocked image's place at its own size (24px when unsized); the reason
// is a label and tooltip because a small icon cell cannot fit it as text.
function ImagePlaceholder({ style, reason }: { style: CSSProperties; reason: string }) {
return <span className="formatted-mail-image-placeholder" role="img" aria-label={reason} title={reason}
style={{ width: style.width ?? 24, height: style.height ?? 24, maxHeight: style.maxHeight }} />;
}
function FormattedImage({ src, alt, style }: { src: string; alt: string; style: CSSProperties }) {
const [status, setStatus] = useState("loading");
// `display: contents` keeps the image in the sender's line, so its own
// vertical-align and float apply as they would in a mail client.
return <span className="formatted-mail-image" style={{ display: "contents" }}>
{status !== "error" && <img src={src} alt={alt} referrerPolicy="no-referrer" decoding="async" loading="lazy"
// With a width, auto height keeps the shape when max-width narrows it;
// icons sized only by height keep that height.
style={{ ...style, maxWidth: "100%", height: style.width || !style.height ? "auto" : style.height }}
onLoad={() => setStatus("loaded")} onError={() => setStatus("error")} />}
{status === "error" && <ImagePlaceholder style={style} reason="Image could not be loaded." />}
</span>;
}
// HTML's align attribute as browsers apply it: a centered table gets auto
// margins, an aligned image floats, and other elements use the -webkit- text
// alignment that also centers nested tables, which mail layouts rely on.
function legacyAlign(tag: string, align: string): CSSProperties {
if (tag === "center") return { textAlign: "-webkit-center" as CSSProperties["textAlign"] };
if (tag === "table") return align === "center" ? { marginLeft: "auto", marginRight: "auto" }
: align === "right" ? { marginLeft: "auto" } : {};
if (tag === "img") return align === "left" || align === "right" ? { float: align }
: /^(top|middle|bottom|baseline)$/.test(align) ? { verticalAlign: align } : {};
if (align === "justify") return { textAlign: "justify" };
return /^(left|center|right)$/.test(align) ? { textAlign: `-webkit-${align}` as CSSProperties["textAlign"] } : {};
}
const blockTags = new Set("div p br hr h1 h2 h3 h4 h5 h6 blockquote pre ul ol li table tr td th caption center".split(" "));
function contentParts(nodes: MailContentNode[]): string[] {
return nodes.flatMap((node) => typeof node === "string" ? [node] :
[...contentParts(node.children), ...(blockTags.has(node.tag) ? ["\n"] : [])]);
}
function hasText(nodes: readonly MailContentNode[]): boolean {
return nodes.some((node) => typeof node === "string" ? !!node.trim() : hasText(node.children));
}
function hasImage(nodes: MailContentNode[]): boolean {
return nodes.some((node) => typeof node !== "string" && ((node.tag === "img" && !!imageSource(node.attributes.src || "")) || hasImage(node.children)));
}