feat: mail phase 1 - HTML iframe display, bulk read/unread, IMAP flag sync
- 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)
This commit is contained in:
@@ -3,7 +3,7 @@
|
||||
* Shows mail headers, sanitized HTML body, attachments, reply/forward/create-event actions.
|
||||
*/
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
import React, { useMemo, useRef, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import clsx from 'clsx';
|
||||
import type { Mail, MailAttachment } from '@/api/mail';
|
||||
@@ -38,14 +38,6 @@ function formatBytes(bytes: number): string {
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
function isSanitizedHtmlSafe(html: string | null): boolean {
|
||||
if (!html) return true;
|
||||
const lower = html.toLowerCase();
|
||||
const dangerous = ['<script', 'javascript:', 'onerror=', 'onload=', 'onclick=', '<iframe'];
|
||||
return !dangerous.some((pattern) => lower.includes(pattern));
|
||||
|
||||
}
|
||||
|
||||
export function MailDetail({
|
||||
mail,
|
||||
loading,
|
||||
@@ -63,7 +55,21 @@ export function MailDetail({
|
||||
return mail.sanitized_html || mail.body_html;
|
||||
}, [mail]);
|
||||
|
||||
const isSafe = useMemo(() => isSanitizedHtmlSafe(safeHtml), [safeHtml]);
|
||||
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 (
|
||||
@@ -172,17 +178,16 @@ export function MailDetail({
|
||||
|
||||
{/* Body */}
|
||||
<div className="flex-1 overflow-y-auto px-3 py-3 md:px-4 md:py-4" data-testid="mail-detail-body">
|
||||
{safeHtml && isSafe ? (
|
||||
<div
|
||||
className="prose prose-sm max-w-none text-secondary-800"
|
||||
dangerouslySetInnerHTML={{ __html: safeHtml }}
|
||||
{safeHtml ? (
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
srcDoc={safeHtml}
|
||||
sandbox=""
|
||||
onLoad={handleIframeLoad}
|
||||
className="w-full border-0"
|
||||
data-testid="mail-html-body"
|
||||
title={t('mail.subject')}
|
||||
/>
|
||||
) : safeHtml && !isSafe ? (
|
||||
<div className="p-4 bg-danger-50 border border-danger-200 rounded-md" role="alert">
|
||||
<p className="text-sm text-danger-700">{t('mail.htmlUnsafe')}</p>
|
||||
<pre className="mt-2 text-xs text-secondary-600 whitespace-pre-wrap">{mail.body_text}</pre>
|
||||
</div>
|
||||
) : (
|
||||
<pre className="whitespace-pre-wrap text-sm text-secondary-800" data-testid="mail-text-body">{mail.body_text}</pre>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user