"use client"; import { useState, useEffect, useMemo } from "react"; import DOMPurify from "dompurify"; import { Email } from "@/lib/jmap/types"; import { Button } from "@/components/ui/button"; import { Avatar } from "@/components/ui/avatar"; import { formatFileSize, cn } from "@/lib/utils"; import { getSecurityStatus } from "@/lib/email-headers"; import { Reply, ReplyAll, Forward, Trash2, Archive, Star, MoreVertical, ChevronDown, ChevronUp, Download, Paperclip, Mail, Clock, Loader2, Printer, FileText, FileImage, FileVideo, FileAudio, FileArchive, File, Shield, Image, Circle, X, Check, AlertTriangle, Minus, ShieldCheck, ShieldAlert, Network, Hash, List, Code, Copy, Brain, Sparkles, } from "lucide-react"; import { useTranslations } from "next-intl"; import { useSettingsStore } from "@/stores/settings-store"; interface EmailViewerProps { email: Email | null; isLoading?: boolean; onReply?: () => 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; currentUserEmail?: string; currentUserName?: 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; }; // Color options for email tags const colorOptions = [ { name: "Red", value: "red", color: "bg-red-500" }, { name: "Orange", value: "orange", color: "bg-orange-500" }, { name: "Yellow", value: "yellow", color: "bg-yellow-500" }, { name: "Green", value: "green", color: "bg-green-500" }, { name: "Blue", value: "blue", color: "bg-blue-500" }, { name: "Purple", value: "purple", color: "bg-purple-500" }, { name: "Pink", value: "pink", color: "bg-pink-500" }, ]; const getCurrentColor = (keywords: Record | undefined) => { if (!keywords) return null; for (const key of Object.keys(keywords)) { if (key.startsWith("$color:") && keywords[key] === true) { return key.replace("$color:", ""); } } return null; }; export function EmailViewer({ email, isLoading = false, onReply, onReplyAll, onForward, onDelete, onArchive, onToggleStar, onMarkAsRead, onSetColorTag, onDownloadAttachment, onQuickReply, currentUserEmail, currentUserName, className, }: EmailViewerProps) { const t = useTranslations('email_viewer'); const tNotifications = useTranslations('notifications'); const externalContentPolicy = useSettingsStore((state) => state.externalContentPolicy); const [showFullHeaders, setShowFullHeaders] = useState(false); const [allowExternalContent, setAllowExternalContent] = useState(false); const [hasBlockedContent, setHasBlockedContent] = useState(false); const [quickReplyText, setQuickReplyText] = useState(""); const [isQuickReplyFocused, setIsQuickReplyFocused] = useState(false); const [isSendingQuickReply, setIsSendingQuickReply] = useState(false); const [showSourceModal, setShowSourceModal] = useState(false); const currentColor = getCurrentColor(email?.keywords); 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); }, [email?.id, externalContentPolicy]); // 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; // Check if HTML is just a minimal wrapper around plain text // by checking if it lacks common HTML formatting elements const tempDiv = document.createElement('div'); tempDiv.innerHTML = htmlContent; const hasRichFormatting = tempDiv.querySelector('table, img, style, b, strong, i, em, u, font, div[style], span[style], p[style], h1, h2, h3, h4, h5, h6, ul, ol, blockquote'); const hasMultipleParagraphs = tempDiv.querySelectorAll('p').length > 2; const hasBrTags = tempDiv.querySelectorAll('br').length > 0; // Use HTML if it has rich formatting, multiple paragraphs, or explicit line breaks useHtmlVersion = !!(hasRichFormatting || hasMultipleParagraphs || hasBrTags); } // If we should use HTML version and it exists if (useHtmlVersion && htmlContent) { // Create a custom DOMPurify hook to handle external content let blockedExternalContent = false; const sanitizeConfig = { ADD_TAGS: ['style'], ADD_ATTR: ['target', 'style', 'class', 'width', 'height', 'align', 'valign', 'bgcolor', 'color'], ALLOW_DATA_ATTR: false, FORCE_BODY: true, FORBID_TAGS: ['script', 'iframe', 'object', 'embed', 'form', 'input', 'button'], FORBID_ATTR: ['onerror', 'onload', 'onclick', 'onmouseover', 'onfocus', 'onblur'], }; // Block external content based on policy: // 'allow' = never block, 'block' = always block, 'ask' = block until user allows const shouldBlockExternal = externalContentPolicy === 'block' || (externalContentPolicy === 'ask' && !allowExternalContent); if (shouldBlockExternal) { sanitizeConfig.FORBID_TAGS.push('link'); sanitizeConfig.FORBID_ATTR.push('background'); // Hook to modify src attributes DOMPurify.addHook('afterSanitizeAttributes', (node) => { const htmlNode = node as HTMLElement; // Block external images 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); // Use a subtle transparent placeholder node.setAttribute('src', 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMSIgaGVpZ2h0PSIxIiB2aWV3Qm94PSIwIDAgMSAxIiBmaWxsPSJub25lIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPgo8cmVjdCB3aWR0aD0iMSIgaGVpZ2h0PSIxIiBmaWxsPSJ0cmFuc3BhcmVudCIvPgo8L3N2Zz4='); node.setAttribute('alt', ''); htmlNode.style.display = 'none'; // Hide blocked images completely for cleaner look blockedExternalContent = true; } } // Block external stylesheets and resources in style attributes 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; } } } }); } // Sanitize HTML to prevent XSS const cleanHtml = DOMPurify.sanitize(htmlContent, sanitizeConfig); // Remove the hook after sanitization DOMPurify.removeAllHooks(); // 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'); // Don't match across tags 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]); // 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 (

No conversation selected

Choose a conversation from the list to read it here

); } const sender = email.from?.[0]; const isStarred = email.keywords?.$flagged; const isImportant = email.keywords?.["$important"]; return (
{/* Loading overlay when fetching new email */} {isLoading && (
Loading email...
)} {/* Modern Header Section */}
{/* Subject Bar */}

{email.subject || "(no subject)"}

{new Date(email.receivedAt).toLocaleString('en-US', { weekday: 'short', year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })} {email.hasAttachment && ( Attachments )} {isImportant && ( Important )}
{/* Quick Actions */}
{/* Loading indicator */} {isLoading && (
Loading...
)} {/* Primary Reply Button */} {/* Reply Options Dropdown - hidden on mobile */}
{/* Compact Dynamic Color Picker - hidden on mobile */}
{/* Colors appear on hover */}
{colorOptions.map((option) => ( )}
{/* More Actions Dropdown */}
{/* Sender Info */}
{sender?.name || sender?.email || "Unknown"} {sender?.email && sender?.name && ( <{sender.email}> )}
{email.to && email.to.length > 0 && (
To: {email.to.slice(0, 2).map(r => r.name || r.email).join(", ")} {email.to.length > 2 && ( )}
)} {(email.cc && email.cc.length > 0) && (
CC: {email.cc.map(r => r.name || r.email).join(", ")}
)}
{/* Modern Expandable Details */} {showFullHeaders && (
{/* Security & Authentication Section */} {(email.authenticationResults || email.spamScore !== undefined) && (

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)) && (

Technical Details

{/* Message ID */} {email.messageId && (
Message-ID:
{email.messageId}
)} {/* Reply-To if different from sender */} {email.replyTo && email.replyTo.length > 0 && (!email.from || email.replyTo[0].email !== email.from[0]?.email) && (
Reply-To:
{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 && (
Delivery time:
{(() => { 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); if (days > 0) return `${days} day${days > 1 ? 's' : ''} ${hours % 24} hour${hours % 24 !== 1 ? 's' : ''}`; if (hours > 0) return `${hours} hour${hours > 1 ? 's' : ''} ${minutes % 60} minute${minutes % 60 !== 1 ? 's' : ''}`; return `${minutes} minute${minutes > 1 ? 's' : ''}`; })()}
)} {/* Part of conversation */} {email.references && email.references.length > 0 && (
Part of conversation:
{email.references.length} previous message{email.references.length > 1 ? 's' : ''} in this thread
)}
)}
)}
{/* Email Content Area */}
{/* Ultra Minimalist External Content Banner - only show in 'ask' mode */} {hasBlockedContent && !allowExternalContent && externalContentPolicy === 'ask' && (
)}
{/* Inline Attachments */} {email.attachments && email.attachments.length > 0 && (
{/* Image attachments as thumbnails */} {email.attachments.filter(a => a.type?.startsWith('image/') || ['jpg', 'jpeg', 'png', 'gif', 'webp'].includes(a.name?.split('.').pop()?.toLowerCase() || '') ).length > 0 && (
{email.attachments .filter(a => a.type?.startsWith('image/') || ['jpg', 'jpeg', 'png', 'gif', 'webp'].includes(a.name?.split('.').pop()?.toLowerCase() || '') ) .map((attachment, i) => (
{ if (attachment.blobId && onDownloadAttachment) { onDownloadAttachment(attachment.blobId, attachment.name || 'download', attachment.type); } }} >
{attachment.name}
))}
)} {/* Non-image attachments in a compact list */} {email.attachments.filter(a => !a.type?.startsWith('image/') && !['jpg', 'jpeg', 'png', 'gif', 'webp'].includes(a.name?.split('.').pop()?.toLowerCase() || '') ).length > 0 && (
{email.attachments .filter(a => !a.type?.startsWith('image/') && !['jpg', 'jpeg', 'png', 'gif', 'webp'].includes(a.name?.split('.').pop()?.toLowerCase() || '') ) .map((attachment, i) => { const FileIcon = getFileIcon(attachment.name, attachment.type); return ( ); })}
)}
)} {/* Email Body */}
{emailContent.isHtml ? (
) : (
)}
{/* Quick Reply Section */}