From 4e100e9d33a0eb5479f2bd76876871e7035e4a4d Mon Sep 17 00:00:00 2001 From: Agent Zero Date: Wed, 15 Jul 2026 20:17:27 +0200 Subject: [PATCH] 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) --- app/plugins/builtins/mail/routes.py | 16 +++++++- frontend/src/api/mail.ts | 8 ++-- frontend/src/components/mail/MailList.tsx | 50 +++++++++++++++++++++++ frontend/src/i18n/locales/de.json | 8 +++- frontend/src/i18n/locales/en.json | 8 +++- frontend/src/pages/Mail.tsx | 20 ++++++++- 6 files changed, 101 insertions(+), 9 deletions(-) diff --git a/app/plugins/builtins/mail/routes.py b/app/plugins/builtins/mail/routes.py index 793c8e6..2a011e9 100644 --- a/app/plugins/builtins/mail/routes.py +++ b/app/plugins/builtins/mail/routes.py @@ -14,7 +14,7 @@ from pathlib import Path from fastapi import APIRouter, Body, Depends, File, HTTPException, Query, UploadFile from fastapi.responses import StreamingResponse -from sqlalchemy import and_, desc, func, or_, select +from sqlalchemy import and_, asc, desc, func, or_, select from sqlalchemy.ext.asyncio import AsyncSession from app.core.db import get_db @@ -1488,6 +1488,8 @@ async def list_mails( account_id: str | None = None, page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=100), + sort_by: str = Query("date", pattern="^(date|from|subject)$"), + sort_order: str = Query("desc", pattern="^(asc|desc)$"), db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), ): @@ -1499,8 +1501,18 @@ async def list_mails( if account_id: a_id = _parse_uuid(account_id, "account_id") stmt = stmt.where(Mail.account_id == a_id) + # Exclude soft-deleted mails + stmt = stmt.where(Mail.deleted_at.is_(None)) total = (await db.execute(select(func.count()).select_from(stmt.subquery()))).scalar() - stmt = stmt.order_by(desc(Mail.received_at)).offset((page - 1) * page_size).limit(page_size) + # Dynamic sorting + sort_columns = { + "date": Mail.received_at, + "from": Mail.from_address, + "subject": Mail.subject, + } + sort_col = sort_columns.get(sort_by, Mail.received_at) + order_func = desc if sort_order == "desc" else asc + stmt = stmt.order_by(order_func(sort_col)).offset((page - 1) * page_size).limit(page_size) mails = (await db.execute(stmt)).scalars().all() return { "mails": [mail_to_response(m) for m in mails], diff --git a/frontend/src/api/mail.ts b/frontend/src/api/mail.ts index 7194d3d..3794cf9 100644 --- a/frontend/src/api/mail.ts +++ b/frontend/src/api/mail.ts @@ -406,9 +406,11 @@ export function deleteFolder(folderId: string): Promise { // ─── Mails ────────────────────────────────────────────────────────────────── -export function fetchMails(folderId: string, page: number): Promise { - const qs = new URLSearchParams({ folder_id: folderId, page: String(page) }).toString(); - return apiGet(`/mail?${qs}`); +export function fetchMails(folderId: string, page: number, sortBy?: string, sortOrder?: string): Promise { + const params = new URLSearchParams({ folder_id: folderId, page: String(page) }); + if (sortBy) params.set('sort_by', sortBy); + if (sortOrder) params.set('sort_order', sortOrder); + return apiGet(`/mail?${params.toString()}`); } export function getMail(mailId: string): Promise { diff --git a/frontend/src/components/mail/MailList.tsx b/frontend/src/components/mail/MailList.tsx index 4087e85..97541ea 100644 --- a/frontend/src/components/mail/MailList.tsx +++ b/frontend/src/components/mail/MailList.tsx @@ -22,6 +22,9 @@ export interface MailListProps { selectedMailIds: Set; onToggleSelect: (mailId: string) => void; onSelectAll: () => void; + sortBy?: 'date' | 'from' | 'subject'; + sortOrder?: 'asc' | 'desc'; + onSortChange?: (sortBy: 'date' | 'from' | 'subject', sortOrder: 'asc' | 'desc') => void; } function formatDate(dateStr: string): string { @@ -45,11 +48,24 @@ export function MailList({ selectedMailIds, onToggleSelect, onSelectAll, + sortBy = 'date', + sortOrder = 'desc', + onSortChange, }: MailListProps) { const { t } = useTranslation(); const totalPages = Math.ceil(total / pageSize); const allSelected = mails.length > 0 && mails.every((m) => selectedMailIds.has(m.id)); + const handleSortFieldChange = (e: React.ChangeEvent) => { + const newSortBy = e.target.value as 'date' | 'from' | 'subject'; + onSortChange?.(newSortBy, sortOrder); + }; + + const handleSortOrderToggle = () => { + const newOrder = sortOrder === 'asc' ? 'desc' : 'asc'; + onSortChange?.(sortBy, newOrder); + }; + if (loading) { return (
@@ -90,6 +106,40 @@ export function MailList({ )}
+ {/* Sort header */} + {onSortChange && ( +
+ + + +
+ )}
    {mails.map((mail) => (
  • diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index 9d8784c..3be11d5 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -553,6 +553,12 @@ "saveDraft": "Entwurf speichern", "editDraft": "Entwurf bearbeiten", "draftSaved": "Entwurf gespeichert.", - "draft": "Entwurf" + "draft": "Entwurf", + "sortBy": "Sortieren nach", + "sortDate": "Datum", + "sortFrom": "Absender", + "sortSubject": "Betreff", + "sortAsc": "Aufsteigend", + "sortDesc": "Absteigend" } } diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 5660e37..8587c1f 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -553,6 +553,12 @@ "saveDraft": "Save Draft", "editDraft": "Edit Draft", "draftSaved": "Draft saved.", - "draft": "Draft" + "draft": "Draft", + "sortBy": "Sort by", + "sortDate": "Date", + "sortFrom": "From", + "sortSubject": "Subject", + "sortAsc": "Ascending", + "sortDesc": "Descending" } } diff --git a/frontend/src/pages/Mail.tsx b/frontend/src/pages/Mail.tsx index 61df038..7099647 100644 --- a/frontend/src/pages/Mail.tsx +++ b/frontend/src/pages/Mail.tsx @@ -78,6 +78,8 @@ export function MailPage() { const [activeView, setActiveView] = useState<'folders' | 'list' | 'detail'>('folders'); const [selectedMailIds, setSelectedMailIds] = useState>(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 () => { @@ -150,7 +152,7 @@ export function MailPage() { setMails(result.mails); setMailsTotal(result.total); } else { - const result = await fetchMails(selectedFolderId, mailsPage); + const result = await fetchMails(selectedFolderId, mailsPage, sortBy, sortOrder); setMails(result.mails); setMailsTotal(result.total); } @@ -160,7 +162,7 @@ export function MailPage() { setMails([]); } setLoadingMails(false); - }, [selectedFolderId, mailsPage, searchQuery]); + }, [selectedFolderId, mailsPage, searchQuery, sortBy, sortOrder]); useEffect(() => { loadMails(); @@ -722,6 +724,13 @@ export function MailPage() { selectedMailIds={selectedMailIds} onToggleSelect={handleToggleSelect} onSelectAll={handleSelectAll} + sortBy={sortBy} + sortOrder={sortOrder} + onSortChange={(by, order) => { + setSortBy(by); + setSortOrder(order); + setMailsPage(1); + }} /> @@ -792,6 +801,13 @@ export function MailPage() { selectedMailIds={selectedMailIds} onToggleSelect={handleToggleSelect} onSelectAll={handleSelectAll} + sortBy={sortBy} + sortOrder={sortOrder} + onSortChange={(by, order) => { + setSortBy(by); + setSortOrder(order); + setMailsPage(1); + }} /> )}