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:
@@ -57,6 +57,7 @@ from app.plugins.builtins.mail.schemas import (
|
||||
TemplateSubstituteRequest,
|
||||
VacationConfig,
|
||||
)
|
||||
import app.plugins.builtins.mail.services as mail_services
|
||||
from app.plugins.builtins.mail.services import (
|
||||
MAX_ATTACHMENT_SIZE,
|
||||
_attachment_storage_path,
|
||||
@@ -1216,6 +1217,12 @@ async def update_flags(
|
||||
if data.is_forwarded is not None:
|
||||
mail.is_forwarded = data.is_forwarded
|
||||
await db.flush()
|
||||
# Sync flags to IMAP server (non-critical — failures are logged but don't break the API)
|
||||
try:
|
||||
await mail_services.imap_sync_mail_flags(db, m_id, tenant_id)
|
||||
except Exception as exc:
|
||||
import logging
|
||||
logging.getLogger(__name__).warning("IMAP flag sync failed for mail %s: %s", m_id, exc)
|
||||
return mail_to_response(mail)
|
||||
|
||||
|
||||
|
||||
@@ -1295,3 +1295,111 @@ def label_to_response(label: MailLabel) -> dict:
|
||||
"name": label.name,
|
||||
"color": label.color,
|
||||
}
|
||||
|
||||
|
||||
# ─── IMAP Flag Sync ───
|
||||
|
||||
|
||||
async def imap_sync_mail_flags(
|
||||
db: AsyncSession,
|
||||
mail_id: uuid.UUID,
|
||||
tenant_id: uuid.UUID,
|
||||
) -> None:
|
||||
"""Sync is_seen/is_flagged flags from DB to IMAP server.
|
||||
|
||||
Connects to the IMAP server, selects the mail's folder,
|
||||
and uses UID STORE to set/remove \\Seen and \\Flagged flags.
|
||||
Non-critical: logs warnings on failure but does not raise.
|
||||
"""
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Load the mail with its folder and account
|
||||
mail = (
|
||||
await db.execute(
|
||||
select(Mail).where(
|
||||
and_(Mail.id == mail_id, Mail.tenant_id == tenant_id)
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if not mail:
|
||||
logger.warning("imap_sync_mail_flags: mail %s not found", mail_id)
|
||||
return
|
||||
|
||||
folder = (
|
||||
await db.execute(
|
||||
select(MailFolder).where(
|
||||
and_(MailFolder.id == mail.folder_id, MailFolder.tenant_id == tenant_id)
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if not folder:
|
||||
logger.warning("imap_sync_mail_flags: folder %s not found for mail %s", mail.folder_id, mail_id)
|
||||
return
|
||||
|
||||
account = (
|
||||
await db.execute(
|
||||
select(MailAccount).where(
|
||||
and_(MailAccount.id == folder.account_id, MailAccount.tenant_id == tenant_id)
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if not account:
|
||||
logger.warning("imap_sync_mail_flags: account not found for mail %s", mail_id)
|
||||
return
|
||||
|
||||
if not mail.message_id:
|
||||
logger.warning("imap_sync_mail_flags: mail %s has no message_id, cannot sync", mail_id)
|
||||
return
|
||||
|
||||
password = await get_account_password(account)
|
||||
client = None
|
||||
|
||||
try:
|
||||
client = aioimaplib.IMAP4_SSL(host=account.imap_host, port=account.imap_port)
|
||||
await client.wait_hello_from_server()
|
||||
await client.login(account.username, password)
|
||||
|
||||
# Select the folder
|
||||
select_resp = await client.select(folder.imap_name)
|
||||
if select_resp.result != 'OK':
|
||||
logger.warning("imap_sync_mail_flags: cannot select folder %s", folder.imap_name)
|
||||
return
|
||||
|
||||
# Find the UID by searching for the Message-ID header
|
||||
search_resp = await client.uid_search(f'HEADER Message-ID "{mail.message_id}"')
|
||||
uids_raw = search_resp[1][0] if search_resp[1] and search_resp[1][0] else b''
|
||||
if isinstance(uids_raw, (bytes, bytearray)):
|
||||
uids = uids_raw.split()
|
||||
else:
|
||||
uids = []
|
||||
|
||||
if not uids:
|
||||
logger.warning("imap_sync_mail_flags: no UID found for Message-ID %s", mail.message_id)
|
||||
return
|
||||
|
||||
uid_str = uids[0].decode() if isinstance(uids[0], bytes) else str(uids[0])
|
||||
|
||||
# Sync \Seen flag
|
||||
if mail.is_seen:
|
||||
await client.uid('store', uid_str, '+FLAGS (\\Seen)')
|
||||
else:
|
||||
await client.uid('store', uid_str, '-FLAGS (\\Seen)')
|
||||
|
||||
# Sync \Flagged flag
|
||||
if mail.is_flagged:
|
||||
await client.uid('store', uid_str, '+FLAGS (\\Flagged)')
|
||||
else:
|
||||
await client.uid('store', uid_str, '-FLAGS (\\Flagged)')
|
||||
|
||||
logger.info("imap_sync_mail_flags: synced flags for mail %s (UID %s)", mail_id, uid_str)
|
||||
|
||||
except Exception as exc:
|
||||
logger.warning("imap_sync_mail_flags: failed for mail %s: %s", mail_id, exc)
|
||||
finally:
|
||||
if client is not None:
|
||||
try:
|
||||
await client.logout()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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}
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
# Test Report — Mail Attachment Support
|
||||
|
||||
**Date:** 2026-07-15
|
||||
**Task:** Mail attachment support — sync, display, download, upload
|
||||
|
||||
## Changes Summary
|
||||
|
||||
### Backend
|
||||
- **services.py**: Added `_sanitize_filename`, `_attachment_storage_path`, `_save_attachment_to_storage`, `attachment_to_response` helpers. Modified IMAP sync to save attachment content to disk + create `MailAttachment` DB records. Modified `send_mail_via_smtp` to accept `attachment_paths` and attach files to `EmailMessage`. Updated `mail_to_response` to use `attachment_to_response`.
|
||||
- **routes.py**: Added `POST /api/v1/mail/upload-attachment` endpoint (multipart/form-data). Added `_resolve_attachment_paths` helper. Modified `send_mail` route to resolve attachment IDs and pass to `send_mail_via_smtp`.
|
||||
|
||||
### Frontend
|
||||
- **api/mail.ts**: Updated `MailAttachment` interface (added `size_bytes`, `dms_file_id`). Added `uploadAttachment` function and `UploadedAttachment` interface.
|
||||
- **MailDetail.tsx**: Fixed `att.size` → `att.size_bytes` for formatBytes call.
|
||||
- **ComposeModal.tsx**: Added file input (multiple), attachment upload via `uploadAttachment`, attachment list with remove buttons, attachment IDs passed in send payload.
|
||||
- **i18n locales**: Added `mail.addAttachment` and `common.remove` keys to en.json and de.json.
|
||||
|
||||
## Test Results
|
||||
|
||||
### Python Syntax Check
|
||||
```
|
||||
/opt/venv/bin/python -c "import py_compile; py_compile.compile('app/plugins/builtins/mail/services.py', doraise=True); py_compile.compile('app/plugins/builtins/mail/routes.py', doraise=True)"
|
||||
→ Python syntax OK
|
||||
```
|
||||
|
||||
### Frontend TypeScript Check
|
||||
```
|
||||
npx tsc --noEmit 2>&1 | grep -E 'mail|Mail|Compose|MailDetail'
|
||||
→ No errors in mail files
|
||||
```
|
||||
Pre-existing errors in SettingsPlugins.tsx and SettingsRoles.tsx (unrelated to this task).
|
||||
|
||||
### Frontend Tests (vitest)
|
||||
|
||||
| Test File | Result |
|
||||
|-----------|--------|
|
||||
| ComposeModal.test.tsx | ✅ 10/10 passed |
|
||||
| MailSettings.test.tsx | ✅ 10/10 passed |
|
||||
| MailPage.test.tsx | ⚠️ 6 passed, 8 failed (PRE-EXISTING — confirmed via git stash) |
|
||||
|
||||
### Backend Tests (pytest)
|
||||
```
|
||||
pytest tests/test_mail.py
|
||||
→ ImportError: No module named 'redis' (pre-existing environment issue)
|
||||
```
|
||||
|
||||
## Smoke Test Description
|
||||
|
||||
1. **IMAP Sync**: Attachment content is now captured during IMAP sync (`part.get_payload(decode=True)`), saved to `{storage_path}/mail_attachments/{mail_id}/{filename}`, and `MailAttachment` records are created in the DB.
|
||||
2. **Download**: Existing `GET /{mail_id}/attachments/{att_id}` route streams the file from disk with correct Content-Type and Content-Disposition headers.
|
||||
3. **Upload**: New `POST /upload-attachment` endpoint accepts multipart/form-data, validates 25MB max size, saves to temp directory, returns attachment ID.
|
||||
4. **Send with attachments**: `send_mail` route resolves attachment IDs to file paths, passes them to `send_mail_via_smtp` which adds them to the `EmailMessage` via `msg.add_attachment()`.
|
||||
5. **Frontend display**: MailDetail shows attachments with icon, filename, size (using `size_bytes`), and download button.
|
||||
6. **Frontend compose**: ComposeModal has file input button, uploads files via `uploadAttachment`, shows list with remove option, passes attachment IDs in send payload.
|
||||
|
||||
## Security
|
||||
- Filenames sanitized via `_sanitize_filename` (removes path components, replaces dangerous characters)
|
||||
- Max 25MB per attachment enforced in both upload endpoint and frontend
|
||||
- No path traversal possible (basename extraction + character replacement)
|
||||
Reference in New Issue
Block a user