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
@@ -198,6 +198,18 @@ class ImapSmtpProvider(MailProvider):
if messages:
yield messages
async def list_uids(self, mailbox: str) -> set[int]:
"""Full set of UIDs currently present in the mailbox (expunge reconciliation).
Cheap — a single ``UID SEARCH ALL``. The CONDSTORE poll only reports new and
changed messages, not vanished ones (that needs QRESYNC/VANISHED); callers use
this to detect server-side expunges for small folders like Drafts.
"""
await self._select(mailbox)
imap = await self._connect()
resp = await imap.uid_search("ALL")
return set(parse_search_uids(resp.lines))
async def _fetch_messages(self, imap: aioimaplib.IMAP4, uids: list[int]) -> list[RemoteMessage]:
if not uids:
return []