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:
Agent Zero
2026-07-15 19:26:37 +02:00
parent 2108bdb9c2
commit df83cee10c
9 changed files with 364 additions and 135 deletions
+24 -19
View File
@@ -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>
)}