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
+75
View File
@@ -32,6 +32,10 @@ _MAX_TRIES = 5
# Folder names to create when a target special-use folder is missing on the server.
_DEFAULT_FOLDER_NAME = {"archive": "Archive", "trash": "Trash", "junk": "Spam"}
# Moves into these folders record where the message came from (restore_mailbox_id)
# so the Restore action can return it to its original folder.
_REMOVAL_FOLDERS = frozenset({"archive", "trash", "junk"})
def sanitize_flags(flags: Iterable[str]) -> frozenset[str]:
return frozenset(f for f in flags if f in ALLOWED_FLAGS)
@@ -214,6 +218,9 @@ async def move_thread_to_folder_job(ctx: dict[str, Any], thread_id: str, special
)
message = await session.get(Message, mid)
if message is not None and resolved is not None:
# Remember where it came from so Restore returns it there, not the Inbox.
if special_use in _REMOVAL_FOLDERS and message.mailbox_id != resolved:
message.restore_mailbox_id = message.mailbox_id
message.mailbox_id = resolved
message.uid = new_uid
@@ -222,6 +229,74 @@ async def move_thread_to_folder_job(ctx: dict[str, Any], thread_id: str, special
)
async def restore_thread_job(ctx: dict[str, Any], thread_id: str) -> None:
"""Move every message in a thread back to its original folder (Restore action).
Each message returns to ``restore_mailbox_id`` (the folder it was in before it
was archived/trashed) — e.g. a deleted Sent message goes back to Sent. Messages
with no recorded origin fall back to the account's Inbox.
"""
tid = uuid.UUID(thread_id)
async with session_scope() as session:
messages = list(await session.scalars(select(Message).where(Message.thread_id == tid)))
if not messages:
return
user_id = messages[0].user_id
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)))
}
mailboxes = list(
await session.scalars(select(Mailbox).where(Mailbox.account_id.in_(account_ids)))
)
path_by_id = {mb.id: mb.path for mb in mailboxes}
inbox_by_account = {
mb.account_id: mb.id for mb in mailboxes if mb.special_use == "inbox"
}
# (message_id, account_id, source_path, target_id) — resolve the target now.
plan: list[tuple[uuid.UUID, uuid.UUID, str, uuid.UUID, int]] = []
for m in messages:
target_id = m.restore_mailbox_id
if target_id is None or target_id not in path_by_id:
target_id = inbox_by_account.get(m.account_id)
if target_id is None or target_id == m.mailbox_id:
continue # nowhere to restore to, or already there
plan.append(
(m.id, m.account_id, path_by_id.get(m.mailbox_id, "INBOX"), target_id, m.uid)
)
# (message_id, target_id, new_uid) applied after the network moves.
moves: list[tuple[uuid.UUID, uuid.UUID, int]] = []
for account_id in account_ids:
provider = build_provider(accounts[account_id])
try:
for mid, m_account, src_path, target_id, uid in plan:
if m_account != account_id:
continue
target_path = path_by_id[target_id]
try:
new_uid = await provider.move(uid, src_path, target_path)
except Exception:
logger.exception("restore move failed for message %s -> %s", mid, target_path)
continue
moves.append((mid, target_id, new_uid))
finally:
await provider.close()
async with session_scope() as session:
for mid, target_id, new_uid in moves:
message = await session.get(Message, mid)
if message is not None:
message.mailbox_id = target_id
message.uid = new_uid
message.restore_mailbox_id = None
await get_event_bus().publish(
"thread.updated", {"user_id": str(user_id), "thread_id": str(tid)}
)
async def delete_thread_job(ctx: dict[str, Any], thread_id: str) -> None:
"""Permanently delete every message in a thread (server EXPUNGE + DB rows).