Files
webmail/components/email/thread-conversation-view.tsx
T
Linus Rath cf0eb03c4a feat: show the sender's BIMI logo as the sender icon when the message passes DMARC
Stalwart does no BIMI, so sender icons were only ever favicons, which
anyone controls for any domain. The webmail server now does the
receiver side itself at /api/bimi, and avatars ask it only for
messages whose own Authentication-Results say dmarc=pass for the exact
From domain.

- `lib/bimi/`: DMARC policy check (quarantine or reject, pct=100, the
  parent domain's sp and p for subdomains), the assertion record with
  fallback to the parent domain, mark certificate validation (chain to
  pinned DigiCert, GlobalSign and SSL.com VMC roots, BIMI key purpose,
  validity, SAN, SCTs present, CRL revocation, logo from the logotype
  extension), SVG Tiny PS checks and rendering to PNG, caching
- logos backed only by the DNS record show by default; the admin
  setting "Require a mark certificate" (`BIMI_REQUIRE_CERTIFICATE`)
  limits them to VMC and CMC backed ones
- a check mark beside the sender's name for certificate-backed logos
- avatars try contact photo, BIMI logo, plugin avatar, favicon; the
  sender warning shield still wins, and logos follow the sender icons
  setting and the no-third-party-traffic mode
- the two-label public suffix list moved to `lib/root-domain.ts`

SCT signatures are not verified and only the default selector is used.
2026-10-30 00:12:24 +01:00

1019 lines
44 KiB
TypeScript

"use client";
import React, { useState, useEffect, useLayoutEffect, useMemo, useRef, useCallback } from "react";
import DOMPurify from "dompurify";
import { Email, ThreadGroup } from "@/lib/jmap/types";
import { EMAIL_SANITIZE_CONFIG, TRANSPARENT_BLOCKED_PIXEL, blockExternalResourcesOnNode, collapseBlockedImageContainers, emailIframeCsp, isOpenableLinkHref, plainTextToSafeHtml, restrictDataUriResourcesOnNode, sanitizeEmailBodyForIframe, sanitizePlainTextRenderedHtml } from "@/lib/email-sanitization";
import { resolveExternalLink } from "@/lib/plugin-hook-result";
import { stripBidiControls } from "@/lib/bidi";
import { requestInternalMailto } from "@/lib/protocol-handlers/mailto";
import { emailDisplayDate } from "@/lib/email-date";
import { getRenderableHtmlBody } from "@/lib/email-body-selection";
import { findVerificationCode, verificationCodeBodyText } from "@/lib/verification-code";
import { VerificationCodeChip } from "./verification-code-chip";
import { attachmentIcon } from "./attachment-chips";
import { ExternalSenderChip } from "./external-sender-chip";
import { collectReferencedCids, isEmbeddedInBody, normalizeCid, rewriteCidReferences } from "@/lib/attachment-visibility";
import { collapsePlainTextQuotes, setupQuoteCollapse } from "@/lib/quote-collapse";
import { fitEmailBodyWidth } from "@/lib/email-fit-width";
import { mailLanguage } from "@/lib/mail-language";
import { htmlToReadableText } from "@/lib/html-to-readable-text";
import { pauseAnimatedImages } from "@/lib/animated-images";
import { applyMailTextSize, mailFitOptions } from "@/lib/mail-reading";
import { useMailReading } from "@/hooks/use-mail-reading";
import { DARK_PENDING_CLASS, applyEmailDarkMode, emailDarkModeCss } from "@/lib/email-dark-mode";
import { useThemeStore } from "@/stores/theme-store";
import { useShallow } from "zustand/react/shallow";
import { Avatar } from "@/components/ui/avatar";
import { useSenderWarning } from "@/hooks/use-sender-warning";
import { brandIndicatorDomain } from "@/lib/email-headers";
import { Button } from "@/components/ui/button";
import { formatDate, formatFileSize, cn, cleanPreview } from "@/lib/utils";
import { IconButton } from "@/components/ui/icon-button";
import { PaneSpinner } from "@/components/ui/spinner";
import {
ArrowLeft,
ChevronDown,
ChevronUp,
Reply,
ReplyAll,
Forward,
Paperclip,
Star,
Download,
Eye,
CalendarClock,
} from "@/components/icons";
import { formatScheduledSendAt } from "@/lib/scheduled-send-label";
import { useTranslations } from "next-intl";
import { useSettingsStore } from "@/stores/settings-store";
import { useContactStore } from "@/stores/contact-store";
import { useEmailStore, resolveEmailBlobContext, type EmailBlobContext } from "@/stores/email-store";
import { useAuthStore } from "@/stores/auth-store";
import { isFilePreviewable, toInertBlob } from "@/lib/file-preview";
import { useWopiStatus, canWopiOpen } from "@/hooks/use-wopi-status";
import { useOwnDomainAddress } from "@/hooks/use-own-domain-address";
import { emitThreadExpand } from "@/lib/plugin-thread-expand";
import { PluginSlot } from "@/components/plugins/plugin-slot";
import { ReaderSelectionMenu, useReaderSelectionMenu, watchBodySelection } from "./reader-selection-menu";
import { PluginActions, useMessageActionTarget } from "@/components/plugins/plugin-actions";
import { usePluginSlotOffers } from "@/hooks/use-plugin-slot-offers";
import { useDeviceDetection } from "@/hooks/use-media-query";
import { usePluginRenderedBody } from "@/hooks/use-plugin-rendered-body";
import { emailToReadView } from "@/lib/plugin-projection";
import { AttachmentPluginMenu, ReaderAboveQuoteSlot } from "./reader-plugin-surfaces";
import { useNoThirdPartyTraffic } from "@/hooks/use-third-party-mode";
import { ConversationHeaderActions, type ConversationActions } from "./conversation-header-actions";
export type ConversationAttachmentHandler = (
blobId: string, name: string, type: string | undefined, source: EmailBlobContext,
) => void;
interface ThreadConversationViewProps {
thread: ThreadGroup;
emails: Email[];
isLoading?: boolean;
onBack: () => void;
onReply?: (email: Email) => void;
onReplyAll?: (email: Email) => void;
onForward?: (email: Email) => void;
onDownloadAttachment?: ConversationAttachmentHandler;
/** Routing captured when this conversation was opened. */
blobSource?: EmailBlobContext;
onMarkAsRead?: (emailId: string, read: boolean) => void;
/** Role of the folder the conversation was opened from, for plugin actions limited to some folders. */
mailboxRole?: string | null;
/**
* The conversation's actions in the header, on its newest message as the
* list row offers them: Archive, Delete, Move and ⋮ with the rest.
*/
actions?: ConversationActions;
}
/**
* Whether a conversation message may load remote content: the user's policy,
* a trusted sender, or a one-off "load" for that message.
*/
export function useConversationExternalContent() {
const externalContentPolicy = useSettingsStore((state) => state.externalContentPolicy);
const addTrustedSender = useSettingsStore((state) => state.addTrustedSender);
const isSenderTrusted = useSettingsStore((state) => state.isSenderTrusted);
const trustedSendersAddressBook = useSettingsStore((state) => state.trustedSendersAddressBook);
const isTrustedAddressBookSender = useContactStore((state) => state.isTrustedAddressBookSender);
const addToTrustedSendersBook = useContactStore((state) => state.addToTrustedSendersBook);
const { client } = useAuthStore(useShallow((state) => ({ client: state.client })));
const [allowedIds, setAllowedIds] = useState<Set<string>>(new Set());
// No third-party traffic (#903): nothing remote loads and nothing offers to.
const remoteBlockedByOrg = useNoThirdPartyTraffic();
const allow = useCallback((emailId: string) => {
setAllowedIds(prev => new Set(prev).add(emailId));
}, []);
const isAllowed = (email: Email) => {
const senderEmail = email.from?.[0]?.email?.toLowerCase();
const senderIsTrusted = senderEmail
? isSenderTrusted(senderEmail) || (trustedSendersAddressBook && isTrustedAddressBookSender(senderEmail))
: false;
if (remoteBlockedByOrg) return false;
return externalContentPolicy === 'allow' || !!senderIsTrusted || allowedIds.has(email.id);
};
const trustSender = (email: Email) => {
const senderEmail = email.from?.[0]?.email?.toLowerCase();
if (!senderEmail || remoteBlockedByOrg) return undefined;
return () => {
if (trustedSendersAddressBook && client) {
addToTrustedSendersBook(client, senderEmail).catch(console.error);
} else {
addTrustedSender(senderEmail);
}
allow(email.id);
};
};
return { isAllowed, allow, trustSender };
}
/**
* Starts the "mark as read" timer for an unread message while it is shown,
* following the user's mark-as-read delay.
*/
export function useMarkReadWhenShown(
email: Email,
shown: boolean,
onMarkAsRead?: (emailId: string, read: boolean) => void,
) {
useEffect(() => {
if (!shown || !onMarkAsRead || email.keywords?.$seen) {
return;
}
const markAsReadDelay = useSettingsStore.getState().markAsReadDelay;
// Never auto-mark
if (markAsReadDelay === -1) {
return;
}
// Instant mark
if (markAsReadDelay === 0) {
onMarkAsRead(email.id, true);
return;
}
// Delayed mark
const timeout = setTimeout(() => {
onMarkAsRead(email.id, true);
}, markAsReadDelay);
return () => clearTimeout(timeout);
// eslint-disable-next-line react-hooks/exhaustive-deps -- a new handler identity must not restart the timer
}, [shown, email.id, email.keywords?.$seen]);
}
export function ThreadConversationView({
thread,
emails,
isLoading = false,
onBack,
onReply,
onReplyAll,
onForward,
onDownloadAttachment,
blobSource,
onMarkAsRead,
mailboxRole,
actions,
}: ThreadConversationViewProps) {
const t = useTranslations();
const externalContent = useConversationExternalContent();
const emailIdList = emails.map((email) => email.id).join(',');
const { isMobile: summaryOnPhone, isTablet: summaryOnTablet } = useDeviceDetection();
const threadSummaryProps = useMemo(
() => ({ threadId: thread.latestEmail.threadId ?? null, emailIds: emailIdList ? emailIdList.split(',') : [], deviceType: summaryOnPhone ? 'mobile' : summaryOnTablet ? 'tablet' : 'desktop' }),
[thread.latestEmail.threadId, emailIdList, summaryOnPhone, summaryOnTablet],
);
// Track which emails are expanded (most recent by default)
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());
// Auto-expand most recent email AND all unread emails when thread opens
useEffect(() => {
if (emails.length > 0) {
const idsToExpand = new Set<string>();
// Always expand most recent
idsToExpand.add(emails[0].id);
// Also expand all unread emails
emails.forEach(email => {
if (!email.keywords?.$seen) {
idsToExpand.add(email.id);
}
});
setExpandedIds(idsToExpand);
}
}, [emails]);
const toggleExpanded = (emailId: string) => {
const target = emails.find((e) => e.id === emailId);
if (target) emitThreadExpand(target.threadId, [target], !expandedIds.has(emailId));
setExpandedIds(prev => {
const next = new Set(prev);
if (next.has(emailId)) {
next.delete(emailId);
} else {
next.add(emailId);
}
return next;
});
};
if (isLoading) {
// One centred spinner for a whole pane (R9).
return <PaneSpinner label={t("threads.loading")} className="bg-background" />;
}
return (
<div className="flex flex-col h-full bg-background">
{/* Header */}
<div className="flex items-center px-4 border-b border-border bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80 sticky top-0 z-10" style={{ gap: 'var(--density-item-gap)', paddingBlock: 'var(--density-header-py)' }}>
<IconButton size="icon-lg" className="-ms-2 flex-shrink-0" onClick={onBack} label={t('email_viewer.back_to_list')}>
<ArrowLeft />
</IconButton>
<div className="flex-1 min-w-0">
{/* Two lines at most, so a long subject leaves the actions their room. */}
<h2 className="font-semibold text-foreground break-words line-clamp-2" dir="auto" title={thread.latestEmail.subject || undefined}>
{thread.latestEmail.subject || t("email_viewer.no_subject")}
</h2>
<p className="text-sm text-muted-foreground">
{t("threads.messages_count", { count: emails.length })}
</p>
</div>
{actions && <ConversationHeaderActions email={thread.latestEmail} actions={actions} />}
</div>
{/* Email Cards */}
<div className="flex-1 overflow-y-auto">
<PluginSlot name="thread-summary" className="w-full" extraProps={threadSummaryProps} />
<div className="space-y-3" style={{ padding: 'var(--density-card-p)' }}>
{emails.map((email, index) => (
<EmailCard
key={email.id}
email={email}
isExpanded={expandedIds.has(email.id)}
isLatest={index === 0}
allowExternal={externalContent.isAllowed(email)}
onToggleExpanded={() => toggleExpanded(email.id)}
onAllowExternal={() => externalContent.allow(email.id)}
onTrustSender={externalContent.trustSender(email)}
onReply={onReply ? () => onReply(email) : undefined}
onReplyAll={onReplyAll ? () => onReplyAll(email) : undefined}
onForward={onForward ? () => onForward(email) : undefined}
onDownloadAttachment={onDownloadAttachment}
blobSource={blobSource}
onMarkAsRead={onMarkAsRead}
mailboxRole={mailboxRole}
/>
))}
</div>
</div>
</div>
);
}
// Individual email card component
interface EmailCardProps {
email: Email;
isExpanded: boolean;
isLatest: boolean;
allowExternal: boolean;
onToggleExpanded: () => void;
onAllowExternal: () => void;
onTrustSender?: () => void;
onReply?: () => void;
onReplyAll?: () => void;
onForward?: () => void;
onDownloadAttachment?: ConversationAttachmentHandler;
/** Routing captured when this conversation was opened. */
blobSource?: EmailBlobContext;
onMarkAsRead?: (emailId: string, read: boolean) => void;
mailboxRole?: string | null;
}
/**
* The single reader's plugin surfaces for one message of the phone
* conversation, mounted only while the message is unfolded: reader-header
* on top, reader-below-subject and email-banner under the sender,
* email-footer under the body. The props are the same as in the single
* reader and stay stable across re-renders.
*/
function useConversationPluginProps(email: Email) {
const headerOffers = usePluginSlotOffers('reader-header').length;
const belowSubjectOffers = usePluginSlotOffers('reader-below-subject').length;
const bannerOffers = usePluginSlotOffers('email-banner').length;
const wantsView = headerOffers + belowSubjectOffers + bannerOffers > 0;
const { isMobile, isTablet } = useDeviceDetection();
const deviceType = isMobile ? 'mobile' : isTablet ? 'tablet' : 'desktop';
const emailProps = useMemo(() => ({ email: wantsView ? emailToReadView(email) : null, deviceType }), [email, wantsView, deviceType]);
const idProps = useMemo(() => ({ emailId: email.id }), [email.id]);
const actionTarget = useMessageActionTarget(email);
return { emailProps, idProps, actionTarget };
}
function EmailCard({
email,
isExpanded,
isLatest: _isLatest,
allowExternal,
onToggleExpanded,
onAllowExternal,
onTrustSender,
onReply,
onReplyAll,
onForward,
onDownloadAttachment,
blobSource,
onMarkAsRead,
mailboxRole,
}: EmailCardProps) {
const t = useTranslations();
const density = useSettingsStore((state) => state.density);
const timeFormat = useSettingsStore((state) => state.timeFormat);
const plugin = useConversationPluginProps(email);
const sender = email.from?.[0];
const senderWarning = useSenderWarning(email);
const formatAddress = useOwnDomainAddress();
const isUnread = !email.keywords?.$seen;
const isStarred = email.keywords?.$flagged;
// Mark as read when email is expanded
useMarkReadWhenShown(email, isExpanded, onMarkAsRead);
return (
<div className={cn(
"rounded-lg border border-border overflow-hidden transition-all duration-200",
isExpanded ? "bg-background shadow-sm" : "bg-muted/30",
isUnread && !isExpanded && "start-bar border-s-transparent [--start-bar-width:2px] [--start-bar:var(--color-primary)]"
)}>
{isExpanded && <PluginSlot name="reader-header" className="w-full" extraProps={plugin.emailProps} />}
{/* Card Header - Always visible */}
<button
onClick={onToggleExpanded}
aria-expanded={isExpanded}
className={cn(
"w-full flex items-start text-start transition-colors",
!isExpanded && "hover:bg-muted/50"
)}
style={{ gap: 'var(--density-item-gap)', padding: 'var(--density-card-p)' }}
>
{density !== 'extra-compact' && (
<Avatar
name={sender?.name}
email={sender?.email}
size="md"
className="flex-shrink-0"
senderWarning={senderWarning}
brandDomain={brandIndicatorDomain(email)}
/>
)}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-0.5">
<span dir="auto" className={cn(
"font-medium truncate",
isUnread ? "text-foreground" : "text-muted-foreground"
)}>
{sender?.name || (sender?.email && formatAddress(sender.email)) || t('email_viewer.unknown_sender')}
</span>
<ExternalSenderChip from={email.from} accountId={email.sourceClientAccountId} interactive={false} />
{email.isScheduled && (
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs font-medium bg-primary/10 text-primary shrink-0">
<CalendarClock className="w-3.5 h-3.5" />
{t('sidebar.scheduled')}
</span>
)}
{isStarred && (
<Star className="w-4 h-4 fill-amber-400 text-amber-400 flex-shrink-0" />
)}
{email.hasAttachment && (
<Paperclip className="w-4 h-4 text-muted-foreground flex-shrink-0" />
)}
</div>
<div className="text-sm text-muted-foreground">
{email.isScheduled && email.scheduledSendAt ? (
<span className="text-primary font-medium inline-flex items-center gap-1">
<CalendarClock className="w-3.5 h-3.5" />
{formatScheduledSendAt(email.scheduledSendAt, timeFormat)}
</span>
) : (
formatDate(emailDisplayDate(email))
)}
</div>
{!isExpanded && density !== 'extra-compact' && (
<p dir="auto" className="text-sm text-muted-foreground mt-1 line-clamp-2">
{cleanPreview(email.preview) || t('email_viewer.no_preview_available')}
</p>
)}
</div>
<div className="flex-shrink-0 p-1">
{isExpanded ? (
<ChevronUp className="w-5 h-5 text-muted-foreground" />
) : (
<ChevronDown className="w-5 h-5 text-muted-foreground" />
)}
</div>
</button>
{/* Expanded Content */}
{isExpanded && (
<div className="border-t border-border animate-in slide-in-from-top-2 duration-200">
{email.isScheduled && (
<div className="px-4 py-2 bg-primary/10 border-b border-border flex items-center gap-2 text-primary text-xs font-medium">
<CalendarClock className="w-3.5 h-3.5 shrink-0" />
<span>
{t('email_viewer.scheduled_banner', { date: email.scheduledSendAt ? formatScheduledSendAt(email.scheduledSendAt, timeFormat) : '' })}
</span>
</div>
)}
<PluginSlot name="reader-below-subject" className="w-full" extraProps={plugin.emailProps} />
<PluginSlot name="email-banner" className="w-full" extraProps={plugin.emailProps} />
<ConversationEmailBody
email={email}
allowExternal={allowExternal}
onAllowExternal={onAllowExternal}
onTrustSender={onTrustSender}
onDownloadAttachment={onDownloadAttachment}
blobSource={blobSource}
/>
<PluginSlot name="email-footer" className="w-full" extraProps={plugin.idProps} />
{/* Action Buttons; plugin reader actions behind a "more" button, as
in the single reader's More panel on phones. Long names (Arabic
"Reply all") wrap to a second line instead of being cut. */}
<div className="px-4 pb-4 flex flex-wrap gap-2">
{onReply && (
<Button
variant="outline"
size="sm"
onClick={(e) => {
e.stopPropagation();
onReply();
}}
className="flex-auto px-2 whitespace-nowrap"
>
<Reply className="w-4 h-4 me-1.5 shrink-0" />
{t("email_viewer.reply")}
</Button>
)}
{onReplyAll && (
<Button
variant="outline"
size="sm"
onClick={(e) => {
e.stopPropagation();
onReplyAll();
}}
className="flex-auto px-2 whitespace-nowrap"
>
<ReplyAll className="w-4 h-4 me-1.5 shrink-0" />
{t("email_viewer.reply_all")}
</Button>
)}
{onForward && (
<Button
variant="outline"
size="sm"
onClick={(e) => {
e.stopPropagation();
onForward();
}}
className="flex-auto px-2 whitespace-nowrap"
>
<Forward className="w-4 h-4 me-1.5 shrink-0" />
{t("email_viewer.forward")}
</Button>
)}
<PluginActions
surface="reader-actions"
context={plugin.idProps}
target={plugin.actionTarget}
mailboxRole={mailboxRole ?? null}
overflow
className="flex-shrink-0 rounded-md border border-border"
/>
</div>
</div>
)}
</div>
);
}
interface ConversationEmailBodyProps {
email: Email;
allowExternal: boolean;
onAllowExternal: () => void;
onTrustSender?: () => void;
onDownloadAttachment?: ConversationAttachmentHandler;
/** Routing captured when this conversation was opened. */
blobSource?: EmailBlobContext;
/** Padding around the body; the phone card's density padding by default. */
bodyStyle?: React.CSSProperties;
className?: string;
}
/**
* One conversation message's body: the remote-content banner, the sanitized
* body in a sandboxed iframe and its attachments. Shared by the phone thread
* view and Mountain View's expanded conversation messages.
*/
export function ConversationEmailBody({
email,
allowExternal,
onAllowExternal,
onTrustSender,
onDownloadAttachment,
blobSource,
bodyStyle,
className,
}: ConversationEmailBodyProps) {
const remoteBlockedByOrg = useNoThirdPartyTraffic();
const t = useTranslations();
const resolvedTheme = useThemeStore((state) => state.resolvedTheme);
const mailAttachmentAction = useSettingsStore((state) => state.mailAttachmentAction);
const wopiStatus = useWopiStatus(true);
const hideInlineImageAttachments = useSettingsStore((state) => state.hideInlineImageAttachments);
const emailAlwaysLightMode = useSettingsStore((state) => state.emailAlwaysLightMode);
const plainTextFont = useSettingsStore((state) => state.plainTextFont);
const reading = useMailReading();
// No dark recolouring in a forced-colours mode: the browser maps the mail
// to the user's colours itself.
const isDark = resolvedTheme === 'dark' && !emailAlwaysLightMode && !reading.forcedColors && !reading.overrideStyles;
const appFontSources = reading.fontSources.join(' ');
const showVerificationCodes = useSettingsStore((state) => state.showVerificationCodes);
const verificationCode = useMemo(
() => (showVerificationCodes ? findVerificationCode(email.subject, verificationCodeBodyText(email)) : null),
// A keyword change replaces the email object; only another body needs another look.
// eslint-disable-next-line react-hooks/exhaustive-deps
[showVerificationCodes, email.id, email.subject, email.bodyValues],
);
const [hasBlockedContent, setHasBlockedContent] = useState(false);
const { client } = useAuthStore(useShallow((state) => ({
client: state.client, revision: state.connectedAccountsRevision,
})));
const fallbackSource = useEmailStore(useShallow(() => resolveEmailBlobContext(email, client)));
const source = blobSource ?? fallbackSource;
const { client: blobClient, accountId } = source;
// Local CID URLs belong to a particular client/owner/message/part list.
// Hide the previous set during render, before the new fetch's effect runs.
const cidRequest = useMemo(() => ({ blobClient, accountId, emailId: email.id, attachments: email.attachments }),
[blobClient, accountId, email.id, email.attachments]);
const [cidResult, setCidResult] = useState<{ request: typeof cidRequest; urls: Record<string, string> } | null>(null);
const cidBlobUrls = useMemo(() => cidResult?.request === cidRequest ? cidResult.urls : {}, [cidResult, cidRequest]);
// A crypto plugin's decrypted body (PGP, S/MIME) replaces the message's own.
const pluginBody = usePluginRenderedBody(email);
// reader-above-quote: the body the quote was collapsed in, once it is on screen.
const [htmlBodyEl, setHtmlBodyEl] = useState<HTMLElement | null>(null);
const [plainBodyEl, setPlainBodyEl] = useState<HTMLDivElement | null>(null);
const selectionMenu = useReaderSelectionMenu();
const wantsQuoteSlot = usePluginSlotOffers('reader-above-quote').length > 0;
const quoteSlotProps = useMemo(
() => ({ email: wantsQuoteSlot ? emailToReadView(email) : null }),
[email, wantsQuoteSlot],
);
// Fetch inline CID images with authentication to prevent browser auth dialogs
useEffect(() => {
if (!blobClient || !cidRequest.attachments) {
setCidResult(null);
return;
}
const cidAttachments = cidRequest.attachments.filter(att => att.cid && att.blobId);
if (cidAttachments.length === 0) {
setCidResult(null);
return;
}
let cancelled = false;
const objectUrls: string[] = [];
async function fetchCidBlobs() {
const urls: Record<string, string> = {};
await Promise.all(cidAttachments.map(async (att) => {
const cidValue = normalizeCid(att.cid!);
try {
// Re-type sender-declared script-bearing parts before they become a
// blob: URL in our origin (GHSA-xvjh-v9c6-qcvc).
const objectUrl = URL.createObjectURL(toInertBlob(await blobClient!.fetchBlob(att.blobId, att.name || 'inline', att.type, accountId)));
if (!cancelled) {
urls[cidValue] = objectUrl;
objectUrls.push(objectUrl);
} else {
URL.revokeObjectURL(objectUrl);
}
} catch {
// Failed to fetch inline image, will show placeholder
}
}));
if (!cancelled) {
setCidResult({ request: cidRequest, urls });
}
}
fetchCidBlobs();
return () => {
cancelled = true;
objectUrls.forEach(url => URL.revokeObjectURL(url));
};
}, [cidRequest, blobClient, accountId]);
// Sanitize and prepare email HTML content
const formattedContent = useMemo(() => {
if (!email) return { html: "", isHtml: false };
// Plugin-rendered bodies get the reader's sanitizer and remote-content
// blocking, like any other mail body (#797).
if (pluginBody.html) {
const withCids = rewriteCidReferences(pluginBody.html, { ...cidBlobUrls, ...pluginBody.cidUrls }, TRANSPARENT_BLOCKED_PIXEL);
const { html, blockedExternalContent } = sanitizeEmailBodyForIframe(withCids, !allowExternal);
if (blockedExternalContent) {
if (!hasBlockedContent) setHasBlockedContent(true);
return { html: collapseBlockedImageContainers(html), isHtml: true };
}
return { html, isHtml: true };
}
if (pluginBody.text) {
return {
html: collapsePlainTextQuotes(plainTextToSafeHtml(pluginBody.text, 'text-primary hover:underline'), {
show: t('email_viewer.show_quoted_text'),
hide: t('email_viewer.hide_quoted_text'),
}),
isHtml: false,
};
}
if (email.bodyValues) {
let htmlContent = getRenderableHtmlBody(email);
if (htmlContent) {
// Replace cid: references with authenticated blob URLs (fetched via useEffect)
// This prevents browser auth dialogs that occur when loading raw JMAP download URLs
if (email.attachments) {
htmlContent = rewriteCidReferences(htmlContent, cidBlobUrls, TRANSPARENT_BLOCKED_PIXEL);
}
let blockedExternalContent = false;
// Use shared sanitization config as base (more secure)
const sanitizeConfig = { ...EMAIL_SANITIZE_CONFIG };
DOMPurify.addHook('afterSanitizeAttributes', (node) => {
// Re-apply the data:-URI allowlist DOMPurify skips on media tags.
restrictDataUriResourcesOnNode(node);
// The desktop viewer's blocker: srcset, <source>, poster,
// background attributes and escaped url()s as well as img src.
if (!allowExternal && blockExternalResourcesOnNode(node)) {
blockedExternalContent = true;
}
if (node.tagName === 'A' || node.tagName === 'AREA') {
node.setAttribute('target', '_blank');
node.setAttribute('rel', 'noopener noreferrer');
}
});
const sanitized = DOMPurify.sanitize(htmlContent, sanitizeConfig);
DOMPurify.removeHook('afterSanitizeAttributes');
let finalHtml = sanitized;
if (blockedExternalContent) {
if (!hasBlockedContent) setHasBlockedContent(true);
finalHtml = collapseBlockedImageContainers(sanitized);
}
return { html: finalHtml, isHtml: true };
}
// Plain text fallback
if (email.textBody?.[0]?.partId && email.bodyValues[email.textBody[0].partId]) {
const text = email.bodyValues[email.textBody[0].partId].value;
return {
// Trailing ">"-quoted block collapses behind a <details> toggle (#480).
html: collapsePlainTextQuotes(plainTextToSafeHtml(text, 'text-primary hover:underline'), {
show: t('email_viewer.show_quoted_text'),
hide: t('email_viewer.hide_quoted_text'),
}),
isHtml: false,
};
}
}
// Fallback to preview
if (email.preview) {
const previewHtml = email.preview
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
return { html: previewHtml, isHtml: false };
}
return { html: "", isHtml: false };
// `hasBlockedContent` is only read to skip a redundant setState; as a
// dependency it would re-run the sanitizer once the banner shows.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [email, allowExternal, cidBlobUrls, pluginBody, t]);
// "Show mail as plain text" (Settings > Accessibility): the message's own
// text/plain part, or a readable conversion of the HTML.
const mailViewMode = useSettingsStore((state) => state.mailViewMode);
const emailContent = useMemo(() => {
if (mailViewMode !== 'plain' || !formattedContent.isHtml) return formattedContent;
const textPart = !pluginBody.html ? email.textBody?.[0] : undefined;
let text = textPart?.partId && textPart.partId !== email.htmlBody?.[0]?.partId && (textPart.type || 'text/plain').toLowerCase() === 'text/plain'
? email.bodyValues?.[textPart.partId]?.value ?? null
: null;
if (!text?.trim()) text = htmlToReadableText(formattedContent.html);
return {
html: collapsePlainTextQuotes(plainTextToSafeHtml(text, 'text-primary hover:underline'), {
show: t('email_viewer.show_quoted_text'),
hide: t('email_viewer.hide_quoted_text'),
}),
isHtml: false,
};
}, [mailViewMode, formattedContent, pluginBody.html, email, t]);
// Parts the body embeds via cid: stay out of the attachment row while the
// user hides inline images - the desktop viewer's rule, shared through
// lib/attachment-visibility.ts so the two views cannot drift apart.
const visibleAttachments = useMemo(() => {
const attachments = email.attachments ?? [];
if (!hideInlineImageAttachments) return attachments;
const bodyCids = collectReferencedCids(getRenderableHtmlBody(email));
return attachments.filter(att => !isEmbeddedInBody(att, bodyCids));
}, [email, hideInlineImageAttachments]);
// Render the sanitized HTML body inside a sandboxed iframe so a malicious
// (or accidentally-bypassed) email cannot inject styles/scripts/forms into
// the host page. CSP <meta> is defense-in-depth in case the sanitizer ever
// emits a <script> tag through a parser quirk.
const iframeRef = useRef<HTMLIFrameElement>(null);
// The body's language for screen readers (see mailLanguage), and a frame
// title that tells this message from the others in the conversation.
const bodyLanguage = useMemo(() => mailLanguage(email, getRenderableHtmlBody(email)), [email]);
const frameSender = email.from?.[0];
const frameTitle = email.subject
? t('email_viewer.frame_title', { subject: email.subject })
: t('email_viewer.frame_title_from', { sender: frameSender?.name || frameSender?.email || t('email_viewer.unknown_sender') });
const emailIframeSrcDoc = useMemo(() => {
if (!emailContent.isHtml || !emailContent.html) return '';
// Strict while external content is blocked: the network-level backstop
// for whatever the DOM walk above cannot see.
const csp = emailIframeCsp(!allowExternal, appFontSources ? appFontSources.split(' ') : []);
// Colours are rewritten after load (applyEmailDarkMode in handleIframeLoad).
return `<!DOCTYPE html><html${bodyLanguage ? ` lang="${bodyLanguage}"` : ''} data-reading="${reading.frameKey}" class="${isDark ? DARK_PENDING_CLASS : ''}"><head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta http-equiv="Content-Security-Policy" content="${csp}">
<meta name="referrer" content="no-referrer">
<style>
/* overflow-y hidden keeps the scrollHeight measurement below honest. overflow-x
is auto so intrinsically wide content can pan instead of being clipped outright;
on a phone fitEmailBodyWidth() shrinks it to the screen first, so the pan is
only left for content too wide to stay legible when scaled. */
html { overflow: hidden; }
body { overflow-x: auto; overflow-y: hidden; margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; font-size: 0.875rem; line-height: 1.6; color: #1a1a1a; background: #ffffff; word-wrap: break-word; overflow-wrap: break-word; }
/* Only force the cap on images that set no width of their own: an
!important 100% also overrides a sender's inline max-width, and an
image sized by max-width + max-height + width:100% then grows to
the pane width while its max-height still clamps the height - the
picture renders stretched. Same reasoning as the table rule below (#790).
height stays !important so a fixed inline height cannot squash it. */
img:not([style*="max-width"]) { max-width: 100% !important; }
img[style*="max-width"] { max-width: 100%; }
img { height: auto !important; }
a { color: #1a73e8; }
/* Only force the cap on tables that set no width of their own: an
!important 100% would also override a newsletter's inline
max-width:600px and stretch it across the pane. (#790) */
table:not([style*="max-width"]) { max-width: 100% !important; }
table[style*="max-width"] { max-width: 100%; }
table { table-layout: auto; overflow-wrap: break-word; }
td, th { word-break: break-word; padding: 0.5rem; }
pre { white-space: pre-wrap; word-wrap: break-word; }
${isDark ? emailDarkModeCss() : ''}
</style></head><body dir="auto">${emailContent.html}${reading.frameCss ? `<style>${reading.frameCss}</style>` : ''}</body></html>`;
}, [emailContent.isHtml, emailContent.html, allowExternal, isDark, bodyLanguage, reading.frameKey, reading.frameCss, appFontSources]);
useLayoutEffect(() => { setHtmlBodyEl(null); }, [emailIframeSrcDoc]);
// One object per body: React writes innerHTML again whenever this object
// changes, dropping whatever was mounted in the body.
const plainBodyHtml = useMemo(
() => ({ __html: sanitizePlainTextRenderedHtml(emailContent.html) }),
[emailContent.html],
);
const handleIframeLoad = useCallback(() => {
const iframe = iframeRef.current;
if (!iframe) return;
try {
const doc = iframe.contentDocument;
if (!doc?.body) return;
// Collapse the quoted original of a reply behind a "•••" toggle (#480),
// before the first resize so the height reflects the collapsed body.
setupQuoteCollapse(doc, {
show: t('email_viewer.show_quoted_text'),
hide: t('email_viewer.hide_quoted_text'),
});
// Text size and minimum size, before the first resize.
applyMailTextSize(doc, {
factor: reading.textFactor,
minPx: reading.minFontSize,
unjustify: reading.overrideStyles,
sizesFromCss: reading.overrideStyles,
});
// Before the link handling: a Play button inside a link must not open it.
if (reading.pauseImages) {
pauseAnimatedImages(doc, { play: t('email_viewer.play_animation'), pause: t('email_viewer.pause_animation') });
}
if (isDark) applyEmailDarkMode(doc, { remoteContentAllowed: allowExternal });
const resize = () => {
// Fit desktop-width mail to the screen before measuring: the transform
// leaves the layout height untouched, so scale the box by hand.
// Phones always; any screen with "Fit wide mail" on.
const scale = fitEmailBodyWidth(doc, reading.fitWidth ? mailFitOptions(true, true) : {});
const height = Math.max(doc.documentElement.scrollHeight, doc.body.scrollHeight);
iframe.style.height = Math.ceil(height * scale) + 'px';
};
resize();
const ro = new ResizeObserver(resize);
ro.observe(doc.body);
doc.querySelectorAll('a, area').forEach((a) => {
a.setAttribute('target', '_blank');
a.setAttribute('rel', 'noopener noreferrer');
});
// The reader's link gate (email-viewer handleIframeLoad): web links go
// through onBeforeExternalLink, and nothing resolving inside our own
// origin (a relative path, data:) is opened. mailto: is written here
// when the mail page takes it; the blob: URLs of cid: parts are inert.
doc.addEventListener('click', (ev) => {
const link = (ev.target as Element | null)?.closest?.('a[href], area[href]');
if (!link) return;
const href = link.getAttribute('href') || '';
if (!href || href.startsWith('#') || href.startsWith('blob:')) return;
if (/^mailto:/i.test(href)) {
if (requestInternalMailto(href)) ev.preventDefault();
return;
}
ev.preventDefault();
ev.stopPropagation();
if (!isOpenableLinkHref(href)) return;
void resolveExternalLink({ href, target: link.getAttribute('target') ?? undefined, emailId: email.id })
.then((target) => { if (target) window.open(target, '_blank', 'noopener,noreferrer'); });
}, true);
// Selected text: the onTextSelectionChange hook and the right-click
// menu plugins may add entries to.
watchBodySelection(doc, iframe, {
emailId: email.id,
onContextMenu: (point, text) => selectionMenu.openRef.current(point, { emailId: email.id, text }),
});
setHtmlBodyEl(doc.body);
} catch {
// contentDocument may be inaccessible under stricter sandboxes; ignore.
}
}, [t, isDark, allowExternal, email.id, selectionMenu.openRef, reading.textFactor, reading.minFontSize, reading.overrideStyles, reading.fitWidth, reading.pauseImages]);
return (
<div className={className}>
{/* External content warning */}
{hasBlockedContent && !allowExternal && emailContent.isHtml && (
<div className="px-4 py-2 bg-muted/50 flex items-center justify-between text-sm" data-testid="external-content-notice">
<span className="text-muted-foreground">
{remoteBlockedByOrg ? t("third_party.remote_content_blocked") : t("email_viewer.external_content_warning")}
</span>
{!remoteBlockedByOrg && (
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation();
onAllowExternal();
}}
>
{t("email_viewer.load_external_content")}
</Button>
{onTrustSender && (
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation();
onTrustSender();
}}
>
{t("email_viewer.trust_sender")}
</Button>
)}
</div>
)}
</div>
)}
{verificationCode && (
<div className="flex" style={{ paddingInline: 'var(--density-card-p)', paddingTop: 'var(--density-card-p)' }}>
<VerificationCodeChip code={verificationCode} className="py-1 text-sm" />
</div>
)}
{/* Email Body */}
<div className="relative" style={bodyStyle ?? { padding: 'var(--density-card-p)' }}>
{emailContent.isHtml ? (
<iframe
ref={iframeRef}
srcDoc={emailIframeSrcDoc}
sandbox="allow-same-origin allow-popups allow-popups-to-escape-sandbox"
title={frameTitle}
className="w-full border-0 block"
scrolling="no"
style={{ minHeight: '60px' }}
onLoad={handleIframeLoad}
/>
) : (
<div
ref={setPlainBodyEl}
// Its own direction, as in the reader: English text in an Arabic
// UI read "1+" and ".change".
dir="auto"
lang={bodyLanguage ?? undefined}
className={cn(
"prose prose-sm max-w-none",
!emailAlwaysLightMode && "dark:prose-invert",
"prose-p:my-2 prose-headings:my-3",
"prose-a:text-primary prose-a:no-underline hover:prose-a:underline",
"[&_table]:border-collapse [&_td]:p-2 [&_th]:p-2",
"[&_img]:max-w-full [&_img]:h-auto"
)}
style={{ whiteSpace: 'pre-wrap', ...(plainTextFont === 'mono' && { fontFamily: 'ui-monospace, "SF Mono", Consolas, monospace' }), fontSize: reading.plainFontSize(13), ...(reading.comfortableWidth && { maxWidth: '80ch' }) }}
data-plugin-source="email-body"
data-email-id={email.id}
onContextMenu={(e) => selectionMenu.onElementContextMenu(e, email.id)}
dangerouslySetInnerHTML={plainBodyHtml}
/>
)}
<ReaderSelectionMenu menu={selectionMenu} />
<ReaderAboveQuoteSlot
root={emailContent.isHtml ? htmlBodyEl : plainBodyEl}
frame={emailContent.isHtml ? iframeRef.current : null}
extraProps={quoteSlotProps}
version={emailContent.html}
/>
</div>
{/* Attachments */}
{visibleAttachments.length > 0 && (
<div className="px-4 pb-4">
<div className="flex flex-wrap gap-2">
{visibleAttachments.map((attachment, idx) => {
const { icon: Icon, className: iconClass } = attachmentIcon(attachment.type, attachment.name);
const isPreviewable = isFilePreviewable(attachment.name, attachment.type)
|| (!!attachment.blobId && canWopiOpen(wopiStatus, attachment.name));
const opensPreview = isPreviewable && mailAttachmentAction === 'preview';
return (
<React.Fragment key={idx}>
<button
onClick={(e) => {
e.stopPropagation();
onDownloadAttachment?.(attachment.blobId, attachment.name || 'attachment', attachment.type, source);
}}
title={opensPreview ? t('files.preview') : t('email_viewer.download')}
className="flex items-center gap-2 px-3 py-2 rounded-lg bg-muted hover:bg-muted/80 transition-colors text-sm"
>
<Icon className={cn("w-4 h-4", iconClass)} />
{/* The action, for screen readers: the visible name alone
doesn't say whether the button previews or downloads. */}
<span className="sr-only">{opensPreview ? t('files.preview') : t('email_viewer.download')}</span>{' '}
<span className="truncate max-w-[150px]">{attachment.name ? stripBidiControls(attachment.name) : 'Attachment'}</span>
<span className="text-muted-foreground text-xs">
{formatFileSize(attachment.size)}
</span>
{opensPreview ? (
<Eye className="w-4 h-4 text-muted-foreground" />
) : (
<Download className="w-4 h-4 text-muted-foreground" />
)}
</button>
<AttachmentPluginMenu emailId={email.id} attachment={attachment} className="-ms-1.5 self-center p-2 rounded-lg" />
</React.Fragment>
);
})}
</div>
</div>
)}
</div>
);
}