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,
|
TemplateSubstituteRequest,
|
||||||
VacationConfig,
|
VacationConfig,
|
||||||
)
|
)
|
||||||
|
import app.plugins.builtins.mail.services as mail_services
|
||||||
from app.plugins.builtins.mail.services import (
|
from app.plugins.builtins.mail.services import (
|
||||||
MAX_ATTACHMENT_SIZE,
|
MAX_ATTACHMENT_SIZE,
|
||||||
_attachment_storage_path,
|
_attachment_storage_path,
|
||||||
@@ -1216,6 +1217,12 @@ async def update_flags(
|
|||||||
if data.is_forwarded is not None:
|
if data.is_forwarded is not None:
|
||||||
mail.is_forwarded = data.is_forwarded
|
mail.is_forwarded = data.is_forwarded
|
||||||
await db.flush()
|
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)
|
return mail_to_response(mail)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1295,3 +1295,111 @@ def label_to_response(label: MailLabel) -> dict:
|
|||||||
"name": label.name,
|
"name": label.name,
|
||||||
"color": label.color,
|
"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 {
|
export interface FlagUpdatePayload {
|
||||||
seen?: boolean;
|
is_seen?: boolean;
|
||||||
flagged?: boolean;
|
is_flagged?: boolean;
|
||||||
|
is_draft?: boolean;
|
||||||
|
is_answered?: boolean;
|
||||||
|
is_forwarded?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LinkMailPayload {
|
export interface LinkMailPayload {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
* Shows mail headers, sanitized HTML body, attachments, reply/forward/create-event actions.
|
* 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 { useTranslation } from 'react-i18next';
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
import type { Mail, MailAttachment } from '@/api/mail';
|
import type { Mail, MailAttachment } from '@/api/mail';
|
||||||
@@ -38,14 +38,6 @@ function formatBytes(bytes: number): string {
|
|||||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
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({
|
export function MailDetail({
|
||||||
mail,
|
mail,
|
||||||
loading,
|
loading,
|
||||||
@@ -63,7 +55,21 @@ export function MailDetail({
|
|||||||
return mail.sanitized_html || mail.body_html;
|
return mail.sanitized_html || mail.body_html;
|
||||||
}, [mail]);
|
}, [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) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
@@ -172,17 +178,16 @@ export function MailDetail({
|
|||||||
|
|
||||||
{/* Body */}
|
{/* Body */}
|
||||||
<div className="flex-1 overflow-y-auto px-3 py-3 md:px-4 md:py-4" data-testid="mail-detail-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 ? (
|
{safeHtml ? (
|
||||||
<div
|
<iframe
|
||||||
className="prose prose-sm max-w-none text-secondary-800"
|
ref={iframeRef}
|
||||||
dangerouslySetInnerHTML={{ __html: safeHtml }}
|
srcDoc={safeHtml}
|
||||||
|
sandbox=""
|
||||||
|
onLoad={handleIframeLoad}
|
||||||
|
className="w-full border-0"
|
||||||
data-testid="mail-html-body"
|
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>
|
<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;
|
total: number;
|
||||||
pageSize: number;
|
pageSize: number;
|
||||||
onPageChange: (page: number) => void;
|
onPageChange: (page: number) => void;
|
||||||
|
selectedMailIds: Set<string>;
|
||||||
|
onToggleSelect: (mailId: string) => void;
|
||||||
|
onSelectAll: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDate(dateStr: string): string {
|
function formatDate(dateStr: string): string {
|
||||||
@@ -39,9 +42,13 @@ export function MailList({
|
|||||||
total,
|
total,
|
||||||
pageSize,
|
pageSize,
|
||||||
onPageChange,
|
onPageChange,
|
||||||
|
selectedMailIds,
|
||||||
|
onToggleSelect,
|
||||||
|
onSelectAll,
|
||||||
}: MailListProps) {
|
}: MailListProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const totalPages = Math.ceil(total / pageSize);
|
const totalPages = Math.ceil(total / pageSize);
|
||||||
|
const allSelected = mails.length > 0 && mails.every((m) => selectedMailIds.has(m.id));
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
@@ -67,56 +74,84 @@ export function MailList({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div data-testid="mail-list" role="listbox" aria-label={t('mail.selectMail')}>
|
<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">
|
<ul className="divide-y divide-secondary-100" role="list">
|
||||||
{mails.map((mail) => (
|
{mails.map((mail) => (
|
||||||
<li key={mail.id} role="listitem">
|
<li key={mail.id} role="listitem" className={clsx(selectedMailIds.has(mail.id) && 'bg-primary-50')}>
|
||||||
<button
|
<div className="flex items-start gap-2">
|
||||||
onClick={() => onSelectMail(mail)}
|
<div className="flex items-center pt-3 pl-3 md:pl-4">
|
||||||
className={clsx(
|
<input
|
||||||
'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',
|
type="checkbox"
|
||||||
'focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500',
|
checked={selectedMailIds.has(mail.id)}
|
||||||
mail.id === selectedMailId && 'bg-primary-50',
|
onChange={() => onToggleSelect(mail.id)}
|
||||||
!mail.is_seen && 'font-semibold',
|
className="w-4 h-4 rounded border-secondary-300 text-primary-600 focus:ring-primary-500"
|
||||||
)}
|
aria-label={t('mail.selectMail')}
|
||||||
aria-selected={mail.id === selectedMailId}
|
data-testid={`mail-checkbox-${mail.id}`}
|
||||||
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>
|
</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>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -535,6 +535,11 @@
|
|||||||
"tabRules": "Regeln",
|
"tabRules": "Regeln",
|
||||||
"tabLabels": "Labels",
|
"tabLabels": "Labels",
|
||||||
"tabVacation": "Abwesenheit",
|
"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",
|
"tabRules": "Rules",
|
||||||
"tabLabels": "Labels",
|
"tabLabels": "Labels",
|
||||||
"tabVacation": "Vacation",
|
"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 [downloadingAttachmentId, setDownloadingAttachmentId] = useState<string | null>(null);
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [activeView, setActiveView] = useState<'folders' | 'list' | 'detail'>('folders');
|
const [activeView, setActiveView] = useState<'folders' | 'list' | 'detail'>('folders');
|
||||||
|
const [selectedMailIds, setSelectedMailIds] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
// Load accounts
|
// Load accounts
|
||||||
const loadAccounts = useCallback(async () => {
|
const loadAccounts = useCallback(async () => {
|
||||||
@@ -169,6 +170,7 @@ export function MailPage() {
|
|||||||
setMailsPage(1);
|
setMailsPage(1);
|
||||||
setSearchQuery('');
|
setSearchQuery('');
|
||||||
setSelectedMail(null);
|
setSelectedMail(null);
|
||||||
|
setSelectedMailIds(new Set());
|
||||||
setActiveView('list');
|
setActiveView('list');
|
||||||
},
|
},
|
||||||
[folders],
|
[folders],
|
||||||
@@ -184,7 +186,7 @@ export function MailPage() {
|
|||||||
setSelectedMail(detail);
|
setSelectedMail(detail);
|
||||||
// Mark as seen if not seen
|
// Mark as seen if not seen
|
||||||
if (!detail.is_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)));
|
setMails((prev) => prev.map((m) => (m.id === mail.id ? { ...m, is_seen: true } : m)));
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -225,7 +227,7 @@ export function MailPage() {
|
|||||||
const handleToggleFlag = useCallback(
|
const handleToggleFlag = useCallback(
|
||||||
async (mail: Mail) => {
|
async (mail: Mail) => {
|
||||||
try {
|
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));
|
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)));
|
setMails((prev) => prev.map((m) => (m.id === mail.id ? { ...m, is_flagged: !mail.is_flagged } : m)));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -235,6 +237,66 @@ export function MailPage() {
|
|||||||
[toast],
|
[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
|
// Handle create event from mail
|
||||||
const handleCreateEvent = useCallback(
|
const handleCreateEvent = useCallback(
|
||||||
async (mail: Mail) => {
|
async (mail: Mail) => {
|
||||||
@@ -314,12 +376,13 @@ export function MailPage() {
|
|||||||
const selectedMailFlagged = selectedMail?.is_flagged;
|
const selectedMailFlagged = selectedMail?.is_flagged;
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const hasMail = !!selectedMailId;
|
const hasMail = !!selectedMailId;
|
||||||
registerItems('mail', [
|
const hasBulkSelection = selectedMailIds.size > 0;
|
||||||
|
const items = [
|
||||||
{
|
{
|
||||||
id: 'search',
|
id: 'search',
|
||||||
plugin: 'mail',
|
plugin: 'mail',
|
||||||
label: 'Suchen',
|
label: 'Suchen',
|
||||||
type: 'search',
|
type: 'search' as const,
|
||||||
group: 'search',
|
group: 'search',
|
||||||
searchPlaceholder: 'E-Mails durchsuchen...',
|
searchPlaceholder: 'E-Mails durchsuchen...',
|
||||||
onSearch: handleSearch,
|
onSearch: handleSearch,
|
||||||
@@ -402,9 +465,38 @@ export function MailPage() {
|
|||||||
),
|
),
|
||||||
onClick: () => { /* sync trigger */ },
|
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');
|
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) {
|
if (loadingAccounts) {
|
||||||
return (
|
return (
|
||||||
@@ -476,6 +568,9 @@ export function MailPage() {
|
|||||||
total={mailsTotal}
|
total={mailsTotal}
|
||||||
pageSize={PAGE_SIZE}
|
pageSize={PAGE_SIZE}
|
||||||
onPageChange={setMailsPage}
|
onPageChange={setMailsPage}
|
||||||
|
selectedMailIds={selectedMailIds}
|
||||||
|
onToggleSelect={handleToggleSelect}
|
||||||
|
onSelectAll={handleSelectAll}
|
||||||
/>
|
/>
|
||||||
</ResizablePanel>
|
</ResizablePanel>
|
||||||
|
|
||||||
@@ -540,6 +635,9 @@ export function MailPage() {
|
|||||||
total={mailsTotal}
|
total={mailsTotal}
|
||||||
pageSize={PAGE_SIZE}
|
pageSize={PAGE_SIZE}
|
||||||
onPageChange={setMailsPage}
|
onPageChange={setMailsPage}
|
||||||
|
selectedMailIds={selectedMailIds}
|
||||||
|
onToggleSelect={handleToggleSelect}
|
||||||
|
onSelectAll={handleSelectAll}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -574,6 +672,28 @@ export function MailPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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
|
<ComposeModal
|
||||||
open={composeOpen}
|
open={composeOpen}
|
||||||
mode={composeMode}
|
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