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:
@@ -111,8 +111,13 @@ async def trash_thread(thread_id: uuid.UUID, user: CurrentUser, db: DbSession) -
|
||||
|
||||
@router.post("/threads/{thread_id}/restore")
|
||||
async def restore_thread(thread_id: uuid.UUID, user: CurrentUser, db: DbSession) -> dict[str, str]:
|
||||
"""Move a thread back to the Inbox (from Archive or Trash)."""
|
||||
await _move_thread(user.id, thread_id, db, "inbox")
|
||||
"""Restore a thread (from Archive or Trash) to each message's original folder."""
|
||||
thread = await db.scalar(
|
||||
select(Thread).where(Thread.id == thread_id, Thread.user_id == user.id)
|
||||
)
|
||||
if thread is None:
|
||||
raise_api_error(404, "not_found", "thread not found")
|
||||
await enqueue_job("restore_thread_job", str(thread_id))
|
||||
return {"status": "queued"}
|
||||
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ from app.config import get_settings
|
||||
from app.db import get_session
|
||||
from app.identity.deps import CurrentUser, require_csrf
|
||||
from app.indexer import blobstore
|
||||
from app.models import BodyPart, ComposeAttachment, Draft, Mailbox, Message
|
||||
from app.models import Attachment, BodyPart, ComposeAttachment, Draft, Mailbox, Message
|
||||
from app.outbox.schemas import (
|
||||
DraftIn,
|
||||
DraftOut,
|
||||
@@ -24,7 +24,13 @@ from app.outbox.schemas import (
|
||||
SendRequest,
|
||||
SendResponse,
|
||||
)
|
||||
from app.outbox.service import DraftError, cancel_outbox, create_outbox, upsert_draft
|
||||
from app.outbox.service import (
|
||||
DraftError,
|
||||
cancel_outbox,
|
||||
create_outbox,
|
||||
delete_mirrored_draft_row,
|
||||
upsert_draft,
|
||||
)
|
||||
from app.queue import enqueue_job
|
||||
|
||||
router = APIRouter(tags=["compose"], dependencies=[Depends(require_csrf)])
|
||||
@@ -183,6 +189,30 @@ async def adopt_remote_draft(message_id: uuid.UUID, user: CurrentUser, db: DbSes
|
||||
parts = list(await db.scalars(select(BodyPart).where(BodyPart.message_id == msg.id)))
|
||||
html = next((p.content for p in parts if p.kind == "html_sanitized"), "")
|
||||
text = next((p.content for p in parts if p.kind == "text"), "")
|
||||
|
||||
# Carry the draft's real (non-inline) attachments over as compose attachments,
|
||||
# so they show in the editor and are re-attached on save/send. The underlying
|
||||
# blobs are shared by sha256 — we only add new ComposeAttachment references.
|
||||
att_rows = list(
|
||||
await db.scalars(
|
||||
select(Attachment)
|
||||
.where(Attachment.message_id == msg.id, Attachment.content_id.is_(None))
|
||||
.order_by(Attachment.filename)
|
||||
)
|
||||
)
|
||||
attachment_ids: list[str] = []
|
||||
for a in att_rows:
|
||||
ca = ComposeAttachment(
|
||||
user_id=user.id,
|
||||
filename=a.filename,
|
||||
mime_type=a.mime_type,
|
||||
size_bytes=a.size_bytes,
|
||||
blob_sha256=a.blob_sha256,
|
||||
)
|
||||
db.add(ca)
|
||||
await db.flush()
|
||||
attachment_ids.append(str(ca.id))
|
||||
|
||||
payload = {
|
||||
"to": [_fmt_addr(a) for a in msg.to_addr],
|
||||
"cc": [_fmt_addr(a) for a in msg.cc_addr],
|
||||
@@ -190,7 +220,7 @@ async def adopt_remote_draft(message_id: uuid.UUID, user: CurrentUser, db: DbSes
|
||||
"subject": msg.subject,
|
||||
"html": html,
|
||||
"text": text,
|
||||
"attachment_ids": [],
|
||||
"attachment_ids": attachment_ids,
|
||||
}
|
||||
draft = Draft(
|
||||
user_id=user.id,
|
||||
@@ -244,6 +274,10 @@ async def delete_draft(draft_id: uuid.UUID, user: CurrentUser, db: DbSession) ->
|
||||
raise_api_error(404, "not_found", "draft not found")
|
||||
account_id, imap_uid = draft.account_id, draft.imap_uid
|
||||
await db.delete(draft)
|
||||
# Drop the mirrored Drafts-folder message row now so the draft doesn't
|
||||
# resurface as an "other client" remote draft after the local row is gone.
|
||||
if imap_uid:
|
||||
await delete_mirrored_draft_row(db, account_id=account_id, imap_uid=imap_uid)
|
||||
await db.commit()
|
||||
# Remove the mirrored copy from the server Drafts folder too.
|
||||
if imap_uid:
|
||||
|
||||
+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