"use client"; import { useState, useEffect, useLayoutEffect, useMemo, useRef, useCallback } from "react"; import DOMPurify from "dompurify"; import { Email, ContactCard, Mailbox } from "@/lib/jmap/types"; import { EMAIL_IFRAME_SANITIZE_CONFIG, collapseBlockedImageContainers, plainTextToSafeHtml } from "@/lib/email-sanitization"; import { hasMeaningfulHtmlBody } from "@/lib/signature-utils"; import { Button } from "@/components/ui/button"; import { Avatar } from "@/components/ui/avatar"; import { formatFileSize, cn, buildMailboxTree, MailboxNode, formatDateTime, generateUUID } from "@/lib/utils"; import { getSecurityStatus, extractListHeaders } from "@/lib/email-headers"; import { emailToReadView } from "@/lib/plugin-projection"; import { Reply, ReplyAll, Forward, Trash2, Archive, Star, MoreVertical, ChevronDown, ChevronUp, ChevronLeft, ChevronRight, Download, Mail, MailOpen, Loader2, Printer, FileText, FileImage, FileVideo, FileAudio, FileArchive, File, Eye, Shield, Image, Tag, X, Check, AlertTriangle, Minus, ShieldCheck, ShieldAlert, Code, Copy, Brain, Keyboard, Phone, Building, MapPin, StickyNote, PanelRightClose, PanelRightOpen, Send, FolderInput, Inbox, Folder, Sun, Upload, Moon, EditIcon, PlayCircle, PenSquare, } from "lucide-react"; import { useTranslations } from "next-intl"; import type { Attachment as PostalMimeAttachment } from 'postal-mime'; 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 { useEmailStore } from "@/stores/email-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 { useTour } from "@/components/tour/tour-provider"; import { SmimePassphraseDialog } from "@/components/settings/smime-passphrase-dialog"; import { findCalendarAttachment, isCalendarMimeType } from "@/lib/calendar-invitation"; import { RecipientPopover } from "./recipient-popover"; import { isFilePreviewable } from "@/lib/file-preview"; import { SmimeStatusBanner } from "./smime-status-banner"; import { detectSmime } from "@/lib/smime/smime-detect"; import { smimeDecrypt, SmimeKeyLockedError, normalizeCmsBytes } from "@/lib/smime/smime-decrypt"; import { smimeVerify } from "@/lib/smime/smime-verify"; import { useSmimeStore } from "@/stores/smime-store"; import type { SmimeStatus } from "@/lib/smime/types"; import { parseTnef, isTnefAttachment } from "@/lib/tnef"; import { debug } from "@/lib/debug"; import type { TnefAttachment } from "@/lib/tnef"; import { PluginSlot } from "@/components/plugins/plugin-slot"; import { usePluginStore } from "@/stores/plugin-store"; import { ResizeHandle } from "@/components/layout/resize-handle"; import { emailHooks, uiHooks } from "@/lib/plugin-hooks"; import type { AttachmentInfo, AttachmentPreview } from "@/lib/plugin-types"; 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, forceDownload?: boolean) => void; onQuickReply?: (body: string) => Promise; onMarkAsSpam?: () => void; onUndoSpam?: () => void; onMoveToMailbox?: (mailboxId: string) => void; onBack?: () => void; onNavigateNext?: () => void; onNavigatePrev?: () => void; onShowShortcuts?: () => void; onEditDraft?: () => void; onCompose?: () => 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 MIME_TYPE_LABELS: Record = { 'application/pdf': 'Document.pdf', 'application/zip': 'Archive.zip', 'application/x-zip-compressed': 'Archive.zip', 'application/gzip': 'Archive.gz', 'application/x-rar-compressed': 'Archive.rar', 'application/x-7z-compressed': 'Archive.7z', 'application/msword': 'Document.doc', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': 'Document.docx', 'application/vnd.ms-excel': 'Spreadsheet.xls', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': 'Spreadsheet.xlsx', 'application/vnd.ms-powerpoint': 'Presentation.ppt', 'application/vnd.openxmlformats-officedocument.presentationml.presentation': 'Presentation.pptx', 'text/plain': 'Text.txt', 'text/html': 'Document.html', 'text/csv': 'Data.csv', 'application/json': 'Data.json', 'application/xml': 'Data.xml', 'application/octet-stream': 'Attachment', 'message/rfc822': 'Email.eml', }; const getAttachmentDisplayName = (name: string | null | undefined, mimeType?: string): string => { if (name) return name; if (mimeType) { const label = MIME_TYPE_LABELS[mimeType.toLowerCase()]; if (label) return label; const sub = mimeType.split('/')[1]; if (sub) { const clean = sub.replace(/^x-/, '').replace(/^vnd\./, ''); return `Attachment.${clean}`; } } return 'Attachment'; }; const getCurrentColors = (keywords: Record | undefined): string[] => { if (!keywords) return []; const tags: string[] = []; for (const key of Object.keys(keywords)) { if ((key.startsWith("$label:") || key.startsWith("$color:")) && keywords[key] === true) { tags.push( key.startsWith("$label:") ? key.slice("$label:".length) : key.slice("$color:".length) ); } } return tags; }; // 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(', '); }; function parseMimeHeaders(headerText: string): Map { const headers = new Map(); const lines = headerText.split(/\r?\n/); let currentKey: string | null = null; for (const line of lines) { if (!line) continue; if (/^[ \t]/.test(line) && currentKey) { headers.set(currentKey, `${headers.get(currentKey) || ''} ${line.trim()}`.trim()); continue; } const separatorIndex = line.indexOf(':'); if (separatorIndex <= 0) continue; currentKey = line.slice(0, separatorIndex).trim().toLowerCase(); headers.set(currentKey, line.slice(separatorIndex + 1).trim()); } return headers; } function getMimeBoundary(contentType: string): string | null { const match = contentType.match(/boundary=(?:"([^"]+)"|([^;\s]+))/i); return match?.[1] || match?.[2] || null; } function decodeQuotedPrintableUtf8(input: string): string { const normalized = input.replace(/=(\r?\n)/g, ''); const bytes: number[] = []; for (let index = 0; index < normalized.length; index++) { if (normalized[index] === '=' && /^[0-9A-Fa-f]{2}$/.test(normalized.slice(index + 1, index + 3))) { bytes.push(parseInt(normalized.slice(index + 1, index + 3), 16)); index += 2; continue; } bytes.push(normalized.charCodeAt(index) & 0xff); } return new TextDecoder().decode(new Uint8Array(bytes)); } function decodeBase64Utf8(input: string): string { const cleaned = input.replace(/\s/g, ''); if (!cleaned) return ''; try { const binary = atob(cleaned); const bytes = new Uint8Array(binary.length); for (let index = 0; index < binary.length; index++) { bytes[index] = binary.charCodeAt(index); } return new TextDecoder().decode(bytes); } catch { return input; } } function decodeBase64Bytes(input: string): Uint8Array | null { const cleaned = input.replace(/\s/g, ''); if (!cleaned) return null; try { const binary = atob(cleaned); const bytes = new Uint8Array(binary.length); for (let index = 0; index < binary.length; index++) { bytes[index] = binary.charCodeAt(index); } return bytes; } catch { return null; } } function splitMimeHeadersAndBody(rawText: string): { headerText: string; bodyText: string } { const separatorMatch = rawText.match(/\r?\n\r?\n/); const separatorIndex = separatorMatch?.index ?? -1; const separator = separatorMatch?.[0] ?? ''; if (separatorIndex < 0) { return { headerText: '', bodyText: rawText }; } return { headerText: rawText.slice(0, separatorIndex), bodyText: rawText.slice(separatorIndex + separator.length), }; } function getAttachmentContentBytes(attachment: { content?: ArrayBuffer | Uint8Array | string; encoding?: 'base64' | 'utf8'; }): Uint8Array | null { const { content, encoding } = attachment; if (content instanceof Uint8Array) { return content; } if (content instanceof ArrayBuffer) { return new Uint8Array(content); } if (typeof content === 'string') { if (encoding === 'base64') { return decodeBase64Bytes(content); } return new TextEncoder().encode(content); } return null; } function extractNestedSignedDataCandidate( parsed: { attachments?: Array; headers?: Array<{ key: string; value: string }> }, rawBytes: Uint8Array, ): { source: string; bytes: ArrayBuffer } | null { const topLevelContentType = (parsed.headers?.find(h => h.key === 'content-type')?.value || '').toLowerCase(); if (topLevelContentType.includes('application/pkcs7-mime') && topLevelContentType.includes('signed-data')) { const rawText = new TextDecoder().decode(rawBytes); const { bodyText } = splitMimeHeadersAndBody(rawText); const topLevelTransferEncoding = ( parsed.headers?.find(h => h.key === 'content-transfer-encoding')?.value || '' ).toLowerCase(); if (topLevelTransferEncoding.includes('base64')) { const decoded = decodeBase64Bytes(bodyText); if (decoded) { return { source: 'top-level-content-type-body', bytes: decoded.buffer.slice(decoded.byteOffset, decoded.byteOffset + decoded.byteLength) as ArrayBuffer, }; } } const bodyBytes = new TextEncoder().encode(bodyText); return { source: 'top-level-content-type-body-text', bytes: bodyBytes.buffer.slice(bodyBytes.byteOffset, bodyBytes.byteOffset + bodyBytes.byteLength) as ArrayBuffer, }; } const rawText = new TextDecoder().decode(rawBytes); const messageContent = splitMimeHeadersAndBody(rawText).bodyText; const { headerText, bodyText } = splitMimeHeadersAndBody(messageContent); const bodyHeaders = parseMimeHeaders(headerText); const bodyContentType = (bodyHeaders.get('content-type') || '').toLowerCase(); const bodyTransferEncoding = (bodyHeaders.get('content-transfer-encoding') || '').toLowerCase(); if (bodyContentType.includes('application/pkcs7-mime') && bodyContentType.includes('signed-data')) { if (bodyTransferEncoding.includes('base64')) { const decoded = decodeBase64Bytes(bodyText); if (decoded) { return { source: 'message-body-signed-data', bytes: decoded.buffer.slice(decoded.byteOffset, decoded.byteOffset + decoded.byteLength) as ArrayBuffer, }; } } const bodyBytes = new TextEncoder().encode(bodyText); return { source: 'message-body-signed-data-text', bytes: bodyBytes.buffer.slice(bodyBytes.byteOffset, bodyBytes.byteOffset + bodyBytes.byteLength) as ArrayBuffer, }; } const nestedAttachment = parsed.attachments?.find(attachment => { const mimeType = ((attachment as { mimeType?: string }).mimeType || '').toLowerCase(); const filename = ((attachment as { filename?: string | null }).filename || '').toLowerCase(); return mimeType.includes('application/pkcs7-mime') || filename.endsWith('.p7m'); }) as { filename?: string | null; mimeType?: string; encoding?: 'base64' | 'utf8'; content?: ArrayBuffer | Uint8Array | string; } | undefined; if (!nestedAttachment) { return null; } const attachmentBytes = getAttachmentContentBytes(nestedAttachment); if (!attachmentBytes) { return null; } return { source: nestedAttachment.mimeType || nestedAttachment.filename || 'attachment-signed-data', bytes: attachmentBytes.buffer.slice( attachmentBytes.byteOffset, attachmentBytes.byteOffset + attachmentBytes.byteLength, ) as ArrayBuffer, }; } /** * Check if an HTML body string is effectively empty (just boilerplate/whitespace). * Outlook often generates HTML bodies with Word CSS +   but no real text. */ function isHtmlBodyEffectivelyEmpty(html: string): boolean { const textContent = html .replace(/]*>[\s\S]*?<\/style>/gi, '') .replace(/<[^>]+>/g, '') .replace(/ /gi, ' ') .replace(/ /g, ' ') .replace(/\s+/g, '') .trim(); return textContent.length === 0; } function extractMimePartContent(rawText: string, depth = 0): { html: string | null; text: string | null } { if (depth > 6) { const trimmed = rawText.trim(); return { html: null, text: trimmed || null }; } const separatorMatch = rawText.match(/\r?\n\r?\n/); const separatorIndex = separatorMatch?.index ?? -1; const separator = separatorMatch?.[0] ?? ''; const headerText = separatorIndex >= 0 ? rawText.slice(0, separatorIndex) : ''; const bodyText = separatorIndex >= 0 ? rawText.slice(separatorIndex + separator.length) : rawText; const headers = parseMimeHeaders(headerText); const contentType = (headers.get('content-type') || '').toLowerCase(); const transferEncoding = (headers.get('content-transfer-encoding') || '').toLowerCase(); if (contentType.includes('multipart/')) { const boundary = getMimeBoundary(contentType); if (boundary) { const boundaryMarker = `--${boundary}`; const sections = bodyText.split(boundaryMarker); let bestHtml: string | null = null; let bestText: string | null = null; for (const section of sections) { const trimmedSection = section.trim(); if (!trimmedSection || trimmedSection === '--') continue; const normalizedSection = trimmedSection.endsWith('--') ? trimmedSection.slice(0, -2).trim() : trimmedSection; const extracted = extractMimePartContent(normalizedSection, depth + 1); if (extracted.html && !bestHtml) { bestHtml = extracted.html; } if (extracted.text && !bestText) { bestText = extracted.text; } if (bestHtml && bestText) break; } return { html: bestHtml, text: bestText }; } } if (contentType.includes('message/rfc822')) { return extractMimePartContent(bodyText, depth + 1); } let decodedBody = bodyText; if (transferEncoding.includes('quoted-printable')) { decodedBody = decodeQuotedPrintableUtf8(bodyText); } else if (transferEncoding.includes('base64')) { decodedBody = decodeBase64Utf8(bodyText); } const trimmedBody = decodedBody.trim(); if (!trimmedBody) { return { html: null, text: null }; } if (contentType.includes('text/html')) { return { html: decodedBody, text: null }; } if (contentType.includes('text/plain')) { return { html: null, text: decodedBody }; } if (/^\s* }, rawBytes: Uint8Array, ): { html: string | null; text: string | null; fallbackUsed: boolean } { const parsedHtml = parsed.html?.trim() ? parsed.html : null; const parsedText = parsed.text?.trim() ? parsed.text : null; if (parsedHtml || parsedText) { return { html: parsedHtml, text: parsedText, fallbackUsed: false }; } const rawText = new TextDecoder().decode(rawBytes); const fallback = extractMimePartContent(rawText); if (fallback.html || fallback.text) { return { html: fallback.html, text: fallback.text, fallbackUsed: true }; } const trimmed = rawText.trim(); return { html: null, text: trimmed || null, fallbackUsed: !!trimmed, }; } interface EffectiveAttachment { id: string; name: string | null; type: string; size: number; blobId?: string; cid?: string; decryptedAttachment?: PostalMimeAttachment; tnefData?: Uint8Array; } function getPostalMimeAttachmentSize(attachment: PostalMimeAttachment): number { const bytes = getAttachmentContentBytes(attachment); return bytes?.byteLength ?? 0; } // 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, senderName, onClose, onAddToContacts, }: { email: string; contact: ContactCard | null; senderName?: string; onClose: () => void; onAddToContacts?: (email: string, name?: string) => void; }) { const name = contact ? getContactDisplayName(contact) : senderName || 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.full || a.fullAddress ? (a.full || a.fullAddress) : a.components && a.components.length > 0 ? a.components.filter(c => c.kind !== 'separator').map(c => c.value).filter(Boolean).join(", ") : [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

{onAddToContacts && ( )}
)}
); } 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, onEditDraft, onCompose, currentUserEmail, currentUserName, currentMailboxRole, mailboxes = [], selectedMailbox = "", className, }: EmailViewerProps) { const t = useTranslations('email_viewer'); const tNotifications = useTranslations('notifications'); const tCommon = useTranslations('common'); const tSmime = useTranslations('smime'); const tFiles = useTranslations('files'); const tDemoWelcome = useTranslations('demo_welcome'); const tWelcome = useTranslations('welcome'); const externalContentPolicy = useSettingsStore((state) => state.externalContentPolicy); const mailAttachmentAction = useSettingsStore((state) => state.mailAttachmentAction); const attachmentPosition = useSettingsStore((state) => state.attachmentPosition); 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 emailKeywords = useSettingsStore((state) => state.emailKeywords); const toolbarPosition = useSettingsStore((state) => state.toolbarPosition); const showToolbarLabels = useSettingsStore((state) => state.showToolbarLabels); const mailLayout = useSettingsStore((state) => state.mailLayout); const calendarInvitationParsingEnabled = useSettingsStore((state) => state.calendarInvitationParsingEnabled); const hideInlineImageAttachments = useSettingsStore((state) => state.hideInlineImageAttachments); const attachmentImagePreviewsEnabled = useSettingsStore((state) => state.attachmentImagePreviewsEnabled); const timeFormat = useSettingsStore((state) => state.timeFormat); const isFocusedMailLayout = mailLayout === 'focus'; // Detect if current mailbox is Junk folder const isInJunkFolder = currentMailboxRole === 'junk'; // Detect if the email is a draft const isDraft = email?.keywords?.['$draft'] === true; // 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, isDemoMode, activeAccountId } = useAuthStore(); const resolvedTheme = useThemeStore((state) => state.resolvedTheme); const { startTour } = useTour(); const [showFullHeaders, setShowFullHeaders] = useState(false); const [showAllBesideAttachments, setShowAllBesideAttachments] = useState(false); const [showAllMobileAttachments, setShowAllMobileAttachments] = useState(false); const [showAllBelowHeaderAttachments, setShowAllBelowHeaderAttachments] = useState(false); const [visibleBelowHeaderCount, setVisibleBelowHeaderCount] = useState(null); const belowHeaderRowRef = useRef(null); const belowHeaderGhostRef = useRef(null); const [imageThumbUrls, setImageThumbUrls] = useState>({}); 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 [moreMenuSub, setMoreMenuSub] = useState<'move' | 'tag' | null>(null); 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 [hiddenPriorities, setHiddenPriorities] = useState>(new Set()); const currentColors = getCurrentColors(email?.keywords); const currentColor = currentColors[0] ?? null; // S/MIME state const [smimeStatus, setSmimeStatus] = useState(null); const [smimeDecryptedHtml, setSmimeDecryptedHtml] = useState(null); const [smimeDecryptedText, setSmimeDecryptedText] = useState(null); const [smimeDecryptedAttachments, setSmimeDecryptedAttachments] = useState([]); const [smimeUnlockDialogOpen, setSmimeUnlockDialogOpen] = useState(false); const [smimeUnlockTargetId, setSmimeUnlockTargetId] = useState(null); const [smimeUnlockError, setSmimeUnlockError] = useState(null); const smimeStore = useSmimeStore(); // TNEF (winmail.dat) support const [tnefHtml, setTnefHtml] = useState(null); const [tnefText, setTnefText] = useState(null); const [tnefAttachments, setTnefAttachments] = useState([]); // Embedded message/rfc822 unwrapping (Outlook forward-as-attachment) const [embeddedEmailHtml, setEmbeddedEmailHtml] = useState(null); const [embeddedEmailText, setEmbeddedEmailText] = useState(null); const [embeddedEmailAttachments, setEmbeddedEmailAttachments] = useState([]); const [embeddedEmailUnwrapped, setEmbeddedEmailUnwrapped] = useState(false); // Plugin detail sidebar state const detailSlots = usePluginStore(s => s.slots['email-detail-sidebar']); const hasDetailSidebar = detailSlots && detailSlots.length > 0; const [detailSidebarCollapsed, setDetailSidebarCollapsed] = useState(false); const [detailSidebarWidth, setDetailSidebarWidth] = useState(280); const detailSidebarWidthRef = useRef(280); // Ensure S/MIME key records are loaded from IndexedDB useLayoutEffect(() => { smimeStore.load(activeAccountId ?? undefined); // eslint-disable-next-line react-hooks/exhaustive-deps }, [activeAccountId]); // 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); setMoreMenuSub(null); } 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; let rafId: number | null = null; const calculate = () => { rafId = null; const items = Array.from(el.querySelectorAll('[data-overflow-item]')); if (items.length === 0) { setHiddenPriorities(prev => prev.size === 0 ? prev : new Set()); return; } // Sort descending by priority so highest number (least important) is hidden 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; // Temporarily prevent flex shrinking so we can measure natural widths leftGroup.style.flexShrink = '0'; rightGroup.style.flexShrink = '0'; el.style.overflow = 'hidden'; // Iteratively hide items until content fits const hidden = new Set(); 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'; hidden.add(Number(item.dataset.overflowPriority)); } // Restore layout leftGroup.style.flexShrink = ''; rightGroup.style.flexShrink = ''; el.style.overflow = ''; setHiddenPriorities(prev => { if (prev.size === hidden.size && [...hidden].every(p => prev.has(p))) return prev; return hidden; }); }; const scheduleCalculate = () => { if (rafId !== null) cancelAnimationFrame(rafId); rafId = requestAnimationFrame(calculate); }; // Recalculate on container resize const resizeObserver = new ResizeObserver(scheduleCalculate); resizeObserver.observe(el); // Recalculate when children change (conditional items, label visibility) const mutationObserver = new MutationObserver(scheduleCalculate); mutationObserver.observe(el, { childList: true, subtree: true }); // Initial synchronous calculation to avoid flash calculate(); return () => { if (rafId !== null) cancelAnimationFrame(rafId); resizeObserver.disconnect(); mutationObserver.disconnect(); }; }, [ toolbarPosition, email?.id, showToolbarLabels, isLoading, moveTree.length, colorOptions.length, currentColor, isInJunkFolder, isTablet, tabletListVisible, onBack, onMarkAsSpam, onUndoSpam, ]); // 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(); } ); const autoMarkedEmailRef = useRef(null); // Reset auto-mark tracking when email changes useEffect(() => { autoMarkedEmailRef.current = null; }, [email?.id]); useEffect(() => { // Mark as read when email is viewed, respecting the delay setting if (!email || !onMarkAsRead) { return; } // Already read - record that so manual unread toggle won't re-trigger auto-mark if (email.keywords?.$seen) { autoMarkedEmailRef.current = email.id; return; } // Don't re-trigger if we already auto-marked this email (user may have toggled it back to unread) if (autoMarkedEmailRef.current === email.id) { return; } const markAsReadDelay = useSettingsStore.getState().markAsReadDelay; // Never auto-mark if (markAsReadDelay === -1) { return; } // Instant mark if (markAsReadDelay === 0) { autoMarkedEmailRef.current = email.id; onMarkAsRead(email.id, true); return; } // Delayed mark const timeout = setTimeout(() => { autoMarkedEmailRef.current = email.id; onMarkAsRead(email.id, true); }, markAsReadDelay); return () => clearTimeout(timeout); }, [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); setSmimeStatus(null); setSmimeDecryptedHtml(null); setSmimeDecryptedText(null); setSmimeDecryptedAttachments([]); setSmimeUnlockDialogOpen(false); setSmimeUnlockTargetId(null); setSmimeUnlockError(null); setTnefHtml(null); setTnefText(null); setTnefAttachments([]); setEmbeddedEmailHtml(null); setEmbeddedEmailText(null); setEmbeddedEmailAttachments([]); setEmbeddedEmailUnwrapped(false); }, [email?.id, externalContentPolicy]); const prepareSmimeUnlock = useCallback((keyRecordId: string) => { setSmimeUnlockTargetId(keyRecordId); setSmimeUnlockError(null); }, []); const openSmimeUnlockDialog = useCallback(() => { if (!smimeUnlockTargetId) { return; } setSmimeUnlockDialogOpen(true); }, [smimeUnlockTargetId]); const handleSmimeUnlockSubmit = useCallback(async (passphrase: string) => { if (!smimeUnlockTargetId) { return; } try { await smimeStore.unlockKey(smimeUnlockTargetId, passphrase); setSmimeUnlockDialogOpen(false); setSmimeUnlockTargetId(null); setSmimeUnlockError(null); } catch (error) { setSmimeUnlockError(error instanceof Error ? error.message : 'Unlock failed'); } }, [smimeStore, smimeUnlockTargetId]); // S/MIME detection and processing useEffect(() => { if (!email || !client) return; const smimeDebug = (...args: unknown[]) => { if (useSettingsStore.getState().debugMode) { console.debug(...args); } }; const smimeWarn = (...args: unknown[]) => { if (useSettingsStore.getState().debugMode) { console.warn(...args); } }; const smimeError = (...args: unknown[]) => { console.error(...args); }; const rawContentType = email.headers?.['content-type'] || email.headers?.['Content-Type']; const contentType = Array.isArray(rawContentType) ? rawContentType[0] : rawContentType; const detection = detectSmime( contentType, email.bodyStructure as Parameters[1], email.attachments as Parameters[2], ); smimeDebug('[S/MIME] detection:', { contentType, bodyStructure: email.bodyStructure, attachments: email.attachments, detection }); if (!detection.type) return; // Unsupported type (e.g., detached signature) if (!detection.supported) { setSmimeStatus({ isSigned: detection.type === 'detached-sig', isEncrypted: false, unsupportedReason: 'Detached S/MIME signatures are not yet supported', }); return; } if (!detection.blobId) return; let cancelled = false; async function processSmime() { try { const toHex = (bytes: Uint8Array, count: number) => Array.from(bytes.slice(0, count)).map(b => b.toString(16).padStart(2, '0')).join(' '); const toAsciiPreview = (bytes: Uint8Array, count: number) => { try { return new TextDecoder().decode(bytes.slice(0, count)); } catch { return ''; } }; const toExactArrayBuffer = (view: Uint8Array): ArrayBuffer => view.buffer.slice(view.byteOffset, view.byteOffset + view.byteLength) as ArrayBuffer; const cmsCandidates: Array<{ source: string; raw: ArrayBuffer }> = []; const findPartById = ( part: Parameters[1], targetPartId: string, ): Parameters[1] | undefined => { if (!part) return undefined; if (part.partId === targetPartId) return part; if (part.subParts) { for (const sub of part.subParts) { const found = findPartById(sub as Parameters[1], targetPartId); if (found) return found; } } return undefined; }; const detectedPart = detection.partId ? findPartById(email!.bodyStructure as Parameters[1], detection.partId) : undefined; const detectedPartName = detectedPart?.name || 'smime.p7m'; const detectedPartType = detectedPart?.type || 'application/pkcs7-mime'; const detectedPartSize = (detectedPart as { size?: number } | undefined)?.size; if (detectedPartSize === 0) { smimeWarn('[S/MIME] detected part has size=0; trying multiple blob fetch variants', { partId: detection.partId, blobId: detection.blobId, name: detectedPartName, type: detectedPartType, }); } // Primary source: Blob/download endpoint try { const blobBytes = await client!.fetchBlobArrayBuffer(detection.blobId!); if (blobBytes.byteLength > 0) { cmsCandidates.push({ source: 'blob-default', raw: blobBytes }); } smimeWarn('[S/MIME] blob-default fetch result:', { byteLength: blobBytes.byteLength, }); } catch (error) { smimeWarn('[S/MIME] blob fetch failed:', error); // Fallback sources below may still work } // Variant source: same blob with explicit part name/type in URL template try { const typedBlobBytes = await client!.fetchBlobArrayBuffer( detection.blobId!, detectedPartName, detectedPartType, ); if (typedBlobBytes.byteLength > 0) { cmsCandidates.push({ source: 'blob-typed', raw: typedBlobBytes }); } smimeWarn('[S/MIME] blob-typed fetch result:', { byteLength: typedBlobBytes.byteLength, name: detectedPartName, type: detectedPartType, }); } catch (error) { smimeWarn('[S/MIME] typed blob fetch failed:', error); } // Fallback source: bodyValues entry for the detected S/MIME part const bodyValue = detection.partId ? email!.bodyValues?.[detection.partId]?.value : undefined; const bodyValueMeta = detection.partId ? email!.bodyValues?.[detection.partId] : undefined; smimeWarn('[S/MIME] bodyValues candidate:', { partId: detection.partId, exists: !!bodyValueMeta, valueLength: bodyValue?.length ?? 0, isTruncated: bodyValueMeta?.isTruncated ?? false, isEncodingProblem: bodyValueMeta?.isEncodingProblem ?? false, }); if (bodyValue) { const bodyValueBytes = new TextEncoder().encode(bodyValue); cmsCandidates.push({ source: 'bodyValues', raw: toExactArrayBuffer(bodyValueBytes) }); } // Fallback source: fetch full RFC822 blob and extract CMS bytes from message body // Some servers return empty bytes for part blobId=0 while Email.blobId still has full content. if (email!.blobId) { try { const fullMessageBytes = await client!.fetchBlobArrayBuffer( email!.blobId, 'message.eml', 'message/rfc822', ); if (fullMessageBytes.byteLength > 0) { cmsCandidates.push({ source: 'email-blob', raw: fullMessageBytes }); } smimeWarn('[S/MIME] email-blob fetch result:', { blobId: email!.blobId, byteLength: fullMessageBytes.byteLength, }); } catch (error) { smimeWarn('[S/MIME] email-blob fetch failed:', error); } } else { smimeWarn('[S/MIME] email-blob unavailable: Email.blobId not present'); } if (cmsCandidates.length === 0) { throw new Error('No usable CMS bytes found (blob-default/blob-typed/bodyValues/email-blob all empty)'); } const expandedCandidates: Array<{ source: string; raw: ArrayBuffer }> = []; for (const candidate of cmsCandidates) { expandedCandidates.push(candidate); if (candidate.source === 'email-blob') { // Candidate 1: raw message body (strip RFC822 headers) try { const fullText = new TextDecoder().decode(candidate.raw); const headerEnd = fullText.search(/\r?\n\r?\n/); if (headerEnd >= 0) { const headerSep = fullText.slice(headerEnd).match(/^\r?\n\r?\n/)?.[0] ?? '\r\n\r\n'; const bodyText = fullText.slice(headerEnd + headerSep.length); if (bodyText.trim().length > 0) { const bodyBytes = new TextEncoder().encode(bodyText); expandedCandidates.push({ source: 'email-blob-body', raw: toExactArrayBuffer(bodyBytes), }); } } } catch { // ignore extraction failures } // Candidate 2: parse MIME and extract pkcs7 attachment content try { const { default: PostalMime } = await import('postal-mime'); const parser = new PostalMime(); const parsedFull = await parser.parse(candidate.raw); const smimeAttachment = parsedFull.attachments?.find(att => { const mimeType = ((att as { mimeType?: string }).mimeType || '').toLowerCase(); const filename = ((att as { filename?: string }).filename || '').toLowerCase(); return mimeType.includes('application/pkcs7-mime') || filename.endsWith('.p7m'); }); if (smimeAttachment) { const content = (smimeAttachment as { content?: unknown }).content; if (content instanceof Uint8Array) { expandedCandidates.push({ source: 'email-blob-attachment', raw: toExactArrayBuffer(content), }); } else if (content instanceof ArrayBuffer) { expandedCandidates.push({ source: 'email-blob-attachment', raw: content, }); } else if (typeof content === 'string') { const contentBytes = new TextEncoder().encode(content); expandedCandidates.push({ source: 'email-blob-attachment', raw: toExactArrayBuffer(contentBytes), }); } } } catch (error) { smimeWarn('[S/MIME] email-blob MIME parse/extract failed:', error); } } } const normalizedCandidates = expandedCandidates.map(candidate => ({ source: candidate.source, raw: candidate.raw, normalized: normalizeCmsBytes(candidate.raw), })); const candidateSummaries = normalizedCandidates.map((candidate, index) => { const rawBytes = new Uint8Array(candidate.raw); const normalizedBytes = new Uint8Array(candidate.normalized); return { index, source: candidate.source, rawLength: candidate.raw.byteLength, normalizedLength: candidate.normalized.byteLength, rawFirstBytesHex: toHex(rawBytes, 24), normalizedFirstBytesHex: toHex(normalizedBytes, 24), rawAsciiPreview: toAsciiPreview(rawBytes, 180), }; }); smimeWarn('[S/MIME] CMS candidates:', { detection, candidateCount: candidateSummaries.length, candidates: candidateSummaries, }); if (useSettingsStore.getState().debugMode && typeof window !== 'undefined') { const debugPayload = { emailId: email!.id, detection, generatedAt: new Date().toISOString(), candidates: candidateSummaries, }; const exportCandidate = (index = 0, normalized = true) => { const candidate = normalizedCandidates[index]; if (!candidate) { throw new Error(`Invalid candidate index: ${index}`); } const bytes = normalized ? candidate.normalized : candidate.raw; const mode = normalized ? 'normalized' : 'raw'; const filename = `smime-${email!.id}-${candidate.source}-${index}-${mode}.p7m`; const blob = new Blob([bytes], { type: 'application/pkcs7-mime' }); const url = URL.createObjectURL(blob); const anchor = document.createElement('a'); anchor.href = url; anchor.download = filename; document.body.appendChild(anchor); anchor.click(); anchor.remove(); setTimeout(() => URL.revokeObjectURL(url), 1000); return { filename, byteLength: bytes.byteLength, source: candidate.source, mode }; }; (window as unknown as { __smimeDebugLast?: unknown; __smimeDebugExport?: (index?: number, normalized?: boolean) => unknown; }).__smimeDebugLast = debugPayload; (window as unknown as { __smimeDebugLast?: unknown; __smimeDebugExport?: (index?: number, normalized?: boolean) => unknown; }).__smimeDebugExport = exportCandidate; smimeWarn('[S/MIME] debug helpers ready: window.__smimeDebugLast, window.__smimeDebugExport(index, normalized=true)'); } const isCmsParseError = (error: unknown) => { if (!(error instanceof Error)) return false; return ( error.message.includes('Invalid ASN.1 data') || error.message.includes('Unexpected CMS content type') || error.message.includes('Object\'s schema was not verified against input data for ContentInfo') ); }; const fromEmail = email!.from?.[0]?.email; if (detection.type === 'enveloped-data') { // Encrypted message const { keyRecords, unlockedDecryptionKeys, unlockedLegacyDecryptionKeys } = smimeStore; smimeDebug('[S/MIME] decrypt attempt:', { keyRecordCount: keyRecords.length, unlockedKeyCount: unlockedDecryptionKeys.size, legacyKeyCount: unlockedLegacyDecryptionKeys.size, keyRecordIds: keyRecords.map(k => k.id), }); // Short-circuit: no keys imported at all if (keyRecords.length === 0) { smimeDebug('[S/MIME] no key records available, skipping decrypt'); setSmimeStatus({ isSigned: false, isEncrypted: true, decryptionError: 'no-key', }); return; } try { let result: Awaited> | null = null; let lastError: unknown = null; for (const candidate of normalizedCandidates) { try { result = await smimeDecrypt({ cmsBytes: candidate.normalized, keyRecords, unlockedKeys: unlockedDecryptionKeys, legacyUnlockedKeys: unlockedLegacyDecryptionKeys, }); smimeDebug('[S/MIME] decrypt success with candidate:', { source: candidate.source, byteLength: candidate.normalized.byteLength, }); break; } catch (error) { lastError = error; smimeWarn('[S/MIME] decrypt candidate failed:', { source: candidate.source, error: error instanceof Error ? error.message : String(error), }); // SmimeKeyLockedError should bubble up immediately so the UI can prompt for passphrase if (error instanceof SmimeKeyLockedError) { throw error; } // For other errors (CMS parse, decrypt failure), try the next candidate } } if (!result) { throw lastError instanceof Error ? lastError : new Error('Decryption failed'); } if (cancelled) return; // Parse inner MIME const { default: PostalMime } = await import('postal-mime'); const parser = new PostalMime(); const parsed = await parser.parse(result.mimeBytes); if (cancelled) return; const parsedContent = getRenderableSmimeContent(parsed, result.mimeBytes); smimeDebug('[S/MIME] decrypted MIME parsed:', { subject: parsed.subject, htmlLength: parsed.html?.length ?? 0, textLength: parsed.text?.length ?? 0, attachmentCount: parsed.attachments?.length ?? 0, fallbackUsed: parsedContent.fallbackUsed, renderHtmlLength: parsedContent.html?.length ?? 0, renderTextLength: parsedContent.text?.length ?? 0, }); // Check if inner content is also signed const nestedSignedData = extractNestedSignedDataCandidate(parsed, result.mimeBytes); if (nestedSignedData) { // Nested sign-then-encrypt - verify inner signature const innerBytes = normalizeCmsBytes(nestedSignedData.bytes); smimeDebug('[S/MIME] nested signed-data candidate:', { source: nestedSignedData.source, byteLength: innerBytes.byteLength, }); try { const verifyResult = await smimeVerify(innerBytes, fromEmail); if (cancelled) return; // Parse the verified inner content const innerParsed = await new PostalMime().parse(verifyResult.mimeBytes); if (cancelled) return; const innerParsedContent = getRenderableSmimeContent(innerParsed, verifyResult.mimeBytes); smimeDebug('[S/MIME] verified inner MIME parsed:', { subject: innerParsed.subject, htmlLength: innerParsed.html?.length ?? 0, textLength: innerParsed.text?.length ?? 0, attachmentCount: innerParsed.attachments?.length ?? 0, fallbackUsed: innerParsedContent.fallbackUsed, renderHtmlLength: innerParsedContent.html?.length ?? 0, renderTextLength: innerParsedContent.text?.length ?? 0, }); setSmimeDecryptedHtml(innerParsedContent.html); setSmimeDecryptedText(innerParsedContent.text); setSmimeDecryptedAttachments(innerParsed.attachments ?? []); setSmimeStatus({ ...verifyResult.status, isEncrypted: true, decryptionSuccess: true, }); // Auto-import signer cert if enabled if (smimeStore.autoImportSignerCerts && verifyResult.status.signatureValid && verifyResult.status.signerCert) { const existing = smimeStore.getPublicCertForEmail(verifyResult.status.signerCert.email); if (!existing) { try { await smimeStore.importPublicCert(verifyResult.status.signerCert.certificate, 'signed-email'); smimeDebug('[S/MIME] auto-imported signer cert:', { email: verifyResult.status.signerCert.email, fingerprint: verifyResult.status.signerCert.fingerprint }); } catch (importErr) { smimeError('[S/MIME] auto-import signer cert failed:', importErr); } } else { smimeDebug('[S/MIME] signer cert already imported:', { email: existing.email, fingerprint: existing.fingerprint }); } } else if (verifyResult.status.signatureValid && verifyResult.status.signerCert) { smimeDebug('[S/MIME] auto-import disabled, skipping signer cert:', { email: verifyResult.status.signerCert.email }); } } catch (error) { smimeError('[S/MIME] nested signature verify failed:', { source: nestedSignedData.source, error: error instanceof Error ? error.message : String(error), }); // Verification failed but decryption worked setSmimeDecryptedHtml(parsedContent.html); setSmimeDecryptedText(parsedContent.text); setSmimeDecryptedAttachments((parsed.attachments ?? []) as PostalMimeAttachment[]); setSmimeStatus({ isSigned: false, isEncrypted: true, decryptionSuccess: true, }); } } else { setSmimeDecryptedHtml(parsedContent.html); setSmimeDecryptedText(parsedContent.text); setSmimeDecryptedAttachments((parsed.attachments ?? []) as PostalMimeAttachment[]); setSmimeStatus({ isSigned: false, isEncrypted: true, decryptionSuccess: true, }); } } catch (err) { if (cancelled) return; smimeError('[S/MIME] decrypt error:', err); if (err instanceof SmimeKeyLockedError) { prepareSmimeUnlock(err.keyRecordId); setSmimeStatus({ isSigned: false, isEncrypted: true, decryptionError: 'locked', }); } else { const errMsg = err instanceof Error ? err.message : 'Decryption failed'; const isNoKeyError = errMsg.includes('No imported S/MIME key matches'); setSmimeStatus({ isSigned: false, isEncrypted: true, decryptionError: isNoKeyError ? 'no-key' : errMsg, }); } } } else if (detection.type === 'signed-data') { // Signed message try { let result: Awaited> | null = null; let lastError: unknown = null; for (const candidate of normalizedCandidates) { try { result = await smimeVerify(candidate.normalized, fromEmail); smimeDebug('[S/MIME] verify success with candidate:', { source: candidate.source, byteLength: candidate.normalized.byteLength, }); break; } catch (error) { lastError = error; smimeWarn('[S/MIME] verify candidate failed:', { source: candidate.source, error: error instanceof Error ? error.message : String(error), }); if (!isCmsParseError(error)) { throw error; } } } if (!result) { throw lastError instanceof Error ? lastError : new Error('Verification failed'); } if (cancelled) return; // Parse inner MIME const { default: PostalMime } = await import('postal-mime'); const parser = new PostalMime(); const parsed = await parser.parse(result.mimeBytes); if (cancelled) return; const parsedContent = getRenderableSmimeContent(parsed, result.mimeBytes); smimeDebug('[S/MIME] verified MIME parsed:', { subject: parsed.subject, htmlLength: parsed.html?.length ?? 0, textLength: parsed.text?.length ?? 0, attachmentCount: parsed.attachments?.length ?? 0, fallbackUsed: parsedContent.fallbackUsed, renderHtmlLength: parsedContent.html?.length ?? 0, renderTextLength: parsedContent.text?.length ?? 0, }); setSmimeDecryptedHtml(parsedContent.html); setSmimeDecryptedText(parsedContent.text); setSmimeDecryptedAttachments((parsed.attachments ?? []) as PostalMimeAttachment[]); setSmimeStatus(result.status); // Auto-import signer cert if enabled if (smimeStore.autoImportSignerCerts && result.status.signatureValid && result.status.signerCert) { const existing = smimeStore.getPublicCertForEmail(result.status.signerCert.email); if (!existing) { try { await smimeStore.importPublicCert(result.status.signerCert.certificate, 'signed-email'); smimeDebug('[S/MIME] auto-imported signer cert:', { email: result.status.signerCert.email, fingerprint: result.status.signerCert.fingerprint }); } catch (importErr) { smimeError('[S/MIME] auto-import signer cert failed:', importErr); } } else { smimeDebug('[S/MIME] signer cert already imported:', { email: existing.email, fingerprint: existing.fingerprint }); } } else if (result.status.signatureValid && result.status.signerCert) { smimeDebug('[S/MIME] auto-import disabled, skipping signer cert:', { email: result.status.signerCert.email }); } } catch (err) { if (cancelled) return; setSmimeStatus({ isSigned: true, isEncrypted: false, signatureValid: false, signatureError: err instanceof Error ? err.message : 'Verification failed', }); } } } catch (err) { if (cancelled) return; smimeError('[S/MIME] processing failed before decrypt/verify:', err); // Failed to fetch CMS blob setSmimeStatus({ isSigned: false, isEncrypted: detection.type === 'enveloped-data', decryptionError: err instanceof Error ? err.message : 'Failed to fetch encrypted content', }); } } processSmime(); return () => { cancelled = true; }; }, [ email, client, prepareSmimeUnlock, smimeStore.autoImportSignerCerts, smimeStore.keyRecords, smimeStore.unlockedDecryptionKeys, smimeStore.unlockedLegacyDecryptionKeys, smimeStore, ]); // TNEF (winmail.dat) detection and processing useEffect(() => { if (!email?.attachments || !client) return; const tnefAtt = email.attachments.find(att => isTnefAttachment(att.name, att.type)); if (!tnefAtt?.blobId) { debug.log('email', 'TNEF: No winmail.dat attachment found in email', email?.id); return; } debug.group('TNEF Processing', 'email'); debug.log('email', 'Found TNEF attachment:', tnefAtt.name, 'type:', tnefAtt.type, 'blobId:', tnefAtt.blobId, 'size:', tnefAtt.size); // Check if the email already has a usable HTML body with real content // Outlook often forwards TNEF emails with an HTML body that's just Word // boilerplate (CSS +  ) - treat these as effectively empty. const htmlPartId = email.htmlBody?.[0]?.partId; const htmlValue = htmlPartId ? email.bodyValues?.[htmlPartId]?.value?.trim() : ''; let hasRealHtmlBody = !!htmlValue; if (hasRealHtmlBody && htmlValue && isHtmlBodyEffectivelyEmpty(htmlValue)) { hasRealHtmlBody = false; debug.log('email', 'TNEF: Email HTML body is effectively empty (only boilerplate/whitespace), treating as no body'); } if (hasRealHtmlBody) { debug.log('email', 'TNEF: Email has real HTML body, will extract attachments only'); } else { debug.log('email', 'TNEF: Email has no usable HTML body, proceeding with full TNEF extraction'); } let cancelled = false; async function processTnef() { try { debug.time('TNEF fetch blob', 'email'); const blobBytes = await client!.fetchBlobArrayBuffer(tnefAtt!.blobId!); debug.timeEnd('TNEF fetch blob', 'email'); debug.log('email', 'TNEF: Fetched blob, size:', blobBytes.byteLength, 'bytes'); if (cancelled) { debug.log('email', 'TNEF: Processing cancelled after fetch'); debug.groupEnd(); return; } if (blobBytes.byteLength === 0) { debug.warn('email', 'TNEF: Fetched blob is empty (0 bytes)'); debug.groupEnd(); return; } const tnefData = new Uint8Array(blobBytes); debug.time('TNEF parse', 'email'); const parsed = parseTnef(tnefData); debug.timeEnd('TNEF parse', 'email'); if (cancelled) { debug.log('email', 'TNEF: Processing cancelled after parse'); debug.groupEnd(); return; } debug.log('email', 'TNEF parse result - htmlBody:', !!parsed.htmlBody, '(' + (parsed.htmlBody?.length ?? 0) + ' chars)', ', body:', !!parsed.body, '(' + (parsed.body?.length ?? 0) + ' chars)', ', attachments:', parsed.attachments.length); if (parsed.htmlBody && !hasRealHtmlBody) { setTnefHtml(parsed.htmlBody); } if (parsed.body && !hasRealHtmlBody) { setTnefText(parsed.body); } if (parsed.attachments.length > 0) { setTnefAttachments(parsed.attachments); debug.log('email', 'TNEF extracted attachments:', parsed.attachments.map(a => a.name + ' (' + a.mimeType + ', ' + a.data.byteLength + ' bytes)').join(', ')); } if (!parsed.htmlBody && !parsed.body && parsed.attachments.length === 0) { debug.warn('email', 'TNEF: Parsing succeeded but no content was extracted - the winmail.dat may use an unsupported format'); } debug.groupEnd(); } catch (err) { debug.error('TNEF processing failed for email', email?.id, err); debug.groupEnd(); } } processTnef(); return () => { cancelled = true; }; }, [email, client]); // Embedded message/rfc822 unwrapping // When Outlook forwards an email as an attachment, the outer email body is // often empty Word boilerplate and the real content is inside a message/rfc822 // attachment. Detect this pattern and unwrap the embedded email. useEffect(() => { if (!email?.attachments || !client) return; // Find message/rfc822 attachment const rfc822Att = email.attachments.find( att => att.type === 'message/rfc822' && att.blobId ); if (!rfc822Att?.blobId) return; // Only unwrap if the outer body is effectively empty const htmlPartId = email.htmlBody?.[0]?.partId; const htmlValue = htmlPartId ? email.bodyValues?.[htmlPartId]?.value?.trim() : ''; const textPartId = email.textBody?.[0]?.partId; const textValue = textPartId ? email.bodyValues?.[textPartId]?.value?.trim() : ''; const hasRealHtml = !!htmlValue && !isHtmlBodyEffectivelyEmpty(htmlValue); const hasRealText = !!textValue; if (hasRealHtml || hasRealText) { debug.log('email', 'Embedded RFC822: Outer email has real body content, not unwrapping'); return; } debug.group('Embedded RFC822 Unwrapping', 'email'); debug.log('email', 'Found message/rfc822 attachment:', rfc822Att.name, 'blobId:', rfc822Att.blobId, 'size:', rfc822Att.size); debug.log('email', 'Outer email body is empty, will unwrap embedded email'); let cancelled = false; async function unwrapEmbedded() { try { const blobBytes = await client!.fetchBlobArrayBuffer(rfc822Att!.blobId!); if (cancelled) { debug.groupEnd(); return; } if (blobBytes.byteLength === 0) { debug.warn('email', 'Embedded RFC822: Fetched blob is empty'); debug.groupEnd(); return; } const { default: PostalMime } = await import('postal-mime'); const parser = new PostalMime(); const parsed = await parser.parse(new Uint8Array(blobBytes)); if (cancelled) { debug.groupEnd(); return; } debug.log('email', 'Embedded RFC822 parsed - html:', !!parsed.html, '(' + (parsed.html?.length ?? 0) + ' chars)', ', text:', !!parsed.text, '(' + (parsed.text?.length ?? 0) + ' chars)', ', attachments:', parsed.attachments?.length ?? 0); if (parsed.html) { setEmbeddedEmailHtml(parsed.html); } if (parsed.text) { setEmbeddedEmailText(parsed.text); } if (parsed.attachments && parsed.attachments.length > 0) { setEmbeddedEmailAttachments(parsed.attachments as PostalMimeAttachment[]); debug.log('email', 'Embedded RFC822 attachments:', parsed.attachments.map( a => (a.filename || 'unnamed') + ' (' + a.mimeType + ')' ).join(', ')); } setEmbeddedEmailUnwrapped(true); debug.groupEnd(); } catch (err) { debug.error('Embedded RFC822 unwrapping failed:', err); debug.groupEnd(); } } unwrapEmbedded(); return () => { cancelled = true; }; }, [email, client]); // Fetch inline CID images with authentication to prevent browser auth dialogs useEffect(() => { let cancelled = false; const objectUrls: string[] = []; const decryptedCidAttachments = smimeDecryptedAttachments.filter(att => att.contentId); if (decryptedCidAttachments.length > 0) { const urls: Record = {}; decryptedCidAttachments.forEach((att) => { const bytes = getAttachmentContentBytes(att); if (!bytes) return; const cidValue = att.contentId!.replace(/^<|>$/g, ''); const buffer = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer; const blob = new Blob([buffer], { type: att.mimeType || 'application/octet-stream' }); const objectUrl = URL.createObjectURL(blob); urls[cidValue] = objectUrl; objectUrls.push(objectUrl); }); setCidBlobUrls(urls); return () => { cancelled = true; objectUrls.forEach(url => URL.revokeObjectURL(url)); }; } if (!client || !email?.attachments) { setCidBlobUrls({}); return; } const cidAttachments = email.attachments.filter(att => att.cid && att.blobId); if (cidAttachments.length === 0) { setCidBlobUrls({}); return; } 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, smimeDecryptedAttachments, email?.attachments]); const effectiveAttachments = useMemo(() => { if (smimeDecryptedAttachments.length > 0) { return smimeDecryptedAttachments .filter(att => !(hideInlineImageAttachments && att.contentId && (att.mimeType || '').startsWith('image/'))) .map((attachment, index) => ({ id: `smime-${index}-${attachment.filename || attachment.mimeType}`, name: attachment.filename, type: attachment.mimeType || 'application/octet-stream', size: getPostalMimeAttachmentSize(attachment), cid: attachment.contentId, decryptedAttachment: attachment, })); } const hasCalInvitation = calendarInvitationParsingEnabled && !!email && !!findCalendarAttachment(email); const jmapAttachments = (email?.attachments ?? []) // Hide winmail.dat when we have successfully extracted TNEF content or attachments .filter(att => !(tnefHtml || tnefText || tnefAttachments.length > 0) || !isTnefAttachment(att.name, att.type)) // Hide message/rfc822 when we have unwrapped the embedded email .filter(att => !embeddedEmailUnwrapped || att.type !== 'message/rfc822') // Hide calendar MIME parts (text/calendar, application/ics) when the invitation // banner is shown - prevents raw ICS files appearing as spurious attachments. .filter(att => !hasCalInvitation || !isCalendarMimeType(att.type)) // Hide inline cid-referenced images when the user has opted to keep them // out of the attachment list (default on): these are embedded in the body. .filter(att => !(hideInlineImageAttachments && att.cid && att.disposition === 'inline' && (att.type || '').startsWith('image/'))) .map((attachment, index) => ({ id: attachment.blobId || `${attachment.name || 'attachment'}-${index}`, name: attachment.name || null, type: attachment.type || 'application/octet-stream', size: attachment.size, blobId: attachment.blobId, cid: attachment.cid, })); // Append attachments extracted from TNEF const tnefExtracted: EffectiveAttachment[] = tnefAttachments.map((att, index) => ({ id: `tnef-${index}-${att.name}`, name: att.name, type: att.mimeType, size: att.data.byteLength, tnefData: att.data, })); // Append attachments extracted from embedded message/rfc822 const embeddedExtracted: EffectiveAttachment[] = embeddedEmailAttachments .filter(att => !att.contentId) // Skip inline CID images .map((att, index) => ({ id: `embedded-${index}-${att.filename || att.mimeType}`, name: att.filename || null, type: att.mimeType || 'application/octet-stream', size: getPostalMimeAttachmentSize(att), decryptedAttachment: att, })); return [...jmapAttachments, ...tnefExtracted, ...embeddedExtracted]; }, [email?.attachments, smimeDecryptedAttachments, tnefHtml, tnefText, tnefAttachments, embeddedEmailUnwrapped, embeddedEmailAttachments, calendarInvitationParsingEnabled, hideInlineImageAttachments]); // Measure attachment chips in the below-header row to determine how many fit // on a single line; the rest collapse into a "+N attachments" overflow pill. useLayoutEffect(() => { if (attachmentPosition !== 'below-header' || effectiveAttachments.length === 0) { setVisibleBelowHeaderCount(null); return; } const container = belowHeaderRowRef.current; const ghost = belowHeaderGhostRef.current; if (!container || !ghost) return; const measure = () => { const containerWidth = container.clientWidth; const chips = Array.from(ghost.children) as HTMLElement[]; if (chips.length === 0) return; const ghostLeft = ghost.getBoundingClientRect().left; // Reserve space for the "+N attachments" overflow pill const RESERVED = 140; let count = chips.length; for (let i = 0; i < chips.length; i++) { const right = chips[i].getBoundingClientRect().right - ghostLeft; const isLast = i === chips.length - 1; const limit = isLast ? containerWidth : containerWidth - RESERVED; if (right > limit) { count = i; break; } } setVisibleBelowHeaderCount(count >= chips.length ? null : Math.max(1, count)); }; measure(); const ro = new ResizeObserver(measure); ro.observe(container); return () => ro.disconnect(); }, [effectiveAttachments, attachmentPosition, imageThumbUrls]); // 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, hasStyleTag: 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; // Per RFC 8621 § 4.1.4, when a message has only one alternative the server // exposes the same part in both htmlBody and textBody. The shared part may // actually be text/plain (plain-text-only mail) — rendering that as HTML // collapses newlines and skips linkification, so route by the part's type. const htmlPart = email.htmlBody[0]; if (htmlPart.type && htmlPart.type.toLowerCase() !== 'text/html') { useHtmlVersion = false; } else { // Prefer textBody when HTML is auto-generated minimal wrapper (no rich formatting). // Server-generated HTML from text/plain emails often lacks
tags, collapsing newlines. const textPartId = email.textBody?.[0]?.partId; const htmlPartId = htmlPart.partId; const hasDistinctTextBody = !!textPartId && textPartId !== htmlPartId && !!email.bodyValues[textPartId]; if (hasDistinctTextBody && htmlContent) { useHtmlVersion = hasMeaningfulHtmlBody(htmlContent); } else { 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_IFRAME_SANITIZE_CONFIG }; // Check if sender is trusted (localStorage list or address book) const senderEmail = email.from?.[0]?.email?.toLowerCase(); const senderIsTrusted = senderEmail ? isSenderTrusted(senderEmail) || (trustedSendersAddressBook && isTrustedAddressBookSender(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 = [...sanitizeConfig.FORBID_TAGS, 'link']; } 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; } } const bgAttr = node.getAttribute?.('background'); if (bgAttr && (bgAttr.startsWith('http://') || bgAttr.startsWith('https://') || bgAttr.startsWith('//'))) { node.setAttribute('data-blocked-background', bgAttr); node.removeAttribute('background'); 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) { node.setAttribute('data-blocked-style', style); 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, hasStyleTag: /]/i.test(htmlContent), }; } // 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; return { html: plainTextToSafeHtml(textContent), isHtml: false, hasStyleTag: false, }; } } // If no body content is available, show the preview or a message if (email.preview) { const previewHtml = email.preview .replace(/&/g, '&') .replace(//g, '>'); return { html: `
${previewHtml}
`, isHtml: false, hasStyleTag: false, }; } return { html: '

No content available

', isHtml: false, hasStyleTag: false, }; // Intentionally omit allowExternalContent and trust state from deps: // toggling permission imperatively unblocks content via restoreBlockedContent // in an effect below, so the iframe srcDoc stays stable and doesn't reload/flash. // eslint-disable-next-line react-hooks/exhaustive-deps }, [email, externalContentPolicy, cidBlobUrls]); // Override email content with S/MIME decrypted content when available const effectiveEmailContent = useMemo(() => { if (smimeDecryptedHtml) { const htmlWithCidUrls = smimeDecryptedHtml.replace( /\bcid:([^"'\s)]+)/gi, (_match, cidRef) => { return cidBlobUrls[cidRef] || 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'; } ); const cleanHtml = DOMPurify.sanitize(htmlWithCidUrls, EMAIL_IFRAME_SANITIZE_CONFIG); return { html: cleanHtml, isHtml: true, hasStyleTag: /]/i.test(smimeDecryptedHtml) }; } if (smimeDecryptedText) { return { html: plainTextToSafeHtml(smimeDecryptedText), isHtml: false, hasStyleTag: false }; } // TNEF (winmail.dat) extracted content if (tnefHtml) { const cleanHtml = DOMPurify.sanitize(tnefHtml, EMAIL_IFRAME_SANITIZE_CONFIG); return { html: cleanHtml, isHtml: true, hasStyleTag: /]/i.test(tnefHtml) }; } if (tnefText) { return { html: plainTextToSafeHtml(tnefText), isHtml: false, hasStyleTag: false }; } // Embedded message/rfc822 unwrapped content if (embeddedEmailHtml) { const cleanHtml = DOMPurify.sanitize(embeddedEmailHtml, EMAIL_IFRAME_SANITIZE_CONFIG); return { html: cleanHtml, isHtml: true, hasStyleTag: /]/i.test(embeddedEmailHtml) }; } if (embeddedEmailText) { return { html: plainTextToSafeHtml(embeddedEmailText), isHtml: false, hasStyleTag: false }; } return emailContent; }, [cidBlobUrls, emailContent, smimeDecryptedHtml, smimeDecryptedText, tnefHtml, tnefText, embeddedEmailHtml, embeddedEmailText]); const handleEffectiveAttachmentOpen = useCallback(async (attachment: EffectiveAttachment) => { const isPreviewable = isFilePreviewable(attachment.name || undefined, attachment.type); const opensPreview = isPreviewable && mailAttachmentAction === 'preview'; const info: AttachmentInfo = { name: attachment.name || '', type: attachment.type, size: attachment.size, blobId: attachment.blobId, emailId: email?.id, }; if (attachment.blobId && onDownloadAttachment) { emailHooks.onAttachmentDownload.emit(info); onDownloadAttachment(attachment.blobId, attachment.name || 'download', attachment.type); return; } // Handle TNEF-extracted attachments if (attachment.tnefData) { const buffer = attachment.tnefData.buffer.slice( attachment.tnefData.byteOffset, attachment.tnefData.byteOffset + attachment.tnefData.byteLength, ) as ArrayBuffer; const blob = new Blob([buffer], { type: attachment.type || 'application/octet-stream' }); const objectUrl = URL.createObjectURL(blob); if (opensPreview) { const transformed = await emailHooks.onAttachmentPreview.transform({ previewUrl: objectUrl } as AttachmentPreview, info); window.open(transformed.previewUrl || objectUrl, '_blank', 'noopener,noreferrer'); } else { emailHooks.onAttachmentDownload.emit(info); const anchor = document.createElement('a'); anchor.href = objectUrl; anchor.download = attachment.name || 'download'; document.body.appendChild(anchor); anchor.click(); anchor.remove(); } setTimeout(() => URL.revokeObjectURL(objectUrl), 60000); return; } if (!attachment.decryptedAttachment) { return; } const bytes = getAttachmentContentBytes(attachment.decryptedAttachment); if (!bytes || bytes.byteLength === 0) { return; } const buffer = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer; const blob = new Blob([buffer], { type: attachment.type || 'application/octet-stream' }); const objectUrl = URL.createObjectURL(blob); if (opensPreview) { const transformed = await emailHooks.onAttachmentPreview.transform({ previewUrl: objectUrl } as AttachmentPreview, info); window.open(transformed.previewUrl || objectUrl, '_blank', 'noopener,noreferrer'); } else { emailHooks.onAttachmentDownload.emit(info); const anchor = document.createElement('a'); anchor.href = objectUrl; anchor.download = attachment.name || 'download'; document.body.appendChild(anchor); anchor.click(); anchor.remove(); } setTimeout(() => URL.revokeObjectURL(objectUrl), 60_000); }, [mailAttachmentAction, onDownloadAttachment, email?.id]); const handleEffectiveAttachmentDownload = useCallback((attachment: EffectiveAttachment) => { const info: AttachmentInfo = { name: attachment.name || '', type: attachment.type, size: attachment.size, blobId: attachment.blobId, emailId: email?.id, }; emailHooks.onAttachmentDownload.emit(info); if (attachment.blobId && onDownloadAttachment) { onDownloadAttachment(attachment.blobId, attachment.name || 'download', attachment.type, true); return; } if (attachment.tnefData) { const buffer = attachment.tnefData.buffer.slice( attachment.tnefData.byteOffset, attachment.tnefData.byteOffset + attachment.tnefData.byteLength, ) as ArrayBuffer; const blob = new Blob([buffer], { type: attachment.type || 'application/octet-stream' }); const objectUrl = URL.createObjectURL(blob); const anchor = document.createElement('a'); anchor.href = objectUrl; anchor.download = attachment.name || 'download'; document.body.appendChild(anchor); anchor.click(); anchor.remove(); setTimeout(() => URL.revokeObjectURL(objectUrl), 60000); return; } if (!attachment.decryptedAttachment) return; const bytes = getAttachmentContentBytes(attachment.decryptedAttachment); if (!bytes || bytes.byteLength === 0) return; const buffer = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer; const blob = new Blob([buffer], { type: attachment.type || 'application/octet-stream' }); const objectUrl = URL.createObjectURL(blob); const anchor = document.createElement('a'); anchor.href = objectUrl; anchor.download = attachment.name || 'download'; document.body.appendChild(anchor); anchor.click(); anchor.remove(); setTimeout(() => URL.revokeObjectURL(objectUrl), 60_000); }, [onDownloadAttachment, email?.id]); // Pre-fetch object URLs for image attachments so their actual contents can be // rendered as thumbnails inside the chip. Skips images larger than 10 MB. useEffect(() => { let cancelled = false; const createdUrls: string[] = []; if (!attachmentImagePreviewsEnabled) { setImageThumbUrls({}); return; } const imageAttachments = effectiveAttachments.filter( (att) => (att.type || '').startsWith('image/') && att.size <= 10_000_000, ); if (imageAttachments.length === 0) { setImageThumbUrls({}); return; } (async () => { const next: Record = {}; await Promise.all(imageAttachments.map(async (att) => { let url: string | undefined; try { if (att.blobId && client) { url = await client.fetchBlobAsObjectUrl(att.blobId, att.name || 'thumb', att.type); } else if (att.decryptedAttachment) { const bytes = getAttachmentContentBytes(att.decryptedAttachment); if (!bytes || bytes.byteLength === 0) return; const buffer = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer; url = URL.createObjectURL(new Blob([buffer], { type: att.type || 'application/octet-stream' })); } else if (att.tnefData) { const buffer = att.tnefData.buffer.slice( att.tnefData.byteOffset, att.tnefData.byteOffset + att.tnefData.byteLength, ) as ArrayBuffer; url = URL.createObjectURL(new Blob([buffer], { type: att.type || 'application/octet-stream' })); } } catch { return; } if (!url) return; if (cancelled) { URL.revokeObjectURL(url); return; } createdUrls.push(url); next[att.id] = url; })); if (!cancelled) setImageThumbUrls(next); })(); return () => { cancelled = true; createdUrls.forEach((url) => URL.revokeObjectURL(url)); }; }, [effectiveAttachments, client, attachmentImagePreviewsEnabled]); // 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 (!effectiveEmailContent.isHtml) return false; return /prefers-color-scheme\s*:\s*dark/i.test(effectiveEmailContent.html); }, [effectiveEmailContent.html, effectiveEmailContent.isHtml]); const emailAlwaysLightMode = useSettingsStore((state) => state.emailAlwaysLightMode); const [emailViewDarkOverride, setEmailViewDarkOverride] = useState(null); const isDark = emailViewDarkOverride !== null ? emailViewDarkOverride : (emailAlwaysLightMode ? false : resolvedTheme === 'dark'); const emailIframeSrcDoc = useMemo(() => { if (!effectiveEmailContent.isHtml) return ''; // If email has native dark mode, let it handle its own theming // Otherwise, use CSS filter inversion for dark mode (preserves layout) // Re-invert leaf media elements so they appear normal. // Container selectors (bgcolor, background, etc.) use :not(:has(...)) to avoid // double re-inverting images nested inside those containers. const darkModeCSS = isDark && !emailHasNativeDarkMode ? ` html { background: #1a1a1a; } body { filter: invert(1) hue-rotate(180deg); } img, video, svg, canvas, object, embed, input[type="image"] { filter: invert(1) hue-rotate(180deg); } [style*="background-image"]:not(:has(img, video, svg, canvas, object, embed)), [style*="background:"]:not(:has(img, video, svg, canvas, object, embed)), [background]:not(:has(img, video, svg, canvas, object, embed)), [bgcolor]:not(:has(img, video, svg, canvas, object, embed)), td[background]:not(:has(img, video, svg, canvas, object, embed)), table[background]:not(:has(img, video, svg, canvas, object, embed)) { filter: invert(1) hue-rotate(180deg); } ` : ''; const colorScheme = isDark && emailHasNativeDarkMode ? 'light dark' : 'light'; // Bare HTML emails (no ${effectiveEmailContent.html}`; }, [effectiveEmailContent.html, effectiveEmailContent.isHtml, isDark, emailHasNativeDarkMode]); // Imperatively restore blocked external content inside the iframe document. // Avoids re-rendering the iframe srcDoc (which would reload and flash) when // the user clicks "Load images" or "Trust sender". const restoreBlockedContent = useCallback(() => { const doc = iframeRef.current?.contentDocument; if (!doc) return; doc.querySelectorAll('img[data-blocked-src]').forEach((node) => { const el = node as HTMLImageElement; const src = el.getAttribute('data-blocked-src'); if (src) { el.setAttribute('src', src); el.style.display = ''; el.removeAttribute('data-blocked-src'); } }); doc.querySelectorAll('[data-blocked-style]').forEach((node) => { const el = node as HTMLElement; const style = el.getAttribute('data-blocked-style'); if (style !== null) { el.style.cssText = style; el.removeAttribute('data-blocked-style'); } }); doc.querySelectorAll('[data-blocked-background]').forEach((node) => { const el = node as HTMLElement; const bg = el.getAttribute('data-blocked-background'); if (bg) { el.setAttribute('background', bg); el.removeAttribute('data-blocked-background'); } }); doc.querySelectorAll('[data-blocked-collapsed-style]').forEach((node) => { const el = node as HTMLElement; const style = el.getAttribute('data-blocked-collapsed-style'); if (style !== null) { el.style.cssText = style; el.removeAttribute('data-blocked-collapsed-style'); } }); }, []); // Whenever permission is granted (allow toggled, or sender becomes trusted), // restore blocked content in the existing iframe - no srcDoc rebuild. const senderEmailLower = email?.from?.[0]?.email?.toLowerCase(); const senderIsTrustedNow = senderEmailLower ? isSenderTrusted(senderEmailLower) || (trustedSendersAddressBook && isTrustedAddressBookSender(senderEmailLower)) : false; useEffect(() => { if (!hasBlockedContent) return; if (!allowExternalContent && !senderIsTrustedNow) return; restoreBlockedContent(); }, [allowExternalContent, senderIsTrustedNow, hasBlockedContent, restoreBlockedContent]); // Tracks the last rendered body height so the loading skeleton can hold // the same size - avoids the body shrink/expand flash when switching emails. const lastBodyHeightRef = useRef(300); // True while the new email's body is still being fetched. Catches the // window between selectedEmail changing and isLoading flipping true, so the // quick reply / body don't flicker through a partial render. const isBodyLoading = isLoading || !email?.bodyValues || Object.keys(email.bodyValues).length === 0; // Gates the quick reply on the iframe having loaded the current srcDoc, so // it doesn't flash in below a still-resizing iframe. const [iframeReady, setIframeReady] = useState(false); useLayoutEffect(() => { setIframeReady(false); }, [emailIframeSrcDoc]); 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'; lastBodyHeightRef.current = height; }); resizeObserver.observe(doc.body); const initialHeight = doc.documentElement.scrollHeight; iframe.style.height = initialHeight + 'px'; lastBodyHeightRef.current = initialHeight; setIframeReady(true); // Make links open in new tab doc.querySelectorAll('a').forEach(a => { a.setAttribute('target', '_blank'); a.setAttribute('rel', 'noopener noreferrer'); }); // Plugin intercept: let plugins cancel or rewrite external links inside // the email body before navigation happens. Bound on the iframe doc so // it survives DOM mutations from dark-mode pass below. const onLinkClick = async (ev: Event) => { const targetEl = (ev.target as Element | null)?.closest?.('a[href]') as HTMLAnchorElement | null; if (!targetEl) return; const href = targetEl.getAttribute('href') || ''; if (!href || href.startsWith('#') || href.startsWith('mailto:')) return; ev.preventDefault(); ev.stopPropagation(); const ctx = { href, target: targetEl.getAttribute('target') ?? undefined, emailId: email?.id, }; const ok = await uiHooks.onBeforeExternalLink.intercept(ctx); if (!ok) return; window.open(ctx.href, '_blank', 'noopener,noreferrer'); }; doc.addEventListener('click', onLinkClick, true); // Dark mode: re-invert elements with stylesheet-defined background images // (CSS attribute selectors only catch inline styles, not
${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)}
` : ''}
${effectiveEmailContent.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 ? calendarInvitationParsingEnabled && !!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) { if (isDemoMode) { const logoSrc = resolvedTheme === 'dark' ? '/branding/Bulwark_Logo_with_Lettering_White_and_Color.svg' : '/branding/Bulwark_Logo_with_Lettering_Dark_Color.svg'; return (
Bulwark Mail

{tDemoWelcome('title')}

{tDemoWelcome('description')}

{tDemoWelcome('feature_email')}
{tDemoWelcome('feature_organize')}
{tDemoWelcome('feature_shortcuts')}
{tDemoWelcome('feature_privacy')}

{tDemoWelcome('hint')}

); } return (

{t('no_conversation_selected')}

{t('no_conversation_description')}

{onCompose && ( )}
); } const sender = email.from?.[0]; const isStarred = email.keywords?.$flagged; const isUnread = !email.keywords?.$seen; const isImportant = email.keywords?.["$important"]; // Shared toolbar items used by both 'top' and 'below-subject' positions const renderToolbarItems = (showBackButton: boolean) => ( <> {/* Left: Reply actions */}
{showBackButton && (isMobile || (isTablet && !tabletListVisible) || (isFocusedMailLayout && !isMobile)) && onBack && ( )} {isDraft && onEditDraft && ( )} {!isDraft && (<> )}
{/* Right: Organize actions - order: archive, delete, move, tag, spam, read state, print, view source */}
{/* Archive */} {/* Delete */} {/* Move to folder */} {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 - hidden on mobile, overflows to More menu */}
{tagMenuOpen && (
{colorOptions.map((option) => { const isActive = currentColors.includes(option.value); return ( ); })} {currentColors.length > 0 && ( <>
)}
)}
{/* Spam */} {(onMarkAsSpam || onUndoSpam) && ( )} {/* Toggle read state */} {/* Print - hidden on mobile, overflows to More menu */} {/* View source - hidden on mobile, overflows to More menu */} {/* Dark/light mode toggle for HTML emails */} {effectiveEmailContent.isHtml && ( )} {/* More menu - click-based */}
{moreMenuOpen && !isMobile && (
{/* Star toggle */} {/* Overflow: reply */} {/* Overflow: reply all */} {/* Overflow: forward */} {/* Overflow: archive */} {/* Overflow: move to folder - submenu */} {moveTree.length > 0 && onMoveToMailbox && (
setMoreMenuSub('move')} onMouseLeave={() => setMoreMenuSub(null)} > {moreMenuSub === 'move' && (
{(() => { 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); })()}
)}
)} {/* Overflow: tag - submenu */} {colorOptions.length > 0 && (
setMoreMenuSub('tag')} onMouseLeave={() => setMoreMenuSub(null)} > {moreMenuSub === 'tag' && (
{colorOptions.map((option) => { const isActive = currentColors.includes(option.value); return ( ); })} {currentColors.length > 0 && ( <>
)}
)}
)} {/* Overflow: spam */} {(onMarkAsSpam || onUndoSpam) && ( )} {/* Overflow: toggle read */} {/* Overflow: print */} {/* Overflow: view source */} {/* Overflow: dark/light mode toggle */} {effectiveEmailContent.isHtml && ( )}
{/* Export email */} {/* Import email */} {onShowShortcuts && ( )}
)}
); return (
{/* Mobile More menu sidebar overlay */} {isMobile && moreMenuOpen && (
setMoreMenuOpen(false)} /> )} {isMobile && (
{moreMenuSub ? ( ) : ( {t('more_actions')} )}
{moreMenuSub === null && ( <> {/* Star toggle */} {/* Tag (opens sub-view) */} {colorOptions.length > 0 && ( )} {effectiveEmailContent.isHtml && ( )}
{onShowShortcuts && ( )} )} {moreMenuSub === 'move' && moveTree.length > 0 && onMoveToMailbox && (() => { 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); })()} {moreMenuSub === 'tag' && colorOptions.length > 0 && ( <> {colorOptions.map((option) => { const isActive = currentColors.includes(option.value); return ( ); })} {currentColors.length > 0 && ( )} )}
)} {/* Main email content */}
{/* === TOOLBAR (top position) === */} {toolbarPosition === 'top' && (
{renderToolbarItems(true)}
)} {/* === SUBJECT BLOCK === */}
{/* Back button (for below-subject mode on tablet) */} {toolbarPosition === 'below-subject' && (isMobile || (isTablet && !tabletListVisible) || (isFocusedMailLayout && !isMobile)) && onBack && ( )}

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

{/* Star inline with subject (top toolbar mode) */} {toolbarPosition === 'top' && ( )} {/* Color tag dots */} {currentColors.length > 0 && ( {currentColors.map((tagId) => { const kw = emailKeywords.find(k => k.id === tagId) ?? { id: tagId, label: tagId, color: 'gray' }; const dotClass = KEYWORD_PALETTE[kw.color]?.dot || 'bg-gray-500'; return ( ); })} )} {isImportant && ( {t('important')} )}
{/* Date/time on the right of subject row - hidden on mobile, shown next to sender */}
{formatDateTime(email.receivedAt, timeFormat, { weekday: 'short', year: 'numeric', month: 'short', day: 'numeric' })} {email.size > 0 && (
{formatFileSize(email.size)}
)}
{/* === TOOLBAR (below-subject position) === */} {toolbarPosition === 'below-subject' && (
{renderToolbarItems(false)}
)} {/* Email Content Area */}
{/* === SENDER INFO (Desktop) === */}
{/* Row 1: Sender name + badges */}
{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 address under name */} {sender?.email && sender?.name && (
{sender.email}
)}
{/* Row 2: Recipients + Show details */}
{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} )} )}
{/* Expandable Details */} {showFullHeaders && (() => { const translateAuthResult = (result?: string) => { const r = (result || '').toLowerCase(); switch (r) { case 'pass': return t('authentication.result.pass'); case 'fail': return t('authentication.result.fail'); case 'softfail': return t('authentication.result.softfail'); case 'neutral': return t('authentication.result.neutral'); case 'permerror': return t('authentication.result.permerror'); case 'temperror': return t('authentication.result.temperror'); case 'none': return t('authentication.result.none'); default: return result || ''; } }; const replyToDifferent = !!email.replyTo?.length && (!email.from || email.replyTo[0].email !== email.from[0]?.email); const deliveryDeltaMs = email.sentAt && email.receivedAt ? Math.abs(new Date(email.receivedAt).getTime() - new Date(email.sentAt).getTime()) : 0; const formatDelta = (diff: number) => { 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} ${hourUnit} ${minutes % 60} ${minuteUnit}`; return `${minutes} ${minuteUnitSingle}`; }; const fullDate = (iso?: string) => iso ? formatDateTime(iso, timeFormat, { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric', second: '2-digit', timeZoneName: 'short' }) : '-'; const auth = email.authenticationResults; const totalAttachmentSize = effectiveAttachments.reduce((s, a) => s + (a.size || 0), 0); const topMimeType = email.bodyStructure?.type; const SectionHeader = ({ children }: { children: React.ReactNode }) => (
{children}
); const Row = ({ label, children, mono }: { label: string; children: React.ReactNode; mono?: boolean }) => ( <>
{label}
{children}
); const AuthChip = ({ name, result, extra, tooltip }: { name: string; result?: string; extra?: React.ReactNode; tooltip?: string }) => { if (!result) return null; const status = getSecurityStatus(result); const Icon = status.icon === 'check' ? Check : status.icon === 'x' ? X : status.icon === 'alert' ? AlertTriangle : Minus; return ( {name} {translateAuthResult(result)} {extra && ( <> · {extra} )} ); }; const hasIdentifiers = !!(email.messageId || email.inReplyTo?.length || email.references?.length || email.threadId); const hasListInfo = !!(listHeaders?.listId || listHeaders?.listUnsubscribe || listHeaders?.listHelp || listHeaders?.listPost); const hasAuthSection = !!(auth?.spf || auth?.dkim || auth?.dmarc || auth?.iprev || email.spamScore !== undefined || email.spamLLM); return (
{t('details.recipients_routing')}
` : undefined} onViewContact={handleViewContactSidebar} className="text-sm text-left" />
{replyToDifferent && (
{email.replyTo!.map((r, i) => ( ))}
)} {email.to && email.to.length > 0 && (
{renderClickableRecipients(email.to, currentUserEmail, t, handleViewContactSidebar, 100)}
)} {email.cc && email.cc.length > 0 && (
{renderClickableRecipients(email.cc, currentUserEmail, t, handleViewContactSidebar, 100)}
)} {email.bcc && email.bcc.length > 0 && (
{renderClickableRecipients(email.bcc, currentUserEmail, t, handleViewContactSidebar, 100)}
)} {email.sentAt && ( {fullDate(email.sentAt)} )} {fullDate(email.receivedAt)} {deliveryDeltaMs > 60000 && ( · {formatDelta(deliveryDeltaMs)} {t('details.delivery_time').toLowerCase()} )}
{hasAuthSection && (
{t('details.authentication_security')}
{auth?.spf && ( )} {auth?.dkim && ( )} {auth?.dmarc && ( )} {auth?.iprev && ( )} {email.spamScore !== undefined && ( 5 ? "bg-red-500/[0.07] border-red-500/30" : email.spamScore > 2 ? "bg-amber-500/[0.07] border-amber-500/30" : "bg-green-500/[0.07] border-green-500/30", )}> 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", )} /> {t('authentication.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} )} )}
{email.spamLLM && (
{email.spamLLM.verdict === 'LEGITIMATE' ? : email.spamLLM.verdict === 'SPAM' ? : }
{email.spamLLM.verdict} · {email.spamLLM.explanation}
)}
)} {hasIdentifiers && (
{t('details.identifiers_threading')}
{email.messageId && ( {email.messageId} )} {email.inReplyTo && email.inReplyTo.length > 0 && (
{email.inReplyTo.map((id, i) =>
{id}
)}
)} {email.references && email.references.length > 0 && (
{t(email.references.length === 1 ? 'previous_messages' : 'previous_messages_plural', { count: email.references.length })}
{email.references.map((id, i) =>
{id}
)}
)} {email.threadId && ( {email.threadId} )}
)}
{t('details.message_properties')}
{email.subject !== undefined && ( {email.subject || {t('details.no_subject')}} )} {formatFileSize(email.size)} {topMimeType && ( · {topMimeType} )} {effectiveAttachments.length > 0 && ( {t('details.attachments_summary', { count: effectiveAttachments.length, size: formatFileSize(totalAttachmentSize), })} )} {email.accountLabel && ( {email.accountLabel} )}
{hasListInfo && (
{t('details.mailing_list')}
{listHeaders?.listId && ( {listHeaders.listId} )} {listHeaders?.listUnsubscribe?.preferred && ( {listHeaders.listUnsubscribe.preferred === 'http' ? listHeaders.listUnsubscribe.http : listHeaders.listUnsubscribe.mailto} )} {listHeaders?.listHelp && ( {listHeaders.listHelp} )} {listHeaders?.listPost && ( {listHeaders.listPost} )}
)}
); })()}
{/* Attachments on the right (beside-sender mode) */} {attachmentPosition === 'beside-sender' && effectiveAttachments.length > 0 && (
{effectiveAttachments.slice(0, 2).map((attachment) => { const FileIcon = getFileIcon(attachment.name || undefined, attachment.type); const isPreviewable = isFilePreviewable(attachment.name || undefined, attachment.type); const opensPreview = isPreviewable && mailAttachmentAction === 'preview'; const thumbUrl = imageThumbUrls[attachment.id]; return (
handleEffectiveAttachmentOpen(attachment)} > {thumbUrl && (
)}
{getAttachmentDisplayName(attachment.name, attachment.type)} {formatFileSize(attachment.size)}
{opensPreview && ( )}
); })} {effectiveAttachments.length > 2 && ( )} {/* Floating popup for remaining attachments */} {showAllBesideAttachments && effectiveAttachments.length > 2 && ( <>
setShowAllBesideAttachments(false)} />
{effectiveAttachments.slice(2).map((attachment) => { const FileIcon = getFileIcon(attachment.name || undefined, attachment.type); const isPreviewable = isFilePreviewable(attachment.name || undefined, attachment.type); const opensPreview = isPreviewable && mailAttachmentAction === 'preview'; return (
{ handleEffectiveAttachmentOpen(attachment); setShowAllBesideAttachments(false); }} > {getAttachmentDisplayName(attachment.name, attachment.type)} {formatFileSize(attachment.size)}
{opensPreview && ( )}
); })}
)}
)}
{/* Mobile/Tablet Sender Info - scrolls with content */}
{/* Row 1: Sender name + badges */}
{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 address under name */} {sender?.email && sender?.name && (
{sender.email}
)} {/* Row 2: Recipients */}
{email.to && email.to.length > 0 && ( <> → {t('recipient_to_prefix')} {renderClickableRecipients(email.to, currentUserEmail, t, handleViewContactSidebar)} )} {email.cc && email.cc.length > 0 && ( <> | CC: {renderClickableRecipients(email.cc, currentUserEmail, t, handleViewContactSidebar)} {email.cc.length > 2 && ( +{email.cc.length - 2} )} )}
{/* Date/time + size on the right (mobile) */}
{formatDateTime(email.receivedAt, timeFormat, { weekday: 'short', year: 'numeric', month: 'short', day: 'numeric' })} {email.size > 0 && (
{formatFileSize(email.size)}
)}
{/* S/MIME Status Banner */} {smimeStatus && (
)} {/* Draft Banner */} {isDraft && (
{t('draft_banner')}
{onEditDraft && ( )}
)} { setSmimeUnlockDialogOpen(false); setSmimeUnlockError(null); }} onSubmit={handleSmimeUnlockSubmit} title={tSmime('unlock_key')} description={tSmime('unlock_key_desc')} error={smimeUnlockError} /> {/* Unified Notification Banner - External Content + Calendar Invitation */} {((hasBlockedContent && !allowExternalContent && externalContentPolicy !== 'allow') || hasCalendarInvitation) && (
{/* External Content Controls */} {hasBlockedContent && !allowExternalContent && externalContentPolicy !== 'allow' && (
External Content
{t('external_content_warning')}
{externalContentPolicy === 'ask' && ( )} {email.from?.[0]?.email && ( )}
)} {/* Calendar Invitation Banner */} {hasCalendarInvitation && (
)}
)} {/* === ATTACHMENTS below header (below-header mode, desktop only) === */} {attachmentPosition === 'below-header' && effectiveAttachments.length > 0 && (
{/* Hidden ghost row used purely for measuring chip widths */} {showAllBelowHeaderAttachments && visibleBelowHeaderCount !== null && effectiveAttachments.length > visibleBelowHeaderCount && ( <>
setShowAllBelowHeaderAttachments(false)} />
{effectiveAttachments.slice(visibleBelowHeaderCount).map((attachment) => { const FileIcon = getFileIcon(attachment.name || undefined, attachment.type); const isPreviewable = isFilePreviewable(attachment.name || undefined, attachment.type); const opensPreview = isPreviewable && mailAttachmentAction === 'preview'; return (
{ handleEffectiveAttachmentOpen(attachment); setShowAllBelowHeaderAttachments(false); }} > {getAttachmentDisplayName(attachment.name, attachment.type)} {formatFileSize(attachment.size)}
{opensPreview && ( )}
); })}
)}
)} {/* Mobile/Tablet Attachments */} {effectiveAttachments.length > 0 && (
{effectiveAttachments.slice(0, 2).map((attachment) => { const FileIcon = getFileIcon(attachment.name || undefined, attachment.type); const isPreviewable = isFilePreviewable(attachment.name || undefined, attachment.type); const opensPreview = isPreviewable && mailAttachmentAction === 'preview'; const thumbUrl = imageThumbUrls[attachment.id]; return (
handleEffectiveAttachmentOpen(attachment)} > {thumbUrl && (
)}
{getAttachmentDisplayName(attachment.name, attachment.type)} {formatFileSize(attachment.size)}
{opensPreview && ( )}
); })} {effectiveAttachments.length > 2 && ( )} {showAllMobileAttachments && effectiveAttachments.length > 2 && ( <>
setShowAllMobileAttachments(false)} />
{effectiveAttachments.slice(2).map((attachment) => { const FileIcon = getFileIcon(attachment.name || undefined, attachment.type); const isPreviewable = isFilePreviewable(attachment.name || undefined, attachment.type); const opensPreview = isPreviewable && mailAttachmentAction === 'preview'; return (
{ handleEffectiveAttachmentOpen(attachment); setShowAllMobileAttachments(false); }} > {getAttachmentDisplayName(attachment.name, attachment.type)} {formatFileSize(attachment.size)}
{opensPreview && ( )}
); })}
)}
)}
{/* Email Body */}
{isBodyLoading ? (
) : effectiveEmailContent.isHtml ? (