JMAP has an extension for this -- RFC 9007's MDN/send -- and Stalwart does not implement it, so ihasmail assembles the RFC 8098 multipart/report itself and sends it the long way round: raw MIME uploaded as a blob, imported, submitted. That is also why the receipt lands in Sent, which is where it honestly belongs. The plumbing is the easy half. A receipt tells whoever asked that the address is live and when the message was read, to an address the sender chose, so the refusals are the feature: nothing marked Auto-Submitted (RFC 3834, or two servers answer each other forever), nothing carrying Precedence bulk/list/junk or a List-Id, nothing already acknowledged, nothing that never arrived. A receipt aimed anywhere other than the sender is offered, but says so first. There is no "always send" setting, only ask or never. Sending is recorded with RFC 3503's $mdnsent keyword on the original rather than remembered locally, so a second look -- or another client entirely -- knows not to ask again. Non-ASCII parts go base64 rather than 8bit, so nothing rests on 8BITMIME surviving every hop. Verified against the mock end to end: the blob uploads, the receipt imports and submits, and the original reads back marked. Not yet exercised against the live server.
529 lines
28 KiB
TypeScript
529 lines
28 KiB
TypeScript
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";
|
|
|
|
interface Props {
|
|
email: Email;
|
|
expanded: boolean;
|
|
onToggle: () => void;
|
|
isLast: boolean;
|
|
actions: ListActions;
|
|
}
|
|
|
|
export const MessageView = memo(function MessageView({ email: e, expanded, 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<string | null>(null);
|
|
const [allowRemote, setAllowRemote] = useState(false);
|
|
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<string, string> = {};
|
|
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 ?? "<div>unsubscribe</div>", 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 (
|
|
<article className={`message ${expanded ? "" : "collapsed"} ${!e.keywords.$seen ? "unread-msg" : ""}`} data-msg-id={e.id} onClick={collapsedClick}>
|
|
<header className="message-head" onClick={(ev) => { if (expanded && !(ev.target as HTMLElement).closest("button,a,.message-details")) onToggle(); }}>
|
|
<Avatar who={from ?? null} />
|
|
<div className="who">
|
|
<div className="from" onContextMenu={(ev) => from && addrMenu.open(ev, from)}>
|
|
<span className="addr">{displayName(from)}</span>
|
|
{expanded && from && <span className="email addr"><{from.email}></span>}
|
|
{isHighPriority && <span className="tag" style={{ background: "var(--danger)" }}>Important</span>}
|
|
{authFailed && <span className="tag" style={{ background: "var(--warn)" }} title={e["header:Authentication-Results:asText"] ?? ""}><ShieldAlert size={12} /> Unverified</span>}
|
|
</div>
|
|
{expanded ? (
|
|
<div className="to">
|
|
<span className="truncate">to {summarizeRecipients(e)}</span>
|
|
<button onClick={(ev) => { ev.stopPropagation(); setDetails((v) => !v); }} aria-label="Show details" title="Show details">
|
|
{details ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
|
|
</button>
|
|
</div>
|
|
) : (
|
|
<div className="snippet">{e.preview}</div>
|
|
)}
|
|
</div>
|
|
<div className="meta">
|
|
{e.hasAttachment && !expanded && <Paperclip size={14} />}
|
|
<span className="date" title={formatFullDate(e.receivedAt)}>{expanded ? formatFullDate(e.receivedAt) : formatListDate(e.receivedAt)}</span>
|
|
<button className={`icon-btn sm ${e.keywords.$flagged ? "active" : ""}`} style={e.keywords.$flagged ? { color: "var(--star)", background: "transparent" } : undefined} title="Star" onClick={(ev) => { ev.stopPropagation(); void actions.star(!e.keywords.$flagged, [e.id]); }}>
|
|
<Star size={17} fill={e.keywords.$flagged ? "currentColor" : "none"} />
|
|
</button>
|
|
{expanded && (
|
|
<>
|
|
<button className="icon-btn sm hide-mobile" title="Reply (r)" onClick={(ev) => { ev.stopPropagation(); void reply(e, "reply"); }}><Reply size={17} /></button>
|
|
<button className="icon-btn sm" onClick={(ev) => { ev.stopPropagation(); moreMenu.open(ev); }} aria-label="More"><MoreVertical size={17} /></button>
|
|
</>
|
|
)}
|
|
</div>
|
|
</header>
|
|
<Popover anchor={moreMenu.anchor} onClose={moreMenu.close} align="end" width={240}>
|
|
<MenuItem icon={<Reply size={16} />} label="Reply" onClick={() => void reply(e, "reply")} />
|
|
<MenuItem icon={<ReplyAll size={16} />} label="Reply all" onClick={() => void reply(e, "replyAll")} />
|
|
<MenuItem icon={<Forward size={16} />} label="Forward" onClick={() => void reply(e, "forward")} />
|
|
<MenuSep />
|
|
<MenuItem icon={<Mail size={16} />} label={e.keywords.$seen ? "Mark as unread" : "Mark as read"} onClick={() => void useMail.getState().markRead([e.id], !e.keywords.$seen)} />
|
|
<MenuItem icon={<Trash2 size={16} />} label="Delete this message" onClick={() => void useMail.getState().trash([e.id])} />
|
|
<MenuSep />
|
|
<MenuItem icon={<Eye size={16} />} label="Show original" onClick={() => void openSource()} />
|
|
<MenuItem icon={<Code size={16} />} label="Show headers" onClick={() => setShowHeaders(true)} />
|
|
<MenuItem icon={<Download size={16} />} label="Download (.eml)" onClick={downloadEml} />
|
|
<MenuItem icon={<Printer size={16} />} label="Print" onClick={() => window.print()} />
|
|
<MenuItem icon={<Filter size={16} />} label="Filter messages like this…" onClick={() => setFilterOpen(true)} />
|
|
{from && (
|
|
<>
|
|
<MenuSep />
|
|
<MenuItem icon={<Ban size={16} />} 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()] })} />
|
|
</>
|
|
)}
|
|
</Popover>
|
|
|
|
{expanded && (
|
|
<>
|
|
{details && (
|
|
<dl className="message-details" onClick={(ev) => ev.stopPropagation()}>
|
|
<dt>From</dt><dd><AddressList list={e.from} onContext={addrMenu.open} /></dd>
|
|
{e.sender?.length && !(e.sender.length === 1 && e.from?.some((f) => f.email === e.sender![0]!.email)) ? <><dt>Sender</dt><dd><AddressList list={e.sender} onContext={addrMenu.open} /></dd></> : null}
|
|
{e.replyTo?.length ? <><dt>Reply-To</dt><dd><AddressList list={e.replyTo} onContext={addrMenu.open} /></dd></> : null}
|
|
<dt>To</dt><dd><AddressList list={e.to} onContext={addrMenu.open} /></dd>
|
|
{e.cc?.length ? <><dt>Cc</dt><dd><AddressList list={e.cc} onContext={addrMenu.open} /></dd></> : null}
|
|
{e.bcc?.length ? <><dt>Bcc</dt><dd><AddressList list={e.bcc} onContext={addrMenu.open} /></dd></> : null}
|
|
<dt>Date</dt><dd>{formatFullDate(e.sentAt ?? e.receivedAt)}</dd>
|
|
<dt>Subject</dt><dd>{e.subject || "(no subject)"}</dd>
|
|
{e.messageId?.[0] && <><dt>Message-ID</dt><dd className="mono small">{e.messageId[0]}</dd></>}
|
|
{e["header:List-Id:asText"] && <><dt>List</dt><dd>{e["header:List-Id:asText"]}</dd></>}
|
|
<dt>Size</dt><dd>{formatSize(e.size)}</dd>
|
|
{receiptRequested && <><dt>Receipt</dt><dd>{receipt.offer ? `Requested, to ${receipt.to!.email}. Never sent automatically.` : refusalText(receipt.refusal!)}</dd></>}
|
|
</dl>
|
|
)}
|
|
{receipt.offer && settings.readReceiptPolicy !== "never" && receiptDone !== "dismissed" && (
|
|
<div className="receipt-banner" style={{ margin: "0 16px 8px" }}>
|
|
<CheckCheck size={16} />
|
|
<span className="grow">
|
|
The sender asked for a read receipt.
|
|
{receipt.redirected && (
|
|
<> It would go to <strong>{receipt.to!.email}</strong>, which is not where the message came from.</>
|
|
)}
|
|
</span>
|
|
<button
|
|
disabled={receiptDone === "sending"}
|
|
onClick={async () => {
|
|
setReceiptDone("sending");
|
|
try {
|
|
await sendReadReceipt(e);
|
|
toast.success("Read receipt sent");
|
|
} catch (err) {
|
|
setReceiptDone(null);
|
|
toast.error(`Could not send the receipt: ${(err as Error).message}`);
|
|
}
|
|
}}
|
|
>
|
|
{receiptDone === "sending" ? "Sending…" : "Send receipt"}
|
|
</button>
|
|
<button onClick={() => setReceiptDone("dismissed")}>Not this time</button>
|
|
</div>
|
|
)}
|
|
{scheduled && (
|
|
<div className="scheduled-banner" style={{ margin: "0 16px 8px" }}>
|
|
<Clock size={16} />
|
|
<span className="grow">Waiting on the server — goes out {formatScheduleTime(new Date(scheduled.sendAt))}.</span>
|
|
<button
|
|
onClick={async () => {
|
|
try {
|
|
await cancelScheduled(e.id);
|
|
toast.success("Send cancelled — the message is back in Drafts");
|
|
} catch (err) {
|
|
toast.error(`Could not cancel: ${(err as Error).message}`);
|
|
}
|
|
}}
|
|
>
|
|
Cancel send
|
|
</button>
|
|
</div>
|
|
)}
|
|
{rendered && rendered.remoteCount > 0 && !remoteAllowed && (
|
|
<div className="remote-banner" style={{ margin: "0 16px 8px" }}>
|
|
<ImageIcon size={16} />
|
|
<span className="grow">Remote images are blocked to protect your privacy.</span>
|
|
<button onClick={() => setAllowRemote(true)}>Show images</button>
|
|
{from && <button onClick={() => updateSettings({ trustedImageSenders: [...settings.trustedImageSenders, from.email.toLowerCase()] })}>Always from {from.email}</button>}
|
|
</div>
|
|
)}
|
|
{icsPart && <InviteCard email={e} part={icsPart} />}
|
|
{vcfParts.map((p) => <VCardCard key={p.blobId ?? p.partId ?? ""} part={p} accountId={accountId} />)}
|
|
<div className="message-body">
|
|
{showHtml && rendered ? <HtmlBody html={rendered.html} bodyStyle={rendered.bodyStyle} themed={themed} onShowImages={() => setAllowRemote(true)} /> : <TextBody text={textRaw ?? ""} />}
|
|
</div>
|
|
{attachments.length > 0 && <AttachmentList attachments={attachments} accountId={accountId} email={e} />}
|
|
{unsubscribe && (
|
|
<div className="unsubscribe-row">
|
|
<span>This looks like a mailing list.</span>
|
|
<button className="btn btn-ghost btn-sm" onClick={() => void onUnsubscribe()}>Unsubscribe</button>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
{addrMenu.node}
|
|
{filterOpen && <FilterFromMessageDialog email={e} mailboxId={Object.keys(e.mailboxIds)[0] ?? null} onClose={() => setFilterOpen(false)} />}
|
|
<Dialog open={showSource} onClose={() => setShowSource(false)} title="Original message" size="xl">
|
|
{source === null ? <div className="center"><span className="spinner" /></div> : <pre className="code" style={{ minHeight: 300, maxHeight: "65vh" }}>{source}</pre>}
|
|
</Dialog>
|
|
<Dialog open={showHeaders} onClose={() => setShowHeaders(false)} title="Message headers" size="lg">
|
|
<dl className="message-details" style={{ margin: 0 }}>
|
|
{Object.entries(e).filter(([k]) => k.startsWith("header:")).map(([k, v]) => (
|
|
<>
|
|
<dt key={`${k}-t`}>{k.split(":")[1]}</dt>
|
|
<dd key={`${k}-d`} className="mono small">{Array.isArray(v) ? v.map((x: unknown) => (typeof x === "object" && x ? formatAddress(x as EmailAddress) : String(x))).join(", ") : String(v ?? "—")}</dd>
|
|
</>
|
|
))}
|
|
<dt>Received</dt><dd>{formatFullDate(e.receivedAt)}</dd>
|
|
{e.inReplyTo?.length ? <><dt>In-Reply-To</dt><dd className="mono small">{e.inReplyTo.join(" ")}</dd></> : null}
|
|
{e.references?.length ? <><dt>References</dt><dd className="mono small">{e.references.join(" ")}</dd></> : null}
|
|
</dl>
|
|
<p className="hint">Use “Show original” for the complete raw message.</p>
|
|
</Dialog>
|
|
</article>
|
|
);
|
|
});
|
|
|
|
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<HTMLDivElement>(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 = `<style>${EMAIL_BASE_CSS}</style><div class="ihm-email-root${themed ? " themed" : ""}" style="${bodyStyle.replace(/"/g, "'")}">${html}</div>`;
|
|
// 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);
|
|
root.addEventListener("click", onClick);
|
|
return () => root.removeEventListener("click", onClick);
|
|
}, [html, bodyStyle, themed, onClick]);
|
|
|
|
useEffect(() => {
|
|
const root = hostRef.current?.shadowRoot;
|
|
const q = root?.querySelector<HTMLElement>(".ihm-quoted");
|
|
if (q) q.hidden = !quoteOpen;
|
|
}, [quoteOpen]);
|
|
|
|
return (
|
|
<>
|
|
<div ref={hostRef} className="body-host" />
|
|
{hasQuote && (
|
|
<button className="quote-toggle" onClick={() => setQuoteOpen((v) => !v)} title={quoteOpen ? "Hide quoted text" : "Show quoted text"}>
|
|
{quoteOpen ? <ChevronUp size={12} /> : <span style={{ letterSpacing: 2 }}>•••</span>}
|
|
{quoteOpen ? "Hide quoted text" : ""}
|
|
</button>
|
|
)}
|
|
</>
|
|
);
|
|
}
|
|
|
|
function TextBody({ text }: { text: string }) {
|
|
const hostRef = useRef<HTMLDivElement>(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 = `<style>${TEXT_EMAIL_CSS}</style><div class="ihm-text-root">${textToHtml(main)}${quoted ? `<div class="ihm-quoted" ${quoteOpen ? "" : "hidden"}>\n${textToHtml(quoted)}</div>` : ""}</div>`;
|
|
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 (
|
|
<>
|
|
<div ref={hostRef} className="body-host" />
|
|
{quoted && (
|
|
<button className="quote-toggle" onClick={() => setQuoteOpen((v) => !v)}>
|
|
{quoteOpen ? <ChevronUp size={12} /> : <span style={{ letterSpacing: 2 }}>•••</span>}
|
|
{quoteOpen ? "Hide quoted text" : ""}
|
|
</button>
|
|
)}
|
|
</>
|
|
);
|
|
}
|
|
|
|
/* ---------- Attachments ---------- */
|
|
|
|
export function attachmentIcon(type: string, name?: string | null) {
|
|
const t = type.toLowerCase();
|
|
const n = (name ?? "").toLowerCase();
|
|
if (t.startsWith("image/")) return <ImageIcon size={18} />;
|
|
if (t.startsWith("video/")) return <Film size={18} />;
|
|
if (t.startsWith("audio/")) return <Music size={18} />;
|
|
if (t === "application/pdf") return <FileText size={18} />;
|
|
if (/zip|tar|gzip|7z|rar|compressed/.test(t) || /\.(zip|tgz|gz|7z|rar)$/.test(n)) return <FileArchive size={18} />;
|
|
if (/spreadsheet|excel|csv/.test(t) || /\.(xlsx?|csv)$/.test(n)) return <FileSpreadsheet size={18} />;
|
|
if (t === "text/calendar") return <Calendar size={18} />;
|
|
if (t.includes("vcard")) return <UserPlus size={18} />;
|
|
if (t.startsWith("text/") || /word|document/.test(t)) return <FileText size={18} />;
|
|
return <File size={18} />;
|
|
}
|
|
|
|
function AttachmentList({ attachments, accountId, email }: { attachments: EmailBodyPart[]; accountId: Id; email: Email }) {
|
|
const [preview, setPreview] = useState<EmailBodyPart | null>(null);
|
|
const viewable = (a: EmailBodyPart) => (a.type.startsWith("image/") && a.type !== "image/svg+xml") || a.type === "application/pdf" || a.type === "text/plain";
|
|
return (
|
|
<>
|
|
<div className="attachments">
|
|
{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 (
|
|
<a key={a.blobId ?? i} className="attachment" href={url} download={a.name ?? undefined} title={`${a.name ?? "attachment"} (${formatSize(a.size)})`} onClick={(ev) => { if (viewable(a)) { ev.preventDefault(); setPreview(a); } }}>
|
|
<span className="att-icon">{a.type.startsWith("image/") && a.type !== "image/svg+xml" && a.blobId ? <img src={inlineUrl} alt="" loading="lazy" /> : attachmentIcon(a.type, a.name)}</span>
|
|
<span className="att-text">
|
|
<span className="att-name">{a.name ?? "(unnamed)"}</span>
|
|
<span className="att-size">{formatSize(a.size)}</span>
|
|
<span className="att-actions">
|
|
<button className="icon-btn xs" title="Download" onClick={(ev) => { ev.preventDefault(); ev.stopPropagation(); const l = document.createElement("a"); l.href = url; l.download = a.name ?? ""; l.click(); }}><Download size={14} /></button>
|
|
{viewable(a) && <button className="icon-btn xs" title="Open in new tab" onClick={(ev) => { ev.preventDefault(); ev.stopPropagation(); window.open(inlineUrl, "_blank", "noopener"); }}><ExternalLink size={14} /></button>}
|
|
</span>
|
|
</span>
|
|
</a>
|
|
);
|
|
})}
|
|
{attachments.length > 1 && (
|
|
<button className="btn btn-ghost btn-sm" style={{ alignSelf: "center" }} onClick={() => { for (const a of attachments) { if (!a.blobId) continue; const l = document.createElement("a"); l.href = client.downloadUrl(accountId, a.blobId, a.name ?? "attachment", a.type); l.download = a.name ?? ""; l.click(); } }}>
|
|
<Download size={14} /> Download all
|
|
</button>
|
|
)}
|
|
</div>
|
|
<Dialog open={Boolean(preview)} onClose={() => setPreview(null)} title={preview?.name ?? "Preview"} size="xl" footer={preview && <a className="btn" href={client.downloadUrl(accountId, preview.blobId!, preview.name ?? "file", preview.type)} download><Download size={16} /> Download</a>}>
|
|
{preview?.type.startsWith("image/") && <img src={client.downloadUrl(accountId, preview.blobId!, preview.name ?? "image", preview.type, true)} alt={preview.name ?? ""} style={{ maxHeight: "70vh", display: "block", margin: "0 auto" }} />}
|
|
{preview?.type === "application/pdf" && <iframe title="PDF" src={client.downloadUrl(accountId, preview.blobId!, preview.name ?? "file.pdf", preview.type, true)} style={{ width: "100%", height: "70vh", border: 0 }} />}
|
|
{preview?.type === "text/plain" && <TextAttachment url={client.downloadUrl(accountId, preview.blobId!, preview.name ?? "file.txt", preview.type, true)} />}
|
|
<p className="hint" style={{ marginTop: 8 }}>From: {displayName(email.from?.[0])}</p>
|
|
</Dialog>
|
|
</>
|
|
);
|
|
}
|
|
|
|
function TextAttachment({ url }: { url: string }) {
|
|
const [text, setText] = useState<string | null>(null);
|
|
useEffect(() => {
|
|
fetch(url, { credentials: "same-origin" }).then((r) => r.text()).then(setText).catch(() => setText("Could not load."));
|
|
}, [url]);
|
|
return <pre className="code" style={{ maxHeight: "65vh" }}>{text ?? "Loading…"}</pre>;
|
|
}
|