Initial commit: webmail client (M0/M1)
Three-pane webmail client. - Backend: FastAPI + async SQLAlchemy + arq worker (Postgres/Redis), IMAP/SMTP sync, threading, search, drafts/outbox with undo, attachments, 2FA. - Frontend: React 18 + Vite + TypeScript + TanStack Query + Tailwind; three-pane UI with rich-text composer, light/dark themes, resizable panels. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,159 @@
|
||||
"""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)}
|
||||
Reference in New Issue
Block a user