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:
+54
-10
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user