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
+7
View File
@@ -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)
+108
View File
@@ -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
+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}
-59
View File
@@ -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)