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)
This commit is contained in:
Agent Zero
2026-07-15 20:17:27 +02:00
parent ed1eec87dc
commit 4e100e9d33
6 changed files with 101 additions and 9 deletions
+14 -2
View File
@@ -14,7 +14,7 @@ from pathlib import Path
from fastapi import APIRouter, Body, Depends, File, HTTPException, Query, UploadFile from fastapi import APIRouter, Body, Depends, File, HTTPException, Query, UploadFile
from fastapi.responses import StreamingResponse 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 sqlalchemy.ext.asyncio import AsyncSession
from app.core.db import get_db from app.core.db import get_db
@@ -1488,6 +1488,8 @@ async def list_mails(
account_id: str | None = None, account_id: str | None = None,
page: int = Query(1, ge=1), page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100), 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), db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user), current_user: dict = Depends(get_current_user),
): ):
@@ -1499,8 +1501,18 @@ async def list_mails(
if account_id: if account_id:
a_id = _parse_uuid(account_id, "account_id") a_id = _parse_uuid(account_id, "account_id")
stmt = stmt.where(Mail.account_id == a_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() 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() mails = (await db.execute(stmt)).scalars().all()
return { return {
"mails": [mail_to_response(m) for m in mails], "mails": [mail_to_response(m) for m in mails],
+5 -3
View File
@@ -406,9 +406,11 @@ export function deleteFolder(folderId: string): Promise<void> {
// ─── Mails ────────────────────────────────────────────────────────────────── // ─── Mails ──────────────────────────────────────────────────────────────────
export function fetchMails(folderId: string, page: number): Promise<MailListResult> { export function fetchMails(folderId: string, page: number, sortBy?: string, sortOrder?: string): Promise<MailListResult> {
const qs = new URLSearchParams({ folder_id: folderId, page: String(page) }).toString(); const params = new URLSearchParams({ folder_id: folderId, page: String(page) });
return apiGet<MailListResult>(`/mail?${qs}`); if (sortBy) params.set('sort_by', sortBy);
if (sortOrder) params.set('sort_order', sortOrder);
return apiGet<MailListResult>(`/mail?${params.toString()}`);
} }
export function getMail(mailId: string): Promise<Mail> { export function getMail(mailId: string): Promise<Mail> {
+50
View File
@@ -22,6 +22,9 @@ export interface MailListProps {
selectedMailIds: Set<string>; selectedMailIds: Set<string>;
onToggleSelect: (mailId: string) => void; onToggleSelect: (mailId: string) => void;
onSelectAll: () => void; onSelectAll: () => void;
sortBy?: 'date' | 'from' | 'subject';
sortOrder?: 'asc' | 'desc';
onSortChange?: (sortBy: 'date' | 'from' | 'subject', sortOrder: 'asc' | 'desc') => void;
} }
function formatDate(dateStr: string): string { function formatDate(dateStr: string): string {
@@ -45,11 +48,24 @@ export function MailList({
selectedMailIds, selectedMailIds,
onToggleSelect, onToggleSelect,
onSelectAll, onSelectAll,
sortBy = 'date',
sortOrder = 'desc',
onSortChange,
}: MailListProps) { }: MailListProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const totalPages = Math.ceil(total / pageSize); const totalPages = Math.ceil(total / pageSize);
const allSelected = mails.length > 0 && mails.every((m) => selectedMailIds.has(m.id)); const allSelected = mails.length > 0 && mails.every((m) => selectedMailIds.has(m.id));
const handleSortFieldChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
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) { if (loading) {
return ( return (
<div className="flex items-center justify-center py-12" data-testid="mail-list-loading"> <div className="flex items-center justify-center py-12" data-testid="mail-list-loading">
@@ -90,6 +106,40 @@ export function MailList({
</span> </span>
)} )}
</div> </div>
{/* Sort header */}
{onSortChange && (
<div className="flex items-center gap-2 px-3 py-1.5 md:px-4 border-b border-secondary-200 bg-secondary-50" data-testid="mail-sort-bar">
<label htmlFor="mail-sort-by" className="text-xs text-secondary-600">
{t('mail.sortBy')}:
</label>
<select
id="mail-sort-by"
value={sortBy}
onChange={handleSortFieldChange}
className="text-xs rounded border border-secondary-300 bg-white px-1.5 py-0.5 text-secondary-700 focus:outline-none focus:ring-1 focus:ring-primary-500"
aria-label={t('mail.sortBy')}
data-testid="mail-sort-by"
>
<option value="date">{t('mail.sortDate')}</option>
<option value="from">{t('mail.sortFrom')}</option>
<option value="subject">{t('mail.sortSubject')}</option>
</select>
<button
onClick={handleSortOrderToggle}
className="inline-flex items-center gap-1 text-xs rounded border border-secondary-300 bg-white px-1.5 py-0.5 text-secondary-700 hover:bg-secondary-100 focus:outline-none focus:ring-1 focus:ring-primary-500"
aria-label={sortOrder === 'asc' ? t('mail.sortAsc') : t('mail.sortDesc')}
title={sortOrder === 'asc' ? t('mail.sortAsc') : t('mail.sortDesc')}
data-testid="mail-sort-order"
>
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
{sortOrder === 'asc'
? <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 15l7-7 7 7" />
: <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />}
</svg>
<span>{sortOrder === 'asc' ? t('mail.sortAsc') : t('mail.sortDesc')}</span>
</button>
</div>
)}
<ul className="divide-y divide-secondary-100" role="list"> <ul className="divide-y divide-secondary-100" role="list">
{mails.map((mail) => ( {mails.map((mail) => (
<li key={mail.id} role="listitem" className={clsx(selectedMailIds.has(mail.id) && 'bg-primary-50')}> <li key={mail.id} role="listitem" className={clsx(selectedMailIds.has(mail.id) && 'bg-primary-50')}>
+7 -1
View File
@@ -553,6 +553,12 @@
"saveDraft": "Entwurf speichern", "saveDraft": "Entwurf speichern",
"editDraft": "Entwurf bearbeiten", "editDraft": "Entwurf bearbeiten",
"draftSaved": "Entwurf gespeichert.", "draftSaved": "Entwurf gespeichert.",
"draft": "Entwurf" "draft": "Entwurf",
"sortBy": "Sortieren nach",
"sortDate": "Datum",
"sortFrom": "Absender",
"sortSubject": "Betreff",
"sortAsc": "Aufsteigend",
"sortDesc": "Absteigend"
} }
} }
+7 -1
View File
@@ -553,6 +553,12 @@
"saveDraft": "Save Draft", "saveDraft": "Save Draft",
"editDraft": "Edit Draft", "editDraft": "Edit Draft",
"draftSaved": "Draft saved.", "draftSaved": "Draft saved.",
"draft": "Draft" "draft": "Draft",
"sortBy": "Sort by",
"sortDate": "Date",
"sortFrom": "From",
"sortSubject": "Subject",
"sortAsc": "Ascending",
"sortDesc": "Descending"
} }
} }
+18 -2
View File
@@ -78,6 +78,8 @@ export function MailPage() {
const [activeView, setActiveView] = useState<'folders' | 'list' | 'detail'>('folders'); const [activeView, setActiveView] = useState<'folders' | 'list' | 'detail'>('folders');
const [selectedMailIds, setSelectedMailIds] = useState<Set<string>>(new Set()); const [selectedMailIds, setSelectedMailIds] = useState<Set<string>>(new Set());
const [showMoveDropdown, setShowMoveDropdown] = useState(false); const [showMoveDropdown, setShowMoveDropdown] = useState(false);
const [sortBy, setSortBy] = useState<'date' | 'from' | 'subject'>('date');
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc');
// Load accounts // Load accounts
const loadAccounts = useCallback(async () => { const loadAccounts = useCallback(async () => {
@@ -150,7 +152,7 @@ export function MailPage() {
setMails(result.mails); setMails(result.mails);
setMailsTotal(result.total); setMailsTotal(result.total);
} else { } else {
const result = await fetchMails(selectedFolderId, mailsPage); const result = await fetchMails(selectedFolderId, mailsPage, sortBy, sortOrder);
setMails(result.mails); setMails(result.mails);
setMailsTotal(result.total); setMailsTotal(result.total);
} }
@@ -160,7 +162,7 @@ export function MailPage() {
setMails([]); setMails([]);
} }
setLoadingMails(false); setLoadingMails(false);
}, [selectedFolderId, mailsPage, searchQuery]); }, [selectedFolderId, mailsPage, searchQuery, sortBy, sortOrder]);
useEffect(() => { useEffect(() => {
loadMails(); loadMails();
@@ -722,6 +724,13 @@ export function MailPage() {
selectedMailIds={selectedMailIds} selectedMailIds={selectedMailIds}
onToggleSelect={handleToggleSelect} onToggleSelect={handleToggleSelect}
onSelectAll={handleSelectAll} onSelectAll={handleSelectAll}
sortBy={sortBy}
sortOrder={sortOrder}
onSortChange={(by, order) => {
setSortBy(by);
setSortOrder(order);
setMailsPage(1);
}}
/> />
</ResizablePanel> </ResizablePanel>
@@ -792,6 +801,13 @@ export function MailPage() {
selectedMailIds={selectedMailIds} selectedMailIds={selectedMailIds}
onToggleSelect={handleToggleSelect} onToggleSelect={handleToggleSelect}
onSelectAll={handleSelectAll} onSelectAll={handleSelectAll}
sortBy={sortBy}
sortOrder={sortOrder}
onSortChange={(by, order) => {
setSortBy(by);
setSortOrder(order);
setMailsPage(1);
}}
/> />
</div> </div>
)} )}