Files
webmail-client/backend/app/api/mailboxes.py
T
lukasandClaude Opus 4.8 fdb6ee002e 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
2026-07-22 07:40:29 +02:00

78 lines
2.6 KiB
Python

"""Mailbox (folder) listing (SPEC §9.3 folder views, archive action)."""
from __future__ import annotations
import uuid
from typing import Annotated
from fastapi import APIRouter, Depends
from pydantic import BaseModel
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 Draft, Mailbox, Message
router = APIRouter(prefix="/mailboxes", tags=["mail"])
DbSession = Annotated[AsyncSession, Depends(get_session)]
class MailboxOut(BaseModel):
id: uuid.UUID
account_id: uuid.UUID
path: str
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(
select(Mailbox).where(Mailbox.user_id == user.id).order_by(Mailbox.account_id, Mailbox.path)
)
return [
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)