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:
@@ -133,6 +133,19 @@ async def delete_thread(thread_id: uuid.UUID, user: CurrentUser, db: DbSession)
|
||||
return {"status": "queued"}
|
||||
|
||||
|
||||
# Folders whose "Empty" action permanently deletes everything they hold.
|
||||
_EMPTYABLE_FOLDERS = frozenset({"trash", "junk", "archive"})
|
||||
|
||||
|
||||
@router.post("/folders/{special}/empty")
|
||||
async def empty_folder(special: str, user: CurrentUser, db: DbSession) -> dict[str, str]:
|
||||
"""Permanently delete every message in a Trash/Spam/Archive folder."""
|
||||
if special not in _EMPTYABLE_FOLDERS:
|
||||
raise_api_error(400, "bad_folder", "this folder cannot be emptied")
|
||||
await enqueue_job("empty_folder_job", str(user.id), special)
|
||||
return {"status": "queued"}
|
||||
|
||||
|
||||
@router.post("/messages/{message_id}/move")
|
||||
async def move_message(
|
||||
message_id: uuid.UUID, body: MoveRequest, user: CurrentUser, db: DbSession
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -13,7 +13,7 @@ import uuid
|
||||
from collections.abc import Iterable
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy import func, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.db import session_scope
|
||||
@@ -358,3 +358,83 @@ async def delete_thread_job(ctx: dict[str, Any], thread_id: str) -> None:
|
||||
await get_event_bus().publish(
|
||||
"thread.updated", {"user_id": str(user_id), "thread_id": str(tid)}
|
||||
)
|
||||
|
||||
|
||||
async def empty_folder_job(ctx: dict[str, Any], user_id: str, special_use: str) -> None:
|
||||
"""Permanently delete every message in the user's folder of this special-use.
|
||||
|
||||
Backs the Trash/Spam/Archive "Empty" action: EXPUNGE on the server, drop the DB
|
||||
rows, and re-denormalize (or delete) affected threads.
|
||||
"""
|
||||
uid = uuid.UUID(user_id)
|
||||
async with session_scope() as session:
|
||||
mailboxes = list(
|
||||
await session.scalars(
|
||||
select(Mailbox).where(
|
||||
Mailbox.user_id == uid, Mailbox.special_use == special_use
|
||||
)
|
||||
)
|
||||
)
|
||||
if not mailboxes:
|
||||
return
|
||||
mb_ids = [mb.id for mb in mailboxes]
|
||||
path_by_id = {mb.id: mb.path for mb in mailboxes}
|
||||
messages = list(
|
||||
await session.scalars(
|
||||
select(Message).where(Message.user_id == uid, Message.mailbox_id.in_(mb_ids))
|
||||
)
|
||||
)
|
||||
if not messages:
|
||||
return
|
||||
account_ids = list({m.account_id for m in messages})
|
||||
accounts = {
|
||||
a.id: a
|
||||
for a in await session.scalars(select(Account).where(Account.id.in_(account_ids)))
|
||||
}
|
||||
thread_ids = list({m.thread_id for m in messages if m.thread_id is not None})
|
||||
by_box: dict[tuple[uuid.UUID, str], list[int]] = {}
|
||||
for m in messages:
|
||||
by_box.setdefault((m.account_id, path_by_id[m.mailbox_id]), []).append(m.uid)
|
||||
|
||||
for account_id in account_ids:
|
||||
provider = build_provider(accounts[account_id])
|
||||
try:
|
||||
for (acct, path), uids in by_box.items():
|
||||
if acct != account_id:
|
||||
continue
|
||||
try:
|
||||
await provider.delete_messages(path, uids)
|
||||
except Exception:
|
||||
logger.exception("empty folder: expunge failed for %s in %s", uids, path)
|
||||
finally:
|
||||
await provider.close()
|
||||
|
||||
async with session_scope() as session:
|
||||
msgs = list(
|
||||
await session.scalars(
|
||||
select(Message).where(Message.user_id == uid, Message.mailbox_id.in_(mb_ids))
|
||||
)
|
||||
)
|
||||
msg_ids = [m.id for m in msgs]
|
||||
if msg_ids:
|
||||
await session.execute(
|
||||
update(Draft)
|
||||
.where(Draft.reply_to_message_id.in_(msg_ids))
|
||||
.values(reply_to_message_id=None)
|
||||
)
|
||||
for m in msgs:
|
||||
await session.delete(m)
|
||||
await session.flush()
|
||||
# Threads with no messages left get dropped (drafts un-linked first); the
|
||||
# rest are re-denormalized so their counts/snippet reflect the removal.
|
||||
for tid in thread_ids:
|
||||
remaining = await session.scalar(
|
||||
select(func.count()).select_from(Message).where(Message.thread_id == tid)
|
||||
)
|
||||
if not remaining:
|
||||
await session.execute(
|
||||
update(Draft).where(Draft.thread_id == tid).values(thread_id=None)
|
||||
)
|
||||
await denormalize_thread(session, tid)
|
||||
|
||||
await get_event_bus().publish("message.deleted", {"user_id": str(uid)})
|
||||
|
||||
Reference in New Issue
Block a user