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
|
2026-08-02 21:34:22 +02:00
|
|
|
from app.api.schemas import FlagChangeRequest, MarkReadRequest, MoveRequest
|
2026-07-17 23:20:34 +02:00
|
|
|
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
|
2026-07-18 19:53:31 +02:00
|
|
|
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:
|
2026-07-18 19:53:31 +02:00
|
|
|
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)}
|
|
|
|
|
|
|
|
|
|
|
2026-08-02 21:34:22 +02:00
|
|
|
@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:<special>, or account:<uuid>."""
|
|
|
|
|
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(
|
2026-10-14 22:08:06 +02:00
|
|
|
select(Message).where(Message.thread_id.in_(thread_ids), Message.user_id == user.id)
|
2026-08-02 21:34:22 +02:00
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
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:
|
2026-10-14 22:08:06 +02:00
|
|
|
await bus.publish("thread.updated", {"user_id": str(user.id), "thread_id": str(tid)})
|
2026-08-02 21:34:22 +02:00
|
|
|
return {"changed": len(changed)}
|
|
|
|
|
|
|
|
|
|
|
2026-07-17 23:20:34 +02:00
|
|
|
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")
|
2026-07-18 19:53:31 +02:00
|
|
|
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]:
|
2026-07-20 23:12:25 +02:00
|
|
|
"""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")
|
2026-07-18 19:53:31 +02:00
|
|
|
await enqueue_job("delete_thread_job", str(thread_id))
|
2026-07-17 23:20:34 +02:00
|
|
|
return {"status": "queued"}
|
|
|
|
|
|
|
|
|
|
|
2026-07-22 07:40:29 +02:00
|
|
|
# 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"}
|
|
|
|
|
|
|
|
|
|
|
2026-07-17 23:20:34 +02:00
|
|
|
@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()
|
|
|
|
|
|
2026-07-18 19:53:31 +02:00
|
|
|
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)}
|