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")}}
} 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 && (
{receipt.offer ? translate("Requested, to {address}. Never sent automatically.", { address: receipt.to!.email }) : translate(refusalText(receipt.refusal!))}
{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 ?? "")},
})}
`;
// 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.")}
);
}
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) => (
))}