Files
leocrm/frontend/src/pages/Mail.tsx
T
Agent Zero 4e100e9d33 feat: mail phase 3 - sorting (date/from/subject, asc/desc)
- Add sort_by and sort_order query params to list_mails endpoint
- Add deleted_at IS NULL filter to exclude soft-deleted mails
- Frontend: sort state in Mail.tsx, sort header bar in MailList
- Sort dropdown (Datum/Absender/Betreff) + asc/desc toggle
- fetchMails API accepts optional sortBy/sortOrder params
- i18n: 6 new sort keys (de/en)
2026-07-15 20:17:27 +02:00

929 lines
32 KiB
TypeScript

/**
* Mail page — folder tree + mail list + reading pane + compose.
* Uses ResizablePanel for drag-to-resize columns.
* Account selection is integrated into the folder tree (left pane).
*/
import React, { useState, useEffect, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { useToast } from '@/components/ui/Toast';
import { EmptyState } from '@/components/ui/EmptyState';
import { ResizablePanel } from '@/components/ui/ResizablePanel';
import { MailFolderTree } from '@/components/mail/MailFolderTree';
import { MailList } from '@/components/mail/MailList';
import { MailDetail } from '@/components/mail/MailDetail';
import { ComposeModal, type ComposeMode } from '@/components/mail/ComposeModal';
import { SharedMailboxSelector } from '@/components/mail/SharedMailboxSelector';
import { MailSearchBar } from '@/components/mail/MailSearchBar';
import { usePluginToolbarStore } from '@/store/pluginToolbarStore';
import {
fetchAccounts,
fetchFolders,
fetchMails,
getMail,
sendMail,
replyMail,
forwardMail,
updateFlags,
createEventFromMail,
downloadAttachment,
fetchSignatures,
searchMails,
deleteMail,
moveMail,
saveDraft,
updateDraft,
type MailAccount,
type MailFolder,
type Mail,
type MailSignature,
type MailAttachment,
type SendMailPayload,
type ReplyPayload,
type ForwardPayload,
type CreateEventFromMailPayload,
type MailDraftPayload,
} from '@/api/mail';
const PAGE_SIZE = 25;
export function MailPage() {
const { t } = useTranslation();
const toast = useToast();
const [accounts, setAccounts] = useState<MailAccount[]>([]);
const [selectedAccountId, setSelectedAccountId] = useState('');
const [folders, setFolders] = useState<MailFolder[]>([]);
const [selectedFolderId, setSelectedFolderId] = useState<string | null>(null);
const [mails, setMails] = useState<Mail[]>([]);
const [mailsTotal, setMailsTotal] = useState(0);
const [mailsPage, setMailsPage] = useState(1);
const [selectedMail, setSelectedMail] = useState<Mail | null>(null);
const [loadingMail, setLoadingMail] = useState(false);
const [loadingAccounts, setLoadingAccounts] = useState(true);
const [loadingFolders, setLoadingFolders] = useState(false);
const [loadingMails, setLoadingMails] = useState(false);
const [error, setError] = useState<string | null>(null);
const [signatures, setSignatures] = useState<MailSignature[]>([]);
const [composeOpen, setComposeOpen] = useState(false);
const [composeMode, setComposeMode] = useState<ComposeMode>('new');
const [replyToMail, setReplyToMail] = useState<Mail | null>(null);
const [forwardMailState, setForwardMailState] = useState<Mail | null>(null);
const [draftMailState, setDraftMailState] = useState<Mail | null>(null);
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());
const [showMoveDropdown, setShowMoveDropdown] = useState(false);
const [sortBy, setSortBy] = useState<'date' | 'from' | 'subject'>('date');
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc');
// Load accounts
const loadAccounts = useCallback(async () => {
setLoadingAccounts(true);
try {
const accs = await fetchAccounts();
setAccounts(accs);
if (accs.length > 0 && !selectedAccountId) {
setSelectedAccountId(accs[0].id);
}
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
setError(msg);
}
setLoadingAccounts(false);
}, [selectedAccountId]);
// Load signatures
const loadSignatures = useCallback(async () => {
try {
const sigs = await fetchSignatures();
setSignatures(sigs);
} catch {
// non-critical
}
}, []);
useEffect(() => {
loadAccounts();
loadSignatures();
}, [loadAccounts, loadSignatures]);
// Load folders for ALL accounts
const loadAllFolders = useCallback(async () => {
if (accounts.length === 0) return;
setLoadingFolders(true);
try {
const allFolders: MailFolder[] = [];
for (const acc of accounts) {
const folderList = await fetchFolders(acc.id);
allFolders.push(...folderList);
}
setFolders(allFolders);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
setError(msg);
}
setLoadingFolders(false);
}, [accounts]);
useEffect(() => {
loadAllFolders();
}, [loadAllFolders]);
// Auto-select first folder when folders are loaded
useEffect(() => {
if (folders.length > 0 && !selectedFolderId) {
setSelectedFolderId(folders[0].id);
setSelectedAccountId(folders[0].account_id);
}
}, [folders, selectedFolderId]);
// Load mails when folder or page changes
const loadMails = useCallback(async () => {
if (!selectedFolderId) return;
setLoadingMails(true);
try {
if (searchQuery.trim()) {
const result = await searchMails(searchQuery);
setMails(result.mails);
setMailsTotal(result.total);
} else {
const result = await fetchMails(selectedFolderId, mailsPage, sortBy, sortOrder);
setMails(result.mails);
setMailsTotal(result.total);
}
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
setError(msg);
setMails([]);
}
setLoadingMails(false);
}, [selectedFolderId, mailsPage, searchQuery, sortBy, sortOrder]);
useEffect(() => {
loadMails();
}, [loadMails]);
// Handle folder selection — derive account from folder
const handleSelectFolder = useCallback(
(folderId: string) => {
setSelectedFolderId(folderId);
const folder = folders.find((f) => f.id === folderId);
if (folder) {
setSelectedAccountId(folder.account_id);
}
setMailsPage(1);
setSearchQuery('');
setSelectedMail(null);
setSelectedMailIds(new Set());
setActiveView('list');
},
[folders],
);
// Handle mail selection
const handleSelectMail = useCallback(
async (mail: Mail) => {
setActiveView('detail');
setLoadingMail(true);
try {
const detail = await getMail(mail.id);
setSelectedMail(detail);
// Mark as seen if not seen
if (!detail.is_seen) {
await updateFlags(mail.id, { is_seen: true });
setMails((prev) => prev.map((m) => (m.id === mail.id ? { ...m, is_seen: true } : m)));
}
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
toast.error(msg);
setSelectedMail(mail);
}
setLoadingMail(false);
},
[toast],
);
// Handle compose
const handleCompose = useCallback(() => {
setComposeMode('new');
setReplyToMail(null);
setForwardMailState(null);
setDraftMailState(null);
setComposeOpen(true);
}, []);
// Handle reply
const handleReply = useCallback((mail: Mail) => {
setComposeMode('reply');
setReplyToMail(mail);
setForwardMailState(null);
setDraftMailState(null);
setComposeOpen(true);
}, []);
// Handle forward
const handleForward = useCallback((mail: Mail) => {
setComposeMode('forward');
setForwardMailState(mail);
setReplyToMail(null);
setDraftMailState(null);
setComposeOpen(true);
}, []);
// Handle delete single mail
const handleDeleteMail = useCallback(
async (mail: Mail) => {
if (!window.confirm(t('mail.deleteConfirm'))) return;
try {
await deleteMail(mail.id);
setMails((prev) => prev.filter((m) => m.id !== mail.id));
setSelectedMail(null);
toast.success(t('mail.delete'));
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
},
[toast, t],
);
// Handle bulk delete
const handleBulkDelete = useCallback(async () => {
if (!window.confirm(t('mail.bulkDeleteConfirm'))) return;
const ids = Array.from(selectedMailIds);
try {
await Promise.all(ids.map((id) => deleteMail(id)));
setMails((prev) => prev.filter((m) => !selectedMailIds.has(m.id)));
setSelectedMailIds(new Set());
setSelectedMail(null);
toast.success(t('mail.delete'));
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
}, [selectedMailIds, toast, t]);
// Handle move single mail
const handleMoveMail = useCallback(
async (mail: Mail, targetFolderId: string) => {
try {
await moveMail(mail.id, targetFolderId);
setMails((prev) => prev.filter((m) => m.id !== mail.id));
setSelectedMail(null);
setShowMoveDropdown(false);
toast.success(t('mail.move'));
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
},
[toast, t],
);
// Handle bulk move
const handleBulkMove = useCallback(
async (targetFolderId: string) => {
const ids = Array.from(selectedMailIds);
try {
await Promise.all(ids.map((id) => moveMail(id, targetFolderId)));
setMails((prev) => prev.filter((m) => !selectedMailIds.has(m.id)));
setSelectedMailIds(new Set());
setShowMoveDropdown(false);
toast.success(t('mail.move'));
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
},
[selectedMailIds, toast, t],
);
// Handle save draft (new or existing)
const handleSaveDraft = useCallback(
async (payload: MailDraftPayload) => {
try {
if (composeMode === 'draft' && draftMailState) {
await updateDraft(draftMailState.id, payload);
} else {
await saveDraft(payload);
}
toast.success(t('mail.draftSaved'));
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
throw err;
}
},
[composeMode, draftMailState, toast, t],
);
// Handle edit draft
const handleEditDraft = useCallback((mail: Mail) => {
setComposeMode('draft');
setDraftMailState(mail);
setReplyToMail(null);
setForwardMailState(null);
setComposeOpen(true);
}, []);
// Handle toggle flag
const handleToggleFlag = useCallback(
async (mail: Mail) => {
try {
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) {
toast.error(err instanceof Error ? err.message : String(err));
}
},
[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) => {
const title = mail.subject || t('mail.noSubject');
const now = new Date();
const start = now.toISOString();
const end = new Date(now.getTime() + 60 * 60 * 1000).toISOString();
const payload: CreateEventFromMailPayload = {
title,
start,
end,
description: mail.body_text.slice(0, 500),
};
try {
await createEventFromMail(mail.id, payload);
toast.success(t('mail.eventCreated'));
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
},
[toast, t],
);
// Handle attachment download
const handleDownloadAttachment = useCallback(
async (mailId: string, attachment: MailAttachment) => {
setDownloadingAttachmentId(attachment.id);
try {
const blob = await downloadAttachment(mailId, attachment.id);
const url = window.URL.createObjectURL(blob);
const a = window.document.createElement('a');
a.href = url;
a.download = attachment.filename;
window.document.body.appendChild(a);
a.click();
window.document.body.removeChild(a);
window.URL.revokeObjectURL(url);
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
} finally {
setDownloadingAttachmentId(null);
}
},
[toast],
);
// Handle send (compose)
const handleSend = useCallback(
async (payload: SendMailPayload | ReplyPayload | ForwardPayload, mode: ComposeMode) => {
try {
if (mode === 'reply' && replyToMail) {
await replyMail(replyToMail.id, payload as ReplyPayload);
} else if (mode === 'forward' && forwardMailState) {
await forwardMail(forwardMailState.id, payload as ForwardPayload);
} else {
await sendMail(payload as SendMailPayload);
}
toast.success(t('mail.sent'));
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
throw err;
}
},
[replyToMail, forwardMailState, toast, t],
);
// Handle search
const handleSearch = useCallback((query: string) => {
setSearchQuery(query);
setMailsPage(1);
}, []);
// Register toolbar items (account-select removed — account is now in folder tree)
const registerItems = usePluginToolbarStore((s) => s.registerItems);
const unregisterPlugin = usePluginToolbarStore((s) => s.unregisterPlugin);
const selectedMailId = selectedMail?.id;
const selectedMailFlagged = selectedMail?.is_flagged;
useEffect(() => {
const hasMail = !!selectedMailId;
const hasBulkSelection = selectedMailIds.size > 0;
const items = [
{
id: 'search',
plugin: 'mail',
label: 'Suchen',
type: 'search' as const,
group: 'search',
searchPlaceholder: 'E-Mails durchsuchen...',
onSearch: handleSearch,
onClick: () => {},
},
{
id: 'compose',
plugin: 'mail',
label: 'Verfassen',
group: 'compose',
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="M12 4v16m8-8H4" />
</svg>
),
onClick: handleCompose,
},
{
id: 'reply',
plugin: 'mail',
label: 'Antworten',
group: 'mail-actions',
disabled: !hasMail,
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 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6" />
</svg>
),
onClick: () => selectedMail && handleReply(selectedMail),
},
{
id: 'forward',
plugin: 'mail',
label: 'Weiterleiten',
group: 'mail-actions',
disabled: !hasMail,
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="M21 10H11a8 8 0 00-8 8v2m18-10l-6 6m6-6l-6-6" />
</svg>
),
onClick: () => selectedMail && handleForward(selectedMail),
},
{
id: 'flag',
plugin: 'mail',
label: 'Flagge',
group: 'mail-actions',
disabled: !hasMail,
active: selectedMailFlagged,
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 21v-4m0 0V5a2 2 0 012-2h6.5l1 1H21l-3 6 3 6h-8.5l-1-1H5a2 2 0 00-2 2zm9-13.5V9" />
</svg>
),
onClick: () => selectedMail && handleToggleFlag(selectedMail),
},
{
id: 'create-event',
plugin: 'mail',
label: 'Termin',
group: 'mail-actions',
disabled: !hasMail,
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="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
),
onClick: () => selectedMail && handleCreateEvent(selectedMail),
},
{
id: 'delete',
plugin: 'mail',
label: t('mail.delete'),
group: 'mail-actions',
disabled: !hasMail,
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="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
),
onClick: () => selectedMail && handleDeleteMail(selectedMail),
},
{
id: 'move',
plugin: 'mail',
label: t('mail.move'),
group: 'mail-actions',
disabled: !hasMail,
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="M17 8l4 4m0 0l-4 4m4-4H3" />
</svg>
),
onClick: () => setShowMoveDropdown((v) => !v),
},
{
id: 'sync',
plugin: 'mail',
label: 'Sync',
group: 'tools',
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="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
),
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,
},
{
id: 'bulk-delete',
plugin: 'mail',
label: t('mail.bulkDelete'),
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="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
),
onClick: handleBulkDelete,
},
{
id: 'bulk-move',
plugin: 'mail',
label: t('mail.bulkMove'),
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="M17 8l4 4m0 0l-4 4m4-4H3" />
</svg>
),
onClick: () => setShowMoveDropdown((v) => !v),
},
);
}
registerItems('mail', items);
return () => unregisterPlugin('mail');
}, [selectedMailId, selectedMailFlagged, selectedMailIds, handleSearch, handleCompose, handleReply, handleForward, handleToggleFlag, handleCreateEvent, handleDeleteMail, handleBulkDelete, handleBulkMarkRead, handleBulkMarkUnread, t]);
if (loadingAccounts) {
return (
<div className="flex items-center justify-center py-12" data-testid="mail-page-loading">
<svg className="animate-spin h-6 w-6 text-secondary-400" fill="none" viewBox="0 0 24 24" aria-hidden="true">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
<span className="ml-2 text-secondary-500">{t('common.loading')}</span>
</div>
);
}
if (accounts.length === 0) {
return (
<div className="p-6 max-w-7xl mx-auto" data-testid="mail-page">
<EmptyState
title={t('mail.noAccounts')}
description={t('mail.noAccountsDesc')}
action={<Link to="/settings/mail" className="text-primary-600 hover:text-primary-700">{t('mail.configureAccount')}</Link>}
/>
</div>
);
}
return (
<div className="flex flex-col h-full" data-testid="mail-page">
{error && (
<div className="mb-2 p-3 bg-danger-50 border border-danger-200 rounded-md" role="alert" data-testid="mail-error">
<p className="text-sm text-danger-700">{error}</p>
</div>
)}
{/* Desktop: three-pane layout with resizable panels */}
<div className="hidden md:flex flex-1 overflow-hidden">
{/* Folder tree — resizable */}
<ResizablePanel
initialWidth={224}
minWidth={150}
maxWidth={400}
className="border-r border-secondary-200 bg-white"
data-testid="mail-folder-pane"
>
<div className="p-3">
<MailFolderTree
accounts={accounts}
folders={folders}
selectedFolderId={selectedFolderId}
onSelect={handleSelectFolder}
loading={loadingFolders}
/>
</div>
</ResizablePanel>
{/* Mail list — resizable */}
<ResizablePanel
initialWidth={320}
minWidth={200}
maxWidth={500}
className="border-r border-secondary-200 bg-white"
data-testid="mail-list-pane"
>
<MailList
mails={mails}
selectedMailId={selectedMail?.id || null}
onSelectMail={handleSelectMail}
loading={loadingMails}
currentPage={mailsPage}
total={mailsTotal}
pageSize={PAGE_SIZE}
onPageChange={setMailsPage}
selectedMailIds={selectedMailIds}
onToggleSelect={handleToggleSelect}
onSelectAll={handleSelectAll}
sortBy={sortBy}
sortOrder={sortOrder}
onSortChange={(by, order) => {
setSortBy(by);
setSortOrder(order);
setMailsPage(1);
}}
/>
</ResizablePanel>
{/* Reading pane — flex-1, not resizable */}
<ResizablePanel
resizable={false}
className="bg-white"
data-testid="mail-detail-pane"
>
<MailDetail
mail={selectedMail}
loading={loadingMail}
onReply={handleReply}
onForward={handleForward}
onCreateEvent={handleCreateEvent}
onToggleFlag={handleToggleFlag}
onDownloadAttachment={handleDownloadAttachment}
downloadingAttachmentId={downloadingAttachmentId}
onDelete={handleDeleteMail}
onMove={(mail) => setShowMoveDropdown(true)}
onEditDraft={handleEditDraft}
/>
</ResizablePanel>
</div>
{/* Mobile: single-pane view switching */}
<div className="flex md:hidden flex-1 overflow-hidden flex-col">
{/* View 1: Folder tree */}
{activeView === 'folders' && (
<div className="flex-1 overflow-y-auto bg-white" data-testid="mobile-folder-pane">
<div className="p-3">
<MailFolderTree
accounts={accounts}
folders={folders}
selectedFolderId={selectedFolderId}
onSelect={handleSelectFolder}
loading={loadingFolders}
/>
</div>
</div>
)}
{/* View 2: Mail list */}
{activeView === 'list' && (
<div className="flex-1 overflow-y-auto bg-white" data-testid="mobile-list-pane">
<div className="flex items-center gap-2 px-3 py-2 border-b border-secondary-200 sticky top-0 bg-white z-10">
<button
onClick={() => setActiveView('folders')}
className="inline-flex items-center gap-1 px-2 py-1 rounded text-sm text-secondary-700 hover:bg-secondary-100 min-h-touch"
aria-label="Zurück zu Ordnern"
data-testid="mobile-back-to-folders"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
<span className="text-sm font-medium">Ordner</span>
</button>
</div>
<MailList
mails={mails}
selectedMailId={selectedMail?.id || null}
onSelectMail={handleSelectMail}
loading={loadingMails}
currentPage={mailsPage}
total={mailsTotal}
pageSize={PAGE_SIZE}
onPageChange={setMailsPage}
selectedMailIds={selectedMailIds}
onToggleSelect={handleToggleSelect}
onSelectAll={handleSelectAll}
sortBy={sortBy}
sortOrder={sortOrder}
onSortChange={(by, order) => {
setSortBy(by);
setSortOrder(order);
setMailsPage(1);
}}
/>
</div>
)}
{/* View 3: Mail detail */}
{activeView === 'detail' && (
<div className="flex-1 overflow-y-auto bg-white" data-testid="mobile-detail-pane">
<div className="flex items-center gap-2 px-3 py-2 border-b border-secondary-200 sticky top-0 bg-white z-10">
<button
onClick={() => setActiveView('list')}
className="inline-flex items-center gap-1 px-2 py-1 rounded text-sm text-secondary-700 hover:bg-secondary-100 min-h-touch"
aria-label="Zurück zur Liste"
data-testid="mobile-back-to-list"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
<span className="text-sm font-medium">Zurück</span>
</button>
</div>
<MailDetail
mail={selectedMail}
loading={loadingMail}
onReply={handleReply}
onForward={handleForward}
onCreateEvent={handleCreateEvent}
onToggleFlag={handleToggleFlag}
onDownloadAttachment={handleDownloadAttachment}
downloadingAttachmentId={downloadingAttachmentId}
onDelete={handleDeleteMail}
onMove={(mail) => setShowMoveDropdown(true)}
onEditDraft={handleEditDraft}
/>
</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="danger" size="sm" onClick={handleBulkDelete}>
{t('mail.bulkDelete')}
</Button>
<div className="relative">
<Button variant="secondary" size="sm" onClick={() => setShowMoveDropdown((v) => !v)}>
{t('mail.bulkMove')}
</Button>
{showMoveDropdown && (
<div className="absolute bottom-full mb-2 left-0 bg-white border border-secondary-200 rounded-lg shadow-lg max-h-60 overflow-y-auto min-w-48">
{folders.map((folder) => (
<button
key={folder.id}
onClick={() => handleBulkMove(folder.id)}
className="block w-full text-left px-3 py-2 text-sm hover:bg-secondary-100 min-h-touch"
>
{folder.name}
</button>
))}
</div>
)}
</div>
<Button variant="ghost" size="sm" onClick={() => setSelectedMailIds(new Set())}>
</Button>
</div>
)}
{/* Move dropdown for single mail */}
{showMoveDropdown && selectedMail && selectedMailIds.size === 0 && (
<div
className="fixed bottom-4 right-4 z-50 bg-white border border-secondary-200 rounded-lg shadow-lg max-h-60 overflow-y-auto min-w-48"
data-testid="mail-move-dropdown"
>
<div className="px-3 py-2 text-xs font-medium text-secondary-500 border-b border-secondary-100">
{t('mail.moveTo')}
</div>
{folders
.filter((f) => f.account_id === selectedMail.account_id)
.map((folder) => (
<button
key={folder.id}
onClick={() => handleMoveMail(selectedMail, folder.id)}
className="block w-full text-left px-3 py-2 text-sm hover:bg-secondary-100 min-h-touch"
>
{folder.name}
</button>
))}
</div>
)}
<ComposeModal
open={composeOpen}
mode={composeMode}
accountId={selectedAccountId}
replyToMail={replyToMail}
forwardMail={forwardMailState}
draftMail={draftMailState}
signatures={signatures}
onSend={handleSend}
onSaveDraft={handleSaveDraft}
onClose={() => setComposeOpen(false)}
/>
</div>
);
}