Files
webmail-client/backend/app/api/actions.py
T

161 lines
6.0 KiB
Python
Raw Normal View History

2026-07-17 23:20:34 +02:00
"""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, 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
2026-07-17 23:20:34 +02:00
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))
2026-07-17 23:20:34 +02:00
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)}
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)
2026-07-17 23:20:34 +02:00
@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))
2026-07-17 23:20:34 +02:00
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))
2026-07-17 23:20:34 +02:00
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))
2026-07-17 23:20:34 +02:00
return {"id": str(message.id), "mailbox_id": str(dst.id)}