"use client"; import { useState, useEffect, useMemo } from "react"; import DOMPurify from "dompurify"; import { Email, ThreadGroup } from "@/lib/jmap/types"; import { EMAIL_SANITIZE_CONFIG, collapseBlockedImageContainers, plainTextToSafeHtml } from "@/lib/email-sanitization"; import { hasMeaningfulHtmlBody } from "@/lib/signature-utils"; import { transformInlineStyles, transformColorForDarkMode, transformBgColorForDarkMode } from "@/lib/color-transform"; import { useThemeStore } from "@/stores/theme-store"; import { Avatar } from "@/components/ui/avatar"; import { Button } from "@/components/ui/button"; import { formatDate, formatFileSize, cn } from "@/lib/utils"; import { ArrowLeft, ChevronDown, ChevronUp, Reply, ReplyAll, Forward, Paperclip, Star, Download, Loader2, FileText, FileImage, FileVideo, FileAudio, FileArchive, File, Eye, } from "lucide-react"; import { useTranslations } from "next-intl"; import { useSettingsStore } from "@/stores/settings-store"; import { useContactStore } from "@/stores/contact-store"; import { useAuthStore } from "@/stores/auth-store"; import { isFilePreviewable } from "@/lib/file-preview"; interface ThreadConversationViewProps { thread: ThreadGroup; emails: Email[]; isLoading?: boolean; onBack: () => void; onReply?: (email: Email) => void; onReplyAll?: (email: Email) => void; onForward?: (email: Email) => void; onDownloadAttachment?: (blobId: string, name: string, type?: string) => void; onMarkAsRead?: (emailId: string, read: boolean) => void; } // 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; }; export function ThreadConversationView({ thread, emails, isLoading = false, onBack, onReply, onReplyAll, onForward, onDownloadAttachment, onMarkAsRead, }: ThreadConversationViewProps) { const t = useTranslations(); 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(); // Track which emails are expanded (most recent by default) const [expandedIds, setExpandedIds] = useState>(new Set()); const [allowExternalContent, setAllowExternalContent] = 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) => { setExpandedIds(prev => { const next = new Set(prev); if (next.has(emailId)) { next.delete(emailId); } else { next.add(emailId); } return next; }); }; const toggleAllowExternal = (emailId: string) => { setAllowExternalContent(prev => { const next = new Set(prev); 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) => { const senderEmail = email.from?.[0]?.email?.toLowerCase(); const senderIsTrusted = senderEmail ? isSenderTrusted(senderEmail) || (trustedSendersAddressBook && isTrustedAddressBookSender(senderEmail)) : false; return ( toggleExpanded(email.id)} onAllowExternal={() => toggleAllowExternal(email.id)} onTrustSender={senderEmail ? () => { if (trustedSendersAddressBook && client) { addToTrustedSendersBook(client, senderEmail).catch(console.error); } else { addTrustedSender(senderEmail); } toggleAllowExternal(email.id); } : undefined} onReply={onReply ? () => onReply(email) : undefined} onReplyAll={onReplyAll ? () => onReplyAll(email) : undefined} onForward={onForward ? () => onForward(email) : undefined} onDownloadAttachment={onDownloadAttachment} onMarkAsRead={onMarkAsRead} /> ); })}
); } // 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?: (blobId: string, name: string, type?: string) => void; onMarkAsRead?: (emailId: string, read: boolean) => void; } function EmailCard({ email, isExpanded, isLatest: _isLatest, allowExternal, onToggleExpanded, onAllowExternal, onTrustSender, onReply, onReplyAll, onForward, onDownloadAttachment, onMarkAsRead, }: EmailCardProps) { const t = useTranslations(); const resolvedTheme = useThemeStore((state) => state.resolvedTheme); const density = useSettingsStore((state) => state.density); const mailAttachmentAction = useSettingsStore((state) => state.mailAttachmentAction); const hideInlineImageAttachments = useSettingsStore((state) => state.hideInlineImageAttachments); const emailAlwaysLightMode = useSettingsStore((state) => state.emailAlwaysLightMode); const sender = email.from?.[0]; const isUnread = !email.keywords?.$seen; const isStarred = email.keywords?.$flagged; const [hasBlockedContent, setHasBlockedContent] = useState(false); const [cidBlobUrls, setCidBlobUrls] = useState>({}); const { client } = useAuthStore(); // Mark as read when email is expanded useEffect(() => { // Only trigger if expanded, email is unread, and we have a handler if (!isExpanded || !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); }, [isExpanded, email.id, email.keywords?.$seen, onMarkAsRead]); // Fetch inline CID images with authentication to prevent browser auth dialogs useEffect(() => { if (!client || !email?.attachments) { setCidBlobUrls({}); return; } const cidAttachments = email.attachments.filter(att => att.cid && att.blobId); if (cidAttachments.length === 0) { setCidBlobUrls({}); return; } let cancelled = false; const objectUrls: string[] = []; async function fetchCidBlobs() { const urls: Record = {}; await Promise.all(cidAttachments.map(async (att) => { const cidValue = att.cid!.replace(/^<|>$/g, ''); try { const objectUrl = await client!.fetchBlobAsObjectUrl(att.blobId, att.name || 'inline', att.type); if (!cancelled) { urls[cidValue] = objectUrl; objectUrls.push(objectUrl); } else { URL.revokeObjectURL(objectUrl); } } catch { // Failed to fetch inline image, will show placeholder } })); if (!cancelled) { setCidBlobUrls(urls); } } fetchCidBlobs(); return () => { cancelled = true; objectUrls.forEach(url => URL.revokeObjectURL(url)); }; }, [client, email?.id, email?.attachments]); // Sanitize and prepare email HTML content const emailContent = useMemo(() => { if (!email) return { html: "", isHtml: false }; if (email.bodyValues) { let useHtmlVersion = false; let htmlContent = ''; if (email.htmlBody?.[0]?.partId && email.bodyValues[email.htmlBody[0].partId]) { htmlContent = email.bodyValues[email.htmlBody[0].partId].value; // Prefer textBody when HTML is auto-generated minimal wrapper (no rich formatting). // Server-generated HTML from text/plain emails often lacks
tags, collapsing newlines. // Per RFC 8621, an HTML-only email exposes the same partId in both htmlBody and textBody - // in that case there is no real plain-text alternative, so always render the HTML. const textPartId = email.textBody?.[0]?.partId; const htmlPartId = email.htmlBody[0].partId; const hasDistinctTextBody = !!textPartId && textPartId !== htmlPartId && !!email.bodyValues[textPartId]; if (hasDistinctTextBody && htmlContent) { useHtmlVersion = hasMeaningfulHtmlBody(htmlContent); } else { useHtmlVersion = !!htmlContent; } } if (useHtmlVersion && 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 = htmlContent.replace( /\bcid:([^"'\s)]+)/gi, (_match, cidRef) => { return cidBlobUrls[cidRef] || 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'; } ); } let blockedExternalContent = false; // Use shared sanitization config as base (more secure) const sanitizeConfig = { ...EMAIL_SANITIZE_CONFIG }; DOMPurify.addHook('afterSanitizeAttributes', (node) => { const htmlNode = node as HTMLElement; if (!allowExternal) { if (node.tagName === 'IMG') { const src = node.getAttribute('src'); if (src && (src.startsWith('http://') || src.startsWith('https://') || src.startsWith('//'))) { node.setAttribute('data-blocked-src', src); node.removeAttribute('src'); node.setAttribute('alt', '[Image blocked]'); blockedExternalContent = true; } } if (node.hasAttribute('style')) { const style = node.getAttribute('style'); if (style && /url\s*\(/i.test(style)) { const cleanStyle = style.replace(/url\s*\([^)]*\)/gi, 'none'); node.setAttribute('style', cleanStyle); blockedExternalContent = true; } } } if (node.tagName === 'A') { node.setAttribute('target', '_blank'); node.setAttribute('rel', 'noopener noreferrer'); } if (resolvedTheme === 'dark' && !emailAlwaysLightMode) { if (htmlNode.style) { const originalStyles = htmlNode.style.cssText; const transformedStyles = transformInlineStyles(originalStyles, 'dark'); if (transformedStyles !== originalStyles) { htmlNode.style.cssText = transformedStyles; } } const colorAttr = node.getAttribute('color'); if (colorAttr) { node.setAttribute('color', transformColorForDarkMode(colorAttr)); } const bgcolorAttr = node.getAttribute('bgcolor'); if (bgcolorAttr) { node.setAttribute('bgcolor', transformBgColorForDarkMode(bgcolorAttr)); } } }); const sanitized = DOMPurify.sanitize(htmlContent, sanitizeConfig); DOMPurify.removeHook('afterSanitizeAttributes'); let finalHtml = sanitized; if (blockedExternalContent) { 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 { html: plainTextToSafeHtml(text, 'text-primary hover:underline'), 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 }; }, [email, allowExternal, resolvedTheme, emailAlwaysLightMode, cidBlobUrls]); return (
{/* Card Header - Always visible */} {/* Expanded Content */} {isExpanded && (
{/* External content warning */} {hasBlockedContent && !allowExternal && (
{t("email_viewer.external_content_warning")}
{onTrustSender && ( )}
)} {/* Email Body */}
{/* Attachments */} {(() => { const visibleAttachments = (email.attachments ?? []).filter( att => !(hideInlineImageAttachments && att.cid && att.disposition === 'inline' && (att.type || '').startsWith('image/')) ); return visibleAttachments.length > 0 && (
{visibleAttachments.map((attachment, idx) => { const Icon = getFileIcon(attachment.name, attachment.type); const isPreviewable = isFilePreviewable(attachment.name, attachment.type); const opensPreview = isPreviewable && mailAttachmentAction === 'preview'; return ( ); })}
); })()} {/* Action Buttons */}
{onReply && ( )} {onReplyAll && ( )} {onForward && ( )}
)}
); }