// TODO: P3-F7 — Sanitize iframe HTML rendering to prevent XSS /** * Mail detail reading pane. * Shows mail headers, sanitized HTML body, and attachments. * Actions (reply/forward/delete/etc.) are in the global plugin toolbar. */ import React, { useMemo, useRef, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import type { Mail, MailAttachment } from '@/api/mail'; import { decodeMimeHeader } from '@/api/mail'; import { Button } from '@/components/ui/Button'; import { EmptyState } from '@/components/ui/EmptyState'; import { FileText, Loader2 } from 'lucide-react'; import { formatDateTime } from '@/utils/date'; export interface MailDetailProps { mail: Mail | null; loading: boolean; onReply: (mail: Mail) => void; onForward: (mail: Mail) => void; onCreateEvent: (mail: Mail) => void; onToggleFlag: (mail: Mail) => void; onDownloadAttachment: (mailId: string, attachment: MailAttachment) => void; downloadingAttachmentId: string | null; onDelete?: (mail: Mail) => void; onMove?: (mail: Mail) => void; onEditDraft?: (mail: Mail) => void; } function formatFullDate(dateStr: string): string { return formatDateTime(dateStr) || dateStr; } function formatBytes(bytes: number): string { if (bytes < 1024) return `${bytes} B`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; } export function MailDetail({ mail, loading, onReply, onForward, onDownloadAttachment, downloadingAttachmentId, }: MailDetailProps) { const { t } = useTranslation(); const safeHtml = useMemo(() => { if (!mail) return null; return mail.sanitized_html || mail.body_html; }, [mail]); const iframeRef = useRef(null); const handleIframeLoad = useCallback(() => { const iframe = iframeRef.current; if (!iframe) return; try { const doc = iframe.contentDocument || iframe.contentWindow?.document; if (doc) { const height = doc.documentElement.scrollHeight || doc.body.scrollHeight; iframe.style.height = `${height}px`; } } catch { // Cross-origin restrictions — leave default height } }, []); if (loading) { return ( {t('common.loading')} ); } if (!mail) { return ( ); } return ( {/* Headers */} {decodeMimeHeader(mail.subject) || t('mail.noSubject')} {mail.labels && mail.labels.length > 0 && ( {mail.labels.map((label) => ( {label.name} ))} )} {t('mail.from')}: {decodeMimeHeader(mail.from_name) ? `${decodeMimeHeader(mail.from_name)} <${mail.from_address}>` : mail.from_address} {t('mail.to')}: {(mail.to_addresses ?? []).join(', ')} {mail.cc_addresses && mail.cc_addresses.length > 0 && ( {t('mail.cc')}: {(mail.cc_addresses ?? []).join(', ')} )} {t('mail.date')}: {formatFullDate(mail.date)} {/* Body + Attachments — scrollable together */} {safeHtml ? ( ) : ( {mail.body_text} )} {/* Attachments */} {mail.attachments && mail.attachments.length > 0 && ( {t('mail.attachments')} {mail.attachments.map((att) => ( {decodeMimeHeader(att.filename)} {formatBytes(att.size_bytes)} onDownloadAttachment(mail.id, att)} isLoading={downloadingAttachmentId === att.id} data-testid={`download-attachment-${att.id}`} > {t('mail.download')} ))} )} ); }
{mail.body_text}
{decodeMimeHeader(att.filename)}
{formatBytes(att.size_bytes)}