"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, applyNewTabToAnchor, blockExternalResourcesOnNode, collapseBlockedImageContainers, escapeHtml, plainTextToSafeHtml, sanitizeEmailHtml, sanitizePlainTextRenderedHtml } from "@/lib/email-sanitization"; import { hasMeaningfulHtmlBody } from "@/lib/signature-utils"; import { collapsePlainTextQuotes, setupQuoteCollapse } from "@/lib/quote-collapse"; 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 { TagBadge } from "./tag-badge"; import { TagPicker } from "./tag-picker"; import { useMeasuredTagDisplay } from "@/hooks/use-tag-display"; import { useKeywordFormat } from "@/hooks/use-keyword-format"; import { getEmailTagIds } from "@/lib/thread-utils"; import { getSecurityStatus, extractListHeaders } from "@/lib/email-headers"; import { emailToReadView } from "@/lib/plugin-projection"; import { generateEmailSource } from "@/lib/email-source"; import { Reply, ReplyAll, Forward, Paperclip, 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 } 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 { findCalendarAttachment, isCalendarMimeType } from "@/lib/calendar-invitation"; import { RecipientPopover } from "./recipient-popover"; import { isFilePreviewable, isMimeTypeSafeForInlinePreview } from "@/lib/file-preview"; 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, renderHooks } 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; onForwardAsAttachment?: () => void; onDelete?: () => void; onArchive?: () => void; onToggleStar?: () => void; onMarkAsRead?: (emailId: string, read: boolean) => void; onSetTag?: (emailId: string, tagId: 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'; }; // 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 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 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; } /** * 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; } 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; /** Owner accountId for the blob when it lives in a delegated/shared account. */ accountId?: string; 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, accountId, 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, accountId); } 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, accountId, 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, onForwardAsAttachment, onDelete, onArchive, onToggleStar, onMarkAsRead, onSetTag, 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 tFiles = useTranslations('files'); const tDemoWelcome = useTranslations('demo_welcome'); const tWelcome = useTranslations('welcome'); const externalContentPolicy = useSettingsStore((state) => state.externalContentPolicy); const messageSpacing = useSettingsStore((state) => state.messageSpacing); 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 { sortTagIds, tagColor } = useKeywordFormat(); 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'; // Marking your own outgoing mail as spam makes no sense - hide the action // in Sent, Drafts and Scheduled. const spamApplicable = !['sent', 'drafts', 'scheduled'].includes(currentMailboxRole || ''); // Detect if the email is a draft const isDraft = email?.keywords?.['$draft'] === true; const isScheduled = email?.isScheduled === true; const canCancelScheduled = isScheduled && email?.scheduledUndoStatus === 'pending'; // 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)); // Blobs (inline images, drag-out, TNEF, embedded messages, thumbnails, bundle // downloads) are account-scoped. In the unified / All-Mail view the open // message may belong to another login (route to its client) or a delegated // shared account (same client, owner accountId in the URL). Resolve both from // the message's source so cross-account blob fetches don't 404 against the // active account. const isUnifiedView = useEmailStore((s) => s.isUnifiedView); const blobClient = useMemo(() => { const scid = isUnifiedView ? email?.sourceClientAccountId : undefined; return (scid ? useAuthStore.getState().getClientForAccount(scid) : null) ?? client; }, [isUnifiedView, email?.sourceClientAccountId, client]); const blobAccountId = isUnifiedView ? email?.sourceAccountId : undefined; // List-Unsubscribe mailto: send the message ourselves - this is a webmail // client, handing a mailto: URL to the OS mail handler goes nowhere for // most users. Route to the email's own account in unified views and prefer // the identity that received the newsletter, so the list can match the // subscriber; sendEmail resolves the identity (with its own fallback to // the account default) from the address we pass. const handleSendMailtoUnsubscribe = async (fields: { to: string[]; subject?: string; body?: string }) => { const sendClient = (email?.sourceClientAccountId ? useAuthStore.getState().getClientForAccount(email.sourceClientAccountId) : undefined) ?? client; if (!sendClient) throw new Error('Not connected'); const recipientAddresses = [...(email?.to ?? []), ...(email?.cc ?? [])].map(r => r.email?.toLowerCase()); // In unified views the owning account's identities are not loaded here - // pass nothing and let its client fall back to its default identity. const fromIdentity = email?.sourceClientAccountId ? undefined : identities.find(i => i.email && recipientAddresses.includes(i.email.toLowerCase())); await sendClient.sendEmail(fields.to, fields.subject ?? '', fields.body ?? '', undefined, undefined, fromIdentity?.id, fromIdentity?.email, undefined, fromIdentity?.name); }; 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 handleSendQuickReply = async () => { if (!quickReplyText.trim() || !onQuickReply || isSendingQuickReply) return; setIsSendingQuickReply(true); try { await onQuickReply(quickReplyText); setQuickReplyText(""); setIsQuickReplyFocused(false); } catch (error) { console.error("Failed to send quick reply:", error); } finally { setIsSendingQuickReply(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 currentTagIds = getEmailTagIds(email?.keywords); const sortedTagIds = sortTagIds(currentTagIds); // The header spans the reading pane, so it measures its own width rather than // inheriting the message list's answer. const headerTagsRef = useRef(null); const { variant: headerTagVariant } = useMeasuredTagDisplay(headerTagsRef); const currentColor = currentTagIds[0] ?? null; // Crypto-plugin rendered body (S/MIME, PGP, …) — populated by the generic // onRenderEmailBody hook. Verification/decryption status UI is provided by the // crypto plugin's own email-banner slot, so the host keeps no S/MIME state. const [pluginRenderedHtml, setPluginRenderedHtml] = useState(null); const [pluginRenderedText, setPluginRenderedText] = useState(null); const [pluginRenderedAttachments, setPluginRenderedAttachments] = useState([]); // Bumped when a plugin calls `api.ui.rerenderEmail` (e.g. the S/MIME plugin // after the user unlocks a key from the banner) to force the onRenderEmailBody // hook to run again for the open message so the body re-decrypts. const [pluginRenderNonce, setPluginRenderNonce] = useState(0); useEffect(() => { const bump = () => setPluginRenderNonce((n) => n + 1); window.addEventListener('plugin:rerender-email', bump); return () => window.removeEventListener('plugin:rerender-email', bump); }, []); // 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]); // 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, emailKeywords.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); setPluginRenderedHtml(null); setPluginRenderedText(null); setPluginRenderedAttachments([]); setTnefHtml(null); setTnefText(null); setTnefAttachments([]); setEmbeddedEmailHtml(null); setEmbeddedEmailText(null); setEmbeddedEmailAttachments([]); setEmbeddedEmailUnwrapped(false); }, [email?.id, externalContentPolicy]); // Crypto-plugin body takeover (S/MIME, PGP, …). A privileged crypto plugin // can fetch the raw message via api.jmap.fetchBlob, decrypt/verify it, and // return a replaced body through the onRenderEmailBody hook. The host stays // crypto-agnostic; the plugin renders its own verification/encryption status // via its email-banner slot. Falls through to normal rendering otherwise. useEffect(() => { if (!email) return; let cancelled = false; const dataUrlToBytes = (dataUrl: string): Uint8Array | null => { try { const comma = dataUrl.indexOf(','); if (comma < 0) return null; const meta = dataUrl.slice(0, comma); const data = dataUrl.slice(comma + 1); if (meta.includes(';base64')) { const bin = atob(data); const u8 = new Uint8Array(bin.length); for (let i = 0; i < bin.length; i++) u8[i] = bin.charCodeAt(i); return u8; } return new TextEncoder().encode(decodeURIComponent(data)); } catch { return null; } }; (async () => { try { const rawContentType = email.headers?.['content-type'] || email.headers?.['Content-Type']; const contentType = Array.isArray(rawContentType) ? rawContentType[0] : rawContentType; const initialBody = { html: '', text: '', attachments: [] as unknown[] }; const ctx = { id: email.id, contentType, bodyStructure: email.bodyStructure, bodyValues: email.bodyValues, attachments: email.attachments, blobId: email.blobId, from: email.from, }; const result = await renderHooks.onRenderEmailBody.transform(initialBody, ctx) as { html?: string; text?: string; attachments?: Array<{ name?: string; type?: string; size?: number; dataUrl?: string; cid?: string }>; handledBy?: string; }; if (cancelled) return; if (!result || !result.handledBy) { setPluginRenderedHtml(null); setPluginRenderedText(null); setPluginRenderedAttachments([]); return; } setPluginRenderedHtml(typeof result.html === 'string' && result.html ? result.html : null); setPluginRenderedText(typeof result.text === 'string' && result.text ? result.text : null); // Normalise the plugin's attachment shape into the PostalMime-like shape // the viewer's download / inline-image machinery already understands. const atts = Array.isArray(result.attachments) ? result.attachments : []; const decoded = atts.map((a) => ({ filename: a.name ?? null, mimeType: a.type || 'application/octet-stream', contentId: a.cid, content: (a.dataUrl ? dataUrlToBytes(a.dataUrl) : null) ?? new Uint8Array(0), } as unknown as PostalMimeAttachment)); setPluginRenderedAttachments(decoded); } catch (err) { if (cancelled) return; debug.error('onRenderEmailBody hook failed:', err); setPluginRenderedHtml(null); setPluginRenderedText(null); setPluginRenderedAttachments([]); } })(); return () => { cancelled = true; }; }, [email, pluginRenderNonce]); // 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 blobClient!.fetchBlobArrayBuffer(tnefAtt!.blobId!, undefined, undefined, blobAccountId); 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, blobClient, blobAccountId]); // 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 blobClient!.fetchBlobArrayBuffer(rfc822Att!.blobId!, undefined, undefined, blobAccountId); 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, blobClient, blobAccountId]); // Fetch inline CID images with authentication to prevent browser auth dialogs useEffect(() => { let cancelled = false; const objectUrls: string[] = []; const decryptedCidAttachments = pluginRenderedAttachments.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 blobClient!.fetchBlobAsObjectUrl(att.blobId, att.name || 'inline', att.type, blobAccountId); 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, blobClient, blobAccountId, email?.id, pluginRenderedAttachments, email?.attachments]); const effectiveAttachments = useMemo(() => { if (pluginRenderedAttachments.length > 0) { return pluginRenderedAttachments .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, pluginRenderedAttachments, 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, ,