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:
@@ -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).
|
||||
|
||||
|
||||
Reference in New Issue
Block a user