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,39 @@
|
||||
"""REST API routers (SPEC §9.1). One module per route group."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import FastAPI
|
||||
|
||||
|
||||
def register_routers(app: FastAPI) -> None:
|
||||
"""Mount all API routers under the ``/api`` prefix.
|
||||
|
||||
Routers are added milestone by milestone; imports are local so the app
|
||||
factory stays importable while modules are still being built out.
|
||||
"""
|
||||
from app.api.accounts import router as accounts_router
|
||||
from app.api.actions import router as actions_router
|
||||
from app.api.compose import router as compose_router
|
||||
from app.api.mailboxes import router as mailboxes_router
|
||||
from app.api.messages import router as messages_router
|
||||
from app.api.recipients import router as recipients_router
|
||||
from app.api.search import router as search_router
|
||||
from app.api.settings import router as settings_router
|
||||
from app.api.threads import router as threads_router
|
||||
from app.identity.routes import router as auth_router
|
||||
from app.ws import router as ws_router
|
||||
|
||||
for router in (
|
||||
auth_router,
|
||||
accounts_router,
|
||||
threads_router,
|
||||
messages_router,
|
||||
search_router,
|
||||
actions_router,
|
||||
compose_router,
|
||||
recipients_router,
|
||||
settings_router,
|
||||
mailboxes_router,
|
||||
):
|
||||
app.include_router(router, prefix="/api")
|
||||
app.include_router(ws_router, prefix="/api")
|
||||
@@ -0,0 +1,176 @@
|
||||
"""Accounts API (SPEC §9.1): discover, test, create, list, update, delete."""
|
||||
|
||||
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.accounts.discovery import discover
|
||||
from app.accounts.schemas import (
|
||||
AccountDetailOut,
|
||||
CandidateOut,
|
||||
CreateAccountRequest,
|
||||
DiscoverRequest,
|
||||
DiscoverResponse,
|
||||
DiscoveryStepOut,
|
||||
ServerConfigIn,
|
||||
TestRequest,
|
||||
TestResponse,
|
||||
UpdateAccountRequest,
|
||||
)
|
||||
from app.accounts.service import AccountExistsError, create_account, delete_account
|
||||
from app.accounts.testing import AccountTestError, test_account
|
||||
from app.accounts.types import CandidateConfig, ServerConfig
|
||||
from app.api.errors import raise_api_error
|
||||
from app.api.schemas import AccountOut
|
||||
from app.db import get_session
|
||||
from app.identity.deps import CurrentUser, require_csrf
|
||||
from app.models import Account
|
||||
from app.queue import get_arq_pool
|
||||
from app.redis_client import get_redis
|
||||
|
||||
router = APIRouter(prefix="/accounts", tags=["accounts"])
|
||||
|
||||
DbSession = Annotated[AsyncSession, Depends(get_session)]
|
||||
|
||||
_DISCOVER_LIMIT = 10 # per minute per user (SPEC §10.5)
|
||||
_DISCOVER_WINDOW = 60
|
||||
|
||||
|
||||
async def _rate_limit_discover(user_id: uuid.UUID) -> None:
|
||||
redis = get_redis()
|
||||
key = f"rl:discover:{user_id}"
|
||||
count = await redis.incr(key)
|
||||
if count == 1:
|
||||
await redis.expire(key, _DISCOVER_WINDOW)
|
||||
if count > _DISCOVER_LIMIT:
|
||||
raise_api_error(429, "rate_limited", "too many discovery requests; slow down")
|
||||
|
||||
|
||||
def _server_in(cfg: ServerConfig) -> ServerConfigIn:
|
||||
return ServerConfigIn(host=cfg.host, port=cfg.port, tls=cfg.tls, auth=cfg.auth)
|
||||
|
||||
|
||||
def _candidate_out(c: CandidateConfig) -> CandidateOut:
|
||||
return CandidateOut(
|
||||
imap=_server_in(c.imap),
|
||||
smtp=_server_in(c.smtp),
|
||||
source=c.source,
|
||||
oauth_provider=c.oauth_provider,
|
||||
)
|
||||
|
||||
|
||||
def _to_server_config(cfg: ServerConfigIn) -> ServerConfig:
|
||||
return ServerConfig(cfg.host, cfg.port, cfg.tls, cfg.auth)
|
||||
|
||||
|
||||
@router.get("", response_model=list[AccountOut])
|
||||
async def list_accounts(user: CurrentUser, db: DbSession) -> list[AccountOut]:
|
||||
accounts = await db.scalars(
|
||||
select(Account).where(Account.user_id == user.id).order_by(Account.created_at)
|
||||
)
|
||||
return [
|
||||
AccountOut(
|
||||
id=a.id, email=a.email, display_name=a.display_name, color=a.color, state=a.state
|
||||
)
|
||||
for a in accounts
|
||||
]
|
||||
|
||||
|
||||
@router.post("/discover", response_model=DiscoverResponse, dependencies=[Depends(require_csrf)])
|
||||
async def discover_account(body: DiscoverRequest, user: CurrentUser) -> DiscoverResponse:
|
||||
await _rate_limit_discover(user.id)
|
||||
result = await discover(str(body.email))
|
||||
return DiscoverResponse(
|
||||
candidates=[_candidate_out(c) for c in result.candidates],
|
||||
steps=[
|
||||
DiscoveryStepOut(method=s.method, status=s.status, detail=s.detail)
|
||||
for s in result.steps
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@router.post("/test", response_model=TestResponse, dependencies=[Depends(require_csrf)])
|
||||
async def test_account_credentials(body: TestRequest, _user: CurrentUser) -> TestResponse:
|
||||
try:
|
||||
await test_account(
|
||||
imap=_to_server_config(body.imap),
|
||||
smtp=_to_server_config(body.smtp),
|
||||
username=body.username or str(body.email),
|
||||
password=body.password,
|
||||
)
|
||||
except AccountTestError as exc:
|
||||
raise_api_error(400, exc.code, exc.detail)
|
||||
return TestResponse(ok=True)
|
||||
|
||||
|
||||
@router.post("", response_model=AccountDetailOut, dependencies=[Depends(require_csrf)])
|
||||
async def create_new_account(
|
||||
body: CreateAccountRequest, user: CurrentUser, db: DbSession
|
||||
) -> AccountDetailOut:
|
||||
# Validate credentials before persisting (also the §10.2 decryption sibling).
|
||||
try:
|
||||
await test_account(
|
||||
imap=_to_server_config(body.imap),
|
||||
smtp=_to_server_config(body.smtp),
|
||||
username=body.username or str(body.email),
|
||||
password=body.password,
|
||||
)
|
||||
except AccountTestError as exc:
|
||||
raise_api_error(400, exc.code, exc.detail)
|
||||
|
||||
try:
|
||||
account = await create_account(db, user_id=user.id, req=body)
|
||||
except AccountExistsError:
|
||||
raise_api_error(409, "account_exists", "an account for this email already exists")
|
||||
await db.commit()
|
||||
|
||||
pool = await get_arq_pool()
|
||||
await pool.enqueue_job("sync_bootstrap_account", str(account.id))
|
||||
|
||||
return _detail(account)
|
||||
|
||||
|
||||
@router.patch(
|
||||
"/{account_id}", response_model=AccountDetailOut, dependencies=[Depends(require_csrf)]
|
||||
)
|
||||
async def update_account(
|
||||
account_id: uuid.UUID, body: UpdateAccountRequest, user: CurrentUser, db: DbSession
|
||||
) -> AccountDetailOut:
|
||||
account = await db.scalar(
|
||||
select(Account).where(Account.id == account_id, Account.user_id == user.id)
|
||||
)
|
||||
if account is None:
|
||||
raise_api_error(404, "not_found", "account not found")
|
||||
if body.display_name is not None:
|
||||
account.display_name = body.display_name
|
||||
if body.color is not None:
|
||||
account.color = body.color
|
||||
await db.commit()
|
||||
return _detail(account)
|
||||
|
||||
|
||||
@router.delete("/{account_id}", dependencies=[Depends(require_csrf)])
|
||||
async def remove_account(account_id: uuid.UUID, user: CurrentUser, db: DbSession) -> dict[str, str]:
|
||||
ok = await delete_account(db, user_id=user.id, account_id=account_id)
|
||||
if not ok:
|
||||
raise_api_error(404, "not_found", "account not found")
|
||||
await db.commit()
|
||||
return {"status": "deleted"}
|
||||
|
||||
|
||||
def _detail(account: Account) -> AccountDetailOut:
|
||||
return AccountDetailOut(
|
||||
id=account.id,
|
||||
email=account.email,
|
||||
display_name=account.display_name,
|
||||
color=account.color,
|
||||
state=account.state,
|
||||
state_detail=account.state_detail,
|
||||
imap_config=account.imap_config,
|
||||
smtp_config=account.smtp_config,
|
||||
)
|
||||
@@ -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)}
|
||||
@@ -0,0 +1,163 @@
|
||||
"""Compose endpoints: drafts + send + undo (SPEC §9.1)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import timedelta
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, File, Query, UploadFile
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.errors import raise_api_error
|
||||
from app.api.schemas import AttachmentOut
|
||||
from app.config import get_settings
|
||||
from app.db import get_session
|
||||
from app.identity.deps import CurrentUser, require_csrf
|
||||
from app.indexer import blobstore
|
||||
from app.models import ComposeAttachment, Draft
|
||||
from app.outbox.schemas import DraftIn, DraftOut, SendRequest, SendResponse
|
||||
from app.outbox.service import DraftError, cancel_outbox, create_outbox, upsert_draft
|
||||
from app.queue import get_arq_pool
|
||||
|
||||
router = APIRouter(tags=["compose"], dependencies=[Depends(require_csrf)])
|
||||
|
||||
DbSession = Annotated[AsyncSession, Depends(get_session)]
|
||||
|
||||
MAX_ATTACHMENT_BYTES = 25 * 1024 * 1024 # 25 MB per file (compose Attach limit)
|
||||
|
||||
|
||||
def _att_out(att: ComposeAttachment) -> AttachmentOut:
|
||||
return AttachmentOut(
|
||||
id=att.id, filename=att.filename, mime_type=att.mime_type, size_bytes=att.size_bytes
|
||||
)
|
||||
|
||||
|
||||
@router.post("/attachments", response_model=AttachmentOut)
|
||||
async def upload_attachment(
|
||||
user: CurrentUser, db: DbSession, file: UploadFile = File(...)
|
||||
) -> AttachmentOut:
|
||||
"""Store a file for an outgoing message (≤ 25 MB) and return its metadata."""
|
||||
data = await file.read()
|
||||
if not data:
|
||||
raise_api_error(400, "empty_file", "the file is empty")
|
||||
if len(data) > MAX_ATTACHMENT_BYTES:
|
||||
raise_api_error(413, "too_large", "attachment exceeds the 25 MB limit")
|
||||
sha = blobstore.store(user.id, data)
|
||||
att = ComposeAttachment(
|
||||
user_id=user.id,
|
||||
filename=file.filename or "attachment",
|
||||
mime_type=file.content_type or "application/octet-stream",
|
||||
size_bytes=len(data),
|
||||
blob_sha256=sha,
|
||||
)
|
||||
db.add(att)
|
||||
await db.commit()
|
||||
return _att_out(att)
|
||||
|
||||
|
||||
@router.get("/attachments", response_model=list[AttachmentOut])
|
||||
async def list_compose_attachments(
|
||||
user: CurrentUser, db: DbSession, ids: str = Query("")
|
||||
) -> list[AttachmentOut]:
|
||||
"""Resolve metadata for the given compose-attachment ids (reopening a draft)."""
|
||||
id_list: list[uuid.UUID] = []
|
||||
for value in ids.split(","):
|
||||
value = value.strip()
|
||||
if not value:
|
||||
continue
|
||||
try:
|
||||
id_list.append(uuid.UUID(value))
|
||||
except ValueError:
|
||||
continue
|
||||
if not id_list:
|
||||
return []
|
||||
rows = await db.scalars(
|
||||
select(ComposeAttachment).where(
|
||||
ComposeAttachment.user_id == user.id, ComposeAttachment.id.in_(id_list)
|
||||
)
|
||||
)
|
||||
by_id = {r.id: r for r in rows}
|
||||
return [_att_out(by_id[i]) for i in id_list if i in by_id]
|
||||
|
||||
|
||||
def _draft_out(draft: Draft) -> DraftOut:
|
||||
return DraftOut(
|
||||
id=draft.id,
|
||||
account_id=draft.account_id,
|
||||
thread_id=draft.thread_id,
|
||||
reply_to_message_id=draft.reply_to_message_id,
|
||||
kind=draft.kind,
|
||||
payload=draft.payload,
|
||||
updated_at=draft.updated_at.isoformat(),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/drafts", response_model=list[DraftOut])
|
||||
async def list_drafts(user: CurrentUser, db: DbSession) -> list[DraftOut]:
|
||||
drafts = await db.scalars(
|
||||
select(Draft).where(Draft.user_id == user.id).order_by(Draft.updated_at.desc())
|
||||
)
|
||||
return [_draft_out(d) for d in drafts]
|
||||
|
||||
|
||||
@router.post("/drafts", response_model=DraftOut)
|
||||
async def create_draft(body: DraftIn, user: CurrentUser, db: DbSession) -> DraftOut:
|
||||
try:
|
||||
draft = await upsert_draft(db, user_id=user.id, body=body, draft_id=None)
|
||||
except DraftError as exc:
|
||||
raise_api_error(400, "draft_error", str(exc))
|
||||
await db.commit()
|
||||
return _draft_out(draft)
|
||||
|
||||
|
||||
@router.put("/drafts/{draft_id}", response_model=DraftOut)
|
||||
async def update_draft(
|
||||
draft_id: uuid.UUID, body: DraftIn, user: CurrentUser, db: DbSession
|
||||
) -> DraftOut:
|
||||
try:
|
||||
draft = await upsert_draft(db, user_id=user.id, body=body, draft_id=draft_id)
|
||||
except DraftError as exc:
|
||||
code = 404 if "not found" in str(exc) else 400
|
||||
raise_api_error(code, "draft_error", str(exc))
|
||||
await db.commit()
|
||||
return _draft_out(draft)
|
||||
|
||||
|
||||
@router.delete("/drafts/{draft_id}")
|
||||
async def delete_draft(draft_id: uuid.UUID, user: CurrentUser, db: DbSession) -> dict[str, str]:
|
||||
draft = await db.scalar(select(Draft).where(Draft.id == draft_id, Draft.user_id == user.id))
|
||||
if draft is None:
|
||||
raise_api_error(404, "not_found", "draft not found")
|
||||
await db.delete(draft)
|
||||
await db.commit()
|
||||
return {"status": "deleted"}
|
||||
|
||||
|
||||
@router.post("/send", response_model=SendResponse)
|
||||
async def send(body: SendRequest, user: CurrentUser, db: DbSession) -> SendResponse:
|
||||
draft = await db.scalar(
|
||||
select(Draft).where(Draft.id == body.draft_id, Draft.user_id == user.id)
|
||||
)
|
||||
if draft is None:
|
||||
raise_api_error(404, "not_found", "draft not found")
|
||||
outbox = await create_outbox(db, user_id=user.id, draft=draft)
|
||||
await db.commit()
|
||||
|
||||
pool = await get_arq_pool()
|
||||
await pool.enqueue_job(
|
||||
"send_job",
|
||||
str(outbox.id),
|
||||
_defer_by=timedelta(seconds=get_settings().undo_seconds),
|
||||
)
|
||||
return SendResponse(outbox_id=outbox.id, undo_until=outbox.send_after.isoformat())
|
||||
|
||||
|
||||
@router.post("/send/{outbox_id}/undo")
|
||||
async def undo_send(outbox_id: uuid.UUID, user: CurrentUser, db: DbSession) -> dict[str, str]:
|
||||
ok = await cancel_outbox(db, user_id=user.id, outbox_id=outbox_id)
|
||||
await db.commit()
|
||||
if not ok:
|
||||
raise_api_error(409, "undo_too_late", "the message has already left the undo window")
|
||||
return {"status": "cancelled"}
|
||||
@@ -0,0 +1,23 @@
|
||||
"""Keyset cursor helpers for thread listings (SPEC §9.1: cursor = last_message_at,id)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import datetime as dt
|
||||
import uuid
|
||||
|
||||
|
||||
def encode_cursor(last_message_at: dt.datetime, thread_id: uuid.UUID) -> str:
|
||||
raw = f"{last_message_at.isoformat()}|{thread_id}"
|
||||
return base64.urlsafe_b64encode(raw.encode()).decode()
|
||||
|
||||
|
||||
def decode_cursor(cursor: str | None) -> tuple[dt.datetime, uuid.UUID] | None:
|
||||
if not cursor:
|
||||
return None
|
||||
try:
|
||||
raw = base64.urlsafe_b64decode(cursor.encode()).decode()
|
||||
date_str, id_str = raw.split("|", 1)
|
||||
return dt.datetime.fromisoformat(date_str), uuid.UUID(id_str)
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
@@ -0,0 +1,35 @@
|
||||
"""Consistent API error shape ``{error:{code,message}}`` (SPEC §9.1)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import NoReturn
|
||||
|
||||
from fastapi import FastAPI, HTTPException, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
from starlette.exceptions import HTTPException as StarletteHTTPException
|
||||
|
||||
|
||||
def api_error(status_code: int, code: str, message: str) -> HTTPException:
|
||||
"""Build an :class:`HTTPException` carrying the ``{code,message}`` detail."""
|
||||
return HTTPException(status_code=status_code, detail={"code": code, "message": message})
|
||||
|
||||
|
||||
def raise_api_error(status_code: int, code: str, message: str) -> NoReturn:
|
||||
"""Raise :func:`api_error` (typed as ``NoReturn`` for control-flow clarity)."""
|
||||
raise api_error(status_code, code, message)
|
||||
|
||||
|
||||
async def _handler(request: Request, exc: StarletteHTTPException) -> JSONResponse:
|
||||
detail = exc.detail
|
||||
if isinstance(detail, dict) and "code" in detail:
|
||||
body = {"error": detail}
|
||||
else:
|
||||
body = {"error": {"code": "http_error", "message": str(detail)}}
|
||||
headers = getattr(exc, "headers", None)
|
||||
return JSONResponse(status_code=exc.status_code, content=body, headers=headers)
|
||||
|
||||
|
||||
def register_error_handlers(app: FastAPI) -> None:
|
||||
"""Install the error-shape handler for all HTTP exceptions."""
|
||||
app.add_exception_handler(StarletteHTTPException, _handler) # type: ignore[arg-type]
|
||||
app.add_exception_handler(HTTPException, _handler) # type: ignore[arg-type]
|
||||
@@ -0,0 +1,37 @@
|
||||
"""Mailbox (folder) listing (SPEC §9.3 folder views, archive action)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.db import get_session
|
||||
from app.identity.deps import CurrentUser
|
||||
from app.models import Mailbox
|
||||
|
||||
router = APIRouter(prefix="/mailboxes", tags=["mail"])
|
||||
|
||||
DbSession = Annotated[AsyncSession, Depends(get_session)]
|
||||
|
||||
|
||||
class MailboxOut(BaseModel):
|
||||
id: uuid.UUID
|
||||
account_id: uuid.UUID
|
||||
path: str
|
||||
special_use: str | None
|
||||
|
||||
|
||||
@router.get("", response_model=list[MailboxOut])
|
||||
async def list_mailboxes(user: CurrentUser, db: DbSession) -> list[MailboxOut]:
|
||||
rows = await db.scalars(
|
||||
select(Mailbox).where(Mailbox.user_id == user.id).order_by(Mailbox.account_id, Mailbox.path)
|
||||
)
|
||||
return [
|
||||
MailboxOut(id=m.id, account_id=m.account_id, path=m.path, special_use=m.special_use)
|
||||
for m in rows
|
||||
]
|
||||
@@ -0,0 +1,231 @@
|
||||
"""Message body/attachment endpoints (SPEC §9.1 Mail)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import html as html_lib
|
||||
import io
|
||||
import uuid
|
||||
import zipfile
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from fastapi.responses import HTMLResponse, Response
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.errors import raise_api_error
|
||||
from app.api.schemas import AttachmentOut
|
||||
from app.api.settings import add_allowed_image_sender, allowed_image_senders
|
||||
from app.db import get_session
|
||||
from app.identity.deps import CurrentUser, require_csrf
|
||||
from app.indexer import blobstore
|
||||
from app.indexer.mime import parse_message
|
||||
from app.indexer.sanitize import SANITIZER_VERSION, sanitize_html
|
||||
from app.models import Attachment, BodyPart, Message
|
||||
|
||||
router = APIRouter(prefix="/messages", tags=["mail"])
|
||||
|
||||
DbSession = Annotated[AsyncSession, Depends(get_session)]
|
||||
|
||||
# Content types safe to serve inline (SPEC §10.8); everything else is forced to
|
||||
# an attachment download with a neutral type.
|
||||
_INLINE_SAFE = {"image/png", "image/jpeg", "image/gif", "image/webp", "application/pdf"}
|
||||
|
||||
# Base styles injected around the sanitized fragment so email content renders on a
|
||||
# readable light canvas with images that fit the pane (the iframe is sandboxed).
|
||||
_DOC_TEMPLATE = """<!doctype html><html><head><meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<style>
|
||||
html,body{{margin:0;padding:0}}
|
||||
body{{font-family:system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;font-size:14px;
|
||||
line-height:1.5;color:#1a1a1a;background:#fff;padding:14px;word-break:break-word}}
|
||||
img{{max-width:100%;height:auto}}
|
||||
table{{max-width:100%;border-collapse:collapse}}
|
||||
blockquote{{margin:0 0 0 12px;padding-left:12px;border-left:3px solid #e0e0e0;color:#555}}
|
||||
a{{color:#2563eb}}
|
||||
pre{{white-space:pre-wrap;word-wrap:break-word}}
|
||||
</style></head><body>{body}</body></html>"""
|
||||
|
||||
|
||||
def _wrap(fragment: str) -> str:
|
||||
return _DOC_TEMPLATE.format(body=fragment)
|
||||
|
||||
|
||||
async def _owned_message(db: AsyncSession, user_id: uuid.UUID, message_id: uuid.UUID) -> Message:
|
||||
msg = await db.scalar(
|
||||
select(Message).where(Message.id == message_id, Message.user_id == user_id)
|
||||
)
|
||||
if msg is None:
|
||||
raise_api_error(404, "not_found", "message not found")
|
||||
return msg
|
||||
|
||||
|
||||
def _sender_email(message: Message) -> str:
|
||||
return message.from_addr[0].get("email", "").lower() if message.from_addr else ""
|
||||
|
||||
|
||||
@router.get("/{message_id}/html", response_class=HTMLResponse)
|
||||
async def message_html(message_id: uuid.UUID, user: CurrentUser, db: DbSession) -> HTMLResponse:
|
||||
"""Return sanitized HTML wrapped in a document for the sandboxed iframe (SPEC §9.4, I-8).
|
||||
|
||||
Re-sanitizes from the raw blob when the sender is image-approved, or when the
|
||||
stored copy predates the current sanitizer version (SPEC §10.4).
|
||||
"""
|
||||
message = await _owned_message(db, user.id, message_id)
|
||||
allow_remote = _sender_email(message) in allowed_image_senders(user)
|
||||
|
||||
parts = list(await db.scalars(select(BodyPart).where(BodyPart.message_id == message_id)))
|
||||
html_part = next((p for p in parts if p.kind == "html_sanitized"), None)
|
||||
|
||||
stale = html_part is not None and html_part.sanitizer_v < SANITIZER_VERSION
|
||||
if html_part is not None and not allow_remote and not stale:
|
||||
return HTMLResponse(content=_wrap(html_part.content))
|
||||
|
||||
# Re-sanitize the original HTML (image-approved sender, or a stale stored copy).
|
||||
# If the raw blob is unavailable, fall back to whatever is already stored.
|
||||
if allow_remote or stale:
|
||||
try:
|
||||
raw = blobstore.load(user.id, message.blob_sha256)
|
||||
except FileNotFoundError:
|
||||
raw = None
|
||||
if raw is not None:
|
||||
parsed = parse_message(raw)
|
||||
if parsed.html_body is not None:
|
||||
result = sanitize_html(
|
||||
parsed.html_body, message.id, allow_remote_images=allow_remote
|
||||
)
|
||||
if html_part is not None and not allow_remote:
|
||||
html_part.content = result.html # persist the upgraded sanitization
|
||||
html_part.sanitizer_v = result.sanitizer_v
|
||||
await db.commit()
|
||||
return HTMLResponse(content=_wrap(result.html))
|
||||
|
||||
if html_part is not None:
|
||||
return HTMLResponse(content=_wrap(html_part.content))
|
||||
text_part = next((p for p in parts if p.kind == "text"), None)
|
||||
body = html_lib.escape(text_part.content) if text_part else ""
|
||||
return HTMLResponse(content=_wrap(f"<pre>{body}</pre>"))
|
||||
|
||||
|
||||
@router.post("/{message_id}/allow-remote-images", dependencies=[Depends(require_csrf)])
|
||||
async def allow_remote_images(
|
||||
message_id: uuid.UUID, user: CurrentUser, db: DbSession
|
||||
) -> dict[str, str]:
|
||||
"""Add the message's sender to the per-sender image allowlist (SPEC §9.4)."""
|
||||
message = await _owned_message(db, user.id, message_id)
|
||||
sender = _sender_email(message)
|
||||
if not sender:
|
||||
raise_api_error(400, "no_sender", "message has no sender address")
|
||||
add_allowed_image_sender(user, sender)
|
||||
await db.commit()
|
||||
return {"status": "ok", "sender": sender}
|
||||
|
||||
|
||||
@router.get("/{message_id}/cid/{content_id}")
|
||||
async def message_cid(
|
||||
message_id: uuid.UUID, content_id: str, user: CurrentUser, db: DbSession
|
||||
) -> Response:
|
||||
"""Serve an inline (cid:) attachment image referenced by the sanitized HTML."""
|
||||
msg = await _owned_message(db, user.id, message_id)
|
||||
att = await db.scalar(
|
||||
select(Attachment).where(
|
||||
Attachment.message_id == message_id, Attachment.content_id == content_id
|
||||
)
|
||||
)
|
||||
if att is None:
|
||||
raise_api_error(404, "not_found", "inline part not found")
|
||||
data = blobstore.load(msg.user_id, att.blob_sha256)
|
||||
media_type = att.mime_type if att.mime_type in _INLINE_SAFE else "application/octet-stream"
|
||||
return Response(content=data, media_type=media_type)
|
||||
|
||||
|
||||
async def _real_attachments(db: AsyncSession, message_id: uuid.UUID) -> list[Attachment]:
|
||||
"""Downloadable (non-inline) attachments for a message, in a stable order."""
|
||||
rows = await db.scalars(
|
||||
select(Attachment)
|
||||
.where(Attachment.message_id == message_id, Attachment.content_id.is_(None))
|
||||
.order_by(Attachment.filename)
|
||||
)
|
||||
return list(rows)
|
||||
|
||||
|
||||
@router.get("/{message_id}/attachments", response_model=list[AttachmentOut])
|
||||
async def list_attachments(
|
||||
message_id: uuid.UUID, user: CurrentUser, db: DbSession
|
||||
) -> list[AttachmentOut]:
|
||||
"""List a message's downloadable attachments (inline cid parts excluded)."""
|
||||
await _owned_message(db, user.id, message_id)
|
||||
return [
|
||||
AttachmentOut(id=a.id, filename=a.filename, mime_type=a.mime_type, size_bytes=a.size_bytes)
|
||||
for a in await _real_attachments(db, message_id)
|
||||
]
|
||||
|
||||
|
||||
@router.get("/{message_id}/attachments.zip")
|
||||
async def download_attachments_zip(
|
||||
message_id: uuid.UUID,
|
||||
user: CurrentUser,
|
||||
db: DbSession,
|
||||
ids: str | None = Query(None, description="comma-separated attachment ids; all if omitted"),
|
||||
) -> Response:
|
||||
"""Bundle a message's attachments into a single ``Attachments.zip`` download."""
|
||||
msg = await _owned_message(db, user.id, message_id)
|
||||
wanted: set[uuid.UUID] | None = None
|
||||
if ids:
|
||||
try:
|
||||
wanted = {uuid.UUID(v) for v in ids.split(",") if v.strip()}
|
||||
except ValueError:
|
||||
raise_api_error(400, "bad_ids", "invalid attachment id list")
|
||||
attachments = [
|
||||
a for a in await _real_attachments(db, message_id) if wanted is None or a.id in wanted
|
||||
]
|
||||
if not attachments:
|
||||
raise_api_error(404, "not_found", "no attachments to download")
|
||||
|
||||
buffer = io.BytesIO()
|
||||
with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as zf:
|
||||
seen: dict[str, int] = {}
|
||||
for att in attachments:
|
||||
name = att.filename or "attachment"
|
||||
# De-dupe identical filenames within the archive (name, name-2, …).
|
||||
if name in seen:
|
||||
seen[name] += 1
|
||||
stem, dot, ext = name.rpartition(".")
|
||||
name = f"{stem}-{seen[name]}{dot}{ext}" if dot else f"{name}-{seen[name]}"
|
||||
else:
|
||||
seen[name] = 1
|
||||
zf.writestr(name, blobstore.load(msg.user_id, att.blob_sha256))
|
||||
|
||||
return Response(
|
||||
content=buffer.getvalue(),
|
||||
media_type="application/zip",
|
||||
headers={"Content-Disposition": 'attachment; filename="Attachments.zip"'},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{message_id}/attachments/{attachment_id}")
|
||||
async def message_attachment(
|
||||
message_id: uuid.UUID,
|
||||
attachment_id: uuid.UUID,
|
||||
user: CurrentUser,
|
||||
db: DbSession,
|
||||
download: bool = Query(False),
|
||||
) -> Response:
|
||||
"""Download an attachment (inline for safe types unless ``download`` forces it)."""
|
||||
msg = await _owned_message(db, user.id, message_id)
|
||||
att = await db.scalar(
|
||||
select(Attachment).where(
|
||||
Attachment.id == attachment_id, Attachment.message_id == message_id
|
||||
)
|
||||
)
|
||||
if att is None:
|
||||
raise_api_error(404, "not_found", "attachment not found")
|
||||
data = blobstore.load(msg.user_id, att.blob_sha256)
|
||||
inline = att.mime_type in _INLINE_SAFE and not download
|
||||
media_type = att.mime_type if inline else "application/octet-stream"
|
||||
disposition = "inline" if inline else "attachment"
|
||||
return Response(
|
||||
content=data,
|
||||
media_type=media_type,
|
||||
headers={"Content-Disposition": f'{disposition}; filename="{att.filename}"'},
|
||||
)
|
||||
@@ -0,0 +1,52 @@
|
||||
"""Recipient autocomplete from known_recipients (SPEC §9.1, §4).
|
||||
|
||||
Prefix match on email or name, ranked by ``times_used`` decayed by recency. The
|
||||
composer merges these with plugin capability providers client-side (M3).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.db import get_session
|
||||
from app.identity.deps import CurrentUser
|
||||
from app.models import KnownRecipient
|
||||
|
||||
router = APIRouter(prefix="/recipients", tags=["compose"])
|
||||
|
||||
DbSession = Annotated[AsyncSession, Depends(get_session)]
|
||||
|
||||
|
||||
class Suggestion(BaseModel):
|
||||
name: str
|
||||
email: str
|
||||
|
||||
|
||||
@router.get("/suggest", response_model=list[Suggestion])
|
||||
async def suggest(
|
||||
user: CurrentUser, db: DbSession, q: str = Query("", max_length=200)
|
||||
) -> list[Suggestion]:
|
||||
term = q.strip()
|
||||
if not term:
|
||||
return []
|
||||
like = f"{term}%"
|
||||
# Recency-decayed score: times_used / (1 + age_in_days). Ranks frequent and
|
||||
# recent correspondents ahead of stale ones (SPEC §9.1).
|
||||
age_days = func.extract("epoch", func.now() - KnownRecipient.last_used_at) / 86400.0
|
||||
score = KnownRecipient.times_used / (1.0 + age_days)
|
||||
stmt = (
|
||||
select(KnownRecipient.name, KnownRecipient.email)
|
||||
.where(
|
||||
KnownRecipient.user_id == user.id,
|
||||
KnownRecipient.email.ilike(like) | KnownRecipient.name.ilike(like),
|
||||
)
|
||||
.order_by(score.desc())
|
||||
.limit(10)
|
||||
)
|
||||
rows = (await db.execute(stmt)).all()
|
||||
return [Suggestion(name=row.name, email=row.email) for row in rows]
|
||||
@@ -0,0 +1,91 @@
|
||||
"""Shared API response models (SPEC §9.1)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as dt
|
||||
import uuid
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class Address(BaseModel):
|
||||
name: str = ""
|
||||
email: str = ""
|
||||
|
||||
|
||||
class ThreadSummary(BaseModel):
|
||||
id: uuid.UUID
|
||||
subject: str
|
||||
snippet: str
|
||||
participants: list[Address]
|
||||
account_ids: list[uuid.UUID]
|
||||
message_count: int
|
||||
unread_count: int
|
||||
has_attachments: bool
|
||||
pinned: bool
|
||||
last_message_at: dt.datetime
|
||||
|
||||
|
||||
class ThreadListOut(BaseModel):
|
||||
threads: list[ThreadSummary]
|
||||
cursor: str | None = None
|
||||
|
||||
|
||||
class AttachmentOut(BaseModel):
|
||||
id: uuid.UUID
|
||||
filename: str
|
||||
mime_type: str
|
||||
size_bytes: int
|
||||
|
||||
|
||||
class MessageEnvelope(BaseModel):
|
||||
id: uuid.UUID
|
||||
account_id: uuid.UUID
|
||||
mailbox_id: uuid.UUID
|
||||
thread_id: uuid.UUID | None
|
||||
subject: str
|
||||
from_addr: list[Address]
|
||||
to_addr: list[Address]
|
||||
cc_addr: list[Address]
|
||||
date: dt.datetime
|
||||
flags: list[str]
|
||||
is_unread: bool
|
||||
is_starred: bool
|
||||
has_attachments: bool
|
||||
has_remote_images: bool
|
||||
snippet: str
|
||||
|
||||
|
||||
class ThreadDetail(BaseModel):
|
||||
id: uuid.UUID
|
||||
subject: str
|
||||
order: str # "asc" | "desc"
|
||||
pinned: bool
|
||||
messages: list[MessageEnvelope] # always ASC (SPEC §9.4)
|
||||
|
||||
|
||||
class FlagChangeRequest(BaseModel):
|
||||
add: list[str] = []
|
||||
remove: list[str] = []
|
||||
|
||||
|
||||
class MoveRequest(BaseModel):
|
||||
mailbox_id: uuid.UUID
|
||||
|
||||
|
||||
class AccountOut(BaseModel):
|
||||
id: uuid.UUID
|
||||
email: str
|
||||
display_name: str
|
||||
color: str
|
||||
state: str
|
||||
|
||||
|
||||
class SearchThreadHit(BaseModel):
|
||||
thread: ThreadSummary
|
||||
highlights: dict[str, str]
|
||||
|
||||
|
||||
class SearchOut(BaseModel):
|
||||
threads: list[SearchThreadHit]
|
||||
cursor: str | None = None
|
||||
@@ -0,0 +1,64 @@
|
||||
"""Search endpoint (SPEC §9.1 Search, §8). Results grouped by thread (FR-28)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.schemas import SearchOut, SearchThreadHit
|
||||
from app.api.threads import _summary
|
||||
from app.db import get_session
|
||||
from app.identity.deps import CurrentUser
|
||||
from app.models import Thread
|
||||
from app.search.factory import get_search_backend
|
||||
from app.search.queryparser import to_search_query
|
||||
|
||||
router = APIRouter(prefix="/search", tags=["search"])
|
||||
|
||||
DbSession = Annotated[AsyncSession, Depends(get_session)]
|
||||
|
||||
|
||||
@router.get("", response_model=SearchOut)
|
||||
async def search(
|
||||
user: CurrentUser,
|
||||
db: DbSession,
|
||||
q: str = Query(""),
|
||||
cursor: str | None = None,
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
) -> SearchOut:
|
||||
if not q.strip():
|
||||
return SearchOut(threads=[], cursor=None)
|
||||
|
||||
backend = get_search_backend()
|
||||
query = to_search_query(q, user.id)
|
||||
hits, next_cursor = await backend.query(query, limit=limit, cursor=cursor)
|
||||
|
||||
# Group hits by thread, preserving order and keeping the first highlight.
|
||||
order: list[uuid.UUID] = []
|
||||
highlights: dict[uuid.UUID, dict[str, str]] = {}
|
||||
for hit in hits:
|
||||
if hit.thread_id not in highlights:
|
||||
order.append(hit.thread_id)
|
||||
highlights[hit.thread_id] = hit.highlights
|
||||
elif hit.highlights and not highlights[hit.thread_id]:
|
||||
highlights[hit.thread_id] = hit.highlights
|
||||
|
||||
if not order:
|
||||
return SearchOut(threads=[], cursor=next_cursor)
|
||||
|
||||
threads = {
|
||||
t.id: t
|
||||
for t in await db.scalars(
|
||||
select(Thread).where(Thread.id.in_(order), Thread.user_id == user.id)
|
||||
)
|
||||
}
|
||||
results = [
|
||||
SearchThreadHit(thread=_summary(threads[tid]), highlights=highlights.get(tid, {}))
|
||||
for tid in order
|
||||
if tid in threads
|
||||
]
|
||||
return SearchOut(threads=results, cursor=next_cursor)
|
||||
@@ -0,0 +1,71 @@
|
||||
"""User settings + per-sender image allowlist (SPEC §9.1 Settings, §9.4)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.db import get_session
|
||||
from app.identity.deps import CurrentUser, require_csrf
|
||||
from app.models import User
|
||||
|
||||
router = APIRouter(prefix="/settings", tags=["settings"])
|
||||
|
||||
DbSession = Annotated[AsyncSession, Depends(get_session)]
|
||||
|
||||
_ALLOWED_ORDER = {"asc", "desc"}
|
||||
|
||||
|
||||
class SettingsOut(BaseModel):
|
||||
thread_order: str = "asc"
|
||||
tz: str = "UTC"
|
||||
allowed_image_senders: list[str] = []
|
||||
|
||||
|
||||
class SettingsPatch(BaseModel):
|
||||
thread_order: str | None = None
|
||||
tz: str | None = None
|
||||
|
||||
|
||||
def allowed_image_senders(user: User) -> set[str]:
|
||||
value = user.settings.get("allowed_image_senders", [])
|
||||
if isinstance(value, list):
|
||||
return {str(e).lower() for e in value}
|
||||
return set()
|
||||
|
||||
|
||||
def add_allowed_image_sender(user: User, email: str) -> None:
|
||||
current = sorted(allowed_image_senders(user))
|
||||
if email.lower() not in current:
|
||||
current.append(email.lower())
|
||||
# Reassign the whole dict so SQLAlchemy detects the JSONB change.
|
||||
user.settings = {**user.settings, "allowed_image_senders": current}
|
||||
|
||||
|
||||
def _settings_out(user: User) -> SettingsOut:
|
||||
s = user.settings
|
||||
return SettingsOut(
|
||||
thread_order=str(s.get("thread_order", "asc")),
|
||||
tz=str(s.get("tz", "UTC")),
|
||||
allowed_image_senders=sorted(allowed_image_senders(user)),
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=SettingsOut)
|
||||
async def get_settings_endpoint(user: CurrentUser) -> SettingsOut:
|
||||
return _settings_out(user)
|
||||
|
||||
|
||||
@router.patch("", response_model=SettingsOut, dependencies=[Depends(require_csrf)])
|
||||
async def patch_settings(body: SettingsPatch, user: CurrentUser, db: DbSession) -> SettingsOut:
|
||||
updates = dict(user.settings)
|
||||
if body.thread_order is not None and body.thread_order in _ALLOWED_ORDER:
|
||||
updates["thread_order"] = body.thread_order
|
||||
if body.tz is not None:
|
||||
updates["tz"] = body.tz
|
||||
user.settings = updates
|
||||
await db.commit()
|
||||
return _settings_out(user)
|
||||
@@ -0,0 +1,160 @@
|
||||
"""Thread listing and detail endpoints (SPEC §9.1 Mail)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy import literal, select, tuple_
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.cursors import decode_cursor, encode_cursor
|
||||
from app.api.errors import raise_api_error
|
||||
from app.api.schemas import (
|
||||
Address,
|
||||
MessageEnvelope,
|
||||
ThreadDetail,
|
||||
ThreadListOut,
|
||||
ThreadSummary,
|
||||
)
|
||||
from app.db import get_session
|
||||
from app.identity.deps import CurrentUser
|
||||
from app.models import Mailbox, Message, Thread, User
|
||||
|
||||
router = APIRouter(prefix="/threads", tags=["mail"])
|
||||
|
||||
DbSession = Annotated[AsyncSession, Depends(get_session)]
|
||||
|
||||
|
||||
def _addresses(raw: list[dict[str, str]]) -> list[Address]:
|
||||
return [Address(name=a.get("name", ""), email=a.get("email", "")) for a in raw]
|
||||
|
||||
|
||||
def _summary(thread: Thread) -> ThreadSummary:
|
||||
return ThreadSummary(
|
||||
id=thread.id,
|
||||
subject=thread.subject_norm or "(no subject)",
|
||||
snippet=thread.snippet,
|
||||
participants=_addresses(thread.participants),
|
||||
account_ids=list(thread.account_ids),
|
||||
message_count=thread.message_count,
|
||||
unread_count=thread.unread_count,
|
||||
has_attachments=thread.has_attachments,
|
||||
pinned=thread.pinned,
|
||||
last_message_at=thread.last_message_at,
|
||||
)
|
||||
|
||||
|
||||
def _envelope(msg: Message) -> MessageEnvelope:
|
||||
return MessageEnvelope(
|
||||
id=msg.id,
|
||||
account_id=msg.account_id,
|
||||
mailbox_id=msg.mailbox_id,
|
||||
thread_id=msg.thread_id,
|
||||
subject=msg.subject,
|
||||
from_addr=_addresses(msg.from_addr),
|
||||
to_addr=_addresses(msg.to_addr),
|
||||
cc_addr=_addresses(msg.cc_addr),
|
||||
date=msg.date_hdr or msg.internal_date,
|
||||
flags=list(msg.flags),
|
||||
is_unread="\\Seen" not in msg.flags,
|
||||
is_starred="\\Flagged" in msg.flags,
|
||||
has_attachments=msg.has_attachments,
|
||||
has_remote_images=msg.has_remote_images,
|
||||
snippet=msg.snippet,
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=ThreadListOut)
|
||||
async def list_threads(
|
||||
user: CurrentUser,
|
||||
db: DbSession,
|
||||
view: str = Query("unified"),
|
||||
cursor: str | None = None,
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
) -> ThreadListOut:
|
||||
stmt = select(Thread).where(Thread.user_id == user.id)
|
||||
|
||||
if view.startswith("account:"):
|
||||
# A single account's inbox: threads with a message in THIS account's
|
||||
# inbox mailbox. Keeps the per-account view consistent with the unified
|
||||
# Inbox (archived/trashed/sent threads don't leak in).
|
||||
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",
|
||||
)
|
||||
)
|
||||
stmt = stmt.where(Thread.id.in_(in_account_inbox))
|
||||
elif view.startswith("folder:"):
|
||||
# Threads with at least one message in a mailbox of this special_use,
|
||||
# across all of the user's accounts (unified per-folder view).
|
||||
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)
|
||||
)
|
||||
stmt = stmt.where(Thread.id.in_(in_folder))
|
||||
elif view != "unified":
|
||||
raise_api_error(400, "bad_view", "unsupported view")
|
||||
|
||||
decoded = decode_cursor(cursor)
|
||||
if decoded is not None:
|
||||
cur_date, cur_id = decoded
|
||||
stmt = stmt.where(
|
||||
tuple_(Thread.last_message_at, Thread.id) < tuple_(literal(cur_date), literal(cur_id))
|
||||
)
|
||||
stmt = stmt.order_by(Thread.last_message_at.desc(), Thread.id.desc()).limit(limit + 1)
|
||||
|
||||
threads = list(await db.scalars(stmt))
|
||||
has_more = len(threads) > limit
|
||||
threads = threads[:limit]
|
||||
next_cursor = (
|
||||
encode_cursor(threads[-1].last_message_at, threads[-1].id) if has_more and threads else None
|
||||
)
|
||||
return ThreadListOut(threads=[_summary(t) for t in threads], cursor=next_cursor)
|
||||
|
||||
|
||||
async def _resolve_thread(db: AsyncSession, user: User, thread_id: uuid.UUID) -> Thread:
|
||||
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")
|
||||
return thread
|
||||
|
||||
|
||||
@router.get("/{thread_id}", response_model=ThreadDetail)
|
||||
async def get_thread(
|
||||
thread_id: uuid.UUID,
|
||||
user: CurrentUser,
|
||||
db: DbSession,
|
||||
sort: str = Query("asc"),
|
||||
) -> ThreadDetail:
|
||||
thread = await _resolve_thread(db, user, thread_id)
|
||||
# Messages are always returned ASC (oldest first); the client reverses for
|
||||
# display per the requested order (invariant I-5 / §9.4).
|
||||
messages = list(
|
||||
await db.scalars(
|
||||
select(Message)
|
||||
.where(Message.thread_id == thread_id, Message.user_id == user.id)
|
||||
.order_by(Message.internal_date.asc(), Message.id.asc())
|
||||
)
|
||||
)
|
||||
order = "desc" if sort == "desc" else "asc"
|
||||
return ThreadDetail(
|
||||
id=thread.id,
|
||||
subject=thread.subject_norm or "(no subject)",
|
||||
order=order,
|
||||
pinned=thread.pinned,
|
||||
messages=[_envelope(m) for m in messages],
|
||||
)
|
||||
Reference in New Issue
Block a user