import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { ChevronDown, ChevronUp, Download, ExternalLink, Forward, MailPlus, MoreVertical, Printer, Reply, ReplyAll, Star, Trash2, Code, FileText, Image as ImageIcon, File, Eye, Calendar, CalendarPlus, UserPlus, ShieldAlert, Mail, Ban, Clock, CheckCheck, Paperclip, FileArchive, FileSpreadsheet, Film, Music, Filter } from "lucide-react"; import { useLocation } from "wouter"; 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 { useCalendar } from "@/store/calendar"; import { startAppointment } from "@/lib/appointment"; import { client } from "@/jmap/client"; import { SignatureBanner } from "./SignatureBanner"; import { useSignature } from "@/lib/smime/useSignature"; import { emlFilename } from "@/lib/emlName"; import { isTnef, parseTnef, type TnefAttachment } from "@/lib/tnef"; import { internalDomains, isExternalSender, linkVerdict } from "@/lib/warnings"; import { spamReport, type SpamReport } from "@/lib/spamScore"; import { formatFullDate, formatListDate, formatSize } from "@/lib/format"; import { displayName, domainOf, formatAddress } from "@/lib/address"; import { EMAIL_BASE_CSS, TEXT_EMAIL_CSS, htmlDeclaresColors, markKeptSurfaces, sanitizeEmailHtml } from "@/lib/html"; import { openableInTab, previewKind } from "@/lib/preview"; import { FilePreviewDialog } from "@/ui/filepreview"; import { findQuoteStart, textToHtml } from "@/lib/text"; import { Avatar } from "@/ui/misc"; import { MenuItem, MenuSep, Popover, useMenu } from "@/ui/popover"; import { Dialog, choiceDialog} 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, tNode } 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 signature = useSignature(e, accountId); const settings = useSettings((s) => s.settings); const updateSettings = useSettings((s) => s.update); /** Null when the warning is off, so an ordinary link keeps the browser's own handling. */ const linkGuard = settings.externalLinkWarning ? (href: string, text: string | null) => void followLink(href, text) : null; /* * Following a link out of a message, when the reader has asked to be asked. * * The click is cancelled and the navigation re-issued after the answer, * because there is no way to hold a real navigation open across a dialog. * `window.open` runs in the continuation of the dialog's own click, which is * still the user gesture the popup blocker wants to see. * * Both message bodies go through here -- the sanitised HTML one and the * plain-text one -- because a link in a plain-text mail is linkified by us * and is exactly as capable of pointing somewhere else as one the sender * marked up. */ const followLink = useCallback( async (href: string, text: string | null) => { const verdict = linkVerdict(href, text, settings.trustedLinkDomains); const open = () => window.open(href, "_blank", "noopener,noreferrer"); if (!verdict.warn) { open(); return; } const answer = await choiceDialog({ title: verdict.reason === "mismatch" ? translate("This link does not go where it says") : translate("Open a link to {domain}?", { domain: verdict.domain }), message: verdict.reason === "mismatch" ? tNode("It reads {shown} but goes to {actual}.", { shown: {verdict.shownDomain}, actual: {verdict.domain}, }) : tNode("The full address is {href}.", { href: {href} }), choices: [ { value: "open", label: translate("Open it") }, // Not offered for a mismatch: what would be trusted is the // destination, and the destination is not the thing in question. ...(verdict.reason === "untrusted" ? [{ value: "always", label: translate("Open, and stop asking about {domain}", { domain: verdict.domain }) }] : []), ], }); if (answer === "always") { updateSettings({ trustedLinkDomains: [...settings.trustedLinkDomains, verdict.domain] }); open(); } else if (answer === "open") { open(); } }, [updateSettings, settings.trustedLinkDomains], ); const reply = useCompose((s) => s.reply); const cardRef = useRef(null); 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 [, navigate] = useLocation(); /** Only offered where there is a calendar to put the appointment in. */ const hasCalendar = useCalendar((s) => s.available); 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; const themeStyledMessages = settings.themeStyledMessages; // 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, unless * the reader has asked for the theme over that too. * * `forced` is the second switch and is narrower than `themed`: it only turns * on for mail that actually declares colours, so plain mail is themed the * gentle way and never pays for the override rules. */ const declaresColors = useMemo( () => Boolean(rendered) && htmlDeclaresColors(rendered!.html, rendered!.bodyStyle), [rendered], ); const themed = themeMessageBody && Boolean(rendered) && (!declaresColors || themeStyledMessages); const forced = themed && declaresColors; 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 spam = useMemo(() => spamReport(e), [e]); const identities = useMail((st) => st.identities); /* * Only computed when the warning is on, because the domains it compares * against come from the identities and the settings, and neither is worth * walking for a reader who has not asked for the banner. */ const externalSender = useMemo(() => { if (!settings.externalSenderBanner) return false; return isExternalSender(e.from, internalDomains(identities.map((i) => i.email), settings.internalDomains)); }, [settings.externalSenderBanner, settings.internalDomains, identities, e.from]); const openSource = async () => { setShowSource(true); if (source === null) { try { setSource(await client.fetchBlobText(accountId, e.blobId, "message/rfc822")); } catch (err) { setSource(translate("Could not load source: {error}", { error: (err as Error).message })); } } }; const downloadEml = () => { const a = document.createElement("a"); a.href = client.downloadUrl(accountId, e.blobId, emlFilename(e.subject), "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(translate("Unsubscribe message prepared — just hit Send")); } else if (http) { window.open(http, "_blank", "noopener,noreferrer"); } }; const collapsedClick = () => { if (!expanded) onToggle(); }; /* * Print this message, not the conversation it happens to sit in. * * The card is inside the thread, so a bare window.print() prints every * message on the page -- which is what the toolbar's "Print conversation" * is for, and not what someone asks for from a single message's menu. * The two marker classes let the print stylesheet drop the siblings for * the duration; the subject heading stays, since a printed message with no * subject on it is a page nobody can file. * * window.print() blocks until the dialog is dismissed, so clearing the * marks after it returns is enough on its own; `afterprint` is there for a * browser that ever makes it asynchronous, and running twice is harmless. */ const printThis = () => { const card = cardRef.current; if (!card) { window.print(); return; } const root = document.documentElement; const clear = () => { root.classList.remove("printing-one"); card.classList.remove("print-target"); window.removeEventListener("afterprint", clear); }; window.addEventListener("afterprint", clear); root.classList.add("printing-one"); card.classList.add("print-target"); try { window.print(); } finally { clear(); } }; 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")} /> {/* The same message rather than a quotation of it: headers, attachments and all, for passing one on to be looked at rather than read. */} } label={translate("Forward as attachment")} onClick={() => useCompose.getState().forwardAsAttachment(e)} /> {/* Sends the same mail again rather than passing it on, so it sits with the other three rather than down among the read-only actions. */} } label={translate("Compose as new")} onClick={() => void useCompose.getState().composeAsNew(e)} /> } 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={printThis} /> } label={translate("Filter messages like this…")} onClick={() => setFilterOpen(true)} /> {hasCalendar && } label={translate("Create event…")} onClick={() => void startAppointment(e, navigate).catch((err: unknown) => toast.error((err as Error).message))} />} {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 || translate("(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)}
{spam && <>
{translate("Spam filter")}
} {receiptRequested && <>
{translate("Receipt")}
{receipt.offer ? translate("Requested, to {address}. Never sent automatically.", { address: receipt.to!.email }) : translate(refusalText(receipt.refusal!))}
}
)} {receipt.offer && settings.readReceiptPolicy !== "never" && receiptDone !== "dismissed" && (
{translate("The sender asked for a read receipt.")} {receipt.redirected && ( <>{tNode("It would go to {address}, which is not where the message came from.", { address: {receipt.to!.email} })} )}
)} {scheduled && (
{translate("Waiting on the server — goes out {when}.", { when: formatScheduleTime(new Date(scheduled.sendAt)) })}
)} {externalSender && (
{tNode("This message came from {domain}, which is outside your organisation.", { domain: {domainOf(from?.email ?? "")}, })}
)} {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}
{/* * The action, not a description of where to find it. Telling somebody * an action exists and leaving them to hunt for it is half a job * (#236) -- and one dialog replaces the other, so it reads as going * deeper rather than as opening a second window. * * `tNode` rather than two `translate` calls around a button: the * sentence stays whole for whoever translates it, and languages that * put the verb elsewhere can move the hole. */}

{tNode("Use {action} for the complete raw message.", { action: ( ), })}

); }); 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, forced, onShowImages, onFollowLink }: { html: string; bodyStyle: string; themed: boolean; forced: boolean; onFollowLink: ((href: string, text: string | null) => void) | null; 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; } if (onFollowLink && /^https?:/i.test(href)) { ev.preventDefault(); onFollowLink(href, a.textContent); return; } a.setAttribute("target", "_blank"); a.setAttribute("rel", "noopener noreferrer nofollow"); } const img = t.closest("img[data-ihm-blocked]"); if (img) onShowImages(); }, [openCompose, onShowImages, onFollowLink], ); 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; // Tell the sender's painted surfaces apart from the sheets they sit on, // before anything below reshapes the tree. if (forced && container) markKeptSurfaces(container); 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, forced]); 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, onFollowLink }: { text: string; onFollowLink: ((href: string, text: string | null) => void) | null }) { 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"); const href = a?.getAttribute("href") ?? ""; if (a && href.startsWith("mailto:")) { ev.preventDefault(); openCompose({ to: [{ name: null, email: href.slice(7) }] }); return; } if (a && onFollowLink && /^https?:/i.test(href)) { ev.preventDefault(); void onFollowLink(href, a.textContent); } }; root.addEventListener("click", onClick); return () => root.removeEventListener("click", onClick); }, [main, quoted, quoteOpen, openCompose, onFollowLink]); return ( <> {/* The sender's content, rendered as-is. Translating it would rewrite what someone actually wrote. */}
{quoted && ( )} ); } /* ---------- Spam ---------- */ /** * What the filter said, not what we think of it. The verdict line only claims * as much as the header did: where the filter stated one, it is shown; where * it only left a score, the score is shown on its own rather than being turned * into a verdict here. * * A score is always given its threshold where the header carried one, because * the number is unreadable without it -- 6.7 is damning against 5 and * unremarkable against 15. Where none was stated, that is said. */ function SpamSummary({ report }: { report: SpamReport }) { const { verdict, score, threshold, rules } = report; return (
{verdict === "spam" && {translate("Marked as spam")}} {verdict === "clean" && {translate("Not spam")}} {verdict === null && {translate("No verdict recorded")}} {score !== null && ( {" — "} {threshold !== null ? translate("scored {score} against a threshold of {threshold}", { score: String(score), threshold: String(threshold) }) : translate("scored {score}, with no threshold stated", { score: String(score) })} )}
{rules.length > 0 && (
    {rules.map((r, i) => (
  • {r.name} {r.detail && {r.detail}} {/* Signed, because which way a rule pushed is the whole point. */} 0 ? "bad" : r.score < 0 ? "good" : ""}`}> {r.score > 0 ? `+${r.score}` : String(r.score)}
  • ))}
)}
); } /* ---------- 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 ; } /** * The files inside a `winmail.dat`, once the reader asks for them. * * Opened on request rather than on sight: the blob has to be fetched and * decoded, and doing that to every message carrying one would spend the * bandwidth whether or not anybody wanted what is inside. * * The decode happens here, in the browser. The server never sees the contents * and stores nothing, which is the same bargain as the rest of the app -- * there is nowhere for it to put a decoded copy even if it wanted one. */ function TnefContents({ part, accountId }: { part: EmailBodyPart; accountId: Id }) { const [state, setState] = useState<"idle" | "loading" | "done" | "error">("idle"); const [files, setFiles] = useState([]); const [urls, setUrls] = useState([]); // Object URLs hold their blob alive until they are revoked, so they are // released when the message closes rather than left to the page's lifetime. useEffect(() => () => urls.forEach((u) => URL.revokeObjectURL(u)), [urls]); const open = async () => { if (!part.blobId) return; setState("loading"); try { const blob = await client.fetchBlob(accountId, part.blobId, part.type); const found = parseTnef(await blob.arrayBuffer()); setFiles(found); setUrls(found.map((f) => URL.createObjectURL(new Blob([f.data as unknown as BlobPart], { type: f.type })))); setState("done"); } catch { setState("error"); } }; if (state === "idle") { return (
{translate("This message packs its attachments into a winmail.dat, which most clients cannot open.")}
); } if (state === "loading") return
{translate("Opening…")}
; if (state === "error") { return (
{translate("Could not read winmail.dat. The original is still attached below.")}
); } if (!files.length) { // It decoded, and there was nothing in it. Saying so is better than // leaving the button looking like it did nothing. return (
{translate("No files inside — it carries only the formatted copy of the message.")}
); } return ( ); } function AttachmentList({ attachments, accountId, email }: { attachments: EmailBodyPart[]; accountId: Id; email: Email }) { const [preview, setPreview] = useState(null); /* Whether we can show it, and whether the server will serve it inline, are different questions -- see the note in lib/preview.ts. */ const viewable = (a: EmailBodyPart) => Boolean(a.blobId) && previewKind(a.type, a.name) !== null; return ( <> {attachments.filter((a) => isTnef(a.type, a.name) && a.blobId).map((a) => ( ))}
{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)} {openableInTab(a.type) && a.blobId && } ); })} {attachments.length > 1 && ( )}
setPreview(null)} caption={

{translate("From: {sender}", { sender: displayName(email.from?.[0]) })}

} /> ); }