df83cee10c
- Fix FlagUpdatePayload field names to match backend schema (is_seen/is_flagged) - Replace dangerouslySetInnerHTML with sandboxed iframe (srcDoc, sandbox="") - Add bulk mark read/unread with checkbox selection in MailList - Add floating bulk action bar in Mail.tsx - Add imap_sync_mail_flags() to push Seen/Flagged flags to IMAP server - Call IMAP flag sync in PATCH /flags endpoint - Add i18n keys for bulk actions (de/en)
226 lines
9.2 KiB
TypeScript
226 lines
9.2 KiB
TypeScript
/**
|
|
* Mail detail reading pane.
|
|
* Shows mail headers, sanitized HTML body, attachments, reply/forward/create-event actions.
|
|
*/
|
|
|
|
import React, { useMemo, useRef, useCallback } from 'react';
|
|
import { useTranslation } from 'react-i18next';
|
|
import clsx from 'clsx';
|
|
import type { Mail, MailAttachment } from '@/api/mail';
|
|
import { Button } from '@/components/ui/Button';
|
|
import { Badge } from '@/components/ui/Badge';
|
|
import { EmptyState } from '@/components/ui/EmptyState';
|
|
|
|
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;
|
|
}
|
|
|
|
function formatFullDate(dateStr: string): string {
|
|
const date = new Date(dateStr);
|
|
return date.toLocaleString([], {
|
|
year: 'numeric',
|
|
month: 'short',
|
|
day: 'numeric',
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
});
|
|
}
|
|
|
|
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,
|
|
onCreateEvent,
|
|
onToggleFlag,
|
|
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">
|
|
<svg className="animate-spin h-5 w-5 text-secondary-400" fill="none" viewBox="0 0 24 24" aria-hidden="true">
|
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
|
</svg>
|
|
<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">
|
|
{/* Toolbar */}
|
|
<div className="flex items-center gap-1 sm:gap-2 px-3 py-2 md:px-4 md:py-3 border-b border-secondary-200 overflow-x-auto" data-testid="mail-detail-toolbar">
|
|
<Button variant="secondary" size="sm" onClick={() => onReply(mail)} icon={
|
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6" />
|
|
</svg>
|
|
}>
|
|
{t('mail.reply')}
|
|
</Button>
|
|
<Button variant="secondary" size="sm" onClick={() => onForward(mail)} icon={
|
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 10H11a8 8 0 00-8 8v2m18-10l-6-6m6 6l-6 6" />
|
|
</svg>
|
|
}>
|
|
{t('mail.forward')}
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => onToggleFlag(mail)}
|
|
aria-label={t('mail.toggleFlag')}
|
|
icon={
|
|
<svg className={clsx('w-4 h-4', mail.is_flagged ? 'text-warning-500' : 'text-secondary-400')} fill={mail.is_flagged ? 'currentColor' : 'none'} viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" />
|
|
</svg>
|
|
}
|
|
>
|
|
{mail.is_flagged ? t('mail.unflag') : t('mail.flag')}
|
|
</Button>
|
|
<div className="flex-1" />
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => onCreateEvent(mail)}
|
|
icon={
|
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
|
</svg>
|
|
}
|
|
>
|
|
{t('mail.createEvent')}
|
|
</Button>
|
|
</div>
|
|
|
|
{/* 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">{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">{mail.from_name ? `${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.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 */}
|
|
<div className="flex-1 overflow-y-auto px-3 py-3 md:px-4 md:py-4" data-testid="mail-detail-body">
|
|
{safeHtml ? (
|
|
<iframe
|
|
ref={iframeRef}
|
|
srcDoc={safeHtml}
|
|
sandbox=""
|
|
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-2 md:px-4 md:py-3 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">
|
|
<svg className="w-5 h-5 text-secondary-400 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
|
</svg>
|
|
<div className="flex-1 min-w-0">
|
|
<p className="text-sm text-secondary-800 truncate">{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>
|
|
);
|
|
} |