"""Flag / move / archive endpoints (SPEC ยง9.1). DB-first, then push via worker.""" from __future__ import annotations import uuid from typing import Annotated from fastapi import APIRouter, Depends from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.api.errors import raise_api_error from app.api.schemas import FlagChangeRequest, MarkReadRequest, MoveRequest from app.db import get_session from app.events import get_event_bus from app.identity.deps import CurrentUser, require_csrf from app.models import Mailbox, Message, Thread from app.queue import enqueue_job from app.sync.actions import apply_flags_to_messages, sanitize_flags router = APIRouter(tags=["mail"], dependencies=[Depends(require_csrf)]) DbSession = Annotated[AsyncSession, Depends(get_session)] async def _push_and_publish( user_id: uuid.UUID, changed: list[Message], add: frozenset[str], remove: frozenset[str] ) -> None: bus = get_event_bus() for message in changed: await enqueue_job("push_flags_job", str(message.id), sorted(add), sorted(remove)) await bus.publish( "flags.changed", { "user_id": str(user_id), "message_id": str(message.id), "flags": list(message.flags), }, ) def _would_change(message: Message, add: frozenset[str], remove: frozenset[str]) -> bool: current = set(message.flags) return bool((add - current) or (remove & current)) @router.post("/messages/{message_id}/flags") async def message_flags( message_id: uuid.UUID, body: FlagChangeRequest, user: CurrentUser, db: DbSession ) -> dict[str, object]: message = await db.scalar( select(Message).where(Message.id == message_id, Message.user_id == user.id) ) if message is None: raise_api_error(404, "not_found", "message not found") add, remove = sanitize_flags(body.add), sanitize_flags(body.remove) changed = [message] if _would_change(message, add, remove) else [] await apply_flags_to_messages(db, [message], add, remove) await db.commit() await _push_and_publish(user.id, changed, add, remove) return {"id": str(message.id), "flags": list(message.flags)} @router.post("/threads/{thread_id}/flags") async def thread_flags( thread_id: uuid.UUID, body: FlagChangeRequest, user: CurrentUser, db: DbSession ) -> dict[str, object]: 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") messages = list( await db.scalars( select(Message).where(Message.thread_id == thread_id, Message.user_id == user.id) ) ) add, remove = sanitize_flags(body.add), sanitize_flags(body.remove) changed = [m for m in messages if _would_change(m, add, remove)] await apply_flags_to_messages(db, messages, add, remove) await db.commit() await _push_and_publish(user.id, changed, add, remove) await get_event_bus().publish( "thread.updated", {"user_id": str(user.id), "thread_id": str(thread_id)} ) return {"thread_id": str(thread_id), "changed": len(changed)} @router.post("/threads/mark-read") async def mark_all_read( body: MarkReadRequest, user: CurrentUser, db: DbSession ) -> dict[str, object]: """Mark every unread thread in the given view as read (adds \\Seen). Scopes the same way as the thread list: unified, folder:, or account:.""" view = body.view conditions = [Thread.user_id == user.id, Thread.unread_count > 0] if view.startswith("account:"): try: account_id = uuid.UUID(view.split(":", 1)[1]) except ValueError: raise_api_error(400, "bad_view", "invalid account id in view") in_account_inbox = ( select(Message.thread_id) .join(Mailbox, Mailbox.id == Message.mailbox_id) .where( Message.user_id == user.id, Message.account_id == account_id, Mailbox.special_use == "inbox", ) ) conditions.append(Thread.id.in_(in_account_inbox)) elif view.startswith("folder:"): special = view.split(":", 1)[1] in_folder = ( select(Message.thread_id) .join(Mailbox, Mailbox.id == Message.mailbox_id) .where(Message.user_id == user.id, Mailbox.special_use == special) ) conditions.append(Thread.id.in_(in_folder)) elif view != "unified": raise_api_error(400, "bad_view", "unsupported view") thread_ids = list(await db.scalars(select(Thread.id).where(*conditions))) if not thread_ids: return {"changed": 0} messages = list( await db.scalars( select(Message).where( Message.thread_id.in_(thread_ids), Message.user_id == user.id ) ) ) add, remove = sanitize_flags(["\\Seen"]), sanitize_flags([]) changed = [m for m in messages if _would_change(m, add, remove)] await apply_flags_to_messages(db, messages, add, remove) await db.commit() await _push_and_publish(user.id, changed, add, remove) bus = get_event_bus() for tid in thread_ids: await bus.publish( "thread.updated", {"user_id": str(user.id), "thread_id": str(tid)} ) return {"changed": len(changed)} async def _move_thread( user_id: uuid.UUID, thread_id: uuid.UUID, db: AsyncSession, special: str ) -> None: 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("move_thread_to_folder_job", str(thread_id), special) @router.post("/threads/{thread_id}/archive") async def archive_thread(thread_id: uuid.UUID, user: CurrentUser, db: DbSession) -> dict[str, str]: await _move_thread(user.id, thread_id, db, "archive") return {"status": "queued"} @router.post("/threads/{thread_id}/trash") async def trash_thread(thread_id: uuid.UUID, user: CurrentUser, db: DbSession) -> dict[str, str]: await _move_thread(user.id, thread_id, db, "trash") return {"status": "queued"} @router.post("/threads/{thread_id}/restore") async def restore_thread(thread_id: uuid.UUID, user: CurrentUser, db: DbSession) -> dict[str, str]: """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"} @router.post("/threads/{thread_id}/delete") async def delete_thread(thread_id: uuid.UUID, user: CurrentUser, db: DbSession) -> dict[str, str]: """Permanently delete a thread (server EXPUNGE + DB rows).""" 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("delete_thread_job", str(thread_id)) return {"status": "queued"} # Folders whose "Empty" action permanently deletes everything they hold. _EMPTYABLE_FOLDERS = frozenset({"trash", "junk", "archive"}) @router.post("/folders/{special}/empty") async def empty_folder(special: str, user: CurrentUser, db: DbSession) -> dict[str, str]: """Permanently delete every message in a Trash/Spam/Archive folder.""" if special not in _EMPTYABLE_FOLDERS: raise_api_error(400, "bad_folder", "this folder cannot be emptied") await enqueue_job("empty_folder_job", str(user.id), special) return {"status": "queued"} @router.post("/messages/{message_id}/move") async def move_message( message_id: uuid.UUID, body: MoveRequest, user: CurrentUser, db: DbSession ) -> dict[str, object]: message = await db.scalar( select(Message).where(Message.id == message_id, Message.user_id == user.id) ) if message is None: raise_api_error(404, "not_found", "message not found") dst = await db.scalar( select(Mailbox).where( Mailbox.id == body.mailbox_id, Mailbox.user_id == user.id, Mailbox.account_id == message.account_id, # moves stay within an account ) ) if dst is None: raise_api_error(400, "bad_mailbox", "target mailbox not found in this account") src_mailbox_id = message.mailbox_id message.mailbox_id = dst.id # optimistic await db.commit() await enqueue_job("move_job", str(message.id), str(dst.id), str(src_mailbox_id)) return {"id": str(message.id), "mailbox_id": str(dst.id)}