"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 { collectReferencedCids, isEmbeddedInBody, normalizeCid, rewriteCidReferences } from "@/lib/attachment-visibility"; import { collapsePlainTextQuotes, setupQuoteCollapse } from "@/lib/quote-collapse"; import { fitEmailBodyWidth } from "@/lib/email-fit-width"; 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 { Button } from "@/components/ui/button"; import { formatDate, formatFileSize, cn, cleanPreview } from "@/lib/utils"; import { ArrowLeft, ChevronDown, ChevronUp, Reply, ReplyAll, Forward, Paperclip, Star, Download, Loader2, FileText, FileImage, FileVideo, FileAudio, FileArchive, File, Eye, } from "@/components/icons"; 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 } 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"; 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; } // Helper function to get file icon based on mime type or extension const getFileIcon = (name?: string, type?: string) => { const ext = name?.split('.').pop()?.toLowerCase(); const mimeType = type?.toLowerCase(); if (mimeType?.startsWith('image/') || ['jpg', 'jpeg', 'png', 'gif', 'svg', 'webp'].includes(ext || '')) { return FileImage; } if (mimeType?.startsWith('video/') || ['mp4', 'avi', 'mov', 'wmv'].includes(ext || '')) { return FileVideo; } if (mimeType?.startsWith('audio/') || ['mp3', 'wav', 'ogg', 'flac'].includes(ext || '')) { return FileAudio; } if (mimeType === 'application/pdf' || ext === 'pdf') { return FileText; } if (['zip', 'rar', '7z', 'tar', 'gz'].includes(ext || '')) { return FileArchive; } return File; }; /** * 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>(new Set()); 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; return externalContentPolicy === 'allow' || !!senderIsTrusted || allowedIds.has(email.id); }; const trustSender = (email: Email) => { const senderEmail = email.from?.[0]?.email?.toLowerCase(); if (!senderEmail) 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, }: 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>(new Set()); // Auto-expand most recent email AND all unread emails when thread opens useEffect(() => { if (emails.length > 0) { const idsToExpand = new Set(); // 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) { return (

{t("threads.loading")}

); } return (
{/* Header */}

{thread.latestEmail.subject || t("email_viewer.no_subject")}

{t("threads.messages_other", { count: emails.length })}

{/* Email Cards */}
{emails.map((email, index) => ( 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} /> ))}
); } // 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]); return { emailProps, idProps }; } 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 plugin = useConversationPluginProps(email); const sender = email.from?.[0]; 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 (
{isExpanded && } {/* Card Header - Always visible */} {/* Expanded Content */} {isExpanded && (
{/* Action Buttons; plugin reader actions behind a "more" button, as in the single reader's More panel on phones. */}
{onReply && ( )} {onReplyAll && ( )} {onForward && ( )}
)}
); } 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 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 isDark = resolvedTheme === 'dark' && !emailAlwaysLightMode; const plainTextFont = useSettingsStore((state) => state.plainTextFont); 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 } | 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(null); const [plainBodyEl, setPlainBodyEl] = useState(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 = {}; 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 emailContent = 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, , 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
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, '&') .replace(//g, '>'); 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]); // 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 is defense-in-depth in case the sanitizer ever // emits a