import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { ChevronDown, ChevronUp, Download, ExternalLink, Forward, MoreVertical, Printer, Reply, ReplyAll, Star, Trash2, Code, FileText, Image as ImageIcon, File, Eye, Calendar, UserPlus, ShieldAlert, Mail, Ban, Clock, CheckCheck, Paperclip, FileArchive, FileSpreadsheet, Film, Music, Filter } from "lucide-react"; import { FilterFromMessageDialog } from "./FilterFromMessage"; import type { Email, EmailAddress, EmailBodyPart, Id } from "@/jmap/types"; import { useMail } from "@/store/mail"; import { useSettings } from "@/store/settings"; import { draftFromMailto, useCompose } from "@/store/compose"; import { useContacts } from "@/store/contacts"; import { client } from "@/jmap/client"; import { formatFullDate, formatListDate, formatSize } from "@/lib/format"; import { displayName, formatAddress } from "@/lib/address"; import { EMAIL_BASE_CSS, TEXT_EMAIL_CSS, htmlDeclaresColors, sanitizeEmailHtml } from "@/lib/html"; import { findQuoteStart, textToHtml } from "@/lib/text"; import { Avatar } from "@/ui/misc"; import { MenuItem, MenuSep, Popover, useMenu } from "@/ui/popover"; import { Dialog } from "@/ui/dialog"; import { toast } from "@/ui/toast"; import type { ListActions } from "./MessageList"; import { InviteCard } from "./InviteCard"; import { VCardCard } from "./VCardCard"; import { AddressList, useAddressMenu } from "./AddressMenu"; import { useSession } from "@/store/session"; import { useScheduled } from "@/store/scheduled"; import { formatScheduleTime } from "@/lib/schedule"; import { mdnDecision, refusalText } from "@/lib/mdn"; import { sendReadReceipt } from "@/store/mdn"; import { t as translate } from "@/lib/i18n"; interface Props { email: Email; expanded: boolean; /** Unread when the conversation was opened, which is what the bar marks. */ wasUnread?: boolean; onToggle: () => void; isLast: boolean; actions: ListActions; } export const MessageView = memo(function MessageView({ email: e, expanded, wasUnread, onToggle, actions }: Props) { const accountId = useMail((s) => s.accountId)!; const settings = useSettings((s) => s.settings); const updateSettings = useSettings((s) => s.update); const reply = useCompose((s) => s.reply); const [details, setDetails] = useState(false); const [showSource, setShowSource] = useState(false); const [showHeaders, setShowHeaders] = useState(false); const [source, setSource] = useState(null); const [allowRemote, setAllowRemote] = useState(false); /* Stable, so the body's click handler keeps its identity between renders. Passing an inline arrow here is what made the handler change on every render in the first place. */ const showImages = useCallback(() => setAllowRemote(true), []); const [filterOpen, setFilterOpen] = useState(false); const moreMenu = useMenu(); const addrMenu = useAddressMenu(); const from = e.from?.[0]; const senderTrusted = settings.trustedImageSenders.includes((from?.email ?? "").toLowerCase()); const inContacts = useContacts((s) => Boolean(from && s.loaded && s.lookupByEmail(from.email))); const remoteAllowed = allowRemote || settings.imagePolicy === "always" || senderTrusted || (settings.imagePolicy === "contacts" && inContacts); const imageProxy = useSession((s) => s.session?.ihasmail?.imageProxy ?? true); const scheduled = useScheduled((s) => s.pending[e.id]); const receipt = useMemo(() => mdnDecision(e), [e]); const [receiptDone, setReceiptDone] = useState<"sending" | "dismissed" | null>(null); const cancelScheduled = useScheduled((s) => s.cancel); const htmlPart = e.htmlBody?.[0]; const textPart = e.textBody?.[0]; const htmlRaw = htmlPart?.partId ? e.bodyValues?.[htmlPart.partId]?.value : undefined; const textRaw = textPart?.partId ? e.bodyValues?.[textPart.partId]?.value : undefined; const showHtml = Boolean(htmlRaw); const themeMessageBody = settings.themeMessageBody; // Inline images map const cidMap = useMemo(() => { const map: Record = {}; for (const a of e.attachments ?? []) if (a.cid && a.blobId) map[a.cid] = client.downloadUrl(accountId, a.blobId, a.name ?? "image", a.type, true); const walk = (p?: EmailBodyPart) => { if (!p) return; if (p.cid && p.blobId && !map[p.cid]) map[p.cid] = client.downloadUrl(accountId, p.blobId, p.name ?? "image", p.type, true); p.subParts?.forEach(walk); }; walk(e.bodyStructure); return map; }, [e.attachments, e.bodyStructure, accountId]); const rendered = useMemo(() => { if (!expanded) return null; if (showHtml) return sanitizeEmailHtml(htmlRaw!, { cidMap, allowRemote: remoteAllowed, proxyRemote: imageProxy }); return null; }, [expanded, showHtml, htmlRaw, cidMap, remoteAllowed, imageProxy]); // Mail that paints itself keeps the light card it was designed for; the rest // can follow the app theme when the user has asked for that. const themed = useMemo( () => themeMessageBody && Boolean(rendered) && !htmlDeclaresColors(rendered!.html, rendered!.bodyStyle), [themeMessageBody, rendered], ); const attachments = useMemo(() => (e.attachments ?? []).filter((a) => !(a.cid && a.disposition === "inline" && a.type.startsWith("image/") && htmlRaw?.includes(`cid:${a.cid}`))), [e.attachments, htmlRaw]); const icsPart = useMemo(() => findPart(e.bodyStructure, (p) => p.type === "text/calendar" || (p.name ?? "").toLowerCase().endsWith(".ics")), [e.bodyStructure]); const vcfParts = useMemo(() => (e.attachments ?? []).filter((p) => p.type === "text/vcard" || p.type === "text/x-vcard" || (p.name ?? "").toLowerCase().endsWith(".vcf")), [e.attachments]); const unsubscribe = e["header:List-Unsubscribe:asText"]; const isHighPriority = /^[12]/.test(e["header:X-Priority:asText"] ?? "") || /high/i.test(e["header:Importance:asText"] ?? ""); const receiptRequested = Boolean(e["header:Disposition-Notification-To:asAddresses"]?.length); const authFailed = /\b(dkim|spf|dmarc)=fail\b/i.test(e["header:Authentication-Results:asText"] ?? ""); const openSource = async () => { setShowSource(true); if (source === null) { try { setSource(await client.fetchBlobText(accountId, e.blobId, "message/rfc822")); } catch (err) { setSource(`Could not load source: ${(err as Error).message}`); } } }; const downloadEml = () => { const a = document.createElement("a"); a.href = client.downloadUrl(accountId, e.blobId, `${(e.subject || "message").replace(/[^\w.-]+/g, "_")}.eml`, "message/rfc822"); a.download = ""; a.click(); }; const onUnsubscribe = async () => { if (!unsubscribe) return; const urls = [...unsubscribe.matchAll(/<([^>]+)>/g)].map((m) => m[1]!); const mailto = urls.find((u) => u.startsWith("mailto:")); const http = urls.find((u) => /^https?:/i.test(u)); if (mailto) { const fields = draftFromMailto(mailto); useCompose.getState().open({ ...fields, subject: fields.subject || "unsubscribe", html: fields.html ?? "
unsubscribe
", text: fields.text ?? "unsubscribe" }); toast.show("Unsubscribe message prepared — just hit Send"); } else if (http) { window.open(http, "_blank", "noopener,noreferrer"); } }; const collapsedClick = () => { if (!expanded) onToggle(); }; return ( /* `wasUnread` rather than `$seen`: the bar marks what was unread when the conversation was opened, and keeps marking it after the auto-mark-read timer has told the server otherwise. Losing it mid-read was half of #69. */
{ if (expanded && !(ev.target as HTMLElement).closest("button,a,.message-details")) onToggle(); }}>
from && addrMenu.open(ev, from)}> {displayName(from)} {/* An address, not a sentence. */} {expanded && from && <{from.email}>} {isHighPriority && {translate("Important")}} {authFailed && {translate("Unverified")}}
{expanded ? (
{translate("to {recipients}", { recipients: summarizeRecipients(e) })}
) : (
{e.preview}
)}
{e.hasAttachment && !expanded && } {expanded ? formatFullDate(e.receivedAt) : formatListDate(e.receivedAt)} {expanded && ( <> )}
} label={translate("Reply")} onClick={() => void reply(e, "reply")} /> } label={translate("Reply all")} onClick={() => void reply(e, "replyAll")} /> } label={translate("Forward")} onClick={() => void reply(e, "forward")} /> } label={e.keywords.$seen ? "Mark as unread" : "Mark as read"} onClick={() => void useMail.getState().markRead([e.id], !e.keywords.$seen)} /> } label={translate("Delete this message")} onClick={() => void useMail.getState().trash([e.id])} /> } label={translate("Show original")} onClick={() => void openSource()} /> } label={translate("Show headers")} onClick={() => setShowHeaders(true)} /> } label={translate("Download (.eml)")} onClick={downloadEml} /> } label={translate("Print")} onClick={() => window.print()} /> } label={translate("Filter messages like this…")} onClick={() => setFilterOpen(true)} /> {from && ( <> } label={senderTrusted ? "Stop trusting sender images" : "Always show images from sender"} onClick={() => updateSettings({ trustedImageSenders: senderTrusted ? settings.trustedImageSenders.filter((x) => x !== from.email.toLowerCase()) : [...settings.trustedImageSenders, from.email.toLowerCase()] })} /> )} {expanded && ( <> {details && (
ev.stopPropagation()}>
{translate("From")}
{e.sender?.length && !(e.sender.length === 1 && e.from?.some((f) => f.email === e.sender![0]!.email)) ? <>
{translate("Sender")}
: null} {e.replyTo?.length ? <>
{translate("Reply-To")}
: null}
{translate("To")}
{e.cc?.length ? <>
{translate("Cc")}
: null} {e.bcc?.length ? <>
{translate("Bcc")}
: null}
{translate("Date")}
{formatFullDate(e.sentAt ?? e.receivedAt)}
{translate("Subject")}
{e.subject || "(no subject)"}
{e.messageId?.[0] && <>
{translate("Message-ID")}
{e.messageId[0]}
} {e["header:List-Id:asText"] && <>
{translate("List")}
{e["header:List-Id:asText"]}
}
{translate("Size")}
{formatSize(e.size)}
{receiptRequested && <>
{translate("Receipt")}
{receipt.offer ? `Requested, to ${receipt.to!.email}. Never sent automatically.` : refusalText(receipt.refusal!)}
}
)} {receipt.offer && settings.readReceiptPolicy !== "never" && receiptDone !== "dismissed" && (
{translate("The sender asked for a read receipt.")} {receipt.redirected && ( <> {translate("It would go to")} {receipt.to!.email}{translate(", which is not where the message came from.")} )}
)} {scheduled && (
{translate("Waiting on the server — goes out {when}.", { when: formatScheduleTime(new Date(scheduled.sendAt)) })}
)} {rendered && rendered.remoteCount > 0 && !remoteAllowed && (
{translate("Remote images are blocked to protect your privacy.")} {from && }
)} {icsPart && } {vcfParts.map((p) => )}
{showHtml && rendered ? : }
{attachments.length > 0 && } {unsubscribe && (
{translate("This looks like a mailing list.")}
)} )} {addrMenu.node} {filterOpen && setFilterOpen(false)} />} setShowSource(false)} title={translate("Original message")} size="xl"> {source === null ?
:
{source}
}
setShowHeaders(false)} title={translate("Message headers")} size="lg">
{Object.entries(e).filter(([k]) => k.startsWith("header:")).map(([k, v]) => ( <>
{k.split(":")[1]}
{Array.isArray(v) ? v.map((x: unknown) => (typeof x === "object" && x ? formatAddress(x as EmailAddress) : String(x))).join(", ") : String(v ?? "—")}
))}
{translate("Received")}
{formatFullDate(e.receivedAt)}
{e.inReplyTo?.length ? <>
{translate("In-Reply-To")}
{e.inReplyTo.join(" ")}
: null} {e.references?.length ? <>
{translate("References")}
{e.references.join(" ")}
: null}

{translate("Use “Show original” for the complete raw message.")}

); }); function summarizeRecipients(e: Email): string { const all = [...(e.to ?? []), ...(e.cc ?? [])]; if (!all.length) return "(undisclosed recipients)"; const me = useMail.getState().identities.map((i) => i.email.toLowerCase()); const names = all.map((a) => (me.includes(a.email.toLowerCase()) ? "me" : displayName(a).split(" ")[0] || a.email)); if (names.length <= 3) return names.join(", "); return `${names.slice(0, 3).join(", ")} +${names.length - 3}`; } function findPart(p: EmailBodyPart | undefined, pred: (p: EmailBodyPart) => boolean): EmailBodyPart | null { if (!p) return null; if (pred(p)) return p; for (const s of p.subParts ?? []) { const r = findPart(s, pred); if (r) return r; } return null; } /* ---------- Body renderers ---------- */ const QUOTE_SELECTORS = [".gmail_quote", "blockquote[type=cite]", ".moz-cite-prefix", "#divRplyFwdMsg", ".yahoo_quoted", "div[id^=appendonsend]", ".ms-outlook-mobile-reference-message", "#OLK_SRC_BODY_SECTION", ".protonmail_quote", ".ihm-quote"]; function HtmlBody({ html, bodyStyle, themed, onShowImages }: { html: string; bodyStyle: string; themed: boolean; onShowImages: () => void }) { const hostRef = useRef(null); const [hasQuote, setHasQuote] = useState(false); const [quoteOpen, setQuoteOpen] = useState(false); const openCompose = useCompose((s) => s.open); const onClick = useCallback( (ev: Event) => { const t = ev.target as HTMLElement; const a = t.closest("a"); if (a) { const href = a.getAttribute("href") ?? ""; if (href.startsWith("mailto:")) { ev.preventDefault(); openCompose(draftFromMailto(href)); return; } if (/^(javascript|data|vbscript):/i.test(href)) { ev.preventDefault(); return; } a.setAttribute("target", "_blank"); a.setAttribute("rel", "noopener noreferrer nofollow"); } const img = t.closest("img[data-ihm-blocked]"); if (img) onShowImages(); }, [openCompose, onShowImages], ); useEffect(() => { const host = hostRef.current; if (!host) return; const root = host.shadowRoot ?? host.attachShadow({ mode: "open" }); host.classList.toggle("themed", themed); root.innerHTML = ``; // Collapse quoted content const container = root.querySelector(".ihm-email-root") as HTMLElement | null; let found = false; if (container) { let q: Element | null = null; for (const sel of QUOTE_SELECTORS) { q = container.querySelector(sel); if (q) break; } if (!q) { // Heuristic: a blockquote preceded by text ending in "wrote:" const bqs = Array.from(container.querySelectorAll("blockquote")); for (const bq of bqs) { const prev = bq.previousElementSibling; if (prev && /wrote:\s*$|Original Message|Von:|De :|From:/i.test(prev.textContent ?? "")) { q = prev; break; } } if (!q && bqs.length === 1 && (bqs[0]!.textContent?.length ?? 0) > 200) q = bqs[0]!; } if (q && q.parentElement) { // Move q and subsequent siblings into a hidden wrapper (only if q isn't the whole body) const parent = q.parentElement; const textBefore = (container.textContent ?? "").indexOf((q.textContent ?? "").slice(0, 40)); if (textBefore > 0 || q.previousElementSibling) { const wrap = root.ownerDocument.createElement("div"); wrap.className = "ihm-quoted"; wrap.hidden = true; const nodes: ChildNode[] = []; let n: ChildNode | null = q.classList.contains("moz-cite-prefix") ? q : q; while (n) { nodes.push(n); n = n.nextSibling; } parent.insertBefore(wrap, q); for (const node of nodes) wrap.appendChild(node); found = true; } } } setHasQuote(found); setQuoteOpen(false); /* * `onClick` is deliberately not a dependency of this effect. * * This is the effect that writes the body into the shadow root, so anything * in its dependencies rebuilds the entire message. The click handler used * to be in here, and it changes identity on every render -- it closes over * a prop the parent recreates inline -- so every render of the message * threw the rendered body away and built it again. Marking as read does * exactly that: the store hands back a new email object, the thread * re-renders, and the reader watched the message vanish and come back, * white to dark to white on an unstyled HTML mail, half a second after they * started reading it (#100). The quoted-text toggle reset with it. * * The listener lives in its own effect below. It is attached to the shadow * root rather than to its contents, which survives this rewriting anyway, * so a changing handler now costs a listener swap and nothing else. */ // eslint-disable-next-line react-hooks/exhaustive-deps }, [html, bodyStyle, themed]); useEffect(() => { const root = hostRef.current?.shadowRoot; if (!root) return; root.addEventListener("click", onClick); return () => root.removeEventListener("click", onClick); }, [onClick]); useEffect(() => { const root = hostRef.current?.shadowRoot; const q = root?.querySelector(".ihm-quoted"); if (q) q.hidden = !quoteOpen; }, [quoteOpen]); return ( <> {/* The sender's content, rendered as-is. Translating it would rewrite what someone actually wrote. */}
{hasQuote && ( )} ); } function TextBody({ text }: { text: string }) { const hostRef = useRef(null); const [quoteOpen, setQuoteOpen] = useState(false); const openCompose = useCompose((s) => s.open); const { main, quoted } = useMemo(() => { const lines = text.replace(/\r\n?/g, "\n").split("\n"); const idx = findQuoteStart(lines); if (idx > 2) return { main: lines.slice(0, idx).join("\n"), quoted: lines.slice(idx).join("\n") }; return { main: text, quoted: "" }; }, [text]); useEffect(() => { const host = hostRef.current; if (!host) return; const root = host.shadowRoot ?? host.attachShadow({ mode: "open" }); root.innerHTML = `
${textToHtml(main)}${quoted ? `
\n${textToHtml(quoted)}
` : ""}
`; const onClick = (ev: Event) => { const a = (ev.target as HTMLElement).closest("a"); if (a && a.getAttribute("href")?.startsWith("mailto:")) { ev.preventDefault(); openCompose({ to: [{ name: null, email: a.getAttribute("href")!.slice(7) }] }); } }; root.addEventListener("click", onClick); return () => root.removeEventListener("click", onClick); }, [main, quoted, quoteOpen, openCompose]); return ( <> {/* The sender's content, rendered as-is. Translating it would rewrite what someone actually wrote. */}
{quoted && ( )} ); } /* ---------- Attachments ---------- */ export function attachmentIcon(type: string, name?: string | null) { const t = type.toLowerCase(); const n = (name ?? "").toLowerCase(); if (t.startsWith("image/")) return ; if (t.startsWith("video/")) return ; if (t.startsWith("audio/")) return ; if (t === "application/pdf") return ; if (/zip|tar|gzip|7z|rar|compressed/.test(t) || /\.(zip|tgz|gz|7z|rar)$/.test(n)) return ; if (/spreadsheet|excel|csv/.test(t) || /\.(xlsx?|csv)$/.test(n)) return ; if (t === "text/calendar") return ; if (t.includes("vcard")) return ; if (t.startsWith("text/") || /word|document/.test(t)) return ; return ; } function AttachmentList({ attachments, accountId, email }: { attachments: EmailBodyPart[]; accountId: Id; email: Email }) { const [preview, setPreview] = useState(null); const viewable = (a: EmailBodyPart) => (a.type.startsWith("image/") && a.type !== "image/svg+xml") || a.type === "application/pdf" || a.type === "text/plain"; return ( <>
{attachments.map((a, i) => { const url = a.blobId ? client.downloadUrl(accountId, a.blobId, a.name ?? "attachment", a.type) : "#"; const inlineUrl = a.blobId ? client.downloadUrl(accountId, a.blobId, a.name ?? "attachment", a.type, true) : "#"; return ( { if (viewable(a)) { ev.preventDefault(); setPreview(a); } }}> {a.type.startsWith("image/") && a.type !== "image/svg+xml" && a.blobId ? : attachmentIcon(a.type, a.name)} {a.name ?? "(unnamed)"} {formatSize(a.size)} {viewable(a) && } ); })} {attachments.length > 1 && ( )}
setPreview(null)} title={preview?.name ?? "Preview"} size="xl" footer={preview && {translate("Download")}}> {preview?.type.startsWith("image/") && {preview.name} {preview?.type === "application/pdf" &&