mail UX: restore-to-folder, draft/attachment fixes, printing, sidebar collapse

Backend:
- Restore returns each message to its original folder (e.g. Sent/Archive) instead
  of always the Inbox: new messages.restore_mailbox_id (migration 0010) +
  restore_thread_job.
- Drafts: expunge reconciliation for the Drafts poll (provider.list_uids /
  _reconcile_drafts) clears phantom "remote" drafts; send + discard drop the
  mirrored DB row; adopting a remote draft carries its real attachments into the
  compose flow.
- Thread detail/list are folder-aware: individually trashed/spam messages are
  hidden outside their folder, Trash/Spam show only their own messages, and the
  mail-list message count matches the view.

Frontend:
- Thread reading panel: fix newest-first card overlap/spacing (expand-all and
  single expand), hide Reply/Forward on trashed messages, close the Actions menu
  on message-body (iframe) clicks, scroll to the inline composer, and prompt
  Save draft / Discard on the X close.
- Composer: remount when switching drafts, dirty-tracking to stop duplicate IMAP
  mirrors, and close the From/More dropdowns on outside click.
- Sidebar: collapse/expand toggle (icons-only ~60px rail) and a log-out icon on
  Sign out.
- Mail-standard printing in a new window with reliable page margins (lib/print.ts).

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-20 23:12:25 +02:00
co-authored by Claude Opus 4.8
parent 1a83e0fb9b
commit d650f16feb
33 changed files with 1927 additions and 105 deletions
+54 -10
View File
@@ -6,7 +6,7 @@ import uuid
from typing import Annotated
from fastapi import APIRouter, Depends, Query
from sqlalchemy import literal, select, tuple_
from sqlalchemy import func, literal, or_, select, tuple_
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.cursors import decode_cursor, encode_cursor
@@ -31,14 +31,16 @@ def _addresses(raw: list[dict[str, str]]) -> list[Address]:
return [Address(name=a.get("name", ""), email=a.get("email", "")) for a in raw]
def _summary(thread: Thread) -> ThreadSummary:
def _summary(thread: Thread, message_count: int | None = None) -> ThreadSummary:
return ThreadSummary(
id=thread.id,
subject=thread.subject_norm or "(no subject)",
snippet=thread.snippet,
participants=_addresses(thread.participants),
account_ids=list(thread.account_ids),
message_count=thread.message_count,
# Count only the messages this view actually shows (deleted/spam excluded
# elsewhere); falls back to the denormalized total.
message_count=message_count if message_count is not None else thread.message_count,
unread_count=thread.unread_count,
has_attachments=thread.has_attachments,
pinned=thread.pinned,
@@ -123,7 +125,32 @@ async def list_threads(
next_cursor = (
encode_cursor(threads[-1].last_message_at, threads[-1].id) if has_more and threads else None
)
return ThreadListOut(threads=[_summary(t) for t in threads], cursor=next_cursor)
# Per-thread visible message count matching what get_thread shows for this view:
# Trash/Spam count only their own messages; every other view excludes them. This
# keeps the list count in step with a single deleted message.
counts: dict[uuid.UUID, int] = {}
if threads:
count_stmt = (
select(Message.thread_id, func.count())
.join(Mailbox, Mailbox.id == Message.mailbox_id)
.where(Message.user_id == user.id, Message.thread_id.in_([t.id for t in threads]))
.group_by(Message.thread_id)
)
current_special = view.split(":", 1)[1] if view.startswith("folder:") else None
if current_special in _REMOVAL_FOLDERS:
count_stmt = count_stmt.where(Mailbox.special_use == current_special)
else:
count_stmt = count_stmt.where(
or_(Mailbox.special_use.is_(None), Mailbox.special_use.notin_(_REMOVAL_FOLDERS))
)
counts = {
r[0]: r[1] for r in (await db.execute(count_stmt)).all() if r[0] is not None
}
return ThreadListOut(
threads=[_summary(t, counts.get(t.id)) for t in threads], cursor=next_cursor
)
async def _resolve_thread(db: AsyncSession, user: User, thread_id: uuid.UUID) -> Thread:
@@ -135,23 +162,40 @@ async def _resolve_thread(db: AsyncSession, user: User, thread_id: uuid.UUID) ->
return thread
# Trash/Spam are message-scoped folders: an individually deleted message lives
# there on its own. Viewing a thread from Trash/Spam shows ONLY the messages filed
# there; every other view shows the conversation with trashed/spam messages hidden.
_REMOVAL_FOLDERS = frozenset({"trash", "junk"})
@router.get("/{thread_id}", response_model=ThreadDetail)
async def get_thread(
thread_id: uuid.UUID,
user: CurrentUser,
db: DbSession,
sort: str = Query("asc"),
view: str = Query("unified"),
) -> ThreadDetail:
thread = await _resolve_thread(db, user, thread_id)
current_special = view.split(":", 1)[1] if view.startswith("folder:") else None
# Messages are always returned ASC (oldest first); the client reverses for
# display per the requested order (invariant I-5 / §9.4).
messages = list(
await db.scalars(
select(Message)
.where(Message.thread_id == thread_id, Message.user_id == user.id)
.order_by(Message.internal_date.asc(), Message.id.asc())
)
stmt = (
select(Message)
.join(Mailbox, Mailbox.id == Message.mailbox_id)
.where(Message.thread_id == thread_id, Message.user_id == user.id)
.order_by(Message.internal_date.asc(), Message.id.asc())
)
if current_special in _REMOVAL_FOLDERS:
# Show only the messages actually in this Trash/Spam folder.
stmt = stmt.where(Mailbox.special_use == current_special)
else:
# Conversation view: hide messages that were individually trashed or spammed
# (keep custom folders, special_use IS NULL, and every non-removal folder).
stmt = stmt.where(
or_(Mailbox.special_use.is_(None), Mailbox.special_use.notin_(_REMOVAL_FOLDERS))
)
messages = list(await db.scalars(stmt))
order = "desc" if sort == "desc" else "asc"
return ThreadDetail(
id=thread.id,