Files
leocrm/frontend/src/components/mail/MailDetail.tsx
T
Agent Zero abbe7a18fc fix(audit): P0-P3 audit fixes — 838 ruff errors → 0, 30 F821 bugs fixed, 118 files changed
- P0: hooks.py 3-tuple fix, trigger_dispatcher Contract, contacts/plugin unregister_actions_by_owner
- P0: 5 test files — check_permission mocks removed, hardcoded DB credential → env var
- P1: attachment_service DmsFile via Contract helper, restore_registry/history_hooks dedup
- P1: mail/plugin restore unregister, mcp_client datetime.now(UTC), saved_views/filters patterns
- P1: ProtectedRoute fail-closed, 13 test assertion fixes (bcrypt, DB-URLs, SECRET_KEYs)
- P2: deprecated notifications → post_system_message (3 files), forgejo Base, report_generator lazy import
- P2: webhooks permissions, deps.py/roles.py plugin perms removed, import_export default
- P2: address/tags/entity_links patterns removed, worker.py Contract-Umgehungen fixed
- P2: 28 frontend TODOs (hardcoded constants, deprecated notification API)
- P3: dead code, duplicates, deprecated imports, private attr, __import__ inline
- P3: 8 frontend TODOs (LucideIcons, inline styles, XSS, i18n)
- ruff: 838 → 0 (612 auto-fix + 246 manual + 27 F821 regression fix)
- F821: 30 → 0 (AutomationDefinition, DmsFile, user_id, Path, Any, String)
- Contract-Umgehungen: 2 neue gefunden (worker.py:169, worker.py:280) und gefixt
2026-08-16 01:17:18 +02:00

176 lines
6.9 KiB
TypeScript

// 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<HTMLIFrameElement>(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 (
<div className="flex items-center justify-center py-12" data-testid="mail-detail-loading">
<Loader2 className="animate-spin h-5 w-5 text-secondary-400" aria-hidden="true" />
<span className="ml-2 text-sm text-secondary-500">{t('common.loading')}</span>
</div>
);
}
if (!mail) {
return (
<div data-testid="mail-detail-empty">
<EmptyState
title={t('mail.selectMailToRead')}
description={t('mail.selectMailToReadDesc')}
/>
</div>
);
}
return (
<div className="flex flex-col h-full" data-testid="mail-detail">
{/* Headers */}
<div className="px-3 py-2 md:px-4 md:py-3 border-b border-secondary-200" data-testid="mail-detail-headers">
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-2 sm:gap-4">
<h2 className="text-base md:text-lg font-semibold text-secondary-900 flex-1">{decodeMimeHeader(mail.subject) || t('mail.noSubject')}</h2>
{mail.labels && mail.labels.length > 0 && (
<div className="flex gap-1 flex-shrink-0">
{mail.labels.map((label) => (
<span key={label.id} className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium text-white" style={{ backgroundColor: label.color }}>
{label.name}
</span>
))}
</div>
)}
</div>
<div className="mt-2 space-y-1 text-xs md:text-sm text-secondary-600">
<div className="flex flex-col sm:flex-row sm:gap-2">
<span className="font-medium text-secondary-500 flex-shrink-0">{t('mail.from')}:</span>
<span className="break-words">{decodeMimeHeader(mail.from_name) ? `${decodeMimeHeader(mail.from_name)} <${mail.from_address}>` : mail.from_address}</span>
</div>
<div className="flex flex-col sm:flex-row sm:gap-2">
<span className="font-medium text-secondary-500 flex-shrink-0">{t('mail.to')}:</span>
<span className="break-words">{(mail.to_addresses ?? []).join(', ')}</span>
</div>
{mail.cc_addresses && mail.cc_addresses.length > 0 && (
<div className="flex flex-col sm:flex-row sm:gap-2">
<span className="font-medium text-secondary-500 flex-shrink-0">{t('mail.cc')}:</span>
<span className="break-words">{(mail.cc_addresses ?? []).join(', ')}</span>
</div>
)}
<div className="flex flex-col sm:flex-row sm:gap-2">
<span className="font-medium text-secondary-500 flex-shrink-0">{t('mail.date')}:</span>
<span>{formatFullDate(mail.date)}</span>
</div>
</div>
</div>
{/* Body + Attachments — scrollable together */}
<div className="flex-1 overflow-y-auto" data-testid="mail-detail-body">
<div className="px-3 py-3 md:px-4 md:py-4">
{safeHtml ? (
<iframe
ref={iframeRef}
srcDoc={safeHtml}
sandbox="allow-same-origin"
onLoad={handleIframeLoad}
className="w-full border-0"
data-testid="mail-html-body"
title={t('mail.subject')}
/>
) : (
<pre className="whitespace-pre-wrap text-sm text-secondary-800" data-testid="mail-text-body">{mail.body_text}</pre>
)}
</div>
{/* Attachments */}
{mail.attachments && mail.attachments.length > 0 && (
<div className="px-3 py-3 md:px-4 md:py-4 border-t border-secondary-200" data-testid="mail-detail-attachments">
<h3 className="text-sm font-semibold text-secondary-700 mb-2">{t('mail.attachments')}</h3>
<ul className="space-y-1 md:space-y-2">
{mail.attachments.map((att) => (
<li key={att.id} className="flex items-center gap-3 p-2 rounded-md hover:bg-secondary-50 min-h-touch">
<FileText className="w-5 h-5 text-secondary-400 flex-shrink-0" aria-hidden="true" strokeWidth={2} />
<div className="flex-1 min-w-0">
<p className="text-sm text-secondary-800 truncate">{decodeMimeHeader(att.filename)}</p>
<p className="text-xs text-secondary-400">{formatBytes(att.size_bytes)}</p>
</div>
<Button
variant="secondary"
size="sm"
onClick={() => onDownloadAttachment(mail.id, att)}
isLoading={downloadingAttachmentId === att.id}
data-testid={`download-attachment-${att.id}`}
>
{t('mail.download')}
</Button>
</li>
))}
</ul>
</div>
)}
</div>
</div>
);
}