feat: compact mail folder tree + right-click Ordner leeren

- MailFolderTree: compact styling matching AI assistant SessionList (py-1, text-sm, depth*12+8 padding, no space-y)
- Added right-click context menu on folders with "Ordner leeren" entry
- Backend: POST /mail/folders/{folder_id}/empty soft-deletes all mails in folder
- Frontend API: emptyFolder() function added to mail.ts
- Mail.tsx: onFolderEmptied callback reloads mails and folders
This commit is contained in:
Agent Zero
2026-07-20 09:25:43 +02:00
parent 35fcd2a9d4
commit 9063093a5a
4 changed files with 154 additions and 23 deletions
+44
View File
@@ -587,6 +587,50 @@ async def delete_folder(
await db.delete(folder) await db.delete(folder)
# ─── Empty Folder (soft-delete all mails in folder) ───
@router.post("/folders/{folder_id}/empty")
async def empty_folder(
folder_id: str,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_permission("mail:delete")),
):
"""Soft-delete all mails in a folder (sets deleted_at = now()).
The mails remain in the database but are hidden from the normal list.
Returns the number of mails that were emptied.
"""
tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"])
f_id = _parse_uuid(folder_id, "folder_id")
folder = (
await db.execute(
select(MailFolder).where(and_(MailFolder.id == f_id, MailFolder.tenant_id == tenant_id))
)
).scalar_one_or_none()
if not folder:
raise HTTPException(404, detail={"detail": "Folder not found", "code": "not_found"})
account = await _get_account(db, folder.account_id, tenant_id, user_id)
await _check_delegate_access(db, account, user_id, "delete")
# Soft-delete all non-deleted mails in this folder
result = await db.execute(
select(Mail).where(
and_(
Mail.folder_id == f_id,
Mail.tenant_id == tenant_id,
Mail.deleted_at.is_(None),
)
)
)
mails = result.scalars().all()
now = datetime.now(UTC)
for mail in mails:
mail.deleted_at = now
await db.flush()
return {"emptied_count": len(mails)}
# ─── Attachment Upload (F-MAIL-04) ─── # ─── Attachment Upload (F-MAIL-04) ───
+4
View File
@@ -475,6 +475,10 @@ export function deleteFolder(folderId: string): Promise<void> {
return apiDelete<void>(`/mail/folders/${folderId}`); return apiDelete<void>(`/mail/folders/${folderId}`);
} }
export function emptyFolder(folderId: string): Promise<{ emptied_count: number }> {
return apiPost<{ emptied_count: number }>(`/mail/folders/${folderId}/empty`, {});
}
// ─── Mails ────────────────────────────────────────────────────────────────── // ─── Mails ──────────────────────────────────────────────────────────────────
export function fetchMails(folderId: string, page: number, sortBy?: string, sortOrder?: string): Promise<MailListResult> { export function fetchMails(folderId: string, page: number, sortBy?: string, sortOrder?: string): Promise<MailListResult> {
+105 -23
View File
@@ -2,12 +2,16 @@
* Mail folder tree sidebar component. * Mail folder tree sidebar component.
* Shows accounts as top-level nodes with their folders nested underneath. * Shows accounts as top-level nodes with their folders nested underneath.
* Folders come as a flat list with parent_id and are built into a tree. * Folders come as a flat list with parent_id and are built into a tree.
*
* Styling matches the AI assistant SessionList tree: compact font, tight spacing,
* right-click context menu with "Ordner leeren".
*/ */
import React, { useState, useMemo } from 'react'; import React, { useState, useMemo, useEffect, useRef, useCallback } from 'react';
import clsx from 'clsx'; import clsx from 'clsx';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import type { MailAccount, MailFolder } from '@/api/mail'; import type { MailAccount, MailFolder } from '@/api/mail';
import { emptyFolder } from '@/api/mail';
import { Badge } from '@/components/ui/Badge'; import { Badge } from '@/components/ui/Badge';
import { EmptyState } from '@/components/ui/EmptyState'; import { EmptyState } from '@/components/ui/EmptyState';
@@ -54,6 +58,7 @@ export interface MailFolderTreeProps {
selectedFolderId: string | null; selectedFolderId: string | null;
onSelect: (folderId: string) => void; onSelect: (folderId: string) => void;
loading: boolean; loading: boolean;
onFolderEmptied?: () => void;
} }
/** /**
@@ -84,7 +89,7 @@ function buildFolderTree(folders: MailFolder[], accountId: string): MailFolder[]
function ChevronIcon({ expanded }: { expanded: boolean }) { function ChevronIcon({ expanded }: { expanded: boolean }) {
return ( return (
<svg <svg
className={clsx('w-3 h-3 transition-transform flex-shrink-0', expanded ? 'rotate-90' : '')} className={clsx('w-4 h-4 transition-transform flex-shrink-0', expanded && 'rotate-90')}
fill="none" fill="none"
viewBox="0 0 24 24" viewBox="0 0 24 24"
stroke="currentColor" stroke="currentColor"
@@ -97,17 +102,51 @@ function ChevronIcon({ expanded }: { expanded: boolean }) {
function FolderIcon() { function FolderIcon() {
return ( return (
<svg className="w-4 h-4 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"> <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="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
</svg> </svg>
); );
} }
function MailIcon() { interface ContextMenuState {
x: number;
y: number;
folderId: string;
folderName: string;
}
function ContextMenu({
state,
onClose,
onEmptyFolder,
}: {
state: ContextMenuState;
onClose: () => void;
onEmptyFolder: (folderId: string, folderName: string) => void;
}) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const handler = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) onClose();
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [onClose]);
return ( return (
<svg className="w-4 h-4 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"> <div
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 8l7-7 7 7M5 19h10a2 2 0 002-2V8l-5-5H5a2 2 0 00-2 2v12a2 2 0 002 2z" /> ref={ref}
</svg> className="fixed z-50 bg-white border border-secondary-200 rounded-lg shadow-lg py-1 min-w-[160px]"
style={{ left: state.x, top: state.y }}
>
<button
onClick={() => { onEmptyFolder(state.folderId, state.folderName); onClose(); }}
className="w-full text-left px-3 py-1.5 text-sm text-red-600 hover:bg-red-50"
>
Ordner leeren
</button>
</div>
); );
} }
@@ -116,11 +155,13 @@ function FolderNode({
selectedFolderId, selectedFolderId,
onSelect, onSelect,
depth, depth,
onContextMenu,
}: { }: {
folder: MailFolder; folder: MailFolder;
selectedFolderId: string | null; selectedFolderId: string | null;
onSelect: (folderId: string) => void; onSelect: (folderId: string) => void;
depth: number; depth: number;
onContextMenu: (e: React.MouseEvent, folder: MailFolder) => void;
}) { }) {
const [expanded, setExpanded] = useState(true); const [expanded, setExpanded] = useState(true);
const isSelected = folder.id === selectedFolderId; const isSelected = folder.id === selectedFolderId;
@@ -128,7 +169,10 @@ function FolderNode({
return ( return (
<div role="treeitem" aria-selected={isSelected} aria-label={getFolderDisplayName(folder)}> <div role="treeitem" aria-selected={isSelected} aria-label={getFolderDisplayName(folder)}>
<div className="flex items-center" style={{ paddingLeft: `${depth * 12}px` }}> <div
className="flex items-center"
style={{ paddingLeft: `${depth * 12 + 8}px` }}
>
{hasChildren ? ( {hasChildren ? (
<button <button
onClick={(e) => { onClick={(e) => {
@@ -146,12 +190,13 @@ function FolderNode({
)} )}
<button <button
onClick={() => onSelect(folder.id)} onClick={() => onSelect(folder.id)}
onContextMenu={(e) => onContextMenu(e, folder)}
className={clsx( className={clsx(
'flex-1 flex items-center gap-1.5 px-2 py-1 md:py-1 rounded-md text-sm min-h-touch', 'group flex-1 flex items-center gap-1 px-2 py-1 rounded-md text-sm cursor-pointer',
'motion-safe:transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500', 'motion-safe:transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500',
isSelected isSelected
? 'bg-primary-50 text-primary-700 font-medium' ? 'bg-primary-50 text-primary-700 font-medium'
: 'hover:bg-secondary-50 text-secondary-700', : 'hover:bg-secondary-100 text-secondary-700',
)} )}
data-testid={`folder-${folder.id}`} data-testid={`folder-${folder.id}`}
> >
@@ -174,6 +219,7 @@ function FolderNode({
selectedFolderId={selectedFolderId} selectedFolderId={selectedFolderId}
onSelect={onSelect} onSelect={onSelect}
depth={depth + 1} depth={depth + 1}
onContextMenu={onContextMenu}
/> />
))} ))}
</div> </div>
@@ -187,11 +233,13 @@ function AccountNode({
folders, folders,
selectedFolderId, selectedFolderId,
onSelect, onSelect,
onContextMenu,
}: { }: {
account: MailAccount; account: MailAccount;
folders: MailFolder[]; folders: MailFolder[];
selectedFolderId: string | null; selectedFolderId: string | null;
onSelect: (folderId: string) => void; onSelect: (folderId: string) => void;
onContextMenu: (e: React.MouseEvent, folder: MailFolder) => void;
}) { }) {
const [expanded, setExpanded] = useState(true); const [expanded, setExpanded] = useState(true);
const accountFolders = useMemo( const accountFolders = useMemo(
@@ -203,14 +251,14 @@ function AccountNode({
<div role="treeitem" aria-label={account.email}> <div role="treeitem" aria-label={account.email}>
<button <button
onClick={() => setExpanded(!expanded)} onClick={() => setExpanded(!expanded)}
className="w-full flex items-center gap-1 px-1 py-2 md:py-1.5 rounded-md text-sm font-semibold text-secondary-900 hover:bg-secondary-50 min-h-touch" className="w-full flex items-center gap-1 px-2 py-1.5 rounded-md text-sm font-medium text-secondary-700 hover:bg-secondary-100"
data-testid={`account-${account.id}`} data-testid={`account-${account.id}`}
> >
<ChevronIcon expanded={expanded} /> <ChevronIcon expanded={expanded} />
<span className="flex-1 truncate text-left">{account.display_name || account.email}</span> <span className="flex-1 truncate text-left">{account.display_name || account.email}</span>
</button> </button>
{expanded && ( {expanded && (
<div role="group" className="ml-2"> <div role="group">
{accountFolders.map((folder) => ( {accountFolders.map((folder) => (
<FolderNode <FolderNode
key={folder.id} key={folder.id}
@@ -218,6 +266,7 @@ function AccountNode({
selectedFolderId={selectedFolderId} selectedFolderId={selectedFolderId}
onSelect={onSelect} onSelect={onSelect}
depth={0} depth={0}
onContextMenu={onContextMenu}
/> />
))} ))}
</div> </div>
@@ -226,8 +275,31 @@ function AccountNode({
); );
} }
export function MailFolderTree({ accounts, folders, selectedFolderId, onSelect, loading }: MailFolderTreeProps) { export function MailFolderTree({ accounts, folders, selectedFolderId, onSelect, loading, onFolderEmptied }: MailFolderTreeProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null);
const handleContextMenu = useCallback((e: React.MouseEvent, folder: MailFolder) => {
e.preventDefault();
e.stopPropagation();
setContextMenu({
x: e.clientX,
y: e.clientY,
folderId: folder.id,
folderName: getFolderDisplayName(folder),
});
}, []);
const handleEmptyFolder = useCallback(async (folderId: string, folderName: string) => {
if (!confirm(`Ordner "${folderName}" wirklich leeren? Alle E-Mails werden gelöscht.`)) return;
try {
const result = await emptyFolder(folderId);
if (onFolderEmptied) onFolderEmptied();
} catch (e) {
console.error('Failed to empty folder:', e);
alert('Fehler beim Leeren des Ordners: ' + (e instanceof Error ? e.message : String(e)));
}
}, [onFolderEmptied]);
if (loading) { if (loading) {
return ( return (
@@ -252,16 +324,26 @@ export function MailFolderTree({ accounts, folders, selectedFolderId, onSelect,
} }
return ( return (
<div className="space-y-1" role="tree" data-testid="folder-tree-list"> <>
{accounts.map((account) => ( <div className="flex flex-col" role="tree" data-testid="folder-tree-list">
<AccountNode {accounts.map((account) => (
key={account.id} <AccountNode
account={account} key={account.id}
folders={folders} account={account}
selectedFolderId={selectedFolderId} folders={folders}
onSelect={onSelect} selectedFolderId={selectedFolderId}
onSelect={onSelect}
onContextMenu={handleContextMenu}
/>
))}
</div>
{contextMenu && (
<ContextMenu
state={contextMenu}
onClose={() => setContextMenu(null)}
onEmptyFolder={handleEmptyFolder}
/> />
))} )}
</div> </>
); );
} }
+1
View File
@@ -736,6 +736,7 @@ export function MailPage() {
selectedFolderId={selectedFolderId} selectedFolderId={selectedFolderId}
onSelect={handleSelectFolder} onSelect={handleSelectFolder}
loading={loadingFolders} loading={loadingFolders}
onFolderEmptied={() => { loadMails(); loadAllFolders(); }}
/> />
</div> </div>
</ResizablePanel> </ResizablePanel>