"use client"; import { useState, useEffect, useMemo, useRef, useCallback } from "react"; import DOMPurify from "dompurify"; import { Email, ContactCard, Mailbox } from "@/lib/jmap/types"; import { EMAIL_SANITIZE_CONFIG, collapseBlockedImageContainers } from "@/lib/email-sanitization"; import { Button } from "@/components/ui/button"; import { Avatar } from "@/components/ui/avatar"; import { formatFileSize, cn, buildMailboxTree, MailboxNode } from "@/lib/utils"; import { getSecurityStatus, extractListHeaders } from "@/lib/email-headers"; import { Reply, ReplyAll, Forward, Trash2, Archive, Star, MoreVertical, ChevronDown, ChevronUp, ChevronLeft, ChevronRight, Download, Mail, Clock, Loader2, Printer, FileText, FileImage, FileVideo, FileAudio, FileArchive, File, Shield, Image, Tag, X, Check, AlertTriangle, Minus, ShieldCheck, ShieldAlert, Network, Hash, List, Code, Copy, Brain, Sparkles, Keyboard, Phone, Building, MapPin, StickyNote, PanelRightClose, Send, FolderInput, Inbox, Folder, Sun, Moon, } from "lucide-react"; import { useTranslations } from "next-intl"; import { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store"; import { useUIStore } from "@/stores/ui-store"; import { useContactStore, getContactDisplayName, getContactPrimaryEmail } from "@/stores/contact-store"; import { toast } from "@/stores/toast-store"; import { useDeviceDetection } from "@/hooks/use-media-query"; import { useAuthStore } from "@/stores/auth-store"; import { useThemeStore } from "@/stores/theme-store"; import { EmailIdentityBadge } from "./email-identity-badge"; import { UnsubscribeBanner } from "./unsubscribe-banner"; import { CalendarInvitationBanner } from "./calendar-invitation-banner"; import { findCalendarAttachment } from "@/lib/calendar-invitation"; import { RecipientPopover } from "./recipient-popover"; interface EmailViewerProps { email: Email | null; isLoading?: boolean; onReply?: (draftText?: string) => void; onReplyAll?: () => void; onForward?: () => void; onDelete?: () => void; onArchive?: () => void; onToggleStar?: () => void; onMarkAsRead?: (emailId: string, read: boolean) => void; onSetColorTag?: (emailId: string, color: string | null) => void; onDownloadAttachment?: (blobId: string, name: string, type?: string) => void; onQuickReply?: (body: string) => Promise; onMarkAsSpam?: () => void; onUndoSpam?: () => void; onMoveToMailbox?: (mailboxId: string) => void; onBack?: () => void; onNavigateNext?: () => void; onNavigatePrev?: () => void; onShowShortcuts?: () => void; currentUserEmail?: string; currentUserName?: string; currentMailboxRole?: string; mailboxes?: Mailbox[]; selectedMailbox?: string; className?: string; } // 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; } if (['doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx'].includes(ext || '')) { return FileText; } return File; }; const getCurrentColor = (keywords: Record | undefined) => { if (!keywords) return null; for (const key of Object.keys(keywords)) { if ((key.startsWith("$label:") || key.startsWith("$color:")) && keywords[key] === true) { return key.startsWith("$label:") ? key.slice("$label:".length) : key.slice("$color:".length); } } return null; }; // Helper function to format recipients with contextual display const formatRecipients = ( recipients: Array<{ name?: string; email: string }> | undefined, currentUserEmail: string | undefined, t: (key: string, params?: Record) => string ): string => { if (!recipients || recipients.length === 0) return ''; // Check if the first recipient is the current user const firstRecipient = recipients[0]; const isFirstRecipientMe = currentUserEmail && (firstRecipient.email.toLowerCase() === currentUserEmail.toLowerCase() || firstRecipient.email.toLowerCase().startsWith(currentUserEmail.toLowerCase().split('@')[0] + '+')); // If only one recipient and it's the current user, show "me" if (recipients.length === 1 && isFirstRecipientMe) { return t('recipient_me'); } // Format up to 2 recipients by name (or email if no name) const displayRecipients = recipients.slice(0, 2).map((r, index) => { if (index === 0 && isFirstRecipientMe) { return t('recipient_me'); } return r.name || r.email; }); // If more than 2 recipients, add count if (recipients.length > 2) { const displayName = displayRecipients[0]; return t('recipient_and_others', { name: displayName, count: recipients.length - 1 }); } return displayRecipients.join(', '); }; // Helper to render clickable recipient elements with popovers function renderClickableRecipients( recipients: Array<{ name?: string; email: string }>, currentUserEmail: string | undefined, t: (key: string, params?: Record) => string, onViewContact?: (contact: ContactCard | null, email: string) => void, maxVisible: number = 2 ) { const visible = recipients.slice(0, maxVisible); return visible.map((r, index) => { const isMe = currentUserEmail && (r.email.toLowerCase() === currentUserEmail.toLowerCase() || r.email.toLowerCase().startsWith(currentUserEmail.toLowerCase().split('@')[0] + '+')); return ( {index > 0 && ,} ); }); } // Contact sidebar panel that slides in from the right on desktop function ContactSidebarPanel({ email, contact, onClose, }: { email: string; contact: ContactCard | null; onClose: () => void; }) { const name = contact ? getContactDisplayName(contact) : null; const primaryEmail = contact ? getContactPrimaryEmail(contact) : email; const emails = contact?.emails ? Object.values(contact.emails) : []; const phones = contact?.phones ? Object.values(contact.phones) : []; const orgs = contact?.organizations ? Object.values(contact.organizations) : []; const addresses = contact?.addresses ? Object.values(contact.addresses) : []; const notes = contact?.notes ? Object.values(contact.notes) : []; const handleCopy = async (text: string) => { try { await navigator.clipboard.writeText(text); toast.success("Copied!"); } catch { toast.error("Failed to copy"); } }; return (
{/* Header */}

Contact

{/* Content */}
{/* Profile section */}
{name || email}
{name && (
{primaryEmail}
)} {orgs.length > 0 && orgs[0].name && (
{orgs[0].name}
)}
{/* Quick actions */}
Email
{/* Details sections */} {contact && (
{/* Emails */} {emails.length > 0 && ( {emails.map((e, i) => (
{e.address}
))}
)} {/* Phones */} {phones.length > 0 && ( {phones.map((p, i) => (
{p.number}
))}
)} {/* Organizations */} {orgs.length > 1 && ( {orgs.map((o, i) => (
{o.name} {o.units && o.units.length > 0 && ( — {o.units.map(u => u.name).join(", ")} )}
))}
)} {/* Addresses */} {addresses.length > 0 && ( {addresses.map((a, i) => (
{[a.street, a.locality, a.region, a.postcode, a.country].filter(Boolean).join(", ")}
))}
)} {/* Notes */} {notes.length > 0 && ( {notes.map((n, i) => (

{n.note}

))}
)}
)} {/* No contact found message */} {!contact && (

Not in your contacts

)}
); } function SidebarSection({ icon: Icon, title, children }: { icon: React.ComponentType<{ className?: string }>; title: string; children: React.ReactNode }) { return (

{title}

{children}
); } export function EmailViewer({ email, isLoading = false, onReply, onReplyAll, onForward, onDelete, onArchive, onToggleStar, onMarkAsRead, onSetColorTag, onDownloadAttachment, onQuickReply, onMarkAsSpam, onUndoSpam, onMoveToMailbox, onBack, onNavigateNext, onNavigatePrev, onShowShortcuts, currentUserEmail, currentUserName, currentMailboxRole, mailboxes = [], selectedMailbox = "", className, }: EmailViewerProps) { const t = useTranslations('email_viewer'); const tNotifications = useTranslations('notifications'); const tCommon = useTranslations('common'); const externalContentPolicy = useSettingsStore((state) => state.externalContentPolicy); const addTrustedSender = useSettingsStore((state) => state.addTrustedSender); const isSenderTrusted = useSettingsStore((state) => state.isSenderTrusted); const emailKeywords = useSettingsStore((state) => state.emailKeywords); const toolbarPosition = useSettingsStore((state) => state.toolbarPosition); // Detect if current mailbox is Junk folder const isInJunkFolder = currentMailboxRole === 'junk'; // Color options for email tags (from user-defined keyword settings) const colorOptions = emailKeywords.map((kw) => ({ name: kw.label, value: kw.id, color: KEYWORD_PALETTE[kw.color]?.dot || 'bg-gray-500', })); // Tablet list visibility const { isTablet, isMobile } = useDeviceDetection(); const { tabletListVisible } = useUIStore(); const { identities, client } = useAuthStore(); const resolvedTheme = useThemeStore((state) => state.resolvedTheme); const [showFullHeaders, setShowFullHeaders] = useState(false); const [allowExternalContent, setAllowExternalContent] = useState(false); const [hasBlockedContent, setHasBlockedContent] = useState(false); const [cidBlobUrls, setCidBlobUrls] = useState>({}); const [quickReplyText, setQuickReplyText] = useState(""); const [isQuickReplyFocused, setIsQuickReplyFocused] = useState(false); const [isSendingQuickReply, setIsSendingQuickReply] = useState(false); const [showSourceModal, setShowSourceModal] = useState(false); const [moreMenuOpen, setMoreMenuOpen] = useState(false); const [tagMenuOpen, setTagMenuOpen] = useState(false); const [moveMenuOpen, setMoveMenuOpen] = useState(false); const moreMenuRef = useRef(null); const tagMenuRef = useRef(null); const moveMenuRef = useRef(null); const toolbarRef = useRef(null); const [overflowCount, setOverflowCount] = useState(0); const currentColor = getCurrentColor(email?.keywords); // Build mailbox tree for move-to dropdown const moveTargetIds = useMemo(() => new Set( mailboxes .filter( (m) => m.id !== selectedMailbox && m.role !== "drafts" && !m.id.startsWith("shared-") && m.myRights?.mayAddItems ) .map((m) => m.id) ), [mailboxes, selectedMailbox]); const moveTree = useMemo(() => { const tree = buildMailboxTree(mailboxes); const filterTree = (nodes: MailboxNode[]): MailboxNode[] => { return nodes.reduce((acc, node) => { const filteredChildren = filterTree(node.children); if (moveTargetIds.has(node.id) || filteredChildren.length > 0) { acc.push({ ...node, children: filteredChildren }); } return acc; }, []); }; return filterTree(tree); }, [mailboxes, moveTargetIds]); // Get mailbox icon based on role const getMoveMailboxIcon = (role?: string) => { switch (role) { case "inbox": return Inbox; case "sent": return Send; case "drafts": return File; case "trash": return Trash2; case "archive": return Archive; default: return Folder; } }; // Close dropdown menus on click outside useEffect(() => { if (!moreMenuOpen && !tagMenuOpen && !moveMenuOpen) return; function handleClickOutside(e: MouseEvent) { if (moreMenuOpen && moreMenuRef.current && !moreMenuRef.current.contains(e.target as Node)) { setMoreMenuOpen(false); } if (tagMenuOpen && tagMenuRef.current && !tagMenuRef.current.contains(e.target as Node)) { setTagMenuOpen(false); } if (moveMenuOpen && moveMenuRef.current && !moveMenuRef.current.contains(e.target as Node)) { setMoveMenuOpen(false); } } document.addEventListener('mousedown', handleClickOutside); return () => document.removeEventListener('mousedown', handleClickOutside); }, [moreMenuOpen, tagMenuOpen, moveMenuOpen]); // Close dropdowns when email changes useEffect(() => { setMoreMenuOpen(false); setTagMenuOpen(false); setMoveMenuOpen(false); }, [email?.id]); // Dynamically detect which toolbar items overflow and should move to the More menu useEffect(() => { const el = toolbarRef.current; if (!el) return; const calculate = () => { const items = Array.from(el.querySelectorAll('[data-overflow-item]')); if (items.length === 0) return; // Sort descending by priority so highest number (least important) is first items.sort((a, b) => Number(b.dataset.overflowPriority || 0) - Number(a.dataset.overflowPriority || 0) ); // Show all items to measure their natural widths items.forEach(item => { item.style.display = ''; }); const containerWidth = el.clientWidth; const leftGroup = el.firstElementChild as HTMLElement; const rightGroup = el.lastElementChild as HTMLElement; const mainGap = parseFloat(getComputedStyle(el).gap) || 0; // Iteratively hide items until content fits let count = 0; const isOverflowing = () => leftGroup.scrollWidth + rightGroup.scrollWidth + mainGap > containerWidth + 1; for (const item of items) { if (!isOverflowing()) break; // Skip items already hidden by CSS (e.g., on mobile) if (item.offsetWidth === 0) continue; item.style.display = 'none'; count++; } setOverflowCount(prev => prev === count ? prev : count); }; const observer = new ResizeObserver(calculate); observer.observe(el); return () => observer.disconnect(); }, [toolbarPosition]); // Contact sidebar state const [contactSidebarEmail, setContactSidebarEmail] = useState(null); const contacts = useContactStore((s) => s.contacts); const { isMobile: isMobileDevice } = useDeviceDetection(); const handleViewContactSidebar = (contact: ContactCard | null, recipientEmail: string) => { if (isMobileDevice) return; // no sidebar on mobile setContactSidebarEmail(recipientEmail); }; const sidebarContact = contactSidebarEmail ? contacts.find((c) => { if (!c.emails) return false; return Object.values(c.emails).some( (e) => e.address.toLowerCase() === contactSidebarEmail.toLowerCase() ); }) ?? null : null; // Close contact sidebar when email changes useEffect(() => { setContactSidebarEmail(null); }, [email?.id]); const [dismissedUnsubBanners, setDismissedUnsubBanners] = useState>( () => { if (typeof window === 'undefined') return new Set(); const saved = localStorage.getItem('dismissed-unsub-banners'); return saved ? new Set(JSON.parse(saved)) : new Set(); } ); useEffect(() => { // Mark as read when email is viewed if (email && !email.keywords?.$seen && onMarkAsRead) { onMarkAsRead(email.id, true); } // eslint-disable-next-line react-hooks/exhaustive-deps -- email?.id changes when email changes, which is the intended trigger }, [email?.id, email?.keywords?.$seen, onMarkAsRead]); // Reset external content permission and quick reply when email changes // Initialize allowExternalContent based on externalContentPolicy setting useEffect(() => { // 'allow' = always allow, 'block' = always block, 'ask' = user decides per email setAllowExternalContent(externalContentPolicy === 'allow'); setHasBlockedContent(false); setQuickReplyText(""); setIsQuickReplyFocused(false); setShowSourceModal(false); setEmailViewDarkOverride(null); }, [email?.id, externalContentPolicy]); // 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]); // Generate email source for viewing const generateEmailSource = (email: Email): string => { let source = ''; // Headers source += '=== EMAIL HEADERS ===\n\n'; if (email.messageId) source += `Message-ID: ${email.messageId}\n`; if (email.from) source += `From: ${email.from.map(a => a.name ? `${a.name} <${a.email}>` : a.email).join(', ')}\n`; if (email.to) source += `To: ${email.to.map(a => a.name ? `${a.name} <${a.email}>` : a.email).join(', ')}\n`; if (email.cc) source += `Cc: ${email.cc.map(a => a.name ? `${a.name} <${a.email}>` : a.email).join(', ')}\n`; if (email.bcc) source += `Bcc: ${email.bcc.map(a => a.name ? `${a.name} <${a.email}>` : a.email).join(', ')}\n`; if (email.replyTo) source += `Reply-To: ${email.replyTo.map(a => a.name ? `${a.name} <${a.email}>` : a.email).join(', ')}\n`; if (email.subject) source += `Subject: ${email.subject}\n`; if (email.sentAt) source += `Date: ${new Date(email.sentAt).toUTCString()}\n`; if (email.receivedAt) source += `Received-At: ${new Date(email.receivedAt).toUTCString()}\n`; if (email.inReplyTo) source += `In-Reply-To: ${email.inReplyTo.join(', ')}\n`; if (email.references) source += `References: ${email.references.join(', ')}\n`; // Additional headers if (email.headers) { source += '\n--- Additional Headers ---\n'; // Headers should now always be a Record after client processing Object.entries(email.headers).forEach(([key, value]) => { const val = Array.isArray(value) ? value.join('\n ') : String(value); source += `${key}: ${val}\n`; }); } // Authentication results if (email.authenticationResults) { source += '\n--- Authentication Results ---\n'; if (email.authenticationResults.spf) { source += `SPF: ${email.authenticationResults.spf.result}`; if (email.authenticationResults.spf.domain) source += ` (${email.authenticationResults.spf.domain})`; source += '\n'; } if (email.authenticationResults.dkim) { source += `DKIM: ${email.authenticationResults.dkim.result}`; if (email.authenticationResults.dkim.domain) source += ` (${email.authenticationResults.dkim.domain})`; source += '\n'; } if (email.authenticationResults.dmarc) { source += `DMARC: ${email.authenticationResults.dmarc.result}`; if (email.authenticationResults.dmarc.policy) source += ` policy=${email.authenticationResults.dmarc.policy}`; source += '\n'; } } if (email.spamScore !== undefined) { source += `Spam Score: ${email.spamScore}`; if (email.spamStatus) source += ` (${email.spamStatus})`; source += '\n'; } // Metadata source += '\n=== EMAIL METADATA ===\n\n'; source += `Email ID: ${email.id}\n`; source += `Thread ID: ${email.threadId}\n`; source += `Size: ${formatFileSize(email.size)}\n`; source += `Has Attachment: ${email.hasAttachment ? 'Yes' : 'No'}\n`; if (email.keywords) { const keywords = Object.entries(email.keywords) .filter(([_, v]) => v) .map(([k]) => k) .join(', '); if (keywords) source += `Keywords: ${keywords}\n`; } // Attachments if (email.attachments && email.attachments.length > 0) { source += '\n=== ATTACHMENTS ===\n\n'; email.attachments.forEach((att, i) => { source += `[${i + 1}] ${att.name || 'Unnamed'}\n`; source += ` Type: ${att.type}\n`; source += ` Size: ${formatFileSize(att.size)}\n`; source += ` Blob ID: ${att.blobId}\n`; if (att.cid) source += ` Content-ID: ${att.cid}\n`; source += '\n'; }); } // Body content source += '\n=== EMAIL BODY ===\n\n'; let hasBodyContent = false; // Text version if (email.textBody?.[0]?.partId && email.bodyValues?.[email.textBody[0].partId]) { const textValue = email.bodyValues[email.textBody[0].partId].value; if (textValue && textValue.trim()) { source += '--- Plain Text Version ---\n\n'; source += textValue; source += '\n\n'; hasBodyContent = true; } } // HTML version if (email.htmlBody?.[0]?.partId && email.bodyValues?.[email.htmlBody[0].partId]) { const htmlValue = email.bodyValues[email.htmlBody[0].partId].value; if (htmlValue && htmlValue.trim()) { source += '--- HTML Version ---\n\n'; source += htmlValue; source += '\n\n'; hasBodyContent = true; } } // All body values if we haven't found content yet if (!hasBodyContent && email.bodyValues) { const bodyKeys = Object.keys(email.bodyValues); if (bodyKeys.length > 0) { source += '--- Body Parts ---\n\n'; bodyKeys.forEach((key, index) => { const bodyValue = email.bodyValues![key].value; if (bodyValue && bodyValue.trim()) { source += `Part ${index + 1} (${key}):\n`; source += bodyValue; source += '\n\n'; hasBodyContent = true; } }); } } // Preview if no body if (!hasBodyContent && email.preview) { source += '--- Preview Only ---\n\n'; source += email.preview; source += '\n'; } if (!hasBodyContent && !email.preview) { source += '(No body content available)\n'; } return source; }; const copySourceToClipboard = async () => { if (!email) return; try { const source = generateEmailSource(email); await navigator.clipboard.writeText(source); // Could add a toast notification here console.log(tNotifications('source_copied')); } catch (err) { console.error('Failed to copy source:', err); } }; // Sanitize and prepare email HTML content const emailContent = useMemo(() => { if (!email) return { html: "", isHtml: false }; // Check if we have body values if (email.bodyValues) { // Check if HTML content exists and if it's actually rich HTML or just plain text wrapper let useHtmlVersion = false; let htmlContent = ''; if (email.htmlBody?.[0]?.partId && email.bodyValues[email.htmlBody[0].partId]) { htmlContent = email.bodyValues[email.htmlBody[0].partId].value; useHtmlVersion = !!htmlContent; } // If we should use HTML version and it exists 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'; } ); } // Create a custom DOMPurify hook to handle external content let blockedExternalContent = false; // Use shared sanitization config as base (more secure) const sanitizeConfig = { ...EMAIL_SANITIZE_CONFIG }; // Check if sender is trusted const senderEmail = email.from?.[0]?.email?.toLowerCase(); const senderIsTrusted = senderEmail ? isSenderTrusted(senderEmail) : false; // Block external content based on policy: // 'allow' = never block, 'block' = always block (unless trusted), 'ask' = block until user allows or trusted const shouldBlockExternal = !senderIsTrusted && ( externalContentPolicy === 'block' || (externalContentPolicy === 'ask' && !allowExternalContent) ); if (shouldBlockExternal) { sanitizeConfig.FORBID_TAGS.push('link'); sanitizeConfig.FORBID_ATTR.push('background'); } DOMPurify.addHook('afterSanitizeAttributes', (node) => { const htmlNode = node as HTMLElement; if (shouldBlockExternal) { 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.setAttribute('src', 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMSIgaGVpZ2h0PSIxIiB2aWV3Qm94PSIwIDAgMSAxIiBmaWxsPSJub25lIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPgo8cmVjdCB3aWR0aD0iMSIgaGVpZ2h0PSIxIiBmaWxsPSJ0cmFuc3BhcmVudCIvPgo8L3N2Zz4='); node.setAttribute('alt', ''); htmlNode.style.display = 'none'; blockedExternalContent = true; } } if (htmlNode.style) { const style = htmlNode.style.cssText; if (style && style.includes('url(')) { const urlMatch = style.match(/url\(['"]?(https?:\/\/[^'")\s]+)['"]?\)/gi); if (urlMatch) { htmlNode.style.cssText = style.replace(/url\(['"]?https?:\/\/[^'")\s]+['"]?\)/gi, 'url()'); blockedExternalContent = true; } } } } if (node.tagName === 'A') { node.setAttribute('target', '_blank'); node.setAttribute('rel', 'noopener noreferrer'); } // No dark mode color transforms - emails render true-to-life in iframe }); // Sanitize HTML to prevent XSS let cleanHtml = DOMPurify.sanitize(htmlContent, sanitizeConfig); // Remove the hook after sanitization DOMPurify.removeAllHooks(); // Collapse empty containers left behind by blocked images if (shouldBlockExternal && blockedExternalContent) { cleanHtml = collapseBlockedImageContainers(cleanHtml); } // Update blocked content state if (blockedExternalContent && !hasBlockedContent) { setHasBlockedContent(true); } return { html: cleanHtml, isHtml: true }; } // Use text content if available (either as fallback or when HTML is minimal) if (email.textBody?.[0]?.partId && email.bodyValues[email.textBody[0].partId]) { const textContent = email.bodyValues[email.textBody[0].partId].value; // Convert plain text to HTML with proper formatting const htmlFromText = textContent .replace(/&/g, '&') .replace(//g, '>') .replace(/\r\n/g, '
') // Windows line endings .replace(/\r/g, '
') // Old Mac line endings .replace(/\n/g, '
') // Unix line endings .replace(/\t/g, '    ') // Convert tabs to spaces .replace(/(https?:\/\/[^\s<]+)/g, '$1'); return { html: htmlFromText, isHtml: false }; } } // If no body content is available, show the preview or a message if (email.preview) { const previewHtml = email.preview .replace(/&/g, '&') .replace(//g, '>') .replace(/\r\n/g, '
') .replace(/\r/g, '
') .replace(/\n/g, '
'); return { html: `
${previewHtml}
`, isHtml: false }; } return { html: '

No content available

', isHtml: false }; }, [email, allowExternalContent, hasBlockedContent, externalContentPolicy, isSenderTrusted, cidBlobUrls]); // Iframe for rendering HTML emails true-to-life const iframeRef = useRef(null); // Detect if the email HTML has native dark mode support const emailHasNativeDarkMode = useMemo(() => { if (!emailContent.isHtml) return false; return /prefers-color-scheme\s*:\s*dark/i.test(emailContent.html); }, [emailContent.html, emailContent.isHtml]); const [emailViewDarkOverride, setEmailViewDarkOverride] = useState(null); const isDark = emailViewDarkOverride !== null ? emailViewDarkOverride : resolvedTheme === 'dark'; const emailIframeSrcDoc = useMemo(() => { if (!emailContent.isHtml) return ''; // If email has native dark mode, let it handle its own theming // Otherwise, use CSS filter inversion for dark mode (preserves layout) const darkModeCSS = isDark && !emailHasNativeDarkMode ? ` html { background: #1a1a1a; } body { filter: invert(1) hue-rotate(180deg); } img, video, picture, svg, canvas, object, embed, [style*="background-image"], [style*="background:"], [background], [bgcolor], td[background], table[background], img[src], input[type="image"] { filter: invert(1) hue-rotate(180deg); } ` : ''; const colorScheme = isDark && emailHasNativeDarkMode ? 'light dark' : 'light'; return ` ${emailContent.html}`; }, [emailContent.html, emailContent.isHtml, isDark, emailHasNativeDarkMode]); const handleIframeLoad = useCallback(() => { const iframe = iframeRef.current; if (!iframe) return; try { const doc = iframe.contentDocument; if (doc?.body) { // Auto-resize iframe to fit content const resizeObserver = new ResizeObserver(() => { const height = doc.documentElement.scrollHeight; iframe.style.height = height + 'px'; }); resizeObserver.observe(doc.body); iframe.style.height = doc.documentElement.scrollHeight + 'px'; // Make links open in new tab doc.querySelectorAll('a').forEach(a => { a.setAttribute('target', '_blank'); a.setAttribute('rel', 'noopener noreferrer'); }); } } catch { // Cross-origin restrictions - iframe will still display content } }, []); // Print only the email content in a new window const handlePrint = () => { if (!email) return; const printSender = email.from?.[0]; const date = email.sentAt ? new Date(email.sentAt).toLocaleString() : ''; const toList = email.to?.map(r => r.name ? `${r.name} <${r.email}>` : r.email).join(', ') || ''; const ccList = email.cc?.map(r => r.name ? `${r.name} <${r.email}>` : r.email).join(', ') || ''; const printWindow = window.open('', '_blank'); if (!printWindow) return; printWindow.document.write(` ${DOMPurify.sanitize(email.subject || t('no_subject'))}
${DOMPurify.sanitize(email.subject || t('no_subject'))}
${t('from')}: ${DOMPurify.sanitize(printSender?.name ? `${printSender.name} <${printSender.email}>` : printSender?.email || t('unknown_sender'))}
${toList ? `
${t('to')}: ${toList}
` : ''} ${ccList ? `
CC: ${ccList}
` : ''} ${date ? `
${t('date')}: ${DOMPurify.sanitize(date)}
` : ''}
${emailContent.html}
`); printWindow.document.close(); printWindow.focus(); printWindow.print(); }; // Detect List-Unsubscribe header for newsletter banners const listHeaders = useMemo(() => { if (!email?.headers) return null; return extractListHeaders(email.headers); }, [email?.headers]); const shouldShowUnsubBanner = listHeaders?.listUnsubscribe?.preferred && !dismissedUnsubBanners.has(email?.messageId || ''); const hasCalendarInvitation = email ? !!findCalendarAttachment(email) : false; // Show loading skeleton while email is being fetched if (isLoading && !email) { return (
{/* Loading Header Skeleton - gentler animation */}
{/* Loading Sender Info Skeleton */}
{/* Loading Content Skeleton */}
); } if (!email) { return (

{t('no_conversation_selected')}

{t('no_conversation_description')}

); } const sender = email.from?.[0]; const isStarred = email.keywords?.$flagged; const isImportant = email.keywords?.["$important"]; return (
{/* Mobile More menu sidebar overlay */} {isMobile && moreMenuOpen && (
setMoreMenuOpen(false)} /> )} {isMobile && (
{t('more_actions')}
{(onMarkAsSpam || onUndoSpam) && ( )} {/* Move to folder */} {moveTree.length > 0 && onMoveToMailbox && ( <>
{t('move_to')}
{(() => { const renderMobileNodes = (nodes: MailboxNode[], depth = 0) => { return nodes.map((node) => { const Icon = getMoveMailboxIcon(node.role); const isTarget = moveTargetIds.has(node.id); return (
{isTarget ? ( ) : (
{node.name}
)} {node.children.length > 0 && renderMobileNodes(node.children, depth + 1)}
); }); }; return renderMobileNodes(moveTree); })()}
)} {/* Tags */} {colorOptions.length > 0 && ( <>
{t('tag')}
{colorOptions.map((option) => ( ))} {currentColor && ( )}
)} {onShowShortcuts && ( )}
)} {/* Main email content */}
{/* Loading overlay when fetching new email */} {isLoading && (
{t('loading_email')}
)} {/* === TOOLBAR (top position) === */} {toolbarPosition === 'top' && (
{/* Left: Back + Reply actions */}
{isTablet && !tabletListVisible && onBack && ( )}
{/* Right: Organize actions */}
{isLoading && (
)} {/* Archive - hidden on mobile, overflows to More menu */} {/* Spam - hidden on mobile, overflows to More menu */} {(onMarkAsSpam || onUndoSpam) && ( )} {/* Move to folder - hidden on mobile, overflows to More menu */} {moveTree.length > 0 && onMoveToMailbox && (
{moveMenuOpen && (
{(() => { const renderNodes = (nodes: MailboxNode[], depth = 0) => { return nodes.map((node) => { const Icon = getMoveMailboxIcon(node.role); const isTarget = moveTargetIds.has(node.id); return (
{isTarget ? ( ) : (
{node.name}
)} {node.children.length > 0 && renderNodes(node.children, depth + 1)}
); }); }; return renderNodes(moveTree); })()}
)}
)} {/* Tag Picker + Divider - hidden on mobile, overflows to More menu */}
{tagMenuOpen && (
{colorOptions.map((option) => ( ))} {currentColor && ( <>
)}
)}
{/* Print - hidden on mobile, overflows to More menu */} {/* More menu - click-based */}
{moreMenuOpen && !isMobile && (
{/* Overflow actions - shown when hidden from toolbar or on mobile */} {(onMarkAsSpam || onUndoSpam) && ( )} {/* Move to folder submenu */} {moveTree.length > 0 && onMoveToMailbox && (
= 3 ? "" : "sm:hidden")}>
{t('move_to')}
{(() => { const renderMobileNodes = (nodes: MailboxNode[], depth = 0) => { return nodes.map((node) => { const Icon = getMoveMailboxIcon(node.role); const isTarget = moveTargetIds.has(node.id); return (
{isTarget ? ( ) : (
{node.name}
)} {node.children.length > 0 && renderMobileNodes(node.children, depth + 1)}
); }); }; return renderMobileNodes(moveTree); })()}
)} {/* Tag submenu */} {colorOptions.length > 0 && (
= 2 ? "" : "sm:hidden")}>
{t('tag')}
{colorOptions.map((option) => ( ))} {currentColor && ( )}
)} {onShowShortcuts && ( )}
)}
)} {/* === SUBJECT BLOCK === */}
{/* Back button (for below-subject mode on tablet) */} {toolbarPosition === 'below-subject' && isTablet && !tabletListVisible && onBack && ( )}

{email.subject || t('no_subject')}

{/* Star inline with subject (top toolbar mode) */} {toolbarPosition === 'top' && ( )} {/* Color tag dot */} {currentColor && (() => { const kw = emailKeywords.find(k => k.id === currentColor); const dotClass = kw ? KEYWORD_PALETTE[kw.color]?.dot : null; return dotClass ? ( ) : null; })()}
{new Date(email.receivedAt).toLocaleString('en-US', { weekday: 'short', year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })} {isImportant && ( {t('important')} )}
{/* === TOOLBAR (below-subject position) === */} {toolbarPosition === 'below-subject' && (
{/* Left: Reply actions */}
{/* Right: Organize actions */}
{isLoading && (
)} {/* Archive - hidden on mobile, overflows to More menu */} {/* Spam - hidden on mobile, overflows to More menu */} {(onMarkAsSpam || onUndoSpam) && ( )} {/* Move to folder - hidden on mobile, overflows to More menu */} {moveTree.length > 0 && onMoveToMailbox && (
{moveMenuOpen && (
{(() => { const renderNodes = (nodes: MailboxNode[], depth = 0) => { return nodes.map((node) => { const Icon = getMoveMailboxIcon(node.role); const isTarget = moveTargetIds.has(node.id); return (
{isTarget ? ( ) : (
{node.name}
)} {node.children.length > 0 && renderNodes(node.children, depth + 1)}
); }); }; return renderNodes(moveTree); })()}
)}
)} {/* Tag Picker + Divider - hidden on mobile, overflows to More menu */}
{tagMenuOpen && (
{colorOptions.map((option) => ( ))} {currentColor && ( <>
)}
)}
{/* Print - hidden on mobile, overflows to More menu */} {/* More menu - click-based */}
{moreMenuOpen && !isMobile && (
{/* Overflow actions - shown when hidden from toolbar or on mobile */} {(onMarkAsSpam || onUndoSpam) && ( )} {/* Move to folder submenu */} {moveTree.length > 0 && onMoveToMailbox && (
= 3 ? "" : "sm:hidden")}>
{t('move_to')}
{(() => { const renderMobileNodes = (nodes: MailboxNode[], depth = 0) => { return nodes.map((node) => { const Icon = getMoveMailboxIcon(node.role); const isTarget = moveTargetIds.has(node.id); return (
{isTarget ? ( ) : (
{node.name}
)} {node.children.length > 0 && renderMobileNodes(node.children, depth + 1)}
); }); }; return renderMobileNodes(moveTree); })()}
)} {/* Tag submenu */} {colorOptions.length > 0 && (
= 2 ? "" : "sm:hidden")}>
{t('tag')}
{colorOptions.map((option) => ( ))} {currentColor && ( )}
)} {onShowShortcuts && ( )}
)}
)} {/* Email Content Area */}
{/* === SENDER INFO (Desktop) === */}
{/* Sender line with email and badges */}
{sender?.email && (
{sender.email} {shouldShowUnsubBanner && listHeaders?.listUnsubscribe && ( { const messageId = email?.messageId || ''; const newSet = new Set(dismissedUnsubBanners).add(messageId); setDismissedUnsubBanners(newSet); localStorage.setItem('dismissed-unsub-banners', JSON.stringify([...newSet])); }} /> )}
)}
{/* Date and size on the right */}
{new Date(email.receivedAt).toLocaleString('en-US', { weekday: 'short', year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })}
{email.size > 0 && (
{formatFileSize(email.size)}
)} {emailContent.isHtml && ( )}
{/* Recipient section - separate line */}
{email.to && email.to.length > 0 && (
{t('recipient_to_prefix')} {renderClickableRecipients(email.to, currentUserEmail, t, handleViewContactSidebar)} {email.to.length > 2 && ( )}
)} {email.cc && email.cc.length > 0 && (
CC: {renderClickableRecipients(email.cc, currentUserEmail, t, handleViewContactSidebar)} {email.cc.length > 2 && ( +{email.cc.length - 2} )}
)} {email.bcc && email.bcc.length > 0 && (
{t('bcc')}: {renderClickableRecipients(email.bcc, currentUserEmail, t, handleViewContactSidebar)} {email.bcc.length > 2 && ( +{email.bcc.length - 2} )}
)}
{/* Details toggle - stays in place when expanded */} {/* Expandable Details */} {showFullHeaders && (
{/* Full Recipients Section */}

{t('message_details')}

{/* From */}
{t('from')}:
{/* To - show all */} {email.to && email.to.length > 0 && (
{t('to')}:
{renderClickableRecipients(email.to, currentUserEmail, t, handleViewContactSidebar, 100)}
)} {/* CC - show all */} {email.cc && email.cc.length > 0 && (
{t('cc')}:
{renderClickableRecipients(email.cc, currentUserEmail, t, handleViewContactSidebar, 100)}
)} {/* BCC - show all */} {email.bcc && email.bcc.length > 0 && (
{t('bcc')}:
{renderClickableRecipients(email.bcc, currentUserEmail, t, handleViewContactSidebar, 100)}
)} {/* Date */}
{t('date')}: {new Date(email.receivedAt).toLocaleString('en-US', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric', hour: '2-digit', minute: '2-digit', second: '2-digit', timeZoneName: 'short' })}
{/* Reply-To if different */} {email.replyTo && email.replyTo.length > 0 && (!email.from || email.replyTo[0].email !== email.from[0]?.email) && (
{t('reply_to_label').replace(':', '')}
{email.replyTo.map((r, i) => ( ))}
)}
{/* Security & Authentication Section */} {(email.authenticationResults || email.spamScore !== undefined) && (

{t('security_authentication')}

{/* Authentication Results */} {email.authenticationResults && (
{/* SPF Check */} {email.authenticationResults.spf && (
{getSecurityStatus(email.authenticationResults.spf.result).icon === 'check' && } {getSecurityStatus(email.authenticationResults.spf.result).icon === 'x' && } {getSecurityStatus(email.authenticationResults.spf.result).icon === 'alert' && } {getSecurityStatus(email.authenticationResults.spf.result).icon === 'minus' && }
SPF
{email.authenticationResults.spf.result}
{email.authenticationResults.spf.domain && (
{email.authenticationResults.spf.domain}
)}
)} {/* DKIM Check */} {email.authenticationResults.dkim && (
{getSecurityStatus(email.authenticationResults.dkim.result).icon === 'check' && } {getSecurityStatus(email.authenticationResults.dkim.result).icon === 'x' && } {getSecurityStatus(email.authenticationResults.dkim.result).icon === 'alert' && } {getSecurityStatus(email.authenticationResults.dkim.result).icon === 'minus' && }
DKIM
{email.authenticationResults.dkim.result}
{email.authenticationResults.dkim.domain && (
{email.authenticationResults.dkim.domain}
)}
)} {/* DMARC Check */} {email.authenticationResults.dmarc && (
{getSecurityStatus(email.authenticationResults.dmarc.result).icon === 'check' && } {getSecurityStatus(email.authenticationResults.dmarc.result).icon === 'x' && } {getSecurityStatus(email.authenticationResults.dmarc.result).icon === 'alert' && } {getSecurityStatus(email.authenticationResults.dmarc.result).icon === 'minus' && }
DMARC
{email.authenticationResults.dmarc.result}
{email.authenticationResults.dmarc.policy && (
Policy: {email.authenticationResults.dmarc.policy}
)}
)} {/* Spam Score */} {email.spamScore !== undefined && (
5 ? "bg-gray-50 dark:bg-gray-800 border-l-4 border-red-600 dark:border-red-500" : email.spamScore > 2 ? "bg-gray-50 dark:bg-gray-800 border-l-4 border-amber-600 dark:border-amber-500" : "bg-gray-50 dark:bg-gray-800 border-l-4 border-green-600 dark:border-green-500" )}>
5 ? "text-red-700 dark:text-red-400" : email.spamScore > 2 ? "text-amber-700 dark:text-amber-400" : "text-green-700 dark:text-green-400" )} />
Spam Score
5 ? "text-red-700 dark:text-red-400" : email.spamScore > 2 ? "text-amber-700 dark:text-amber-400" : "text-green-700 dark:text-green-400" )}> {email.spamScore.toFixed(1)}
{email.spamStatus && (
{email.spamStatus}
)}
)}
)} {/* AI Analysis (X-Spam-LLM) - Full width card */} {email.spamLLM && (
{email.spamLLM.verdict === 'LEGITIMATE' ? (
) : email.spamLLM.verdict === 'SPAM' ? ( ) : ( )}
AI Analysis: {email.spamLLM.verdict}

{email.spamLLM.explanation}

)}
)} {/* Technical Details Section - Only show if we have useful technical info */} {(email.messageId || email.replyTo?.length || (email.sentAt && email.receivedAt && Math.abs(new Date(email.sentAt).getTime() - new Date(email.receivedAt).getTime()) > 60000)) && (

{t('technical_details')}

{/* Message ID */} {email.messageId && (
{t('message_id_label')}
{email.messageId}
)} {/* Reply-To if different from sender */} {email.replyTo && email.replyTo.length > 0 && (!email.from || email.replyTo[0].email !== email.from[0]?.email) && (
{t('reply_to_label')}
{email.replyTo.map((recipient, i) => ( {recipient.name && {recipient.name}} {recipient.email} ))}
)} {/* Time delay if significant (>1 minute difference) */} {email.sentAt && email.receivedAt && Math.abs(new Date(email.sentAt).getTime() - new Date(email.receivedAt).getTime()) > 60000 && (
{t('delivery_time_label')}
{(() => { const diff = Math.abs(new Date(email.receivedAt).getTime() - new Date(email.sentAt).getTime()); const minutes = Math.floor(diff / 60000); const hours = Math.floor(minutes / 60); const days = Math.floor(hours / 24); const dayUnit = days > 1 ? t('time.days') : t('time.day'); const hourUnit = (hours % 24) > 1 ? t('time.hours') : t('time.hour'); const minuteUnit = (minutes % 60) > 1 ? t('time.minutes') : t('time.minute'); const minuteUnitSingle = minutes > 1 ? t('time.minutes') : t('time.minute'); if (days > 0) return `${days} ${dayUnit} ${hours % 24} ${hourUnit}`; if (hours > 0) return `${hours} ${hours > 1 ? t('time.hours') : t('time.hour')} ${minutes % 60} ${minuteUnit}`; return `${minutes} ${minuteUnitSingle}`; })()}
)} {/* Part of conversation */} {email.references && email.references.length > 0 && (
{t('conversation_part_label')}
{t(email.references.length === 1 ? 'previous_messages' : 'previous_messages_plural', { count: email.references.length })}
)}
)}
)}
{/* === ATTACHMENTS (integrated into header) === */} {email.attachments && email.attachments.length > 0 && (
{email.attachments.map((attachment, i) => { const FileIcon = getFileIcon(attachment.name, attachment.type); return ( ); })}
)} {/* Mobile/Tablet Sender Info - scrolls with content */}
{/* Mobile 2-line layout */}
{sender?.email && sender?.name && ( <> {sender.email} {shouldShowUnsubBanner && listHeaders?.listUnsubscribe && ( { const messageId = email?.messageId || ''; const newSet = new Set(dismissedUnsubBanners).add(messageId); setDismissedUnsubBanners(newSet); localStorage.setItem('dismissed-unsub-banners', JSON.stringify([...newSet])); }} /> )} · )} {email.to && email.to.length > 0 && ( <> → {t('recipient_to_prefix')} {renderClickableRecipients(email.to, currentUserEmail, t, handleViewContactSidebar)} )}
{/* CC line (mobile - only if present) */} {email.cc && email.cc.length > 0 && (
CC: {renderClickableRecipients(email.cc, currentUserEmail, t, handleViewContactSidebar)} {email.cc.length > 2 && ( +{email.cc.length - 2} )}
)}
{/* Unified Notification Banner - External Content + Calendar Invitation */} {((hasBlockedContent && !allowExternalContent && externalContentPolicy !== 'allow') || hasCalendarInvitation) && (
{/* External Content Controls */} {hasBlockedContent && !allowExternalContent && externalContentPolicy !== 'allow' && (
{externalContentPolicy === 'ask' && ( )} {email.from?.[0]?.email && ( )}
)} {/* Calendar Invitation Banner */} {hasCalendarInvitation && (
)}
)}
{/* Email Body */}
{emailContent.isHtml ? (