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:
2026-07-17 23:20:34 +02:00
co-authored by Claude Opus 4.8
commit a5ec0d21cb
174 changed files with 22239 additions and 0 deletions
+39
View File
@@ -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")
+176
View File
@@ -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,
)
+159
View File
@@ -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)}
+163
View File
@@ -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"}
+23
View File
@@ -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
+35
View File
@@ -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]
+37
View File
@@ -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
]
+231
View File
@@ -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}"'},
)
+52
View File
@@ -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]
+91
View File
@@ -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
+64
View File
@@ -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)
+71
View File
@@ -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)
+160
View File
@@ -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],
)