From 9063093a5ae74cbf8ca38b395cf52d810e72e8ea Mon Sep 17 00:00:00 2001 From: Agent Zero Date: Mon, 20 Jul 2026 09:25:43 +0200 Subject: [PATCH] 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 --- app/plugins/builtins/mail/routes.py | 44 ++++++ frontend/src/api/mail.ts | 4 + .../src/components/mail/MailFolderTree.tsx | 128 ++++++++++++++---- frontend/src/pages/Mail.tsx | 1 + 4 files changed, 154 insertions(+), 23 deletions(-) diff --git a/app/plugins/builtins/mail/routes.py b/app/plugins/builtins/mail/routes.py index b3bb797..685f87f 100644 --- a/app/plugins/builtins/mail/routes.py +++ b/app/plugins/builtins/mail/routes.py @@ -587,6 +587,50 @@ async def 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) ─── diff --git a/frontend/src/api/mail.ts b/frontend/src/api/mail.ts index ae20c17..c269c9c 100644 --- a/frontend/src/api/mail.ts +++ b/frontend/src/api/mail.ts @@ -475,6 +475,10 @@ export function deleteFolder(folderId: string): Promise { return apiDelete(`/mail/folders/${folderId}`); } +export function emptyFolder(folderId: string): Promise<{ emptied_count: number }> { + return apiPost<{ emptied_count: number }>(`/mail/folders/${folderId}/empty`, {}); +} + // ─── Mails ────────────────────────────────────────────────────────────────── export function fetchMails(folderId: string, page: number, sortBy?: string, sortOrder?: string): Promise { diff --git a/frontend/src/components/mail/MailFolderTree.tsx b/frontend/src/components/mail/MailFolderTree.tsx index bbfb490..d583c91 100644 --- a/frontend/src/components/mail/MailFolderTree.tsx +++ b/frontend/src/components/mail/MailFolderTree.tsx @@ -2,12 +2,16 @@ * Mail folder tree sidebar component. * 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. + * + * 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 { useTranslation } from 'react-i18next'; import type { MailAccount, MailFolder } from '@/api/mail'; +import { emptyFolder } from '@/api/mail'; import { Badge } from '@/components/ui/Badge'; import { EmptyState } from '@/components/ui/EmptyState'; @@ -54,6 +58,7 @@ export interface MailFolderTreeProps { selectedFolderId: string | null; onSelect: (folderId: string) => void; loading: boolean; + onFolderEmptied?: () => void; } /** @@ -84,7 +89,7 @@ function buildFolderTree(folders: MailFolder[], accountId: string): MailFolder[] function ChevronIcon({ expanded }: { expanded: boolean }) { return (
+ +
); } @@ -116,11 +155,13 @@ function FolderNode({ selectedFolderId, onSelect, depth, + onContextMenu, }: { folder: MailFolder; selectedFolderId: string | null; onSelect: (folderId: string) => void; depth: number; + onContextMenu: (e: React.MouseEvent, folder: MailFolder) => void; }) { const [expanded, setExpanded] = useState(true); const isSelected = folder.id === selectedFolderId; @@ -128,7 +169,10 @@ function FolderNode({ return (
-
+
{hasChildren ? (
@@ -187,11 +233,13 @@ function AccountNode({ folders, selectedFolderId, onSelect, + onContextMenu, }: { account: MailAccount; folders: MailFolder[]; selectedFolderId: string | null; onSelect: (folderId: string) => void; + onContextMenu: (e: React.MouseEvent, folder: MailFolder) => void; }) { const [expanded, setExpanded] = useState(true); const accountFolders = useMemo( @@ -203,14 +251,14 @@ function AccountNode({
{expanded && ( -
+
{accountFolders.map((folder) => ( ))}
@@ -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 [contextMenu, setContextMenu] = useState(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) { return ( @@ -252,16 +324,26 @@ export function MailFolderTree({ accounts, folders, selectedFolderId, onSelect, } return ( -
- {accounts.map((account) => ( - +
+ {accounts.map((account) => ( + + ))} +
+ {contextMenu && ( + setContextMenu(null)} + onEmptyFolder={handleEmptyFolder} /> - ))} -
+ )} + ); } diff --git a/frontend/src/pages/Mail.tsx b/frontend/src/pages/Mail.tsx index 2fce580..dbef774 100644 --- a/frontend/src/pages/Mail.tsx +++ b/frontend/src/pages/Mail.tsx @@ -736,6 +736,7 @@ export function MailPage() { selectedFolderId={selectedFolderId} onSelect={handleSelectFolder} loading={loadingFolders} + onFolderEmptied={() => { loadMails(); loadAllFolders(); }} />