"use client"; import { useState, useEffect, useMemo } from "react"; import DOMPurify from "dompurify"; import { Email } from "@/lib/jmap/types"; import { hasRichFormatting, EMAIL_SANITIZE_CONFIG, collapseBlockedImageContainers } from "@/lib/email-sanitization"; import { Button } from "@/components/ui/button"; import { Avatar } from "@/components/ui/avatar"; import { formatFileSize, cn } from "@/lib/utils"; import { getSecurityStatus, extractListHeaders } from "@/lib/email-headers"; import { Reply, ReplyAll, Forward, Trash2, Archive, Star, MoreVertical, ChevronDown, ChevronUp, ChevronLeft, 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, Keyboard, } from "lucide-react"; import { useTranslations } from "next-intl"; import { useSettingsStore } from "@/stores/settings-store"; import { useUIStore } from "@/stores/ui-store"; import { useDeviceDetection } from "@/hooks/use-media-query"; import { useAuthStore } from "@/stores/auth-store"; import { useThemeStore } from "@/stores/theme-store"; import { transformInlineStyles, transformColorForDarkMode, transformBgColorForDarkMode } from "@/lib/color-transform"; import { EmailIdentityBadge } from "./email-identity-badge"; import { UnsubscribeBanner } from "./unsubscribe-banner"; import { CalendarInvitationBanner } from "./calendar-invitation-banner"; import { findCalendarAttachment } from "@/lib/calendar-invitation"; 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; onBack?: () => void; onShowShortcuts?: () => void; currentUserEmail?: string; currentUserName?: string; currentMailboxRole?: 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("$color:") && keywords[key] === true) { return key.replace("$color:", ""); } } 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(', '); }; export function EmailViewer({ email, isLoading = false, onReply, onReplyAll, onForward, onDelete, onArchive, onToggleStar, onMarkAsRead, onSetColorTag, onDownloadAttachment, onQuickReply, onMarkAsSpam, onUndoSpam, onBack, onShowShortcuts, currentUserEmail, currentUserName, currentMailboxRole, 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); // Detect if current mailbox is Junk folder const isInJunkFolder = currentMailboxRole === 'junk'; // Color options for email tags (using translations) const colorOptions = [ { name: t("color_tag.red"), value: "red", color: "bg-red-500" }, { name: t("color_tag.orange"), value: "orange", color: "bg-orange-500" }, { name: t("color_tag.yellow"), value: "yellow", color: "bg-yellow-500" }, { name: t("color_tag.green"), value: "green", color: "bg-green-500" }, { name: t("color_tag.blue"), value: "blue", color: "bg-blue-500" }, { name: t("color_tag.purple"), value: "purple", color: "bg-purple-500" }, { name: t("color_tag.pink"), value: "pink", color: "bg-pink-500" }, ]; // Tablet list visibility const { isTablet } = useDeviceDetection(); const { tabletListVisible } = useUIStore(); const { identities } = useAuthStore(); const resolvedTheme = useThemeStore((state) => state.resolvedTheme); 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); 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); }, [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; // Use safe parsing instead of innerHTML to detect rich formatting useHtmlVersion = hasRichFormatting(htmlContent); } // If we should use HTML version and it exists if (useHtmlVersion && htmlContent) { // 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'); } if (resolvedTheme === 'dark') { 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)); } } }); // 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, resolvedTheme]); // 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 (
{/* Loading overlay when fetching new email */} {isLoading && (
{t('loading_email')}
)} {/* Subject Bar - sticky on mobile/tablet for quick actions */}
{/* Tablet Back Button - show when list is hidden */} {isTablet && !tabletListVisible && onBack && ( )}

{email.subject || t('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 && ( {t('attachments')} )} {isImportant && ( {t('important')} )}
{/* Quick Actions */}
{/* Loading indicator */} {isLoading && (
{t('loading')}
)} {/* Primary Reply Button */} {/* Reply Options Dropdown - hidden on mobile/tablet */}
{/* Spam/Not Spam Button - Desktop only, contextual based on folder */} {(onMarkAsSpam || onUndoSpam) && ( )}
{/* Compact Dynamic Color Picker - hidden on mobile/tablet */}
{/* Colors appear on hover */}
{colorOptions.map((option) => ( )}
{/* More Actions Dropdown */}
{onShowShortcuts && ( )} {/* Separator */}
{/* Spam action - contextual */} {(onMarkAsSpam || onUndoSpam) && ( )}
{/* Sender Info - Desktop only (hidden on mobile/tablet, they see it in scrollable content) */}
{/* Sender line with compact badges */}
{sender?.name || sender?.email || t('unknown_sender')}
{/* Recipient section - separate line */}
{email.to && email.to.length > 0 && (
{t('recipient_to_prefix')} {formatRecipients(email.to, currentUserEmail, t)} {email.to.length > 2 && ( )}
)} {email.cc && email.cc.length > 0 && (
CC: {email.cc.slice(0, 2).map(r => r.name || r.email).join(", ")} {email.cc.length > 2 && ` +${email.cc.length - 2}`}
)}
{/* Modern Expandable Details */} {showFullHeaders && (
{/* 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 })}
)}
)}
)}
{/* Email Content Area */}
{/* Mobile/Tablet Sender Info - scrolls with content */}
{/* Mobile 2-line layout */}
{sender?.name || sender?.email || t('unknown_sender')}
{sender?.email && sender?.name && ( <> {sender.email} · )} {email.to && email.to.length > 0 && ( <> → {t('recipient_to_prefix')} {formatRecipients(email.to, currentUserEmail, t)} )}
{/* CC line (mobile - only if present) */} {email.cc && email.cc.length > 0 && (
CC: {email.cc.slice(0, 2).map(r => r.name || r.email).join(", ")} {email.cc.length > 2 && ` +${email.cc.length - 2}`}
)}
{/* Unified Notification Banner - External Content + Unsubscribe + Calendar Invitation */} {((hasBlockedContent && !allowExternalContent && externalContentPolicy !== 'allow') || (shouldShowUnsubBanner && listHeaders?.listUnsubscribe) || hasCalendarInvitation) && (
{/* External Content Controls */} {hasBlockedContent && !allowExternalContent && externalContentPolicy !== 'allow' && (
{externalContentPolicy === 'ask' && ( )} {email.from?.[0]?.email && ( )}
)} {/* Unsubscribe Controls */} {shouldShowUnsubBanner && listHeaders?.listUnsubscribe && (
{ const messageId = email?.messageId || ''; const newSet = new Set(dismissedUnsubBanners).add(messageId); setDismissedUnsubBanners(newSet); localStorage.setItem('dismissed-unsub-banners', JSON.stringify([...newSet])); }} />
)} {/* Calendar Invitation Banner */} {hasCalendarInvitation && (
)}
)}
{/* 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 */}