160 lines
5.9 KiB
Python
160 lines
5.9 KiB
Python
"""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 get_arq_pool
|
||
|
|
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:
|
||
|
|
pool = await get_arq_pool()
|
||
|
|
bus = get_event_bus()
|
||
|
|
for message in changed:
|
||
|
|
await pool.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)}
|
||
|
|
|
||
|
|
|
||
|
|
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")
|
||
|
|
pool = await get_arq_pool()
|
||
|
|
await pool.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]:
|
||
|
|
"""Move a thread back to the Inbox (from Archive or Trash)."""
|
||
|
|
await _move_thread(user.id, thread_id, db, "inbox")
|
||
|
|
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")
|
||
|
|
pool = await get_arq_pool()
|
||
|
|
await pool.enqueue_job("delete_thread_job", str(thread_id))
|
||
|
|
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()
|
||
|
|
|
||
|
|
pool = await get_arq_pool()
|
||
|
|
await pool.enqueue_job("move_job", str(message.id), str(dst.id), str(src_mailbox_id))
|
||
|
|
return {"id": str(message.id), "mailbox_id": str(dst.id)}
|