abbe7a18fc
- 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
176 lines
6.9 KiB
TypeScript
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>
|
|
);
|
|
} |