"use client"; import { useState, useEffect, useLayoutEffect, useMemo, useRef, useCallback } from "react"; import DOMPurify from "dompurify"; import { Email, ContactCard, Mailbox } from "@/lib/jmap/types"; import { emailExportFilename, attachmentDownloadFilename, attachmentsBundleFilename, DEFAULT_EMAIL_TEMPLATE, DEFAULT_ATTACHMENT_TEMPLATE } from "@/lib/download-filename"; import { EML_IMPORT_ACCEPT, expandImportableEmails } from "@/lib/eml-import"; import { EMAIL_IFRAME_SANITIZE_CONFIG, blockExternalResourcesOnNode, collapseBlockedImageContainers, escapeHtml, plainTextToSafeHtml, sanitizeEmailHtml, sanitizePlainTextRenderedHtml } from "@/lib/email-sanitization"; import { hasMeaningfulHtmlBody } from "@/lib/signature-utils"; import { withBasePath } from "@/lib/browser-navigation"; 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 { generateEmailSource } from "@/lib/email-source"; 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, CalendarClock, } from "lucide-react"; import { useTranslations } from "next-intl"; import { useRouter } from "@/i18n/navigation"; 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 { useAccountStore } from "@/stores/account-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 { ReadReceiptBanner } from "./read-receipt-banner"; import { stripCrossAccountIdentityPrefix } from "@/hooks/use-pro-multi-account-identities"; import { useTour } from "@/components/tour/tour-provider"; import { useIsEmbedded } from "@/hooks/use-is-embedded"; import { SmimePassphraseDialog } from "@/components/settings/smime-passphrase-dialog"; import { findCalendarAttachment, isCalendarMimeType } from "@/lib/calendar-invitation"; import { RecipientPopover } from "./recipient-popover"; import { isFilePreviewable, isMimeTypeSafeForInlinePreview } 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 { usePluginSlotOffers } from "@/hooks/use-plugin-slot-offers"; import { ResizeHandle } from "@/components/layout/resize-handle"; import { emailHooks, uiHooks } from "@/lib/plugin-hooks"; import type { AttachmentInfo, AttachmentPreview } from "@/lib/plugin-types"; import { useAttachmentDrag, isDragOutSupported, type AttachmentDragSource } from "@/hooks/use-attachment-drag"; import type { IJMAPClient } from "@/lib/jmap/client-interface"; 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; onCancelScheduled?: () => void; onCancelScheduledForEdit?: () => void; onRescheduleScheduled?: (delayedUntil: string) => 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 export function ContactSidebarPanel({ email, contact, senderName, onClose, onAddToContacts, onEditContact, }: { email: string; contact: ContactCard | null; senderName?: string; onClose: () => void; onAddToContacts?: (email: string, name?: string) => void; onEditContact?: () => void; }) { const t = useTranslations('email_viewer'); const tCommon = useTranslations('common'); 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(t('contact_sidebar.copied')); } catch { toast.error(t('contact_sidebar.copy_failed')); } }; return (
{/* Header */}

{t('contact_sidebar.title')}

{/* Content */}
{/* Profile section */}
{name || email}
{name && (
{primaryEmail}
)} {orgs.length > 0 && orgs[0].name && (
{orgs[0].name}
)}
{/* Quick actions */}
{t('contact_sidebar.action_email')} {contact && onEditContact && ( )}
{/* 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 && (

{t('contact_sidebar.not_in_contacts')}

{onAddToContacts && ( )}
)}
); } interface DraggableAttachmentChipProps { attachment: EffectiveAttachment; client: IJMAPClient | null; enabled: boolean; downloadName?: string; children: (dragProps: { draggable: boolean; onPointerEnter: () => void; onDragStart: (e: React.DragEvent) => void; onDragEnd: (e: React.DragEvent) => void; }) => React.ReactNode; } function DraggableAttachmentChip({ attachment, client, enabled, downloadName, children }: DraggableAttachmentChipProps) { const source = useMemo(() => ({ name: downloadName || attachment.name || 'download', type: attachment.type || 'application/octet-stream', getBlobUrl: async () => { if (attachment.blobId && client) { try { return await client.fetchBlobAsObjectUrl(attachment.blobId, attachment.name || undefined, attachment.type); } catch { return null; } } if (attachment.tnefData) { const bytes = attachment.tnefData; const buffer = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer; return URL.createObjectURL(new Blob([buffer], { type: attachment.type || 'application/octet-stream' })); } if (attachment.decryptedAttachment) { const bytes = getAttachmentContentBytes(attachment.decryptedAttachment); if (!bytes || bytes.byteLength === 0) return null; const buffer = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer; return URL.createObjectURL(new Blob([buffer], { type: attachment.type || 'application/octet-stream' })); } return null; }, }), [attachment, client, downloadName]); const drag = useAttachmentDrag(source, enabled); return <>{children(drag)}; } 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, onCancelScheduled, onCancelScheduledForEdit, onRescheduleScheduled, onCompose, currentUserEmail, currentUserName, currentMailboxRole, mailboxes = [], selectedMailbox = "", className, }: EmailViewerProps) { const t = useTranslations('email_viewer'); const tComposer = useTranslations('email_composer'); 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 readReceiptResponse = useSettingsStore((state) => state.readReceiptResponse); const hideInlineImageAttachments = useSettingsStore((state) => state.hideInlineImageAttachments); const attachmentImagePreviewsEnabled = useSettingsStore((state) => state.attachmentImagePreviewsEnabled); const dragOutActive = useMemo(() => isDragOutSupported(), []); const emailDownloadTemplate = useSettingsStore((state) => state.emailDownloadTemplate) || DEFAULT_EMAIL_TEMPLATE; const attachmentDownloadTemplate = useSettingsStore((state) => state.attachmentDownloadTemplate) || DEFAULT_ATTACHMENT_TEMPLATE; const filenameSpaceReplacement = useSettingsStore((state) => state.filenameSpaceReplacement); const filenameLowercase = useSettingsStore((state) => state.filenameLowercase); const filenameStripDiacritics = useSettingsStore((state) => state.filenameStripDiacritics); const filenameCollapseSeparators = useSettingsStore((state) => state.filenameCollapseSeparators); const emailFilenameOptions = useMemo(() => ({ template: emailDownloadTemplate, spaceReplacement: filenameSpaceReplacement, lowercase: filenameLowercase, stripDiacritics: filenameStripDiacritics, collapseSeparators: filenameCollapseSeparators, }), [emailDownloadTemplate, filenameSpaceReplacement, filenameLowercase, filenameStripDiacritics, filenameCollapseSeparators]); const attachmentFilenameOptions = useMemo(() => ({ template: attachmentDownloadTemplate, spaceReplacement: filenameSpaceReplacement, lowercase: filenameLowercase, stripDiacritics: filenameStripDiacritics, collapseSeparators: filenameCollapseSeparators, }), [attachmentDownloadTemplate, filenameSpaceReplacement, filenameLowercase, filenameStripDiacritics, filenameCollapseSeparators]); 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; const isScheduled = email?.isScheduled === true; const canCancelScheduled = isScheduled && email?.scheduledUndoStatus === 'pending'; // 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 activeAccount = useAccountStore((s) => s.accounts.find((a) => a.id === activeAccountId)); const promptForRescheduleDelayedUntil = useCallback((): string | null => { const value = window.prompt(t('reschedule_prompt')); if (!value) return null; const time = new Date(value).getTime(); if (!Number.isFinite(time)) { toast.error(tComposer('schedule_send_invalid')); return null; } if (time <= Date.now()) { toast.error(tComposer('schedule_send_future')); return null; } if (!client?.hasDelayedSend()) { toast.error(tComposer('schedule_send_unsupported')); return null; } const maxDelayedSend = client.getMaxDelayedSend(); if (maxDelayedSend > 0 && time > Date.now() + maxDelayedSend * 1000) { toast.error(tComposer('schedule_send_too_late')); return null; } return new Date(time).toISOString(); }, [client, t, tComposer]); const resolvedTheme = useThemeStore((state) => state.resolvedTheme); const { startTour } = useTour(); const isEmbedded = useIsEmbedded(); const [showFullHeaders, setShowFullHeaders] = useState(false); const [showAllBesideAttachments, setShowAllBesideAttachments] = useState(false); const [showAllMobileAttachments, setShowAllMobileAttachments] = useState(false); const [showAllBelowHeaderAttachments, setShowAllBelowHeaderAttachments] = useState(false); const [isDownloadingAll, setIsDownloadingAll] = 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. Collapsed/width persist across opens and // sessions so the panel reopens the way the user last left it. const detailSlots = usePluginSlotOffers('email-detail-sidebar'); const hasDetailSidebar = detailSlots.length > 0; // Whether any plugin offers a "more details" section, so we only render the // bottom plugin category wrapper when something will fill it. const hasDetailsSlotOffers = usePluginSlotOffers('email-details-section').length > 0; const [detailSidebarCollapsed, setDetailSidebarCollapsed] = useState(() => { if (typeof window === 'undefined') return false; try { return localStorage.getItem('emailDetailSidebarCollapsed') === '1'; } catch { return false; } }); const [detailSidebarWidth, setDetailSidebarWidth] = useState(() => { if (typeof window === 'undefined') return 280; try { const n = parseInt(localStorage.getItem('emailDetailSidebarWidth') ?? '', 10); return Number.isFinite(n) ? Math.max(200, Math.min(500, n)) : 280; } catch { return 280; } }); const detailSidebarWidthRef = useRef(detailSidebarWidth); useEffect(() => { try { localStorage.setItem('emailDetailSidebarCollapsed', detailSidebarCollapsed ? '1' : '0'); } catch { /* ignore */ } }, [detailSidebarCollapsed]); useEffect(() => { try { localStorage.setItem('emailDetailSidebarWidth', String(detailSidebarWidth)); } catch { /* ignore */ } }, [detailSidebarWidth]); // 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 router = useRouter(); const handleViewContactSidebar = (contact: ContactCard | null, recipientEmail: string) => { if (isMobileDevice) { // No room for a sidebar on mobile - send the user to the contacts page // with params describing what to show. The `from=email` flag turns the // page's mobile back button into a router.back() that returns here. const allRecipients = [ ...(email?.from || []), ...(email?.to || []), ...(email?.cc || []), ...(email?.bcc || []), ...(email?.replyTo || []), ]; const recipientName = allRecipients.find( (r) => r.email.toLowerCase() === recipientEmail.toLowerCase() )?.name; const params = new URLSearchParams(); if (contact) { params.set('contactId', contact.id); } else { params.set('addEmail', recipientEmail); if (recipientName) params.set('addName', recipientName); } params.set('from', 'email'); router.push(`/contacts?${params.toString()}`); return; } 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); // Keyed to email id + $seen only: depending on the whole `email` object // would reset the mark-as-read delay timer whenever any unrelated email // field updates (e.g. a background re-fetch). // eslint-disable-next-line react-hooks/exhaustive-deps }, [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/'))) // Hide machine-readable report parts (MDN read-receipts, DSN bounce // reports). These are required MIME parts, not real user attachments. .filter(att => att.type !== 'message/disposition-notification' && att.type !== 'message/delivery-status') .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]; // The memo derives only from `email.attachments` (findCalendarAttachment // scans that array); depending on the whole `email` object would rebuild the // attachment list — and its downstream layout measurement — on every email // field change. // eslint-disable-next-line react-hooks/exhaustive-deps }, [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 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, externalBlocked: 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) => { if (shouldBlockExternal) { // Blocks every external-resource vector (img src incl. // whitespace/newline tricks, srcset, ,