mail list redesign, folder actions, empty-folder, unread badges & empty-state art

Frontend:
- Redesign the mail list rows (avatar + sender + subject/snippet + right rail),
  even-spaced attachment/pin/archive/trash icons, hover-reveal actions, pink
  always-on pin, monocolor paperclip, row gaps, wider sender-subject gap.
- Sidebar folder badges: Inbox unread + Drafts counts (live via WS + mutations).
- Trash/Spam/Archive: hover Restore icon (replaces Archive), permanent delete in
  Trash; "Empty trash" / "Empty spam" buttons on the sort bar.
- Reading pane empty state: email illustration above an enlarged
  "Select a conversation".
- Search field clear (X) button; mark-read after a 4s dwell instead of instantly.

Backend:
- GET /api/mailboxes/counts (inbox unread + drafts) for the sidebar badges.
- POST /api/folders/{special}/empty + empty_folder_job to permanently clear a
  Trash/Spam/Archive folder (server EXPUNGE + DB rows + thread cleanup).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U72FZiF1W1pb8XxyWBTREA
This commit is contained in:
2026-07-22 07:40:29 +02:00
co-authored by Claude Opus 4.8
parent d650f16feb
commit fdb6ee002e
22 changed files with 1758 additions and 68 deletions
+42 -2
View File
@@ -7,12 +7,12 @@ from typing import Annotated
from fastapi import APIRouter, Depends
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.db import get_session
from app.identity.deps import CurrentUser
from app.models import Mailbox
from app.models import Draft, Mailbox, Message
router = APIRouter(prefix="/mailboxes", tags=["mail"])
@@ -26,6 +26,11 @@ class MailboxOut(BaseModel):
special_use: str | None
class FolderCounts(BaseModel):
inbox_unread: int # unread messages sitting in Inbox mailboxes
drafts: int # drafts shown in the Drafts folder (local + un-adopted remote)
@router.get("", response_model=list[MailboxOut])
async def list_mailboxes(user: CurrentUser, db: DbSession) -> list[MailboxOut]:
rows = await db.scalars(
@@ -35,3 +40,38 @@ async def list_mailboxes(user: CurrentUser, db: DbSession) -> list[MailboxOut]:
MailboxOut(id=m.id, account_id=m.account_id, path=m.path, special_use=m.special_use)
for m in rows
]
@router.get("/counts", response_model=FolderCounts)
async def folder_counts(user: CurrentUser, db: DbSession) -> FolderCounts:
"""Badge counts for the sidebar: unread Inbox messages and Drafts-folder items."""
inbox_unread = await db.scalar(
select(func.count())
.select_from(Message)
.join(Mailbox, Mailbox.id == Message.mailbox_id)
.where(
Message.user_id == user.id,
Mailbox.special_use == "inbox",
~Message.flags.contains(["\\Seen"]),
)
)
# Drafts folder = local drafts + IMAP-Drafts messages we haven't adopted (mirror
# UIDs excluded), matching what list_drafts + list_remote_drafts return.
local = await db.scalar(select(func.count()).select_from(Draft).where(Draft.user_id == user.id))
owned = {
(r.account_id, r.imap_uid)
for r in await db.execute(
select(Draft.account_id, Draft.imap_uid).where(
Draft.user_id == user.id, Draft.imap_uid.isnot(None)
)
)
}
remote_rows = await db.execute(
select(Message.account_id, Message.uid)
.join(Mailbox, Mailbox.id == Message.mailbox_id)
.where(Message.user_id == user.id, Mailbox.special_use == "drafts")
)
remote = sum(1 for r in remote_rows if (r.account_id, r.uid) not in owned)
return FolderCounts(inbox_unread=int(inbox_unread or 0), drafts=int(local or 0) + remote)