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
+5 -2
View File
@@ -246,8 +246,11 @@ export interface ForwardPayload {
}
export interface FlagUpdatePayload {
seen?: boolean;
flagged?: boolean;
is_seen?: boolean;
is_flagged?: boolean;
is_draft?: boolean;
is_answered?: boolean;
is_forwarded?: boolean;
}
export interface LinkMailPayload {
+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>
)}
+82 -47
View File
@@ -19,6 +19,9 @@ export interface MailListProps {
total: number;
pageSize: number;
onPageChange: (page: number) => void;
selectedMailIds: Set<string>;
onToggleSelect: (mailId: string) => void;
onSelectAll: () => void;
}
function formatDate(dateStr: string): string {
@@ -39,9 +42,13 @@ export function MailList({
total,
pageSize,
onPageChange,
selectedMailIds,
onToggleSelect,
onSelectAll,
}: MailListProps) {
const { t } = useTranslation();
const totalPages = Math.ceil(total / pageSize);
const allSelected = mails.length > 0 && mails.every((m) => selectedMailIds.has(m.id));
if (loading) {
return (
@@ -67,56 +74,84 @@ export function MailList({
return (
<div data-testid="mail-list" role="listbox" aria-label={t('mail.selectMail')}>
{/* Select-all header */}
<div className="flex items-center gap-2 px-3 py-2 md:px-4 md:py-2 border-b border-secondary-200 bg-secondary-50">
<input
type="checkbox"
checked={allSelected}
onChange={onSelectAll}
className="w-4 h-4 rounded border-secondary-300 text-primary-600 focus:ring-primary-500"
aria-label={t('mail.selectAll')}
data-testid="mail-select-all"
/>
{selectedMailIds.size > 0 && (
<span className="text-xs text-secondary-600">
{t('mail.selectedCount', { count: selectedMailIds.size })}
</span>
)}
</div>
<ul className="divide-y divide-secondary-100" role="list">
{mails.map((mail) => (
<li key={mail.id} role="listitem">
<button
onClick={() => onSelectMail(mail)}
className={clsx(
'w-full text-left px-3 py-2 md:px-4 md:py-3 hover:bg-secondary-50 min-h-touch motion-safe:transition-colors',
'focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500',
mail.id === selectedMailId && 'bg-primary-50',
!mail.is_seen && 'font-semibold',
)}
aria-selected={mail.id === selectedMailId}
data-testid={`mail-item-${mail.id}`}
>
<div className="flex items-center gap-2">
{!mail.is_seen && (
<span className="w-2 h-2 rounded-full bg-primary-500 flex-shrink-0" aria-label={t('mail.unread')} />
)}
{mail.is_flagged && (
<svg className="w-4 h-4 text-warning-500 flex-shrink-0" fill="currentColor" viewBox="0 0 24 24" aria-label={t('mail.flagged')}>
<path 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.has_attachments && (
<svg className="w-4 h-4 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="M15.172 13l-3.586 3.586a2 2 0 01-2.828 0L5 12.828a2 2 0 010-2.828l5.657-5.657a2 2 0 012.828 0L17 6.343M14.828 8.172a2 2 0 00-2.828 0l-3.586 3.586a2 2 0 000 2.828l1.414 1.414a2 2 0 002.828 0" />
</svg>
)}
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between gap-2">
<span className={clsx('text-sm truncate', !mail.is_seen ? 'text-secondary-900 font-semibold' : 'text-secondary-700')}>
{mail.from_name || mail.from_address}
</span>
<span className="text-xs text-secondary-400 flex-shrink-0">{formatDate(mail.date)}</span>
</div>
<p className={clsx('text-xs md:text-sm truncate mt-0.5', !mail.is_seen ? 'text-secondary-800' : 'text-secondary-600')}>
{mail.subject || t('mail.noSubject')}
</p>
{mail.labels && mail.labels.length > 0 && (
<div className="flex gap-1 mt-1">
{mail.labels.map((label) => (
<span key={label.id} className="inline-flex items-center px-2 py-0.5 rounded-full text-xs text-white" style={{ backgroundColor: label.color }}>
{label.name}
</span>
))}
</div>
)}
</div>
<li key={mail.id} role="listitem" className={clsx(selectedMailIds.has(mail.id) && 'bg-primary-50')}>
<div className="flex items-start gap-2">
<div className="flex items-center pt-3 pl-3 md:pl-4">
<input
type="checkbox"
checked={selectedMailIds.has(mail.id)}
onChange={() => onToggleSelect(mail.id)}
className="w-4 h-4 rounded border-secondary-300 text-primary-600 focus:ring-primary-500"
aria-label={t('mail.selectMail')}
data-testid={`mail-checkbox-${mail.id}`}
/>
</div>
</button>
<button
onClick={() => onSelectMail(mail)}
className={clsx(
'w-full text-left px-1 py-2 md:px-2 md:py-3 hover:bg-secondary-50 min-h-touch motion-safe:transition-colors',
'focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500',
mail.id === selectedMailId && 'bg-primary-50',
!mail.is_seen && 'font-semibold',
)}
aria-selected={mail.id === selectedMailId}
data-testid={`mail-item-${mail.id}`}
>
<div className="flex items-center gap-2">
{!mail.is_seen && (
<span className="w-2 h-2 rounded-full bg-primary-500 flex-shrink-0" aria-label={t('mail.unread')} />
)}
{mail.is_flagged && (
<svg className="w-4 h-4 text-warning-500 flex-shrink-0" fill="currentColor" viewBox="0 0 24 24" aria-label={t('mail.flagged')}>
<path 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.has_attachments && (
<svg className="w-4 h-4 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="M15.172 13l-3.586 3.586a2 2 0 01-2.828 0L5 12.828a2 2 0 010-2.828l5.657-5.657a2 2 0 012.828 0L17 6.343M14.828 8.172a2 2 0 00-2.828 0l-3.586 3.586a2 2 0 000 2.828l1.414 1.414a2 2 0 002.828 0" />
</svg>
)}
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between gap-2">
<span className={clsx('text-sm truncate', !mail.is_seen ? 'text-secondary-900 font-semibold' : 'text-secondary-700')}>
{mail.from_name || mail.from_address}
</span>
<span className="text-xs text-secondary-400 flex-shrink-0">{formatDate(mail.date)}</span>
</div>
<p className={clsx('text-xs md:text-sm truncate mt-0.5', !mail.is_seen ? 'text-secondary-800' : 'text-secondary-600')}>
{mail.subject || t('mail.noSubject')}
</p>
{mail.labels && mail.labels.length > 0 && (
<div className="flex gap-1 mt-1">
{mail.labels.map((label) => (
<span key={label.id} className="inline-flex items-center px-2 py-0.5 rounded-full text-xs text-white" style={{ backgroundColor: label.color }}>
{label.name}
</span>
))}
</div>
)}
</div>
</div>
</button>
</div>
</li>
))}
</ul>
+6 -1
View File
@@ -535,6 +535,11 @@
"tabRules": "Regeln",
"tabLabels": "Labels",
"tabVacation": "Abwesenheit",
"tabPgp": "PGP-Verschlüsselung"
"tabPgp": "PGP-Verschlüsselung",
"markRead": "Als gelesen markieren",
"markUnread": "Als ungelesen markieren",
"selectAll": "Alle auswählen",
"bulkActions": "Sammelaktionen",
"selectedCount": "{{count}} ausgewählt"
}
}
+6 -1
View File
@@ -535,6 +535,11 @@
"tabRules": "Rules",
"tabLabels": "Labels",
"tabVacation": "Vacation",
"tabPgp": "PGP Encryption"
"tabPgp": "PGP Encryption",
"markRead": "Mark as Read",
"markUnread": "Mark as Unread",
"selectAll": "Select All",
"bulkActions": "Bulk Actions",
"selectedCount": "{{count}} selected"
}
}
+126 -6
View File
@@ -70,6 +70,7 @@ export function MailPage() {
const [downloadingAttachmentId, setDownloadingAttachmentId] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState('');
const [activeView, setActiveView] = useState<'folders' | 'list' | 'detail'>('folders');
const [selectedMailIds, setSelectedMailIds] = useState<Set<string>>(new Set());
// Load accounts
const loadAccounts = useCallback(async () => {
@@ -169,6 +170,7 @@ export function MailPage() {
setMailsPage(1);
setSearchQuery('');
setSelectedMail(null);
setSelectedMailIds(new Set());
setActiveView('list');
},
[folders],
@@ -184,7 +186,7 @@ export function MailPage() {
setSelectedMail(detail);
// Mark as seen if not seen
if (!detail.is_seen) {
await updateFlags(mail.id, { seen: true });
await updateFlags(mail.id, { is_seen: true });
setMails((prev) => prev.map((m) => (m.id === mail.id ? { ...m, is_seen: true } : m)));
}
} catch (err) {
@@ -225,7 +227,7 @@ export function MailPage() {
const handleToggleFlag = useCallback(
async (mail: Mail) => {
try {
await updateFlags(mail.id, { flagged: !mail.is_flagged });
await updateFlags(mail.id, { is_flagged: !mail.is_flagged });
setSelectedMail((prev) => (prev ? { ...prev, is_flagged: !mail.is_flagged } : prev));
setMails((prev) => prev.map((m) => (m.id === mail.id ? { ...m, is_flagged: !mail.is_flagged } : m)));
} catch (err) {
@@ -235,6 +237,66 @@ export function MailPage() {
[toast],
);
// Handle toggle select for bulk operations
const handleToggleSelect = useCallback((mailId: string) => {
setSelectedMailIds((prev) => {
const next = new Set(prev);
if (next.has(mailId)) {
next.delete(mailId);
} else {
next.add(mailId);
}
return next;
});
}, []);
// Handle select all mails on current page
const handleSelectAll = useCallback(() => {
setSelectedMailIds((prev) => {
const allSelected = mails.length > 0 && mails.every((m) => prev.has(m.id));
if (allSelected) {
// Deselect all on current page
const next = new Set(prev);
for (const m of mails) {
next.delete(m.id);
}
return next;
}
// Select all on current page
const next = new Set(prev);
for (const m of mails) {
next.add(m.id);
}
return next;
});
}, [mails]);
// Handle bulk mark as read
const handleBulkMarkRead = useCallback(async () => {
const ids = Array.from(selectedMailIds);
try {
await Promise.all(ids.map((id) => updateFlags(id, { is_seen: true })));
setMails((prev) => prev.map((m) => (selectedMailIds.has(m.id) ? { ...m, is_seen: true } : m)));
setSelectedMailIds(new Set());
toast.success(t('mail.markRead'));
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
}, [selectedMailIds, toast, t]);
// Handle bulk mark as unread
const handleBulkMarkUnread = useCallback(async () => {
const ids = Array.from(selectedMailIds);
try {
await Promise.all(ids.map((id) => updateFlags(id, { is_seen: false })));
setMails((prev) => prev.map((m) => (selectedMailIds.has(m.id) ? { ...m, is_seen: false } : m)));
setSelectedMailIds(new Set());
toast.success(t('mail.markUnread'));
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
}, [selectedMailIds, toast, t]);
// Handle create event from mail
const handleCreateEvent = useCallback(
async (mail: Mail) => {
@@ -314,12 +376,13 @@ export function MailPage() {
const selectedMailFlagged = selectedMail?.is_flagged;
useEffect(() => {
const hasMail = !!selectedMailId;
registerItems('mail', [
const hasBulkSelection = selectedMailIds.size > 0;
const items = [
{
id: 'search',
plugin: 'mail',
label: 'Suchen',
type: 'search',
type: 'search' as const,
group: 'search',
searchPlaceholder: 'E-Mails durchsuchen...',
onSearch: handleSearch,
@@ -402,9 +465,38 @@ export function MailPage() {
),
onClick: () => { /* sync trigger */ },
},
]);
];
if (hasBulkSelection) {
items.push(
{
id: 'bulk-mark-read',
plugin: 'mail',
label: t('mail.markRead'),
group: 'bulk-actions',
icon: (
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
),
onClick: handleBulkMarkRead,
},
{
id: 'bulk-mark-unread',
plugin: 'mail',
label: t('mail.markUnread'),
group: 'bulk-actions',
icon: (
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 19l6-6 4 4 8-8" />
</svg>
),
onClick: handleBulkMarkUnread,
},
);
}
registerItems('mail', items);
return () => unregisterPlugin('mail');
}, [selectedMailId, selectedMailFlagged, handleSearch, handleCompose, handleReply, handleForward, handleToggleFlag, handleCreateEvent]);
}, [selectedMailId, selectedMailFlagged, selectedMailIds, handleSearch, handleCompose, handleReply, handleForward, handleToggleFlag, handleCreateEvent, handleBulkMarkRead, handleBulkMarkUnread, t]);
if (loadingAccounts) {
return (
@@ -476,6 +568,9 @@ export function MailPage() {
total={mailsTotal}
pageSize={PAGE_SIZE}
onPageChange={setMailsPage}
selectedMailIds={selectedMailIds}
onToggleSelect={handleToggleSelect}
onSelectAll={handleSelectAll}
/>
</ResizablePanel>
@@ -540,6 +635,9 @@ export function MailPage() {
total={mailsTotal}
pageSize={PAGE_SIZE}
onPageChange={setMailsPage}
selectedMailIds={selectedMailIds}
onToggleSelect={handleToggleSelect}
onSelectAll={handleSelectAll}
/>
</div>
)}
@@ -574,6 +672,28 @@ export function MailPage() {
)}
</div>
{/* Floating bulk action bar */}
{selectedMailIds.size > 0 && (
<div
className="fixed bottom-4 left-1/2 -translate-x-1/2 z-50 flex items-center gap-2 px-4 py-2 bg-white border border-secondary-200 rounded-lg shadow-lg"
data-testid="mail-bulk-action-bar"
>
<span className="text-sm text-secondary-600">
{t('mail.selectedCount', { count: selectedMailIds.size })}
</span>
<div className="w-px h-5 bg-secondary-200" />
<Button variant="secondary" size="sm" onClick={handleBulkMarkRead}>
{t('mail.markRead')}
</Button>
<Button variant="secondary" size="sm" onClick={handleBulkMarkUnread}>
{t('mail.markUnread')}
</Button>
<Button variant="ghost" size="sm" onClick={() => setSelectedMailIds(new Set())}>
</Button>
</div>
)}
<ComposeModal
open={composeOpen}
mode={composeMode}