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
+1
View File
@@ -0,0 +1 @@
"""Webmail backend application package."""
+1
View File
@@ -0,0 +1 @@
"""Accounts: autodiscovery, credential testing, account lifecycle (SPEC §6.2)."""
+255
View File
@@ -0,0 +1,255 @@
"""Autodiscovery resolver (SPEC §6.2).
``discover(email)`` runs the chain — presets, Thunderbird autoconfig, ISPDB, DNS
SRV (RFC 6186), then a live probe — returning on the first success but recording
every step's outcome for wizard UX. All HTTP is SSRF-guarded (§10.6).
"""
from __future__ import annotations
import asyncio
import ssl
from pathlib import Path
import dns.asyncresolver
import dns.exception
import httpx
import yaml
from defusedxml import ElementTree as SafeET
from app.accounts.ssrf import SsrfError, assert_host_is_public, guarded_get
from app.accounts.types import CandidateConfig, DiscoveryResult, DiscoveryStep, ServerConfig
_PROBE_TIMEOUT = 5.0
_presets_cache: dict[str, CandidateConfig] | None = None
# --- presets -----------------------------------------------------------------
def _load_presets() -> dict[str, CandidateConfig]:
global _presets_cache
if _presets_cache is None:
raw = yaml.safe_load((Path(__file__).parent / "presets.yaml").read_text())
mapping: dict[str, CandidateConfig] = {}
for prov in raw["providers"].values():
auth = prov.get("auth", "password")
imap = ServerConfig(
prov["imap"]["host"], prov["imap"]["port"], prov["imap"]["tls"], auth
)
smtp = ServerConfig(
prov["smtp"]["host"], prov["smtp"]["port"], prov["smtp"]["tls"], auth
)
candidate = CandidateConfig(imap, smtp, "preset", prov.get("oauth_provider"))
for domain in prov["domains"]:
mapping[domain.lower()] = candidate
_presets_cache = mapping
return _presets_cache
def domain_of(email: str) -> str:
return email.rsplit("@", 1)[-1].strip().lower()
# --- Thunderbird autoconfig / ISPDB XML --------------------------------------
def _socket_type(text: str | None) -> str:
return {"ssl": "ssl", "starttls": "starttls", "plain": "plain"}.get(
(text or "").strip().lower(), "ssl"
)
def parse_autoconfig_xml(xml_text: str) -> CandidateConfig | None:
"""Parse Thunderbird ``clientConfig`` XML into a CandidateConfig (or None)."""
try:
root = SafeET.fromstring(xml_text)
except Exception:
return None
provider = root.find("emailProvider")
if provider is None:
return None
def _server(kind: str, server_type: str) -> ServerConfig | None:
for node in provider.findall(kind):
if node.get("type") == server_type:
host = node.findtext("hostname")
port = node.findtext("port")
if host and port and port.isdigit():
auth = node.findtext("authentication") or ""
is_oauth = "oauth" in auth.lower()
return ServerConfig(
host.strip(),
int(port),
_socket_type(node.findtext("socketType")),
"oauth2" if is_oauth else "password",
)
return None
imap = _server("incomingServer", "imap")
smtp = _server("outgoingServer", "smtp")
if imap is None or smtp is None:
return None
return CandidateConfig(imap, smtp, "autoconfig")
# --- DNS SRV (RFC 6186) ------------------------------------------------------
async def _srv_lookup(name: str, tls: str) -> ServerConfig | None:
resolver = dns.asyncresolver.Resolver()
resolver.lifetime = 5.0
try:
answer = await resolver.resolve(name, "SRV")
except (dns.exception.DNSException, OSError):
return None
for record in sorted(answer, key=lambda r: (r.priority, r.weight)):
target = str(record.target).rstrip(".")
if target and target != ".": # "." target => service explicitly unavailable
return ServerConfig(target, record.port, tls)
return None
async def _discover_srv(domain: str) -> CandidateConfig | None:
imap = await _srv_lookup(f"_imaps._tcp.{domain}", "ssl") or await _srv_lookup(
f"_imap._tcp.{domain}", "starttls"
)
smtp = await _srv_lookup(f"_submissions._tcp.{domain}", "ssl") or await _srv_lookup(
f"_submission._tcp.{domain}", "starttls"
)
if imap and smtp:
return CandidateConfig(imap, smtp, "srv")
return None
# --- live probe --------------------------------------------------------------
async def _probe_one(host: str, port: int, tls: str, kind: str) -> ServerConfig | None:
try:
await assert_host_is_public(host) # SSRF: never probe internal hosts
except SsrfError:
return None
try:
if tls == "ssl":
ctx = ssl.create_default_context()
conn = asyncio.open_connection(host, port, ssl=ctx)
else:
conn = asyncio.open_connection(host, port)
reader, writer = await asyncio.wait_for(conn, _PROBE_TIMEOUT)
greeting = await asyncio.wait_for(reader.readline(), _PROBE_TIMEOUT)
writer.close()
except (TimeoutError, OSError, ssl.SSLError):
return None
text = greeting.decode("ascii", "ignore")
if kind == "imap" and text.startswith("* OK"):
return ServerConfig(host, port, tls)
if kind == "smtp" and text.startswith("220"):
return ServerConfig(host, port, tls)
return None
async def _first_working(candidates: list[tuple[str, int, str]], kind: str) -> ServerConfig | None:
results = await asyncio.gather(
*(_probe_one(host, port, tls, kind) for host, port, tls in candidates)
)
for result in results: # candidates are in priority order
if result is not None:
return result
return None
async def _discover_probe(domain: str) -> CandidateConfig | None:
imap_hosts = [
(f"imap.{domain}", 993, "ssl"),
(f"mail.{domain}", 993, "ssl"),
(f"imap.{domain}", 143, "starttls"),
(f"mail.{domain}", 143, "starttls"),
]
smtp_hosts = [
(f"smtp.{domain}", 465, "ssl"),
(f"mail.{domain}", 465, "ssl"),
(f"smtp.{domain}", 587, "starttls"),
(f"mail.{domain}", 587, "starttls"),
]
imap, smtp = await asyncio.gather(
_first_working(imap_hosts, "imap"), _first_working(smtp_hosts, "smtp")
)
if imap and smtp:
return CandidateConfig(imap, smtp, "probe")
return None
# --- orchestration -----------------------------------------------------------
async def _try_http_config(url: str, source: str) -> CandidateConfig | None:
xml_text = await guarded_get(url)
if xml_text is None:
return None
candidate = parse_autoconfig_xml(xml_text)
if candidate is None:
return None
return CandidateConfig(candidate.imap, candidate.smtp, source, candidate.oauth_provider)
async def discover(email: str) -> DiscoveryResult:
"""Run the autodiscovery chain for ``email`` (SPEC §6.2)."""
result = DiscoveryResult()
domain = domain_of(email)
# 1. Presets.
presets = _load_presets()
if domain in presets:
result.candidates.append(presets[domain])
result.steps.append(DiscoveryStep("preset", "found", domain))
return result
result.steps.append(DiscoveryStep("preset", "not_found", domain))
# 2. Autoconfig (autoconfig.<domain>, then <domain>/.well-known).
autoconfig_urls = [
f"https://autoconfig.{domain}/mail/config-v1.1.xml?emailaddress={email}",
f"https://{domain}/.well-known/autoconfig/mail/config-v1.1.xml?emailaddress={email}",
]
for url in autoconfig_urls:
try:
candidate = await _try_http_config(url, "autoconfig")
except (SsrfError, httpx.HTTPError) as exc:
result.steps.append(DiscoveryStep("autoconfig", "error", str(exc)[:200]))
continue
if candidate is not None:
result.candidates.append(candidate)
result.steps.append(DiscoveryStep("autoconfig", "found", url))
return result
result.steps.append(DiscoveryStep("autoconfig", "not_found", domain))
# 3. ISPDB (Thunderbird's database).
ispdb_url = f"https://autoconfig.thunderbird.net/v1.1/{domain}"
try:
candidate = await _try_http_config(ispdb_url, "ispdb")
if candidate is not None:
result.candidates.append(candidate)
result.steps.append(DiscoveryStep("ispdb", "found", ispdb_url))
return result
result.steps.append(DiscoveryStep("ispdb", "not_found", domain))
except (SsrfError, httpx.HTTPError) as exc:
result.steps.append(DiscoveryStep("ispdb", "error", str(exc)[:200]))
# 4. DNS SRV (RFC 6186).
srv = await _discover_srv(domain)
if srv is not None:
result.candidates.append(srv)
result.steps.append(DiscoveryStep("srv", "found", domain))
return result
result.steps.append(DiscoveryStep("srv", "not_found", domain))
# 5. Live probe.
probe = await _discover_probe(domain)
if probe is not None:
result.candidates.append(probe)
result.steps.append(DiscoveryStep("probe", "found", domain))
return result
result.steps.append(DiscoveryStep("probe", "not_found", domain))
return result
+68
View File
@@ -0,0 +1,68 @@
# Provider presets (config, not code) — SPEC §6.2 step 1.
# Matched by the domain of the email address. Each entry gives IMAP + SMTP
# host/port/tls, the auth kind, and an oauth provider id when applicable.
# tls: "ssl" (implicit TLS) | "starttls"; auth: "password" | "oauth2".
providers:
google:
domains: [gmail.com, googlemail.com]
imap: { host: imap.gmail.com, port: 993, tls: ssl }
smtp: { host: smtp.gmail.com, port: 465, tls: ssl }
auth: oauth2
oauth_provider: google
microsoft:
domains: [outlook.com, hotmail.com, live.com, msn.com]
imap: { host: outlook.office365.com, port: 993, tls: ssl }
smtp: { host: smtp.office365.com, port: 587, tls: starttls }
auth: oauth2
oauth_provider: microsoft
office365:
domains: [office365.com]
imap: { host: outlook.office365.com, port: 993, tls: ssl }
smtp: { host: smtp.office365.com, port: 587, tls: starttls }
auth: oauth2
oauth_provider: microsoft
yahoo:
domains: [yahoo.com, yahoo.co.uk, ymail.com, rocketmail.com]
imap: { host: imap.mail.yahoo.com, port: 993, tls: ssl }
smtp: { host: smtp.mail.yahoo.com, port: 465, tls: ssl }
auth: password
icloud:
domains: [icloud.com, me.com, mac.com]
imap: { host: imap.mail.me.com, port: 993, tls: ssl }
smtp: { host: smtp.mail.me.com, port: 587, tls: starttls }
auth: password
gmx:
domains: [gmx.com, gmx.net, gmx.de]
imap: { host: imap.gmx.com, port: 993, tls: ssl }
smtp: { host: mail.gmx.com, port: 587, tls: starttls }
auth: password
wp:
domains: [wp.pl]
imap: { host: imap.wp.pl, port: 993, tls: ssl }
smtp: { host: smtp.wp.pl, port: 465, tls: ssl }
auth: password
onet:
domains: [onet.pl, poczta.onet.pl, op.pl]
imap: { host: imap.poczta.onet.pl, port: 993, tls: ssl }
smtp: { host: smtp.poczta.onet.pl, port: 465, tls: ssl }
auth: password
o2:
domains: [o2.pl, tlen.pl]
imap: { host: imap.poczta.o2.pl, port: 993, tls: ssl }
smtp: { host: smtp.poczta.o2.pl, port: 465, tls: ssl }
auth: password
interia:
domains: [interia.pl, interia.eu]
imap: { host: imap.poczta.interia.pl, port: 993, tls: ssl }
smtp: { host: smtp.poczta.interia.pl, port: 465, tls: ssl }
auth: password
+74
View File
@@ -0,0 +1,74 @@
"""Request/response models for the accounts API (SPEC §9.1)."""
from __future__ import annotations
import uuid
from pydantic import BaseModel, Field
class ServerConfigIn(BaseModel):
host: str
port: int = Field(ge=1, le=65535)
tls: str = "ssl" # ssl | starttls
auth: str = "password" # password | oauth2
class DiscoverRequest(BaseModel):
email: str
class CandidateOut(BaseModel):
imap: ServerConfigIn
smtp: ServerConfigIn
source: str
oauth_provider: str | None = None
class DiscoveryStepOut(BaseModel):
method: str
status: str
detail: str = ""
class DiscoverResponse(BaseModel):
candidates: list[CandidateOut]
steps: list[DiscoveryStepOut]
class TestRequest(BaseModel):
imap: ServerConfigIn
smtp: ServerConfigIn
email: str
username: str | None = None # defaults to the email address
password: str = Field(min_length=1)
class TestResponse(BaseModel):
ok: bool = True
class CreateAccountRequest(BaseModel):
email: str
display_name: str = ""
color: str = "#888888"
imap: ServerConfigIn
smtp: ServerConfigIn
username: str | None = None
password: str = Field(min_length=1)
class UpdateAccountRequest(BaseModel):
display_name: str | None = None
color: str | None = None
class AccountDetailOut(BaseModel):
id: uuid.UUID
email: str
display_name: str
color: str
state: str
state_detail: str
imap_config: dict[str, object]
smtp_config: dict[str, object]
+99
View File
@@ -0,0 +1,99 @@
"""Account lifecycle: create (encrypt + enqueue sync), delete (cascade + blobs) (SPEC §6.2/§9.1)."""
from __future__ import annotations
import uuid
from sqlalchemy import delete, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from app.accounts.schemas import CreateAccountRequest
from app.accounts.types import ServerConfig
from app.indexer import blobstore
from app.models import Account, Attachment, Message
from app.security.crypto import encrypt_secret
class AccountExistsError(Exception):
"""The user already has an account for this email."""
def _server_dict(cfg: ServerConfig) -> dict[str, object]:
return {"host": cfg.host, "port": cfg.port, "tls": cfg.tls, "auth": cfg.auth}
async def create_account(
session: AsyncSession, *, user_id: uuid.UUID, req: CreateAccountRequest
) -> Account:
"""Create an account row with encrypted credentials (state=backfilling)."""
imap = ServerConfig(req.imap.host, req.imap.port, req.imap.tls, req.imap.auth)
smtp = ServerConfig(req.smtp.host, req.smtp.port, req.smtp.tls, req.smtp.auth)
account = Account(
user_id=user_id,
email=str(req.email),
display_name=req.display_name,
color=req.color,
imap_config=_server_dict(imap),
smtp_config=_server_dict(smtp),
secret_ct=encrypt_secret(req.password.encode("utf-8")),
discovery_src="manual",
state="backfilling",
)
session.add(account)
try:
await session.flush()
except IntegrityError as exc:
await session.rollback()
raise AccountExistsError(str(req.email)) from exc
return account
async def delete_account(
session: AsyncSession, *, user_id: uuid.UUID, account_id: uuid.UUID
) -> bool:
"""Delete an account and its data. Removes blobs no longer referenced (dedup-aware).
Returns False if the account does not belong to the user. DB rows (mailboxes,
messages, body_parts, attachments) and the Postgres search vector cascade via
FK ``ON DELETE CASCADE``.
"""
account = await session.scalar(
select(Account).where(Account.id == account_id, Account.user_id == user_id)
)
if account is None:
return False
# Gather blob digests this account references, before the cascade delete.
msg_shas = set(
await session.scalars(select(Message.blob_sha256).where(Message.account_id == account_id))
)
att_shas = set(
await session.scalars(
select(Attachment.blob_sha256)
.join(Message, Message.id == Attachment.message_id)
.where(Message.account_id == account_id)
)
)
candidate_shas = msg_shas | att_shas
await session.execute(delete(Account).where(Account.id == account_id))
await session.flush()
# Content-addressed blobs are shared across the user's accounts (dedup): only
# remove a blob when no remaining message/attachment references it.
for sha in candidate_shas:
still_used = await session.scalar(
select(Message.id)
.where(Message.user_id == user_id, Message.blob_sha256 == sha)
.limit(1)
)
if still_used is None:
still_used = await session.scalar(
select(Attachment.id)
.where(Attachment.user_id == user_id, Attachment.blob_sha256 == sha)
.limit(1)
)
if still_used is None:
blobstore.remove(user_id, sha)
return True
+89
View File
@@ -0,0 +1,89 @@
"""SSRF-guarded outbound HTTP for autodiscovery (SPEC §10.6).
All autodiscovery HTTP is HTTPS-only, 5 s timeout, response capped at 256 KB, no
off-domain redirects, and the target host must resolve exclusively to public IPs
(reject private/loopback/link-local/reserved ranges). A residual TOCTOU gap
(DNS rebinding between our resolve and httpx's own resolve) is accepted for M1;
a pinned-IP transport can close it later.
"""
from __future__ import annotations
import asyncio
import ipaddress
import socket
from urllib.parse import urlsplit
import httpx
HTTP_TIMEOUT = 5.0
MAX_RESPONSE_BYTES = 256 * 1024
class SsrfError(Exception):
"""Raised when a URL fails the SSRF policy (bad scheme, private IP, oversize)."""
def _is_public_ip(ip: str) -> bool:
addr = ipaddress.ip_address(ip)
return not (
addr.is_private
or addr.is_loopback
or addr.is_link_local
or addr.is_multicast
or addr.is_reserved
or addr.is_unspecified
)
async def _resolve_all(host: str) -> list[str]:
loop = asyncio.get_running_loop()
infos = await loop.getaddrinfo(host, None, proto=socket.IPPROTO_TCP)
return [info[4][0] for info in infos]
async def assert_host_is_public(host: str) -> None:
"""Resolve ``host`` and reject if any address is non-public (SPEC §10.6)."""
try:
addresses = await _resolve_all(host)
except socket.gaierror as exc:
raise SsrfError(f"cannot resolve {host}") from exc
if not addresses:
raise SsrfError(f"no addresses for {host}")
for ip in addresses:
if not _is_public_ip(ip):
raise SsrfError(f"{host} resolves to non-public address {ip}")
async def guarded_get(url: str) -> str | None:
"""HTTPS GET with the full SSRF policy applied.
Returns the body text on 200, or ``None`` when the resource is absent
(any non-200 response). Raises :class:`SsrfError` on a policy violation
(bad scheme, private IP, oversize); lets httpx network errors propagate.
"""
parts = urlsplit(url)
if parts.scheme != "https":
raise SsrfError("only https is permitted")
if not parts.hostname:
raise SsrfError("missing host")
await assert_host_is_public(parts.hostname)
async with (
httpx.AsyncClient(
follow_redirects=False, # off-domain redirects are forbidden (§10.6)
timeout=HTTP_TIMEOUT,
headers={"User-Agent": "webmail-autodiscovery/1.0"},
) as client,
client.stream("GET", url) as resp,
):
if resp.status_code != 200:
return None
chunks: list[bytes] = []
total = 0
async for chunk in resp.aiter_bytes():
total += len(chunk)
if total > MAX_RESPONSE_BYTES:
raise SsrfError("response exceeds 256 KB cap")
chunks.append(chunk)
return b"".join(chunks).decode("utf-8", "replace")
+104
View File
@@ -0,0 +1,104 @@
"""Credential testing for the account wizard (SPEC §6.2 ``test(config, credentials)``).
Runs a full IMAP LOGIN + LIST and an SMTP EHLO + AUTH (no message sent), mapping
failures to precise user-facing codes. This is a §10.2 decryption-boundary sibling:
it handles plaintext credentials the user just typed, before they are encrypted.
"""
from __future__ import annotations
import socket
import ssl
import aiosmtplib
from app.accounts.ssrf import SsrfError, assert_host_is_public
from app.accounts.types import ServerConfig
from app.providers.base import ProviderAuthError, ProviderConnectError
from app.providers.imap_smtp import ImapSmtpProvider, ProviderConfig
_SMTP_TIMEOUT = 10.0
class AccountTestError(Exception):
"""A credential/connectivity test failed, with a precise ``code`` (SPEC §6.2)."""
def __init__(self, code: str, detail: str) -> None:
super().__init__(detail)
self.code = code
self.detail = detail
def _classify_cause(cause: BaseException | None) -> tuple[str, str]:
if isinstance(cause, ssl.SSLError):
return "tls_failed", str(cause)
if isinstance(cause, socket.gaierror):
return "unknown_host", str(cause)
if isinstance(cause, TimeoutError):
return "connect_timeout", "connection timed out"
return "connect_failed", str(cause or "connection failed")
async def _assert_public(config: ServerConfig, label: str) -> None:
try:
await assert_host_is_public(config.host)
except SsrfError as exc:
raise AccountTestError("host_not_allowed", f"{label} host is not permitted: {exc}") from exc
async def _test_smtp(config: ServerConfig, username: str, password: str) -> None:
smtp = aiosmtplib.SMTP(
hostname=config.host,
port=config.port,
use_tls=config.tls == "ssl",
start_tls=config.tls == "starttls",
timeout=_SMTP_TIMEOUT,
)
try:
await smtp.connect()
await smtp.login(username, password)
await smtp.quit()
except aiosmtplib.SMTPAuthenticationError as exc:
raise AccountTestError("auth_failed", f"SMTP authentication failed: {exc}") from exc
except aiosmtplib.SMTPConnectError as exc:
raise AccountTestError("connect_failed", f"SMTP connect failed: {exc}") from exc
except ssl.SSLError as exc:
raise AccountTestError("tls_failed", f"SMTP TLS failed: {exc}") from exc
except socket.gaierror as exc:
raise AccountTestError("unknown_host", f"SMTP host not found: {exc}") from exc
except (TimeoutError, aiosmtplib.SMTPTimeoutError) as exc:
raise AccountTestError("connect_timeout", "SMTP connection timed out") from exc
except aiosmtplib.SMTPException as exc:
raise AccountTestError("connect_failed", f"SMTP error: {exc}") from exc
async def test_account(
*, imap: ServerConfig, smtp: ServerConfig, username: str, password: str
) -> None:
"""Verify IMAP + SMTP credentials. Raises :class:`AccountTestError` on failure."""
await _assert_public(imap, "IMAP")
await _assert_public(smtp, "SMTP")
provider = ImapSmtpProvider(
ProviderConfig(
imap_host=imap.host,
imap_port=imap.port,
imap_tls=imap.tls,
smtp_host=smtp.host,
smtp_port=smtp.port,
smtp_tls=smtp.tls,
username=username,
password=password,
)
)
try:
await provider.verify() # IMAP LOGIN + LIST
except ProviderAuthError as exc:
raise AccountTestError("auth_failed", exc.detail) from exc
except ProviderConnectError as exc:
code, detail = _classify_cause(exc.__cause__)
raise AccountTestError(code, detail) from exc
finally:
await provider.close()
await _test_smtp(smtp, username, password)
+34
View File
@@ -0,0 +1,34 @@
"""Autodiscovery result types (SPEC §6.2)."""
from __future__ import annotations
from dataclasses import dataclass, field
@dataclass(frozen=True)
class ServerConfig:
host: str
port: int
tls: str # "ssl" | "starttls"
auth: str = "password" # "password" | "oauth2"
@dataclass(frozen=True)
class CandidateConfig:
imap: ServerConfig
smtp: ServerConfig
source: str # preset | autoconfig | ispdb | srv | probe | manual
oauth_provider: str | None = None
@dataclass(frozen=True)
class DiscoveryStep:
method: str # preset | autoconfig | ispdb | srv | probe
status: str # found | not_found | error
detail: str = ""
@dataclass
class DiscoveryResult:
candidates: list[CandidateConfig] = field(default_factory=list)
steps: list[DiscoveryStep] = field(default_factory=list)
+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],
)
+46
View File
@@ -0,0 +1,46 @@
"""Application configuration.
SPEC §14: the complete env list is read exactly once here (pydantic-settings).
No ``os.environ`` access is permitted anywhere else in the codebase.
"""
from __future__ import annotations
from functools import lru_cache
from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
"""All runtime configuration, sourced from the environment (SPEC §14)."""
model_config = SettingsConfigDict(env_file=None, extra="ignore")
app_domain: str = Field(alias="APP_DOMAIN")
database_url: str = Field(alias="DATABASE_URL")
redis_url: str = Field(alias="REDIS_URL")
master_key: str = Field(alias="MASTER_KEY") # 32 bytes, base64
blob_root: str = Field(default="./var/blobs", alias="BLOB_ROOT")
search_backend: str = Field(default="pg", alias="SEARCH_BACKEND")
meili_url: str = Field(default="http://127.0.0.1:7700", alias="MEILI_URL")
meili_key: str = Field(default="", alias="MEILI_KEY")
undo_seconds: int = Field(default=10, alias="UNDO_SECONDS")
poll_interval: int = Field(default=120, alias="POLL_INTERVAL")
session_ttl_days: int = Field(default=30, alias="SESSION_TTL_DAYS")
max_attachment_mb: int = Field(default=25, alias="MAX_ATTACHMENT_MB")
log_level: str = Field(default="INFO", alias="LOG_LEVEL")
oauth_google_client_id: str = Field(default="", alias="OAUTH_GOOGLE_CLIENT_ID")
oauth_google_client_secret: str = Field(default="", alias="OAUTH_GOOGLE_CLIENT_SECRET")
oauth_ms_client_id: str = Field(default="", alias="OAUTH_MS_CLIENT_ID")
oauth_ms_client_secret: str = Field(default="", alias="OAUTH_MS_CLIENT_SECRET")
@lru_cache(maxsize=1)
def get_settings() -> Settings:
"""Return the process-wide settings singleton."""
return Settings() # values are sourced from the environment
+72
View File
@@ -0,0 +1,72 @@
"""Async SQLAlchemy engine/session management (SPEC §2 ``app/db.py``)."""
from __future__ import annotations
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from sqlalchemy.ext.asyncio import (
AsyncEngine,
AsyncSession,
async_sessionmaker,
create_async_engine,
)
from sqlalchemy.orm import DeclarativeBase
from app.config import get_settings
_engine: AsyncEngine | None = None
_sessionmaker: async_sessionmaker[AsyncSession] | None = None
class Base(DeclarativeBase):
"""Declarative base for all ORM models."""
def get_engine() -> AsyncEngine:
"""Return (creating on first use) the process-wide async engine."""
global _engine
if _engine is None:
_engine = create_async_engine(
get_settings().database_url,
pool_pre_ping=True,
future=True,
)
return _engine
def get_sessionmaker() -> async_sessionmaker[AsyncSession]:
"""Return the process-wide session factory."""
global _sessionmaker
if _sessionmaker is None:
_sessionmaker = async_sessionmaker(
get_engine(), expire_on_commit=False, class_=AsyncSession
)
return _sessionmaker
@asynccontextmanager
async def session_scope() -> AsyncIterator[AsyncSession]:
"""Transactional session context manager for workers and scripts."""
async with get_sessionmaker()() as session:
try:
yield session
await session.commit()
except Exception:
await session.rollback()
raise
async def get_session() -> AsyncIterator[AsyncSession]:
"""FastAPI dependency yielding a request-scoped session."""
async with get_sessionmaker()() as session:
yield session
async def dispose_engine() -> None:
"""Dispose the engine (used on app shutdown)."""
global _engine, _sessionmaker
if _engine is not None:
await _engine.dispose()
_engine = None
_sessionmaker = None
+90
View File
@@ -0,0 +1,90 @@
# INTERFACE VERSION: 1
"""EventBus — Redis pub/sub fan-out + in-process durable consumers (SPEC §5.3, §8.3).
``publish`` fans out to the Redis channel ``evt:{event}`` (the WS hub relays these
to the owning user) and invokes any in-process subscribers. Payloads always
include ``user_id`` (SPEC §5.3). Only the event names in SPEC §8.3 are allowed.
NOTE(spec): the frozen contract also enqueues a durable arq job ``on_{event}`` for
out-of-process consumers (plugins). In M0 the only durable consumers (search index,
thread denormalization) run inline in the indexer per FR-30, so cross-process arq
fan-out is deferred to the plugin milestone (M3). The signatures here are frozen.
"""
from __future__ import annotations
import json
import logging
from collections.abc import Awaitable, Callable
from typing import Protocol
from app.redis_client import get_redis
logger = logging.getLogger(__name__)
Handler = Callable[[dict[str, object]], Awaitable[None]]
# The complete, allowed event catalog (SPEC §8.3). Plugin events (plugin.{name}.*)
# are validated separately by the plugin loader (M3).
ALLOWED_EVENTS: frozenset[str] = frozenset(
{
"message.new",
"message.changed",
"message.deleted",
"message.sent",
"flags.changed",
"thread.updated",
"sync.progress",
"account.state_changed",
"draft.saved",
"outbox.state_changed",
}
)
def channel_for(event: str) -> str:
"""Return the Redis pub/sub channel name for an event."""
return f"evt:{event}"
class EventBusProtocol(Protocol):
async def publish(self, event: str, payload: dict[str, object]) -> None: ...
def subscribe(self, event: str, handler: Handler) -> None: ...
class EventBus:
"""Concrete EventBus backed by Redis pub/sub with in-process subscribers."""
def __init__(self) -> None:
self._handlers: dict[str, list[Handler]] = {}
def subscribe(self, event: str, handler: Handler) -> None:
"""Register an in-process durable consumer for ``event``."""
self._handlers.setdefault(event, []).append(handler)
async def publish(self, event: str, payload: dict[str, object]) -> None:
"""Fan out ``event`` to Redis subscribers and in-process handlers."""
if event not in ALLOWED_EVENTS and not event.startswith("plugin."):
raise ValueError(f"unknown event {event!r} (see SPEC §8.3)")
if "user_id" not in payload:
raise ValueError("event payload must include user_id (SPEC §5.3)")
message = json.dumps({"event": event, "payload": payload}, default=str)
await get_redis().publish(channel_for(event), message)
for handler in self._handlers.get(event, ()):
try:
await handler(payload)
except Exception: # a failing consumer must not break the publisher
logger.exception("event handler failed for %s", event)
_bus: EventBus | None = None
def get_event_bus() -> EventBus:
"""Return the process-wide EventBus singleton."""
global _bus
if _bus is None:
_bus = EventBus()
return _bus
+1
View File
@@ -0,0 +1 @@
"""Identity: app accounts, auth, sessions, handles (SPEC §6.1)."""
+76
View File
@@ -0,0 +1,76 @@
"""Session and CSRF cookie handling (SPEC §6.1, §10.3).
Over HTTPS we emit the spec-mandated ``__Host-session`` prefix with ``Secure``.
Over plain HTTP (dev only) we fall back to a plain ``session`` name without
``Secure`` so login works behind the Vite HTTP proxy. See docs/open-questions.md
OQ-3. ``SameSite=Lax``, ``HttpOnly`` (session), ``Path=/`` are always applied.
"""
from __future__ import annotations
import secrets
from fastapi import Request, Response
SESSION_COOKIE_SECURE = "__Host-session"
SESSION_COOKIE_INSECURE = "session"
CSRF_COOKIE_SECURE = "__Host-csrf"
CSRF_COOKIE_INSECURE = "csrf"
CSRF_HEADER = "X-CSRF"
def _is_secure(request: Request) -> bool:
return request.url.scheme == "https"
def session_cookie_name(request: Request) -> str:
"""Return the session cookie name appropriate to the request scheme."""
return SESSION_COOKIE_SECURE if _is_secure(request) else SESSION_COOKIE_INSECURE
def csrf_cookie_name(request: Request) -> str:
"""Return the CSRF cookie name appropriate to the request scheme."""
return CSRF_COOKIE_SECURE if _is_secure(request) else CSRF_COOKIE_INSECURE
def read_session_token(request: Request) -> str:
"""Read the session token from either cookie name (whichever is present)."""
return request.cookies.get(SESSION_COOKIE_SECURE) or request.cookies.get(
SESSION_COOKIE_INSECURE, ""
)
def new_csrf_token() -> str:
"""Return a fresh CSRF token for the double-submit pattern."""
return secrets.token_urlsafe(32)
def set_auth_cookies(
request: Request, response: Response, session_token: str, csrf_token: str, max_age: int
) -> None:
"""Set the session (HttpOnly) and CSRF (JS-readable) cookies."""
secure = _is_secure(request)
response.set_cookie(
session_cookie_name(request),
session_token,
max_age=max_age,
httponly=True,
secure=secure,
samesite="lax",
path="/",
)
response.set_cookie(
csrf_cookie_name(request),
csrf_token,
max_age=max_age,
httponly=False, # double-submit: client JS echoes it in the X-CSRF header
secure=secure,
samesite="lax",
path="/",
)
def clear_auth_cookies(request: Request, response: Response) -> None:
"""Clear both auth cookies (logout)."""
for name in (session_cookie_name(request), csrf_cookie_name(request)):
response.delete_cookie(name, path="/")
+50
View File
@@ -0,0 +1,50 @@
"""Auth dependencies: current user and CSRF enforcement (SPEC §6.1, §10.3)."""
from __future__ import annotations
from typing import Annotated
from fastapi import Depends, Request
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.errors import raise_api_error
from app.db import get_session
from app.identity.cookies import CSRF_HEADER, csrf_cookie_name, read_session_token
from app.identity.sessions import Session, resolve_session
from app.models import User
async def current_session(request: Request) -> Session:
"""Resolve the caller's session or raise 401."""
token = read_session_token(request)
session = await resolve_session(token)
if session is None:
raise_api_error(401, "not_authenticated", "authentication required")
return session
async def current_user(
session: Annotated[Session, Depends(current_session)],
db: Annotated[AsyncSession, Depends(get_session)],
) -> User:
"""Load the authenticated :class:`User` (I-7: scoped by the session's user_id)."""
user = await db.scalar(select(User).where(User.id == session.user_id))
if user is None:
# Session references a deleted user; treat as unauthenticated.
raise_api_error(401, "not_authenticated", "authentication required")
return user
async def require_csrf(request: Request) -> None:
"""Enforce the double-submit CSRF token on mutating requests (SPEC §10.3)."""
if request.method in ("GET", "HEAD", "OPTIONS"):
return
header = request.headers.get(CSRF_HEADER, "")
cookie = request.cookies.get(csrf_cookie_name(request), "")
if not header or not cookie or header != cookie:
raise_api_error(403, "csrf_failed", "missing or invalid CSRF token")
CurrentUser = Annotated[User, Depends(current_user)]
CurrentSession = Annotated[Session, Depends(current_session)]
+56
View File
@@ -0,0 +1,56 @@
"""Handle (localpart) validation per SPEC §4.
Rules (enforced in code, mail-grade from day one):
- localpart: 3-30 chars, characters ``[a-z0-9._-]``
- no leading/trailing dot, no double dot
- case-insensitive (normalize to lowercase)
- not in the reserved list (:mod:`app.identity.reserved`)
The stored handle is the full form ``localpart@{APP_DOMAIN}``.
"""
from __future__ import annotations
import re
from app.config import get_settings
from app.identity.reserved import is_reserved
_LOCALPART_RE = re.compile(r"^[a-z0-9._-]+$")
_MIN_LEN = 3
_MAX_LEN = 30
class InvalidHandle(ValueError):
"""Raised when a proposed localpart violates the SPEC §4 rules."""
def __init__(self, code: str, message: str) -> None:
super().__init__(message)
self.code = code
self.message = message
def normalize_localpart(localpart: str) -> str:
"""Lowercase and strip surrounding whitespace (case-insensitive handles)."""
return localpart.strip().lower()
def validate_localpart(localpart: str) -> str:
"""Validate and normalize ``localpart``; return it or raise :class:`InvalidHandle`."""
lp = normalize_localpart(localpart)
if not (_MIN_LEN <= len(lp) <= _MAX_LEN):
raise InvalidHandle("length", f"handle must be {_MIN_LEN}-{_MAX_LEN} characters")
if not _LOCALPART_RE.match(lp):
raise InvalidHandle("charset", "handle may contain only a-z, 0-9, '.', '_', '-'")
if lp.startswith(".") or lp.endswith("."):
raise InvalidHandle("dot_edge", "handle may not start or end with a dot")
if ".." in lp:
raise InvalidHandle("double_dot", "handle may not contain consecutive dots")
if is_reserved(lp):
raise InvalidHandle("reserved", "that handle is reserved")
return lp
def full_handle(localpart: str) -> str:
"""Return the full handle ``localpart@{APP_DOMAIN}`` for a validated localpart."""
return f"{validate_localpart(localpart)}@{get_settings().app_domain}"
+34
View File
@@ -0,0 +1,34 @@
"""Argon2id password hashing (SPEC §6.1, §10.1).
Params are fixed: ``time_cost=3``, ``memory_cost=64 MiB``, ``parallelism=4``.
No other password hashing is permitted anywhere (SPEC §10.1).
"""
from __future__ import annotations
from argon2 import PasswordHasher
from argon2.exceptions import VerificationError, VerifyMismatchError
_hasher = PasswordHasher(
time_cost=3,
memory_cost=64 * 1024, # KiB -> 64 MiB
parallelism=4,
)
def hash_password(password: str) -> str:
"""Return an argon2id hash for ``password``."""
return _hasher.hash(password)
def verify_password(password_hash: str, password: str) -> bool:
"""Constant-time verify ``password`` against ``password_hash``."""
try:
return _hasher.verify(password_hash, password)
except (VerifyMismatchError, VerificationError):
return False
def needs_rehash(password_hash: str) -> bool:
"""Return True if ``password_hash`` was made with outdated parameters."""
return _hasher.check_needs_rehash(password_hash)
+50
View File
@@ -0,0 +1,50 @@
"""Reserved localparts that may not be registered (SPEC §4/§6.1).
Mail-grade from day one: role addresses (RFC 2142) plus common admin/system
handles. Comparison is case-insensitive; keep entries lowercase.
"""
from __future__ import annotations
RESERVED_LOCALPARTS: frozenset[str] = frozenset(
{
# RFC 2142 role addresses
"postmaster",
"abuse",
"hostmaster",
"webmaster",
"usenet",
"news",
"www",
"uucp",
"ftp",
"noc",
"security",
"mailer-daemon",
# Common system/admin handles
"admin",
"administrator",
"root",
"noreply",
"no-reply",
"donotreply",
"do-not-reply",
"support",
"help",
"info",
"contact",
"sales",
"billing",
"system",
"daemon",
"nobody",
"null",
"void",
"test",
}
)
def is_reserved(localpart: str) -> bool:
"""Return True if ``localpart`` is reserved (case-insensitive)."""
return localpart.lower() in RESERVED_LOCALPARTS
+43
View File
@@ -0,0 +1,43 @@
"""Password reset tokens (SPEC §6.1).
Single-use, 30-minute, Redis-backed tokens. Only issued when the account has a
recovery email on file. Actual delivery of the token to the recovery address
requires an app-level mailer (not part of M0/M1); until then the token is logged
server-side. See docs/open-questions.md.
"""
from __future__ import annotations
import logging
import secrets
import uuid
from app.redis_client import aw, get_redis
logger = logging.getLogger(__name__)
_PREFIX = "pwreset:"
_TTL = 30 * 60 # 30 minutes
async def issue_token(user_id: uuid.UUID, recovery_email: str) -> str:
"""Create a single-use reset token bound to ``user_id`` and return it."""
token = secrets.token_urlsafe(32)
await get_redis().set(_PREFIX + token, str(user_id), ex=_TTL)
# TODO(spec): deliver via the recovery address once an app mailer exists.
logger.info("password reset token issued for user=%s recovery=%s", user_id, recovery_email)
return token
async def consume_token(token: str) -> uuid.UUID | None:
"""Atomically consume a reset token, returning the user_id (or None)."""
if not token:
return None
redis = get_redis()
raw = await aw(redis.getdel(_PREFIX + token)) # single-use: fetch and delete atomically
if raw is None:
return None
try:
return uuid.UUID(raw)
except ValueError:
return None
+213
View File
@@ -0,0 +1,213 @@
"""Auth API routes (SPEC §9.1): register, login (2FA-aware), logout, me,
TOTP setup/enable, session list/revoke, password reset."""
from __future__ import annotations
from typing import Annotated
from fastapi import APIRouter, Depends, Request, Response
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.errors import raise_api_error
from app.config import get_settings
from app.db import get_session
from app.identity import totp as totp_mod
from app.identity.cookies import (
clear_auth_cookies,
new_csrf_token,
set_auth_cookies,
)
from app.identity.deps import CurrentSession, CurrentUser, require_csrf
from app.identity.passwords import hash_password, verify_password
from app.identity.reset import consume_token, issue_token
from app.identity.schemas import (
LoginRequest,
MessageOut,
PasswordResetIn,
PasswordResetRequestIn,
RegisterRequest,
SessionOut,
TotpDisableRequest,
TotpEnableRequest,
TotpSetupResponse,
UserOut,
)
from app.identity.service import (
RateLimited,
RegistrationError,
TotpInvalid,
TotpRequired,
authenticate,
register_user,
resolve_handle,
)
from app.identity.sessions import (
create_session,
destroy_all_sessions,
destroy_session,
list_sessions,
revoke_session,
)
from app.models import User
router = APIRouter(prefix="/auth", tags=["auth"])
DbSession = Annotated[AsyncSession, Depends(get_session)]
def _client_ip(request: Request) -> str:
return request.client.host if request.client else "unknown"
async def _establish_session(request: Request, response: Response, user: User) -> None:
token = await create_session(user.id, request.headers.get("user-agent", ""))
csrf = new_csrf_token()
max_age = get_settings().session_ttl_days * 24 * 3600
set_auth_cookies(request, response, token, csrf, max_age)
def _user_out(user: User) -> UserOut:
return UserOut(
id=user.id,
handle=user.handle,
recovery_email=user.recovery_email,
settings=user.settings,
totp_enabled=user.totp_secret is not None,
)
@router.post("/register", response_model=UserOut)
async def register(
body: RegisterRequest, request: Request, response: Response, db: DbSession
) -> UserOut:
try:
user = await register_user(
db,
localpart=body.localpart,
password=body.password,
recovery_email=body.recovery_email,
)
except RegistrationError as exc:
status = 409 if exc.code == "handle_taken" else 400
raise_api_error(status, exc.code, exc.message)
await db.commit()
await _establish_session(request, response, user)
return _user_out(user)
@router.post("/login", response_model=UserOut)
async def login(body: LoginRequest, request: Request, response: Response, db: DbSession) -> UserOut:
try:
user = await authenticate(
db,
handle_input=body.handle,
password=body.password,
ip=_client_ip(request),
totp=body.totp,
)
except RateLimited as exc:
response.headers["Retry-After"] = str(exc.retry_after)
raise_api_error(429, "rate_limited", "too many attempts; try again later")
except TotpRequired:
raise_api_error(401, "totp_required", "a 2FA code is required")
except TotpInvalid:
raise_api_error(401, "totp_invalid", "incorrect 2FA code")
if user is None:
raise_api_error(401, "invalid_credentials", "incorrect handle or password")
await _establish_session(request, response, user)
return _user_out(user)
@router.post("/logout", response_model=MessageOut, dependencies=[Depends(require_csrf)])
async def logout(request: Request, response: Response, session: CurrentSession) -> MessageOut:
await destroy_session(session.token)
clear_auth_cookies(request, response)
return MessageOut()
@router.get("/me", response_model=UserOut)
async def me(user: CurrentUser) -> UserOut:
return _user_out(user)
# --- Two-factor authentication (SPEC §6.1) ----------------------------------
@router.post("/totp/setup", response_model=TotpSetupResponse, dependencies=[Depends(require_csrf)])
async def totp_setup(user: CurrentUser) -> TotpSetupResponse:
if user.totp_secret is not None:
raise_api_error(409, "totp_already_enabled", "2FA is already enabled")
secret = await totp_mod.start_setup(user.id)
return TotpSetupResponse(
secret=secret, provisioning_uri=totp_mod.provisioning_uri(secret, user.handle)
)
@router.post("/totp/enable", response_model=MessageOut, dependencies=[Depends(require_csrf)])
async def totp_enable(body: TotpEnableRequest, user: CurrentUser, db: DbSession) -> MessageOut:
secret = await totp_mod.get_pending(user.id)
if secret is None:
raise_api_error(400, "totp_no_setup", "start TOTP setup first")
if not totp_mod.verify(secret, body.code):
raise_api_error(400, "totp_invalid", "incorrect 2FA code")
user.totp_secret = totp_mod.encrypt_totp_secret(secret)
await db.commit()
await totp_mod.clear_pending(user.id)
return MessageOut()
@router.post("/totp/disable", response_model=MessageOut, dependencies=[Depends(require_csrf)])
async def totp_disable(body: TotpDisableRequest, user: CurrentUser, db: DbSession) -> MessageOut:
# Re-authenticate with the password before turning off 2FA.
if not verify_password(user.password_hash, body.password):
raise_api_error(401, "invalid_credentials", "incorrect password")
user.totp_secret = None
await db.commit()
return MessageOut()
# --- Session management (SPEC §9.1) -----------------------------------------
@router.get("/sessions", response_model=list[SessionOut])
async def sessions(user: CurrentUser, session: CurrentSession) -> list[SessionOut]:
infos = await list_sessions(user.id, session.token)
return [
SessionOut(sid=i.sid, created=i.created.isoformat(), ua=i.ua, current=i.current)
for i in infos
]
@router.delete("/sessions/{sid}", response_model=MessageOut, dependencies=[Depends(require_csrf)])
async def revoke(sid: str, user: CurrentUser) -> MessageOut:
if not await revoke_session(user.id, sid):
raise_api_error(404, "not_found", "session not found")
return MessageOut()
# --- Password reset (SPEC §6.1) ---------------------------------------------
@router.post("/password/reset-request", response_model=MessageOut)
async def password_reset_request(body: PasswordResetRequestIn, db: DbSession) -> MessageOut:
# Always return ok (do not leak whether the handle exists / has recovery mail).
handle = resolve_handle(body.handle)
user = await db.scalar(select(User).where(User.handle == handle))
if user is not None and user.recovery_email:
await issue_token(user.id, user.recovery_email)
return MessageOut()
@router.post("/password/reset", response_model=MessageOut)
async def password_reset(body: PasswordResetIn, db: DbSession) -> MessageOut:
user_id = await consume_token(body.token)
if user_id is None:
raise_api_error(400, "invalid_token", "reset token is invalid or expired")
user = await db.get(User, user_id)
if user is None:
raise_api_error(400, "invalid_token", "reset token is invalid or expired")
user.password_hash = hash_password(body.password)
await db.commit()
await destroy_all_sessions(user_id) # force re-login everywhere after reset
return MessageOut()
+61
View File
@@ -0,0 +1,61 @@
"""Request/response models for the auth API (SPEC §9.1)."""
from __future__ import annotations
import uuid
from pydantic import BaseModel, Field
class RegisterRequest(BaseModel):
localpart: str = Field(min_length=3, max_length=30)
password: str = Field(min_length=8, max_length=1024)
recovery_email: str | None = None
class LoginRequest(BaseModel):
# Accepts either a bare localpart or the full ``localpart@domain`` handle.
handle: str = Field(min_length=1, max_length=320)
password: str = Field(min_length=1, max_length=1024)
totp: str | None = None # required when the account has 2FA enabled
class TotpSetupResponse(BaseModel):
secret: str # base32, for manual entry
provisioning_uri: str # otpauth:// URI to render as a QR code
class TotpEnableRequest(BaseModel):
code: str = Field(min_length=6, max_length=10)
class TotpDisableRequest(BaseModel):
password: str = Field(min_length=1, max_length=1024)
class SessionOut(BaseModel):
sid: str
created: str
ua: str
current: bool
class PasswordResetRequestIn(BaseModel):
handle: str = Field(min_length=1, max_length=320)
class PasswordResetIn(BaseModel):
token: str = Field(min_length=1)
password: str = Field(min_length=8, max_length=1024)
class UserOut(BaseModel):
id: uuid.UUID
handle: str
recovery_email: str | None
settings: dict[str, object]
totp_enabled: bool = False
class MessageOut(BaseModel):
status: str = "ok"
+124
View File
@@ -0,0 +1,124 @@
"""Registration, authentication, and login rate limiting (SPEC §6.1)."""
from __future__ import annotations
from dataclasses import dataclass
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import get_settings
from app.identity.handles import InvalidHandle, full_handle, normalize_localpart
from app.identity.passwords import hash_password, verify_password
from app.models import User
from app.redis_client import get_redis
# SPEC §6.1: 10 attempts / 15 min per handle AND per IP.
_RL_MAX = 10
_RL_WINDOW = 15 * 60
class RegistrationError(Exception):
"""Registration failed for a user-actionable reason."""
def __init__(self, code: str, message: str) -> None:
super().__init__(message)
self.code = code
self.message = message
@dataclass(frozen=True)
class RateLimited(Exception):
"""Raised when the login rate limit is exceeded."""
retry_after: int
class TotpRequired(Exception):
"""Password verified but the account has 2FA enabled and no code was given."""
class TotpInvalid(Exception):
"""Password verified but the supplied TOTP code was wrong."""
def resolve_handle(handle_input: str) -> str:
"""Normalize a login identifier to the full ``localpart@APP_DOMAIN`` form."""
text = handle_input.strip().lower()
if "@" in text:
return text
return f"{normalize_localpart(text)}@{get_settings().app_domain}"
async def register_user(
db: AsyncSession, *, localpart: str, password: str, recovery_email: str | None
) -> User:
"""Create a new user. Raises :class:`RegistrationError` on validation/uniqueness."""
try:
handle = full_handle(localpart)
except InvalidHandle as exc:
raise RegistrationError(exc.code, exc.message) from exc
user = User(
handle=handle,
password_hash=hash_password(password),
recovery_email=(recovery_email.strip().lower() if recovery_email else None),
)
db.add(user)
try:
await db.flush()
except IntegrityError as exc:
await db.rollback()
raise RegistrationError("handle_taken", "that handle is already registered") from exc
return user
async def _bump_rate_limit(key: str) -> int:
redis = get_redis()
count = await redis.incr(key)
if count == 1:
await redis.expire(key, _RL_WINDOW)
return int(count)
async def _check_rate_limit(handle: str, ip: str) -> None:
for key in (f"rl:login:handle:{handle}", f"rl:login:ip:{ip}"):
count = await _bump_rate_limit(key)
if count > _RL_MAX:
ttl = await get_redis().ttl(key)
raise RateLimited(retry_after=max(ttl, 1))
async def authenticate(
db: AsyncSession, *, handle_input: str, password: str, ip: str, totp: str | None = None
) -> User | None:
"""Verify credentials (constant-time). Returns the user or None; may rate-limit.
When the account has TOTP enabled, raises :class:`TotpRequired` (no code given)
or :class:`TotpInvalid` (wrong code) after the password check passes.
"""
handle = resolve_handle(handle_input)
await _check_rate_limit(handle, ip)
user = await db.scalar(select(User).where(User.handle == handle))
# Always run a verify to keep timing uniform whether or not the user exists.
stored_hash = user.password_hash if user else _DUMMY_HASH
ok = verify_password(stored_hash, password)
if user is None or not ok:
return None
if user.totp_secret is not None:
from app.identity.totp import decrypt_totp_secret
from app.identity.totp import verify as verify_totp
if not totp:
raise TotpRequired
if not verify_totp(decrypt_totp_secret(user.totp_secret), totp):
raise TotpInvalid
return user
# A precomputed hash so authentication of a non-existent handle still spends
# argon2 time (mitigates user-enumeration via timing, SPEC §6.1 constant-time).
_DUMMY_HASH = hash_password("x" * 24)
+141
View File
@@ -0,0 +1,141 @@
"""Server-side sessions in Redis (SPEC §4 "Sessions", §9.1 session management).
``sess:{token}`` -> JSON ``{user_id, sid, created, ua}`` with a sliding TTL of
``SESSION_TTL_DAYS`` days. A per-user index ``usess:{user_id}`` (hash sid->token)
lets us list and revoke a user's sessions without exposing the raw token, which is
the session credential itself. Tokens are opaque and high-entropy.
"""
from __future__ import annotations
import json
import secrets
import uuid
from dataclasses import dataclass
from datetime import UTC, datetime
from app.config import get_settings
from app.redis_client import aw, get_redis
_PREFIX = "sess:"
_INDEX = "usess:"
_TOKEN_BYTES = 32
@dataclass(frozen=True)
class Session:
token: str
user_id: uuid.UUID
sid: str # public session id (safe to expose; not the token)
created: datetime
ua: str
@dataclass(frozen=True)
class SessionInfo:
sid: str
created: datetime
ua: str
current: bool
def _ttl_seconds() -> int:
return get_settings().session_ttl_days * 24 * 3600
def _index_key(user_id: uuid.UUID | str) -> str:
return f"{_INDEX}{user_id}"
async def create_session(user_id: uuid.UUID, user_agent: str) -> str:
"""Create a session for ``user_id`` and return its opaque token."""
token = secrets.token_urlsafe(_TOKEN_BYTES)
sid = secrets.token_hex(8)
payload = {
"user_id": str(user_id),
"sid": sid,
"created": datetime.now(UTC).isoformat(),
"ua": user_agent[:256],
}
redis = get_redis()
ttl = _ttl_seconds()
await redis.set(_PREFIX + token, json.dumps(payload), ex=ttl)
await aw(redis.hset(_index_key(user_id), sid, token))
await redis.expire(_index_key(user_id), ttl)
return token
async def resolve_session(token: str) -> Session | None:
"""Resolve ``token`` to a :class:`Session`, refreshing its sliding TTL."""
if not token:
return None
redis = get_redis()
raw = await redis.get(_PREFIX + token)
if raw is None:
return None
ttl = _ttl_seconds()
data = json.loads(raw)
await redis.expire(_PREFIX + token, ttl) # sliding
await redis.expire(_index_key(data["user_id"]), ttl)
return Session(
token=token,
user_id=uuid.UUID(data["user_id"]),
sid=data.get("sid", ""),
created=datetime.fromisoformat(data["created"]),
ua=data.get("ua", ""),
)
async def destroy_session(token: str) -> None:
"""Delete a session (logout)."""
if not token:
return
redis = get_redis()
raw = await redis.get(_PREFIX + token)
await redis.delete(_PREFIX + token)
if raw is not None:
data = json.loads(raw)
await aw(redis.hdel(_index_key(data["user_id"]), data.get("sid", "")))
async def list_sessions(user_id: uuid.UUID, current_token: str) -> list[SessionInfo]:
"""List a user's active sessions, pruning any whose token has expired."""
redis = get_redis()
mapping: dict[str, str] = await aw(redis.hgetall(_index_key(user_id)))
out: list[SessionInfo] = []
for sid, token in mapping.items():
raw = await redis.get(_PREFIX + token)
if raw is None:
await aw(redis.hdel(_index_key(user_id), sid)) # prune stale index entry
continue
data = json.loads(raw)
out.append(
SessionInfo(
sid=sid,
created=datetime.fromisoformat(data["created"]),
ua=data.get("ua", ""),
current=(token == current_token),
)
)
out.sort(key=lambda s: s.created, reverse=True)
return out
async def revoke_session(user_id: uuid.UUID, sid: str) -> bool:
"""Revoke a specific session by its public sid. Returns False if not found."""
redis = get_redis()
token = await aw(redis.hget(_index_key(user_id), sid))
if token is None:
return False
await redis.delete(_PREFIX + token)
await aw(redis.hdel(_index_key(user_id), sid))
return True
async def destroy_all_sessions(user_id: uuid.UUID) -> None:
"""Revoke every session for a user (e.g. after a password reset)."""
redis = get_redis()
mapping: dict[str, str] = await aw(redis.hgetall(_index_key(user_id)))
for token in mapping.values():
await redis.delete(_PREFIX + token)
await redis.delete(_index_key(user_id))
+59
View File
@@ -0,0 +1,59 @@
"""TOTP two-factor authentication (SPEC §6.1).
The base32 secret is stored envelope-encrypted in ``users.totp_secret``. A pending
(not-yet-enabled) secret lives in Redis until the user confirms one valid code.
"""
from __future__ import annotations
import uuid
import pyotp
from app.config import get_settings
from app.redis_client import get_redis
from app.security.crypto import decrypt_secret, encrypt_secret
_PENDING_PREFIX = "totp_pending:"
_PENDING_TTL = 600 # 10 minutes to complete enrolment
_VALID_WINDOW = 1 # ±1 step tolerance (SPEC §6.1)
def generate_secret() -> str:
"""Return a fresh base32 TOTP secret."""
return pyotp.random_base32()
def provisioning_uri(secret: str, handle: str) -> str:
"""Return the otpauth:// URI for QR provisioning."""
return pyotp.TOTP(secret).provisioning_uri(name=handle, issuer_name=get_settings().app_domain)
def verify(secret: str, code: str) -> bool:
"""Verify a TOTP ``code`` against ``secret`` (±1 window)."""
return bool(pyotp.TOTP(secret).verify(code.strip(), valid_window=_VALID_WINDOW))
async def start_setup(user_id: uuid.UUID) -> str:
"""Generate a pending secret (stored in Redis) and return it."""
secret = generate_secret()
await get_redis().set(_PENDING_PREFIX + str(user_id), secret, ex=_PENDING_TTL)
return secret
async def get_pending(user_id: uuid.UUID) -> str | None:
"""Return the pending (un-enabled) secret for a user, if any."""
secret: str | None = await get_redis().get(_PENDING_PREFIX + str(user_id))
return secret
async def clear_pending(user_id: uuid.UUID) -> None:
await get_redis().delete(_PENDING_PREFIX + str(user_id))
def encrypt_totp_secret(secret: str) -> bytes:
return encrypt_secret(secret.encode("utf-8"))
def decrypt_totp_secret(blob: bytes) -> str:
return decrypt_secret(blob).decode("utf-8")
+1
View File
@@ -0,0 +1 @@
"""Indexer: MIME parsing, sanitization, blob storage, DB rows (SPEC §6.4)."""
+55
View File
@@ -0,0 +1,55 @@
"""Content-addressed blob store (SPEC §4 "Blob store", §6.4).
Layout: ``${BLOB_ROOT}/{user_id}/{sha256[:2]}/{sha256}`` (filesystem default).
Content-addressed, so identical payloads dedupe. Postgres holds metadata only;
deleting a user removes their blob subtree.
"""
from __future__ import annotations
import hashlib
import uuid
from pathlib import Path
from app.config import get_settings
def _root() -> Path:
return Path(get_settings().blob_root)
def sha256_hex(data: bytes) -> str:
"""Return the hex sha256 digest of ``data``."""
return hashlib.sha256(data).hexdigest()
def blob_path(user_id: uuid.UUID, sha: str) -> Path:
"""Return the on-disk path for a user's blob (does not create it)."""
return _root() / str(user_id) / sha[:2] / sha
def store(user_id: uuid.UUID, data: bytes) -> str:
"""Store ``data`` content-addressed; return its sha256. Idempotent (dedupes)."""
sha = sha256_hex(data)
path = blob_path(user_id, sha)
if not path.exists():
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_suffix(".tmp")
tmp.write_bytes(data)
tmp.replace(path) # atomic within the same directory
return sha
def load(user_id: uuid.UUID, sha: str) -> bytes:
"""Load a blob by sha256. Raises FileNotFoundError if absent."""
return blob_path(user_id, sha).read_bytes()
def exists(user_id: uuid.UUID, sha: str) -> bool:
"""Return True if the blob is present."""
return blob_path(user_id, sha).exists()
def remove(user_id: uuid.UUID, sha: str) -> None:
"""Delete a blob if present (used by account/user deletion). Idempotent."""
blob_path(user_id, sha).unlink(missing_ok=True)
+202
View File
@@ -0,0 +1,202 @@
"""Indexer orchestration (SPEC §6.4).
Turns a :class:`RemoteMessage` into persisted rows: raw blob (deduped), parsed
MIME, sanitized HTML, ``messages``/``body_parts``/``attachments`` rows, JWZ
threading, and a ``known_recipients`` upsert. Returns a :class:`SearchDoc` for
the caller to index *after commit* (FR-30 inline indexing) and to drive the
``message.new`` event. Idempotent on ``(mailbox_id, uid)``.
"""
from __future__ import annotations
import uuid
from dataclasses import dataclass
from datetime import UTC, datetime
from sqlalchemy import select
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.ext.asyncio import AsyncSession
from app.indexer import blobstore
from app.indexer.mime import ParsedEmail, parse_message
from app.indexer.sanitize import sanitize_html
from app.models import Attachment, BodyPart, KnownRecipient, Message
from app.providers.base import RemoteMessage
from app.search.base import SearchDoc
from app.threading_.normalize import synthesize_message_id
from app.threading_.threader import thread_message
_NO_RECIPIENT_HARVEST = {"junk", "sent", "drafts", "trash"}
@dataclass
class PersistResult:
message: Message
search_doc: SearchDoc
has_remote_images: bool
def _epoch(value: datetime) -> int:
return int(value.timestamp())
def _build_search_doc(message: Message, parsed: ParsedEmail, special_use: str | None) -> SearchDoc:
return SearchDoc(
message_id=message.id,
thread_id=message.thread_id or message.id,
user_id=message.user_id,
account_id=message.account_id,
mailbox_special_use=special_use,
from_=parsed.from_addr,
to=parsed.to_addr,
cc=parsed.cc_addr,
subject=parsed.subject,
body_text=parsed.text_body,
attachment_names=[a.filename for a in parsed.attachments],
has_attachments=parsed.has_attachments,
is_unread="\\Seen" not in message.flags,
is_starred="\\Flagged" in message.flags,
date=_epoch(message.internal_date),
size_bytes=message.size_bytes,
)
async def _harvest_recipient(session: AsyncSession, message: Message, parsed: ParsedEmail) -> None:
"""Upsert the From address into known_recipients (weight 1) (SPEC §6.4)."""
if not parsed.from_addr:
return
sender = parsed.from_addr[0]
email = (sender.get("email") or "").lower()
if not email:
return
stmt = pg_insert(KnownRecipient).values(
user_id=message.user_id,
email=email,
name=sender.get("name", ""),
times_used=1,
last_used_at=message.internal_date,
)
stmt = stmt.on_conflict_do_update(
index_elements=[KnownRecipient.user_id, KnownRecipient.email],
set_={
"times_used": KnownRecipient.times_used + 1,
"last_used_at": stmt.excluded.last_used_at,
"name": stmt.excluded.name,
},
)
await session.execute(stmt)
async def _resolve_message_id(
session: AsyncSession,
*,
user_id: uuid.UUID,
account_id: uuid.UUID,
parsed_id: str | None,
raw: bytes,
) -> str:
"""Resolve the Message-ID to store, applying SPEC §7.1 duplicate handling.
Missing id -> synthesize. A *duplicate* id already seen in a **different
account** is a separate delivery (e.g. a bulk sender that reuses one
Message-ID for every recipient) -> synthesize a non-authoritative id so the
two do not merge. A same-account duplicate (e.g. Gmail INBOX + All Mail
holding one message) keeps the real id so the copies stay in one thread.
"""
if parsed_id is None:
return synthesize_message_id(raw)
other_account = await session.scalar(
select(Message.account_id)
.where(Message.user_id == user_id, Message.message_id_hdr == parsed_id)
.limit(1)
)
if other_account is not None and other_account != account_id:
return synthesize_message_id(raw)
return parsed_id
async def persist_message(
session: AsyncSession,
*,
user_id: uuid.UUID,
account_id: uuid.UUID,
mailbox_id: uuid.UUID,
mailbox_special_use: str | None,
remote: RemoteMessage,
) -> PersistResult | None:
"""Persist one message. Returns None if it already exists (idempotent)."""
existing = await session.scalar(
select(Message.id).where(Message.mailbox_id == mailbox_id, Message.uid == remote.uid)
)
if existing is not None:
return None
sha = blobstore.store(user_id, remote.raw)
parsed = parse_message(remote.raw)
message_id_hdr = await _resolve_message_id(
session, user_id=user_id, account_id=account_id, parsed_id=parsed.message_id, raw=remote.raw
)
message = Message(
id=uuid.uuid4(),
user_id=user_id,
account_id=account_id,
mailbox_id=mailbox_id,
uid=remote.uid,
message_id_hdr=message_id_hdr,
in_reply_to=parsed.in_reply_to,
references_hdr=parsed.references,
subject=parsed.subject,
from_addr=parsed.from_addr,
to_addr=parsed.to_addr,
cc_addr=parsed.cc_addr,
date_hdr=parsed.date,
internal_date=remote.internal_date or datetime.now(UTC),
flags=sorted(remote.flags),
size_bytes=len(remote.raw),
has_attachments=parsed.has_attachments,
snippet=parsed.snippet,
blob_sha256=sha,
)
session.add(message)
await session.flush()
# Body parts: plain text (for indexing) + sanitized HTML (for display).
has_remote_images = False
if parsed.text_body:
session.add(BodyPart(message_id=message.id, kind="text", content=parsed.text_body))
if parsed.html_body is not None:
result = sanitize_html(parsed.html_body, message.id)
has_remote_images = result.has_remote_images
session.add(
BodyPart(
message_id=message.id,
kind="html_sanitized",
content=result.html,
sanitizer_v=result.sanitizer_v,
)
)
for att in parsed.attachments:
att_sha = blobstore.store(user_id, att.payload)
session.add(
Attachment(
message_id=message.id,
user_id=user_id,
filename=att.filename,
mime_type=att.mime_type,
size_bytes=len(att.payload),
content_id=att.content_id,
blob_sha256=att_sha,
)
)
message.has_remote_images = has_remote_images
await thread_message(session, message)
if mailbox_special_use not in _NO_RECIPIENT_HARVEST:
await _harvest_recipient(session, message, parsed)
await session.flush()
doc = _build_search_doc(message, parsed, mailbox_special_use)
return PersistResult(message=message, search_doc=doc, has_remote_images=has_remote_images)
+147
View File
@@ -0,0 +1,147 @@
"""MIME parsing via the stdlib ``email`` package with ``policy.default`` (SPEC §6.4).
Primary parsing is stdlib per SPEC §3; ``mail-parser`` is available for helpers only.
Header RFC2047 decoding is automatic under ``policy.default``.
"""
from __future__ import annotations
import email
from dataclasses import dataclass, field
from datetime import datetime
from email.headerregistry import AddressHeader
from email.message import EmailMessage
from email.policy import default as default_policy
import nh3
from app.threading_.normalize import normalize_message_id, normalize_reference_list
_SNIPPET_LEN = 160
@dataclass(frozen=True)
class ParsedAttachment:
filename: str
mime_type: str
content_id: str | None
payload: bytes
@dataclass
class ParsedEmail:
message_id: str | None
in_reply_to: str | None
references: list[str] # References + In-Reply-To, normalized, in order (§6.4)
subject: str
from_addr: list[dict[str, str]]
to_addr: list[dict[str, str]]
cc_addr: list[dict[str, str]]
date: datetime | None
text_body: str
html_body: str | None
attachments: list[ParsedAttachment] = field(default_factory=list)
@property
def has_attachments(self) -> bool:
return len(self.attachments) > 0
@property
def snippet(self) -> str:
return " ".join(self.text_body.split())[:_SNIPPET_LEN]
def _addresses(msg: EmailMessage, header: str) -> list[dict[str, str]]:
value = msg[header]
if value is None:
return []
out: list[dict[str, str]] = []
if isinstance(value, AddressHeader):
for addr in value.addresses:
if addr.addr_spec:
out.append({"name": str(addr.display_name or ""), "email": addr.addr_spec})
return out
def _html_to_text(html: str) -> str:
"""Derive plain text from HTML by stripping all tags (SPEC §6.4 nh3 strip)."""
return nh3.clean(html, tags=set(), attributes={}, strip_comments=True)
def _get_body_content(msg: EmailMessage, subtype: str) -> str | None:
part = msg.get_body(preferencelist=(subtype,))
if part is None:
return None
try:
content = part.get_content()
except (LookupError, ValueError, KeyError):
payload = part.get_payload(decode=True)
if not isinstance(payload, bytes):
return None
content = payload.decode("utf-8", "replace")
return content if isinstance(content, str) else None
def _extract_attachments(msg: EmailMessage) -> list[ParsedAttachment]:
out: list[ParsedAttachment] = []
for part in msg.iter_attachments():
if not isinstance(part, EmailMessage):
continue
payload = part.get_payload(decode=True)
if not isinstance(payload, bytes):
continue
cid = part.get("Content-ID")
out.append(
ParsedAttachment(
filename=part.get_filename() or "attachment",
mime_type=part.get_content_type(),
content_id=cid.strip("<>") if cid else None,
payload=payload,
)
)
return out
def parse_message(raw: bytes) -> ParsedEmail:
"""Parse a raw RFC822 message into a :class:`ParsedEmail`."""
msg = email.message_from_bytes(raw, policy=default_policy)
assert isinstance(msg, EmailMessage)
message_id = normalize_message_id(msg.get("Message-ID"))
in_reply_to_ids = normalize_reference_list(msg.get("In-Reply-To"))
in_reply_to = in_reply_to_ids[0] if in_reply_to_ids else None
# references_hdr = References then In-Reply-To message-ids, in order, deduped (§6.4).
references: list[str] = []
for ref in normalize_reference_list(msg.get("References")) + in_reply_to_ids:
if ref not in references:
references.append(ref)
subject = str(msg.get("Subject", "") or "")
date: datetime | None = None
date_header = msg["Date"]
if date_header is not None:
try:
date = date_header.datetime
except (AttributeError, ValueError):
date = None
html_body = _get_body_content(msg, "html")
text_body = _get_body_content(msg, "plain")
if text_body is None and html_body is not None:
text_body = _html_to_text(html_body)
return ParsedEmail(
message_id=message_id,
in_reply_to=in_reply_to,
references=references,
subject=subject,
from_addr=_addresses(msg, "From"),
to_addr=_addresses(msg, "To"),
cc_addr=_addresses(msg, "Cc"),
date=date,
text_body=text_body or "",
html_body=html_body,
attachments=_extract_attachments(msg),
)
+195
View File
@@ -0,0 +1,195 @@
"""HTML sanitization with nh3 (SPEC §6.4, §10.4, invariant I-8).
Mail HTML is hostile: sanitized server-side at index time with a broad-but-safe
allowlist (structural + presentational tags, filtered inline CSS), and rendered
inside a sandboxed iframe. ``cid:`` images are rewritten to the per-message
endpoint; remote images (and CSS ``url()``) are blocked by default and flagged so
the UI can offer "load images" (SPEC §9.4).
"""
from __future__ import annotations
import re
import uuid
from dataclasses import dataclass
import nh3
# Bump when the sanitization policy changes; body_parts.sanitizer_v records it so a
# render re-sanitizes stale content from the raw blob (SPEC §10.4).
SANITIZER_VERSION = 2
_ALLOWED_TAGS: set[str] = {
# structure
"a",
"abbr",
"address",
"article",
"aside",
"b",
"bdi",
"bdo",
"blockquote",
"br",
"caption",
"center",
"cite",
"code",
"col",
"colgroup",
"dd",
"del",
"details",
"dfn",
"div",
"dl",
"dt",
"em",
"figcaption",
"figure",
"font",
"footer",
"h1",
"h2",
"h3",
"h4",
"h5",
"h6",
"header",
"hr",
"i",
"img",
"ins",
"kbd",
"li",
"main",
"mark",
"nav",
"ol",
"p",
"pre",
"q",
"s",
"samp",
"section",
"small",
"span",
"strike",
"strong",
"sub",
"summary",
"sup",
"table",
"tbody",
"td",
"tfoot",
"th",
"thead",
"time",
"tr",
"tt",
"u",
"ul",
"var",
"wbr",
}
# Attributes allowed on any tag (presentational email formatting).
_GENERIC_ATTRS: set[str] = {
"style",
"align",
"valign",
"dir",
"title",
"width",
"height",
"bgcolor",
"color",
"face",
"size",
"span",
"nowrap",
"lang",
}
_TAG_SPECIFIC: dict[str, set[str]] = {
"a": {"href", "target"},
"img": {"src", "alt"},
"td": {"colspan", "rowspan"},
"th": {"colspan", "rowspan", "scope"},
"table": {"cellpadding", "cellspacing", "border"},
"font": {"face", "color", "size"},
}
# nh3 has no "generic attributes" option, so expand the generic set onto every tag.
_ALLOWED_ATTRS: dict[str, set[str]] = {
tag: set(_GENERIC_ATTRS) | _TAG_SPECIFIC.get(tag, set()) for tag in _ALLOWED_TAGS
}
_URL_SCHEMES: set[str] = {"http", "https", "mailto", "cid", "data"}
_STYLE_DANGEROUS = re.compile(
r"(expression\s*\(|javascript:|vbscript:|@import|-moz-binding|behavior\s*:)", re.IGNORECASE
)
_STYLE_URL = re.compile(r"url\s*\([^)]*\)", re.IGNORECASE)
@dataclass(frozen=True)
class SanitizeResult:
html: str
has_remote_images: bool
sanitizer_v: int
def _sanitize_style(value: str, allow_remote: bool) -> str | None:
if _STYLE_DANGEROUS.search(value):
return None # drop the whole style rather than try to repair it
if not allow_remote and _STYLE_URL.search(value):
# Strip CSS url() so backgrounds can't smuggle in remote/tracking images.
return _STYLE_URL.sub("none", value)
return value
def sanitize_html(
html: str, message_id: uuid.UUID, *, allow_remote_images: bool = False
) -> SanitizeResult:
"""Sanitize ``html`` for the message ``message_id``.
Returns the safe HTML, whether any remote images were blocked, and the
sanitizer version. Pass ``allow_remote_images=True`` after the user approves
a sender to render remote images and CSS backgrounds (SPEC §9.4).
"""
state = {"has_remote": False}
def attribute_filter(tag: str, attr: str, value: str) -> str | None:
if attr == "style":
return _sanitize_style(value, allow_remote_images)
if tag == "a" and attr == "target":
return "_blank"
if tag == "img" and attr == "src":
lowered = value.strip().lower()
if lowered.startswith("cid:"):
content_id = value.strip()[4:]
return f"/api/messages/{message_id}/cid/{content_id}"
if lowered.startswith(("http:", "https:")):
state["has_remote"] = True
return value if allow_remote_images else None # block by default (FR-23)
if lowered.startswith("data:image/"):
return value
return None
return value
cleaned = nh3.clean(
html,
tags=_ALLOWED_TAGS,
attributes=_ALLOWED_ATTRS,
url_schemes=_URL_SCHEMES,
link_rel="noopener noreferrer nofollow",
strip_comments=True,
attribute_filter=attribute_filter,
)
return SanitizeResult(
html=cleaned,
has_remote_images=state["has_remote"],
sanitizer_v=SANITIZER_VERSION,
)
+45
View File
@@ -0,0 +1,45 @@
"""FastAPI application factory (SPEC §2 ``app/main.py``)."""
from __future__ import annotations
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from fastapi import FastAPI
from sqlalchemy import text
from app.config import get_settings
from app.db import dispose_engine, get_engine
from app.redis_client import close_redis, get_redis
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
"""Warm shared resources on startup, dispose them on shutdown."""
# Touch settings early so misconfiguration fails fast.
get_settings()
yield
await close_redis()
await dispose_engine()
def create_app() -> FastAPI:
"""Construct the FastAPI application."""
app = FastAPI(title="Webmail", version="0.1.0", lifespan=lifespan)
from app.api.errors import register_error_handlers
register_error_handlers(app)
@app.get("/api/healthz")
async def healthz() -> dict[str, str]:
"""Liveness/readiness probe: checks DB and Redis (dev-setup §6)."""
async with get_engine().connect() as conn:
await conn.execute(text("SELECT 1"))
await get_redis().ping()
return {"status": "ok"}
from app.api import register_routers
register_routers(app)
return app
+296
View File
@@ -0,0 +1,296 @@
"""SQLAlchemy ORM models mirroring the authoritative DDL in SPEC §4.
The Alembic initial migration is generated to match these models exactly.
Invariant I-7: every table carrying user data has an indexed ``user_id``.
"""
from __future__ import annotations
import datetime as dt
import uuid
from sqlalchemy import (
ForeignKey,
Index,
Integer,
Text,
UniqueConstraint,
text,
)
from sqlalchemy.dialects.postgresql import (
ARRAY,
BYTEA,
CITEXT,
JSONB,
TIMESTAMP,
TSVECTOR,
UUID,
)
from sqlalchemy.orm import Mapped, mapped_column
from app.db import Base
def _uuid_pk() -> Mapped[uuid.UUID]:
return mapped_column(
UUID(as_uuid=True),
primary_key=True,
server_default=text("gen_random_uuid()"),
)
def _now() -> Mapped[dt.datetime]:
return mapped_column(TIMESTAMP(timezone=True), nullable=False, server_default=text("now()"))
class User(Base):
__tablename__ = "users"
id: Mapped[uuid.UUID] = _uuid_pk()
handle: Mapped[str] = mapped_column(CITEXT, unique=True, nullable=False)
password_hash: Mapped[str] = mapped_column(Text, nullable=False)
totp_secret: Mapped[bytes | None] = mapped_column(BYTEA)
recovery_email: Mapped[str | None] = mapped_column(CITEXT)
settings: Mapped[dict[str, object]] = mapped_column(
JSONB, nullable=False, server_default=text("'{}'")
)
created_at: Mapped[dt.datetime] = _now()
class Account(Base):
__tablename__ = "accounts"
__table_args__ = (UniqueConstraint("user_id", "email", name="uq_accounts_user_email"),)
id: Mapped[uuid.UUID] = _uuid_pk()
user_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
)
email: Mapped[str] = mapped_column(CITEXT, nullable=False)
display_name: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("''"))
color: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("'#888888'"))
imap_config: Mapped[dict[str, object]] = mapped_column(JSONB, nullable=False)
smtp_config: Mapped[dict[str, object]] = mapped_column(JSONB, nullable=False)
secret_ct: Mapped[bytes] = mapped_column(BYTEA, nullable=False)
discovery_src: Mapped[str] = mapped_column(Text, nullable=False)
state: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("'backfilling'"))
state_detail: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("''"))
last_sync_at: Mapped[dt.datetime | None] = mapped_column(TIMESTAMP(timezone=True))
created_at: Mapped[dt.datetime] = _now()
class Mailbox(Base):
__tablename__ = "mailboxes"
__table_args__ = (UniqueConstraint("account_id", "path", name="uq_mailboxes_account_path"),)
id: Mapped[uuid.UUID] = _uuid_pk()
account_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("accounts.id", ondelete="CASCADE"), nullable=False
)
user_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False
)
path: Mapped[str] = mapped_column(Text, nullable=False)
special_use: Mapped[str | None] = mapped_column(Text)
uidvalidity: Mapped[int] = mapped_column(Integer, nullable=False, server_default=text("0"))
highestmodseq: Mapped[int | None] = mapped_column(Integer)
last_seen_uid: Mapped[int] = mapped_column(Integer, nullable=False, server_default=text("0"))
class Thread(Base):
__tablename__ = "threads"
__table_args__ = (Index("ix_threads_user_last", "user_id", text("last_message_at DESC")),)
id: Mapped[uuid.UUID] = _uuid_pk()
user_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False
)
subject_norm: Mapped[str] = mapped_column(Text, nullable=False)
snippet: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("''"))
participants: Mapped[list[dict[str, str]]] = mapped_column(
JSONB, nullable=False, server_default=text("'[]'")
)
account_ids: Mapped[list[uuid.UUID]] = mapped_column(
ARRAY(UUID(as_uuid=True)), nullable=False, server_default=text("'{}'")
)
message_count: Mapped[int] = mapped_column(Integer, nullable=False, server_default=text("0"))
unread_count: Mapped[int] = mapped_column(Integer, nullable=False, server_default=text("0"))
has_attachments: Mapped[bool] = mapped_column(nullable=False, server_default=text("false"))
pinned: Mapped[bool] = mapped_column(nullable=False, server_default=text("false"))
last_message_at: Mapped[dt.datetime] = mapped_column(TIMESTAMP(timezone=True), nullable=False)
class Message(Base):
__tablename__ = "messages"
__table_args__ = (
UniqueConstraint("mailbox_id", "uid", name="uq_messages_mailbox_uid"),
Index("ix_messages_user_thread", "user_id", "thread_id"),
Index("ix_messages_account_msgid", "account_id", "message_id_hdr"),
Index("ix_messages_user_msgid", "user_id", "message_id_hdr"),
Index("ix_messages_body_indexed", "body_indexed", postgresql_using="gin"),
)
id: Mapped[uuid.UUID] = _uuid_pk()
user_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False
)
account_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("accounts.id", ondelete="CASCADE"), nullable=False
)
mailbox_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("mailboxes.id", ondelete="CASCADE"), nullable=False
)
uid: Mapped[int] = mapped_column(Integer, nullable=False)
thread_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("threads.id")
)
message_id_hdr: Mapped[str | None] = mapped_column(Text)
in_reply_to: Mapped[str | None] = mapped_column(Text)
references_hdr: Mapped[list[str]] = mapped_column(
ARRAY(Text), nullable=False, server_default=text("'{}'")
)
subject: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("''"))
from_addr: Mapped[list[dict[str, str]]] = mapped_column(
JSONB, nullable=False, server_default=text("'[]'")
)
to_addr: Mapped[list[dict[str, str]]] = mapped_column(
JSONB, nullable=False, server_default=text("'[]'")
)
cc_addr: Mapped[list[dict[str, str]]] = mapped_column(
JSONB, nullable=False, server_default=text("'[]'")
)
date_hdr: Mapped[dt.datetime | None] = mapped_column(TIMESTAMP(timezone=True))
internal_date: Mapped[dt.datetime] = mapped_column(TIMESTAMP(timezone=True), nullable=False)
flags: Mapped[list[str]] = mapped_column(
ARRAY(Text), nullable=False, server_default=text("'{}'")
)
size_bytes: Mapped[int] = mapped_column(Integer, nullable=False, server_default=text("0"))
has_attachments: Mapped[bool] = mapped_column(nullable=False, server_default=text("false"))
has_remote_images: Mapped[bool] = mapped_column(nullable=False, server_default=text("false"))
snippet: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("''"))
blob_sha256: Mapped[str] = mapped_column(Text, nullable=False)
body_indexed: Mapped[str | None] = mapped_column(TSVECTOR)
class BodyPart(Base):
__tablename__ = "body_parts"
id: Mapped[uuid.UUID] = _uuid_pk()
message_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("messages.id", ondelete="CASCADE"), nullable=False
)
kind: Mapped[str] = mapped_column(Text, nullable=False) # text | html_sanitized
content: Mapped[str] = mapped_column(Text, nullable=False)
# SPEC §10.4: re-sanitize on render if the sanitizer version changed.
sanitizer_v: Mapped[int] = mapped_column(Integer, nullable=False, server_default=text("0"))
class Attachment(Base):
__tablename__ = "attachments"
id: Mapped[uuid.UUID] = _uuid_pk()
message_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("messages.id", ondelete="CASCADE"), nullable=False
)
user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), nullable=False)
filename: Mapped[str] = mapped_column(Text, nullable=False)
mime_type: Mapped[str] = mapped_column(Text, nullable=False)
size_bytes: Mapped[int] = mapped_column(Integer, nullable=False)
content_id: Mapped[str | None] = mapped_column(Text)
blob_sha256: Mapped[str] = mapped_column(Text, nullable=False)
class ComposeAttachment(Base):
"""A file uploaded for an outgoing message, referenced by a draft's payload."""
__tablename__ = "compose_attachments"
id: Mapped[uuid.UUID] = _uuid_pk()
user_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False
)
filename: Mapped[str] = mapped_column(Text, nullable=False)
mime_type: Mapped[str] = mapped_column(Text, nullable=False)
size_bytes: Mapped[int] = mapped_column(Integer, nullable=False)
blob_sha256: Mapped[str] = mapped_column(Text, nullable=False)
created_at: Mapped[dt.datetime] = _now()
class Draft(Base):
__tablename__ = "drafts"
id: Mapped[uuid.UUID] = _uuid_pk()
user_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False
)
account_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("accounts.id", ondelete="CASCADE"), nullable=False
)
thread_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("threads.id")
)
reply_to_message_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("messages.id")
)
kind: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("'reply'"))
payload: Mapped[dict[str, object]] = mapped_column(JSONB, nullable=False)
updated_at: Mapped[dt.datetime] = _now()
class Outbox(Base):
__tablename__ = "outbox"
id: Mapped[uuid.UUID] = _uuid_pk()
user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), nullable=False)
draft_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), nullable=False)
state: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("'undo_window'"))
send_after: Mapped[dt.datetime] = mapped_column(TIMESTAMP(timezone=True), nullable=False)
error: Mapped[str | None] = mapped_column(Text)
class SavedSearch(Base):
__tablename__ = "saved_searches"
__table_args__ = (UniqueConstraint("user_id", "name", name="uq_saved_searches_user_name"),)
id: Mapped[uuid.UUID] = _uuid_pk()
user_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False
)
name: Mapped[str] = mapped_column(Text, nullable=False)
query: Mapped[str] = mapped_column(Text, nullable=False)
pinned: Mapped[bool] = mapped_column(nullable=False, server_default=text("true"))
class KnownRecipient(Base):
__tablename__ = "known_recipients"
__table_args__ = (
Index(
"ix_known_recipients_user_email",
"user_id",
text("email text_pattern_ops"),
),
)
user_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("users.id", ondelete="CASCADE"),
primary_key=True,
)
email: Mapped[str] = mapped_column(CITEXT, primary_key=True)
name: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("''"))
times_used: Mapped[int] = mapped_column(Integer, nullable=False, server_default=text("0"))
last_used_at: Mapped[dt.datetime] = mapped_column(TIMESTAMP(timezone=True), nullable=False)
__all__ = [
"Account",
"Attachment",
"BodyPart",
"Draft",
"KnownRecipient",
"Mailbox",
"Message",
"Outbox",
"SavedSearch",
"Thread",
"User",
]
+1
View File
@@ -0,0 +1 @@
"""Outbox: drafts, MIME assembly, send queue, undo-send (SPEC §6.5)."""
+161
View File
@@ -0,0 +1,161 @@
"""The scheduled send job (SPEC §6.5).
Fires ``UNDO_SECONDS`` after the user hits send. If the outbox row was cancelled
in the meantime, it does nothing (no SMTP traffic). Otherwise: SMTP send, append
to Sent (skipped for Gmail, which self-appends), harvest recipients into
``known_recipients`` (weight 3), and publish ``message.sent``.
"""
from __future__ import annotations
import logging
import uuid
from datetime import UTC, datetime
from typing import Any
from sqlalchemy import select
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.ext.asyncio import AsyncSession
from app.db import session_scope
from app.events import get_event_bus
from app.indexer import blobstore
from app.models import (
Account,
ComposeAttachment,
Draft,
KnownRecipient,
Mailbox,
Message,
Outbox,
)
from app.outbox.mime_build import build_mime
from app.sync.provider_factory import build_provider
logger = logging.getLogger(__name__)
def _is_gmail(account: Account) -> bool:
host = str(account.imap_config.get("host", "")).lower()
return "gmail.com" in host or "googlemail.com" in host
async def _harvest_recipients(session: AsyncSession, user_id: uuid.UUID, emails: list[str]) -> None:
now = datetime.now(UTC)
for email in {e.lower() for e in emails if e}:
stmt = pg_insert(KnownRecipient).values(
user_id=user_id, email=email, name="", times_used=3, last_used_at=now
)
stmt = stmt.on_conflict_do_update(
index_elements=[KnownRecipient.user_id, KnownRecipient.email],
set_={
"times_used": KnownRecipient.times_used + 3, # sent-to weight (SPEC §4)
"last_used_at": stmt.excluded.last_used_at,
},
)
await session.execute(stmt)
async def _load_attachments(
session: AsyncSession, user_id: uuid.UUID, draft: Draft
) -> list[tuple[str, str, bytes]]:
"""Resolve a draft's compose-attachment ids into (filename, mime, data) tuples."""
raw_ids = draft.payload.get("attachment_ids") or []
if not isinstance(raw_ids, list) or not raw_ids:
return []
ids: list[uuid.UUID] = []
for value in raw_ids:
try:
ids.append(uuid.UUID(str(value)))
except ValueError:
continue
if not ids:
return []
rows = await session.scalars(
select(ComposeAttachment).where(
ComposeAttachment.id.in_(ids), ComposeAttachment.user_id == user_id
)
)
by_id = {r.id: r for r in rows}
out: list[tuple[str, str, bytes]] = []
for i in ids:
att = by_id.get(i)
if att is not None:
out.append((att.filename, att.mime_type, blobstore.load(user_id, att.blob_sha256)))
return out
async def send_job(ctx: dict[str, Any], outbox_id: str) -> None:
"""Send a queued message unless it was cancelled during the undo window."""
oid = uuid.UUID(outbox_id)
async with session_scope() as session:
outbox = await session.get(Outbox, oid)
if outbox is None or outbox.state != "undo_window":
return # cancelled, or already handled -> no SMTP traffic
outbox.state = "sending"
draft = await session.get(Draft, outbox.draft_id)
if draft is None:
outbox.state = "failed"
outbox.error = "draft missing"
return
account = await session.get(Account, draft.account_id)
reply_to = (
await session.get(Message, draft.reply_to_message_id)
if draft.reply_to_message_id
else None
)
if account is None:
outbox.state = "failed"
outbox.error = "account missing"
return
user_id = outbox.user_id
attachments = await _load_attachments(session, user_id, draft)
raw, envelope_from, envelope_to = build_mime(account, draft, reply_to, attachments)
sent_path = await session.scalar(
select(Mailbox.path).where(
Mailbox.account_id == account.id, Mailbox.special_use == "sent"
)
)
gmail = _is_gmail(account)
account_obj = account
provider = build_provider(account_obj)
try:
await provider.send(raw, envelope_from, envelope_to)
if sent_path and not gmail:
await provider.append(sent_path, raw, frozenset({"\\Seen"}))
except Exception as exc: # noqa: BLE001 - record and notify, keep the draft
async with session_scope() as session:
ob = await session.get(Outbox, oid)
if ob is not None:
ob.state = "failed"
ob.error = str(exc)[:500]
await get_event_bus().publish(
"outbox.state_changed",
{"user_id": str(user_id), "outbox_id": str(oid), "state": "failed"},
)
logger.exception("send failed for outbox %s", oid)
return
finally:
await provider.close()
async with session_scope() as session:
ob = await session.get(Outbox, oid)
if ob is not None:
ob.state = "sent"
# The message left successfully: drop its draft so it no longer shows in
# the Drafts folder (undo/cancel returns earlier, keeping the draft).
draft_row = await session.get(Draft, draft.id)
if draft_row is not None:
await session.delete(draft_row)
await _harvest_recipients(session, user_id, envelope_to)
bus = get_event_bus()
await bus.publish(
"message.sent",
{"user_id": str(user_id), "outbox_id": str(oid), "thread_id": str(draft.thread_id)},
)
await bus.publish(
"outbox.state_changed",
{"user_id": str(user_id), "outbox_id": str(oid), "state": "sent"},
)
+102
View File
@@ -0,0 +1,102 @@
"""Assemble an RFC822 message from a draft (SPEC §6.5).
Builds a multipart/alternative (auto-generated text from HTML), sets threading
headers from the message being replied to, and returns the raw bytes plus the
SMTP envelope. Attachment assembly is deferred to M2 (attachment upload).
"""
from __future__ import annotations
from datetime import UTC, datetime
from email.message import EmailMessage
from email.utils import format_datetime, make_msgid
import nh3
from app.models import Account, Draft, Message
def _html_to_text(html: str) -> str:
return nh3.clean(html, tags=set(), attributes={}, strip_comments=True)
def _addr_list(values: object) -> list[str]:
if not isinstance(values, list):
return []
out: list[str] = []
for v in values:
if isinstance(v, str) and v.strip():
out.append(v.strip())
elif isinstance(v, dict) and v.get("email"):
name, email = v.get("name", ""), v["email"]
out.append(f"{name} <{email}>" if name else str(email))
return out
def _email_only(formatted: str) -> str:
if "<" in formatted and ">" in formatted:
return formatted.split("<", 1)[1].split(">", 1)[0].strip()
return formatted.strip()
def _bracket(mid: str) -> str:
mid = mid.strip()
return mid if mid.startswith("<") else f"<{mid}>"
def build_mime(
account: Account,
draft: Draft,
reply_to: Message | None,
attachments: list[tuple[str, str, bytes]] | None = None,
) -> tuple[bytes, str, list[str]]:
"""Return ``(raw_rfc822, envelope_from, envelope_to)`` for ``draft``.
``attachments`` is a list of ``(filename, mime_type, data)`` uploaded via the
compose Attach action; each is added as a file attachment part.
"""
payload = draft.payload
to = _addr_list(payload.get("to"))
cc = _addr_list(payload.get("cc"))
bcc = _addr_list(payload.get("bcc"))
subject = str(payload.get("subject", ""))
html = str(payload.get("html", "") or "")
text = str(payload.get("text", "") or "")
msg = EmailMessage()
from_name = account.display_name or ""
msg["From"] = f"{from_name} <{account.email}>" if from_name else account.email
if to:
msg["To"] = ", ".join(to)
if cc:
msg["Cc"] = ", ".join(cc)
msg["Subject"] = subject
msg["Date"] = format_datetime(datetime.now(UTC))
domain = account.email.rsplit("@", 1)[-1]
msg["Message-ID"] = make_msgid(domain=domain)
# Threading headers so the recipient's client threads the reply (SPEC M1).
if reply_to is not None and reply_to.message_id_hdr:
parent = _bracket(reply_to.message_id_hdr)
msg["In-Reply-To"] = parent
refs = [_bracket(r) for r in reply_to.references_hdr] + [parent]
msg["References"] = " ".join(refs)
# Body: text + HTML alternative (auto text when only HTML given).
if html:
msg.set_content(text or _html_to_text(html))
msg.add_alternative(html, subtype="html")
else:
msg.set_content(text)
for filename, mime_type, data in attachments or []:
maintype, _, subtype = mime_type.partition("/")
msg.add_attachment(
data,
maintype=maintype or "application",
subtype=subtype or "octet-stream",
filename=filename,
)
envelope_to = [_email_only(a) for a in (to + cc + bcc)]
return msg.as_bytes(), account.email, envelope_to
+49
View File
@@ -0,0 +1,49 @@
"""Compose/draft/send request & response models (SPEC §9.1)."""
from __future__ import annotations
import uuid
from pydantic import BaseModel
class DraftPayload(BaseModel):
to: list[str] = []
cc: list[str] = []
bcc: list[str] = []
subject: str = ""
html: str = ""
text: str = ""
attachment_ids: list[uuid.UUID] = []
class DraftIn(BaseModel):
account_id: uuid.UUID
thread_id: uuid.UUID | None = None
reply_to_message_id: uuid.UUID | None = None
kind: str = "new" # reply | reply_all | forward | new
payload: DraftPayload
class DraftOut(BaseModel):
id: uuid.UUID
account_id: uuid.UUID
thread_id: uuid.UUID | None
reply_to_message_id: uuid.UUID | None
kind: str
payload: dict[str, object]
updated_at: str
class SendRequest(BaseModel):
draft_id: uuid.UUID
class SendResponse(BaseModel):
outbox_id: uuid.UUID
undo_until: str
class OutboxStateOut(BaseModel):
outbox_id: uuid.UUID
state: str
+76
View File
@@ -0,0 +1,76 @@
"""Draft persistence and the send/undo flow (SPEC §6.5)."""
from __future__ import annotations
import uuid
from datetime import UTC, datetime, timedelta
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import get_settings
from app.models import Account, Draft, Outbox
from app.outbox.schemas import DraftIn
class DraftError(Exception):
"""Draft could not be created/updated (e.g. account not owned)."""
async def upsert_draft(
session: AsyncSession, *, user_id: uuid.UUID, body: DraftIn, draft_id: uuid.UUID | None
) -> Draft:
"""Create or update a draft owned by ``user_id``."""
account = await session.scalar(
select(Account).where(Account.id == body.account_id, Account.user_id == user_id)
)
if account is None:
raise DraftError("account not found")
payload = body.payload.model_dump(mode="json")
if draft_id is None:
draft = Draft(
user_id=user_id,
account_id=body.account_id,
thread_id=body.thread_id,
reply_to_message_id=body.reply_to_message_id,
kind=body.kind,
payload=payload,
)
session.add(draft)
else:
fetched = await session.scalar(
select(Draft).where(Draft.id == draft_id, Draft.user_id == user_id)
)
if fetched is None:
raise DraftError("draft not found")
draft = fetched
draft.account_id = body.account_id
draft.thread_id = body.thread_id
draft.reply_to_message_id = body.reply_to_message_id
draft.kind = body.kind
draft.payload = payload
draft.updated_at = datetime.now(UTC)
await session.flush()
return draft
async def create_outbox(session: AsyncSession, *, user_id: uuid.UUID, draft: Draft) -> Outbox:
"""Create an outbox row in the undo window for a draft."""
send_after = datetime.now(UTC) + timedelta(seconds=get_settings().undo_seconds)
outbox = Outbox(user_id=user_id, draft_id=draft.id, state="undo_window", send_after=send_after)
session.add(outbox)
await session.flush()
return outbox
async def cancel_outbox(session: AsyncSession, *, user_id: uuid.UUID, outbox_id: uuid.UUID) -> bool:
"""Cancel a send if still in the undo window. Returns False if too late/unknown."""
outbox = await session.scalar(
select(Outbox).where(Outbox.id == outbox_id, Outbox.user_id == user_id)
)
if outbox is None or outbox.state != "undo_window":
return False
outbox.state = "cancelled"
await session.flush()
return True
+2
View File
@@ -0,0 +1,2 @@
"""Mail provider abstraction (SPEC §5.1). Nothing above this package may
import IMAP/SMTP libraries — only concrete providers do."""
+111
View File
@@ -0,0 +1,111 @@
# INTERFACE VERSION: 1
"""MailProvider — the frozen provider contract (SPEC §5.1).
This signature is a frozen contract (SPEC instruction 5): implementations may
evolve, signatures may not without bumping the version comment above.
"""
from __future__ import annotations
from collections.abc import AsyncIterator
from dataclasses import dataclass
from datetime import datetime
from typing import Protocol
class ProviderError(Exception):
"""Base class for provider errors; carries a human-readable ``.detail``."""
def __init__(self, detail: str) -> None:
super().__init__(detail)
self.detail = detail
class ProviderAuthError(ProviderError):
"""Authentication failed (bad credentials / token)."""
class ProviderConnectError(ProviderError):
"""Could not connect / TLS / greeting failure."""
class ProviderNotSupported(ProviderError):
"""A capability (e.g. IDLE) is not supported by the server."""
@dataclass(frozen=True)
class RemoteMailbox:
path: str
special_use: str | None # "inbox"|"sent"|"drafts"|"trash"|"junk"|"archive"|None
uidvalidity: int
@dataclass(frozen=True)
class RemoteMessage:
uid: int
raw: bytes # full RFC822
flags: frozenset[str]
internal_date: datetime
@dataclass(frozen=True)
class FlagChange:
uid: int
flags: frozenset[str]
@dataclass(frozen=True)
class MailboxDelta:
new: list[RemoteMessage]
flag_changes: list[FlagChange]
expunged_uids: list[int]
new_uidvalidity: int | None # non-None => UIDVALIDITY changed, caller must full-resync
new_highestmodseq: int | None
class MailProvider(Protocol):
async def verify(self) -> None:
"""Raises ProviderAuthError / ProviderConnectError with a human-readable .detail."""
...
async def list_mailboxes(self) -> list[RemoteMailbox]: ...
def backfill(
self, mailbox: str, *, newest_first: bool = True, batch_size: int = 200
) -> AsyncIterator[list[RemoteMessage]]:
"""Yields batches until the mailbox is exhausted. Headers+body in one pass."""
...
async def poll(
self, mailbox: str, *, last_seen_uid: int, highestmodseq: int | None
) -> MailboxDelta:
"""One reconciliation pass. Uses QRESYNC/CONDSTORE when highestmodseq is not None,
otherwise UID SEARCH + FETCH FLAGS diff."""
...
async def idle(self, mailbox: str) -> None:
"""Blocks until the server signals change or 25 min elapse (re-issue IDLE). May raise
ProviderNotSupported => sync loop falls back to poll() every POLL_INTERVAL (default 120 s).
"""
...
async def push_flags(
self, mailbox: str, uid: int, add: frozenset[str], remove: frozenset[str]
) -> None: ...
async def move(self, uid: int, src: str, dst: str) -> int: # returns new UID
...
async def delete_messages(self, mailbox: str, uids: list[int]) -> None:
"""Permanently delete messages: set \\Deleted then EXPUNGE (SPEC §6.3)."""
...
async def append(
self, mailbox: str, raw: bytes, flags: frozenset[str]
) -> int: # Sent/Drafts upload
...
async def send(
self, raw: bytes, envelope_from: str, envelope_to: list[str]
) -> None: # SMTP submission
...
@@ -0,0 +1,7 @@
"""IMAP+SMTP provider — the only M0-M2 MailProvider implementation (SPEC §5.1)."""
from __future__ import annotations
from app.providers.imap_smtp.provider import ImapSmtpProvider, ProviderConfig
__all__ = ["ImapSmtpProvider", "ProviderConfig"]
+165
View File
@@ -0,0 +1,165 @@
"""Pure parsing helpers for IMAP responses (unit-testable, no I/O).
aioimaplib returns responses as lists of ``bytes``/``bytearray`` lines. A FETCH
with ``BODY[]`` yields, per message, a header line ending ``... {<size>}`` followed
by a ``bytearray`` literal, then a lone ``)``. LIST lines carry SPECIAL-USE flags.
"""
from __future__ import annotations
import re
from dataclasses import dataclass
from datetime import datetime
from imapclient.imap_utf7 import decode as imap_utf7_decode
# --- SPECIAL-USE mapping (SPEC §4 special_use values) -----------------------
_SPECIAL_USE_FLAGS = {
"\\sent": "sent",
"\\drafts": "drafts",
"\\trash": "trash",
"\\junk": "junk",
"\\archive": "archive",
"\\all": "archive", # Gmail "All Mail" acts as archive
}
_LIST_RE = re.compile(rb'^\(([^)]*)\)\s+(?:"([^"]*)"|NIL)\s+(.+)$')
_UID_RE = re.compile(rb"UID\s+(\d+)")
_FLAGS_RE = re.compile(rb"FLAGS\s+\(([^)]*)\)")
_INTERNALDATE_RE = re.compile(rb'INTERNALDATE\s+"([^"]+)"')
_LITERAL_RE = re.compile(rb"\{(\d+)\}\s*$")
_FETCH_HEADER_RE = re.compile(rb"^\d+\s+FETCH\s+\(", re.IGNORECASE)
@dataclass(frozen=True)
class ParsedMailbox:
path: str
special_use: str | None
@dataclass(frozen=True)
class ParsedFetch:
uid: int
flags: frozenset[str]
internal_date: datetime | None
raw: bytes | None # None when the fetch did not request the body
def _decode_mailbox_name(raw: bytes) -> str:
name = raw.strip()
if name.startswith(b'"') and name.endswith(b'"'):
name = name[1:-1]
# IMAP mailbox names are modified UTF-7 (SPEC §4: store UTF-7 decoded).
return str(imap_utf7_decode(bytes(name)))
def parse_list_line(line: bytes) -> ParsedMailbox | None:
"""Parse one LIST response line into a :class:`ParsedMailbox` (or None)."""
m = _LIST_RE.match(line.strip())
if not m:
return None
flags = m.group(1).lower().split()
name = _decode_mailbox_name(m.group(3))
special: str | None = None
for flag in flags:
mapped = _SPECIAL_USE_FLAGS.get(flag.decode("ascii", "ignore"))
if mapped:
special = mapped
break
if special is None and name.upper() == "INBOX":
special = "inbox"
return ParsedMailbox(path=name, special_use=special)
def parse_internaldate(raw: bytes | str) -> datetime | None:
"""Parse an IMAP INTERNALDATE (``d-Mon-Y H:M:S +ZZZZ``) to an aware datetime."""
text = raw.decode("ascii", "ignore") if isinstance(raw, bytes) else raw
text = text.strip()
try:
return datetime.strptime(text, "%d-%b-%Y %H:%M:%S %z")
except ValueError:
return None
def _parse_flag_token(tok: bytes) -> str:
return tok.decode("ascii", "ignore")
def parse_fetch_header(line: bytes) -> tuple[int | None, frozenset[str], datetime | None]:
"""Extract (uid, flags, internaldate) from a FETCH header line."""
uid_m = _UID_RE.search(line)
uid = int(uid_m.group(1)) if uid_m else None
flags_m = _FLAGS_RE.search(line)
if flags_m:
flags = frozenset(_parse_flag_token(t) for t in flags_m.group(1).split())
else:
flags = frozenset()
date_m = _INTERNALDATE_RE.search(line)
internal = parse_internaldate(date_m.group(1)) if date_m else None
return uid, flags, internal
def parse_fetch_lines(lines: list[bytes | bytearray]) -> list[ParsedFetch]:
"""Walk a full FETCH response, returning one :class:`ParsedFetch` per message.
Handles both metadata-only fetches (FLAGS/INTERNALDATE) and body fetches
(a ``{size}`` literal announced on the header line, body on the next line).
"""
results: list[ParsedFetch] = []
i = 0
n = len(lines)
while i < n:
line = lines[i]
if not isinstance(line, (bytes, bytearray)) or not _FETCH_HEADER_RE.match(bytes(line)):
i += 1
continue
header = bytes(line)
uid, flags, internal = parse_fetch_header(header)
raw: bytes | None = None
if _LITERAL_RE.search(header) and i + 1 < n:
body = lines[i + 1]
raw = bytes(body)
i += 2
# Skip the trailing ")" continuation line if present.
if (
i < n
and isinstance(lines[i], (bytes, bytearray))
and bytes(lines[i]).strip() == b")"
):
i += 1
else:
i += 1
if uid is not None:
results.append(ParsedFetch(uid=uid, flags=flags, internal_date=internal, raw=raw))
return results
def parse_search_uids(lines: list[bytes | bytearray]) -> list[int]:
"""Parse a UID SEARCH response's first line into a list of ints."""
if not lines:
return []
first = lines[0]
if not isinstance(first, (bytes, bytearray)):
return []
return [int(tok) for tok in bytes(first).split() if tok.isdigit()]
def parse_select_untagged(lines: list[bytes | bytearray]) -> dict[str, int]:
"""Extract UIDVALIDITY / UIDNEXT / HIGHESTMODSEQ / EXISTS from SELECT lines."""
out: dict[str, int] = {}
patterns = {
"uidvalidity": re.compile(rb"UIDVALIDITY\s+(\d+)"),
"uidnext": re.compile(rb"UIDNEXT\s+(\d+)"),
"highestmodseq": re.compile(rb"HIGHESTMODSEQ\s+(\d+)"),
"exists": re.compile(rb"^(\d+)\s+EXISTS"),
}
for line in lines:
if not isinstance(line, (bytes, bytearray)):
continue
b = bytes(line)
for key, pat in patterns.items():
m = pat.search(b)
if m:
out[key] = int(m.group(1))
return out
+312
View File
@@ -0,0 +1,312 @@
"""``ImapSmtpProvider`` — IMAP (aioimaplib) + SMTP (aiosmtplib) MailProvider.
One instance == one IMAP connection (SPEC §6.3 caps connections per account);
the sync engine creates separate instances for the IDLE loop vs. backfill/jobs.
Credentials arrive already decrypted (decryption lives only in workers /
accounts.test per SPEC §10.2).
"""
from __future__ import annotations
import asyncio
import contextlib
import re
from collections.abc import AsyncIterator
from dataclasses import dataclass
import aiosmtplib
from aioimaplib import aioimaplib
from app.providers.base import (
FlagChange,
MailboxDelta,
MailProvider,
ProviderAuthError,
ProviderConnectError,
ProviderNotSupported,
RemoteMailbox,
RemoteMessage,
)
from app.providers.imap_smtp.parsing import (
parse_fetch_lines,
parse_list_line,
parse_search_uids,
parse_select_untagged,
)
_FETCH_ITEMS = "(UID FLAGS INTERNALDATE RFC822.SIZE BODY.PEEK[])"
_IDLE_TIMEOUT = 25 * 60 # SPEC §5.1: re-issue IDLE at least every 25 min
_CONNECT_TIMEOUT = 20
_APPENDUID_RE = re.compile(rb"APPENDUID\s+\d+\s+(\d+)")
_COPYUID_RE = re.compile(rb"COPYUID\s+\d+\s+\S+\s+(\d+)")
@dataclass(frozen=True)
class ProviderConfig:
imap_host: str
imap_port: int
imap_tls: str # "ssl" | "starttls" | "plain"
smtp_host: str
smtp_port: int
smtp_tls: str # "ssl" | "starttls" | "plain"
username: str
password: str # plaintext; caller decrypts per §10.2
class ImapSmtpProvider(MailProvider):
def __init__(self, config: ProviderConfig) -> None:
self._cfg = config
self._imap: aioimaplib.IMAP4 | None = None
self._selected: str | None = None
self._uidvalidity_seen: dict[str, int] = {}
# --- connection lifecycle ------------------------------------------------
async def _connect(self) -> aioimaplib.IMAP4:
if self._imap is not None:
return self._imap
cfg = self._cfg
try:
if cfg.imap_tls == "ssl":
imap = aioimaplib.IMAP4_SSL(
host=cfg.imap_host, port=cfg.imap_port, timeout=_CONNECT_TIMEOUT
)
else:
imap = aioimaplib.IMAP4(
host=cfg.imap_host, port=cfg.imap_port, timeout=_CONNECT_TIMEOUT
)
await imap.wait_hello_from_server()
if cfg.imap_tls == "starttls":
await imap.starttls()
except (OSError, TimeoutError) as exc:
raise ProviderConnectError(f"cannot connect to {cfg.imap_host}: {exc}") from exc
resp = await imap.login(cfg.username, cfg.password)
if resp.result != "OK":
detail = (
b" ".join(resp.lines).decode("utf-8", "ignore") if resp.lines else "login failed"
)
raise ProviderAuthError(detail)
self._imap = imap
return imap
async def _select(self, mailbox: str) -> dict[str, int]:
imap = await self._connect()
resp = await imap.select(mailbox)
if resp.result != "OK":
raise ProviderConnectError(f"cannot select {mailbox!r}")
self._selected = mailbox
return parse_select_untagged(resp.lines)
async def close(self) -> None:
if self._imap is not None:
with contextlib.suppress(Exception): # best-effort teardown
await self._imap.logout()
self._imap = None
self._selected = None
# --- MailProvider protocol ----------------------------------------------
async def verify(self) -> None:
imap = await self._connect()
resp = await imap.list('""', "*")
if resp.result != "OK":
raise ProviderConnectError("LIST failed after login")
async def list_mailboxes(self) -> list[RemoteMailbox]:
imap = await self._connect()
resp = await imap.list('""', "*")
if resp.result != "OK":
raise ProviderConnectError("LIST failed")
out: list[RemoteMailbox] = []
for line in resp.lines:
if not isinstance(line, (bytes, bytearray)):
continue
parsed = parse_list_line(bytes(line))
if parsed is None:
continue
uidvalidity = await self._mailbox_uidvalidity(parsed.path)
out.append(
RemoteMailbox(
path=parsed.path, special_use=parsed.special_use, uidvalidity=uidvalidity
)
)
return out
async def _mailbox_uidvalidity(self, path: str) -> int:
imap = await self._connect()
try:
resp = await imap.status(path, "(UIDVALIDITY)")
except Exception:
return 0
if resp.result != "OK":
return 0
for line in resp.lines:
if isinstance(line, (bytes, bytearray)):
m = re.search(rb"UIDVALIDITY\s+(\d+)", bytes(line))
if m:
return int(m.group(1))
return 0
async def backfill(
self, mailbox: str, *, newest_first: bool = True, batch_size: int = 200
) -> AsyncIterator[list[RemoteMessage]]:
info = await self._select(mailbox)
self._uidvalidity_seen[mailbox] = info.get("uidvalidity", 0)
imap = await self._connect()
search = await imap.uid_search("ALL")
uids = parse_search_uids(search.lines)
uids.sort(reverse=newest_first)
for start in range(0, len(uids), batch_size):
batch = uids[start : start + batch_size]
messages = await self._fetch_messages(imap, batch)
if messages:
yield messages
async def _fetch_messages(self, imap: aioimaplib.IMAP4, uids: list[int]) -> list[RemoteMessage]:
if not uids:
return []
uid_set = ",".join(str(u) for u in uids)
resp = await imap.uid("fetch", uid_set, _FETCH_ITEMS)
if resp.result != "OK":
raise ProviderConnectError("FETCH failed")
out: list[RemoteMessage] = []
for pf in parse_fetch_lines(resp.lines):
if pf.raw is None or pf.internal_date is None:
continue
out.append(
RemoteMessage(
uid=pf.uid, raw=pf.raw, flags=pf.flags, internal_date=pf.internal_date
)
)
return out
async def poll(
self, mailbox: str, *, last_seen_uid: int, highestmodseq: int | None
) -> MailboxDelta:
info = await self._select(mailbox)
imap = await self._connect()
current_uidvalidity = info.get("uidvalidity", 0)
new_uidvalidity: int | None = None
prev = self._uidvalidity_seen.get(mailbox)
if prev is not None and current_uidvalidity != prev:
new_uidvalidity = current_uidvalidity
self._uidvalidity_seen[mailbox] = current_uidvalidity
if new_uidvalidity is not None:
return MailboxDelta([], [], [], new_uidvalidity, info.get("highestmodseq"))
# New messages: UID SEARCH beyond last_seen_uid (filter the M:* boundary echo).
search = await imap.uid_search(f"UID {last_seen_uid + 1}:*")
candidate_uids = [u for u in parse_search_uids(search.lines) if u > last_seen_uid]
new_messages = await self._fetch_messages(imap, sorted(candidate_uids))
# Flag changes via CONDSTORE when we have a modseq baseline.
flag_changes: list[FlagChange] = []
if highestmodseq is not None:
fresp = await imap.uid("fetch", "1:*", f"(UID FLAGS) (CHANGEDSINCE {highestmodseq})")
if fresp.result == "OK":
new_uid_set = {m.uid for m in new_messages}
for pf in parse_fetch_lines(fresp.lines):
if pf.uid not in new_uid_set:
flag_changes.append(FlagChange(uid=pf.uid, flags=pf.flags))
# TODO(spec): expunge detection needs QRESYNC/VANISHED (M2 hardening).
return MailboxDelta(
new=new_messages,
flag_changes=flag_changes,
expunged_uids=[],
new_uidvalidity=None,
new_highestmodseq=info.get("highestmodseq"),
)
async def idle(self, mailbox: str) -> None:
imap = await self._connect()
if "IDLE" not in (imap.protocol.capabilities or set()):
raise ProviderNotSupported("server does not support IDLE")
await self._select(mailbox)
idle_task = await imap.idle_start(timeout=_IDLE_TIMEOUT)
try:
await imap.wait_server_push()
finally:
imap.idle_done()
with contextlib.suppress(Exception):
await asyncio.wait_for(idle_task, timeout=10)
async def push_flags(
self, mailbox: str, uid: int, add: frozenset[str], remove: frozenset[str]
) -> None:
await self._select(mailbox)
imap = await self._connect()
if add:
await imap.uid("store", str(uid), "+FLAGS.SILENT", f"({' '.join(sorted(add))})")
if remove:
await imap.uid("store", str(uid), "-FLAGS.SILENT", f"({' '.join(sorted(remove))})")
async def create_mailbox(self, path: str) -> None:
"""Create a mailbox on the server (idempotent — ignores 'already exists')."""
imap = await self._connect()
with contextlib.suppress(Exception):
await imap.create(path)
async def move(self, uid: int, src: str, dst: str) -> int:
await self._select(src)
imap = await self._connect()
resp = await imap.uid("move", str(uid), dst)
if resp.result != "OK":
raise ProviderConnectError(f"MOVE {uid} -> {dst} failed")
for line in resp.lines:
if isinstance(line, (bytes, bytearray)):
m = _COPYUID_RE.search(bytes(line))
if m:
return int(m.group(1))
return uid
async def delete_messages(self, mailbox: str, uids: list[int]) -> None:
"""Flag the given UIDs \\Deleted and EXPUNGE them from ``mailbox``."""
if not uids:
return
await self._select(mailbox)
imap = await self._connect()
uid_set = ",".join(str(u) for u in uids)
await imap.uid("store", uid_set, "+FLAGS.SILENT", "(\\Deleted)")
try:
# UID EXPUNGE (UIDPLUS) removes only our UIDs; fall back to a plain
# EXPUNGE (all \Deleted in the mailbox) when unsupported.
await imap.uid("expunge", uid_set)
except Exception:
await imap.expunge()
async def append(self, mailbox: str, raw: bytes, flags: frozenset[str]) -> int:
imap = await self._connect()
flag_str = f"({' '.join(sorted(flags))})" if flags else None
resp = await imap.append(raw, mailbox=mailbox, flags=flag_str)
if resp.result != "OK":
raise ProviderConnectError(f"APPEND to {mailbox} failed")
for line in resp.lines:
if isinstance(line, (bytes, bytearray)):
m = _APPENDUID_RE.search(bytes(line))
if m:
return int(m.group(1))
return 0
async def send(self, raw: bytes, envelope_from: str, envelope_to: list[str]) -> None:
cfg = self._cfg
use_tls = cfg.smtp_tls == "ssl"
start_tls = cfg.smtp_tls == "starttls"
try:
await aiosmtplib.send(
raw,
sender=envelope_from,
recipients=envelope_to,
hostname=cfg.smtp_host,
port=cfg.smtp_port,
username=cfg.username,
password=cfg.password,
use_tls=use_tls,
start_tls=start_tls,
timeout=_CONNECT_TIMEOUT,
)
except aiosmtplib.SMTPAuthenticationError as exc:
raise ProviderAuthError(f"SMTP auth failed: {exc}") from exc
except aiosmtplib.SMTPException as exc:
raise ProviderConnectError(f"SMTP send failed: {exc}") from exc
+31
View File
@@ -0,0 +1,31 @@
"""arq queue helpers (enqueue side, used by the API)."""
from __future__ import annotations
from arq import create_pool
from arq.connections import ArqRedis, RedisSettings
from app.config import get_settings
_pool: ArqRedis | None = None
def redis_settings() -> RedisSettings:
"""Return arq RedisSettings derived from ``REDIS_URL``."""
return RedisSettings.from_dsn(get_settings().redis_url)
async def get_arq_pool() -> ArqRedis:
"""Return (creating on first use) the shared arq enqueue pool."""
global _pool
if _pool is None:
_pool = await create_pool(redis_settings())
return _pool
async def close_arq_pool() -> None:
"""Close the arq pool."""
global _pool
if _pool is not None:
await _pool.aclose()
_pool = None
+42
View File
@@ -0,0 +1,42 @@
"""Shared async Redis client (sessions, rate limits, pub/sub, arq)."""
from __future__ import annotations
from collections.abc import Awaitable
from typing import Any, cast
import redis.asyncio as aioredis
from app.config import get_settings
def aw(result: Any) -> Awaitable[Any]:
"""Normalize a redis-py ``ResponseT`` (``Awaitable | value``) to an awaitable.
Some redis-py async commands (hash ops, getdel, ...) are typed as a
sync/async union; this keeps call sites awaitable under ``mypy --strict``.
"""
return cast("Awaitable[Any]", result)
_client: aioredis.Redis | None = None
def get_redis() -> aioredis.Redis:
"""Return (creating on first use) the process-wide Redis client."""
global _client
if _client is None:
_client = aioredis.Redis.from_url(
get_settings().redis_url,
encoding="utf-8",
decode_responses=True,
)
return _client
async def close_redis() -> None:
"""Close the Redis client (used on app shutdown)."""
global _client
if _client is not None:
await _client.aclose()
_client = None
+1
View File
@@ -0,0 +1 @@
"""Search: engine-independent query grammar + pluggable backends (SPEC §5.2, §8)."""
+58
View File
@@ -0,0 +1,58 @@
# INTERFACE VERSION: 1
"""SearchBackend — the frozen search contract (SPEC §5.2).
Both backends (``pg.py`` for M0-M1, ``meili.py`` for M2) index the same
:class:`SearchDoc` and honour invariant I-7 (every query carries ``user_id``).
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Protocol
from uuid import UUID
@dataclass(frozen=True)
class SearchQuery:
text: str | None # free-text remainder after operator extraction
filters: dict[str, object] # parsed operators, keys per §8.2
user_id: UUID # ALWAYS set (invariant I-7)
@dataclass(frozen=True)
class SearchHit:
message_id: UUID
thread_id: UUID
score: float
highlights: dict[str, str] # field -> HTML-escaped snippet with <mark>
@dataclass(frozen=True)
class SearchDoc:
"""The document indexed by every backend (SPEC §5.2)."""
message_id: UUID
thread_id: UUID
user_id: UUID
account_id: UUID
mailbox_special_use: str | None
from_: list[dict[str, str]] # name+email
to: list[dict[str, str]]
cc: list[dict[str, str]]
subject: str
body_text: str
attachment_names: list[str] = field(default_factory=list)
has_attachments: bool = False
is_unread: bool = False
is_starred: bool = False
date: int = 0 # unix seconds
size_bytes: int = 0
class SearchBackend(Protocol):
async def index(self, doc: SearchDoc) -> None: ...
async def delete(self, message_id: UUID) -> None: ...
async def delete_user(self, user_id: UUID) -> None: ...
async def query(
self, q: SearchQuery, *, limit: int, cursor: str | None
) -> tuple[list[SearchHit], str | None]: ...
+21
View File
@@ -0,0 +1,21 @@
"""Select the active SearchBackend from config (SPEC §8.1, env SEARCH_BACKEND)."""
from __future__ import annotations
from app.config import get_settings
from app.search.base import SearchBackend
from app.search.pg import PgSearchBackend
_backend: SearchBackend | None = None
def get_search_backend() -> SearchBackend:
"""Return the process-wide SearchBackend (``pg`` in M0-M1, ``meili`` in M2)."""
global _backend
if _backend is None:
which = get_settings().search_backend
if which == "meili":
# TODO(spec): Meilisearch backend lands in M2.
raise NotImplementedError("meili backend is implemented in M2")
_backend = PgSearchBackend()
return _backend
+271
View File
@@ -0,0 +1,271 @@
"""Postgres full-text SearchBackend (SPEC §5.2, §8; M0-M1).
Indexing populates ``messages.body_indexed`` (a ``tsvector``) from the doc's
searchable fields. Querying translates the parsed §8.2 groups (AND of ORs) into
SQL over messages joined to mailboxes/accounts, always filtered by ``user_id``
(invariant I-7). Relevance ranking / typo tolerance is Meilisearch's job (M2);
here results are date-ordered with keyset pagination.
"""
from __future__ import annotations
import base64
import datetime as dt
import html
import re
from uuid import UUID
from sqlalchemy import Text, any_, cast, exists, func, literal, not_, or_, select, tuple_, update
from sqlalchemy.sql.elements import ColumnElement
from app.db import get_sessionmaker
from app.models import Attachment, Mailbox, Message
from app.search.base import SearchBackend, SearchDoc, SearchHit, SearchQuery
_TS_CONFIG = "simple" # language-neutral; avoids wrong-language stemming (M0)
_RELATIVE_MULT = {"d": 1, "w": 7, "m": 30, "y": 365}
def _doc_text_blob(doc: SearchDoc) -> str:
parts = [doc.subject, doc.body_text]
for field in (doc.from_, doc.to, doc.cc):
for addr in field:
parts.append(addr.get("name", ""))
parts.append(addr.get("email", ""))
parts.extend(doc.attachment_names)
return " ".join(p for p in parts if p)
def _parse_date(value: str) -> dt.datetime | None:
try:
return dt.datetime.strptime(value, "%Y-%m-%d").replace(tzinfo=dt.UTC)
except ValueError:
return None
def _parse_relative(value: str) -> dt.timedelta | None:
m = re.fullmatch(r"(\d+)([dwmy])", value.strip().lower())
if not m:
return None
n = int(m.group(1)) * _RELATIVE_MULT[m.group(2)]
return dt.timedelta(days=n)
def _parse_size(value: str) -> int | None:
m = re.fullmatch(r"(\d+)([km])", value.strip().lower())
if not m:
return None
return int(m.group(1)) * (1024 if m.group(2) == "k" else 1024 * 1024)
def _flag_condition(flag: str) -> ColumnElement[bool]:
# flag = ANY(messages.flags) — the array membership operator, not func any().
return literal(flag) == any_(Message.flags)
class PgSearchBackend(SearchBackend):
async def index(self, doc: SearchDoc) -> None:
blob = _doc_text_blob(doc)
async with get_sessionmaker()() as session:
await session.execute(
update(Message)
.where(Message.id == doc.message_id, Message.user_id == doc.user_id)
.values(body_indexed=func.to_tsvector(_TS_CONFIG, blob))
)
await session.commit()
async def delete(self, message_id: UUID) -> None:
async with get_sessionmaker()() as session:
await session.execute(
update(Message).where(Message.id == message_id).values(body_indexed=None)
)
await session.commit()
async def delete_user(self, user_id: UUID) -> None:
# User deletion cascades message rows (FK ON DELETE CASCADE); clearing the
# tsvector here keeps the backend contract satisfied for partial re-index.
async with get_sessionmaker()() as session:
await session.execute(
update(Message).where(Message.user_id == user_id).values(body_indexed=None)
)
await session.commit()
# --- querying ------------------------------------------------------------
def _clause_condition(self, clause: dict[str, object]) -> ColumnElement[bool] | None:
value = str(clause.get("value", ""))
if clause.get("type") == "text":
return Message.body_indexed.op("@@")(func.websearch_to_tsquery(_TS_CONFIG, value))
key = clause.get("key")
cond = self._filter_condition(str(key), value)
return cond
def _filter_condition(self, key: str, value: str) -> ColumnElement[bool] | None:
like = f"%{value}%"
if key == "from":
return cast(Message.from_addr, Text).ilike(like)
if key == "to":
return cast(Message.to_addr, Text).ilike(like)
if key == "cc":
return cast(Message.cc_addr, Text).ilike(like)
if key == "subject":
return Message.subject.ilike(like)
if key == "in":
return or_(Mailbox.special_use == value.lower(), Mailbox.path.ilike(like))
if key == "is":
v = value.lower()
if v == "unread":
return not_(_flag_condition("\\Seen"))
if v == "read":
return _flag_condition("\\Seen")
if v == "starred":
return _flag_condition("\\Flagged")
if v == "answered":
return _flag_condition("\\Answered")
return None
if key == "has":
return Message.has_attachments.is_(True) if value.lower() == "attachment" else None
if key == "filename":
return exists().where(
Attachment.message_id == Message.id, Attachment.filename.ilike(like)
)
if key == "before":
d = _parse_date(value)
return Message.internal_date < d if d else None
if key == "after":
d = _parse_date(value)
return Message.internal_date >= d if d else None
if key == "newer_than":
delta = _parse_relative(value)
return Message.internal_date >= func.now() - delta if delta else None
if key == "older_than":
delta = _parse_relative(value)
return Message.internal_date < func.now() - delta if delta else None
if key == "larger":
n = _parse_size(value)
return Message.size_bytes > n if n is not None else None
if key == "smaller":
n = _parse_size(value)
return Message.size_bytes < n if n is not None else None
return None
def _build_conditions(self, groups: list[list[dict[str, object]]]) -> list[ColumnElement[bool]]:
conditions: list[ColumnElement[bool]] = []
for group in groups:
ors: list[ColumnElement[bool]] = []
for clause in group:
cond = self._clause_condition(clause)
if cond is None:
continue
if clause.get("negate"):
cond = not_(cond)
ors.append(cond)
if ors:
conditions.append(or_(*ors))
return conditions
async def query(
self, q: SearchQuery, *, limit: int, cursor: str | None
) -> tuple[list[SearchHit], str | None]:
groups = q.filters.get("groups", []) if isinstance(q.filters, dict) else []
assert isinstance(groups, list)
conditions = self._build_conditions(groups)
rank = (
func.ts_rank_cd(Message.body_indexed, func.websearch_to_tsquery(_TS_CONFIG, q.text))
if q.text
else literal(0.0)
)
stmt = (
select(
Message.id,
Message.thread_id,
Message.subject,
Message.snippet,
Message.internal_date,
rank.label("score"),
)
.select_from(Message)
.join(Mailbox, Mailbox.id == Message.mailbox_id)
.where(Message.user_id == q.user_id, Message.thread_id.is_not(None))
)
for cond in conditions:
stmt = stmt.where(cond)
decoded = _decode_cursor(cursor)
if decoded is not None:
cur_date, cur_id = decoded
stmt = stmt.where(
tuple_(Message.internal_date, Message.id)
< tuple_(literal(cur_date), literal(cur_id))
)
stmt = stmt.order_by(Message.internal_date.desc(), Message.id.desc()).limit(limit + 1)
terms = _positive_text_terms(groups)
async with get_sessionmaker()() as session:
rows = (await session.execute(stmt)).all()
has_more = len(rows) > limit
rows = rows[:limit]
hits = [
SearchHit(
message_id=row.id,
thread_id=row.thread_id,
score=float(row.score or 0.0),
highlights=_highlights(row.subject, row.snippet, terms),
)
for row in rows
]
next_cursor = (
_encode_cursor(rows[-1].internal_date, rows[-1].id) if has_more and rows else None
)
return hits, next_cursor
def _positive_text_terms(groups: list[list[dict[str, object]]]) -> list[str]:
terms: list[str] = []
for group in groups:
for clause in group:
if clause.get("type") == "text" and not clause.get("negate"):
terms.append(str(clause.get("value", "")))
return terms
def _highlights(subject: str, snippet: str, terms: list[str]) -> dict[str, str]:
out: dict[str, str] = {}
if not terms:
return out
marked_subject = _mark(subject, terms)
if "<mark>" in marked_subject:
out["subject"] = marked_subject
marked_body = _mark(snippet, terms)
if "<mark>" in marked_body:
out["body"] = marked_body
return out
def _mark(value: str, terms: list[str]) -> str:
"""HTML-escape ``value`` and wrap term matches in <mark> (SPEC §5.2 highlights)."""
escaped = html.escape(value)
for term in terms:
if not term:
continue
pattern = re.compile(re.escape(html.escape(term)), re.IGNORECASE)
escaped = pattern.sub(lambda m: f"<mark>{m.group(0)}</mark>", escaped)
return escaped
def _encode_cursor(date: dt.datetime, message_id: UUID) -> str:
raw = f"{date.isoformat()}|{message_id}"
return base64.urlsafe_b64encode(raw.encode()).decode()
def _decode_cursor(cursor: str | None) -> tuple[dt.datetime, 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(id_str)
except (ValueError, TypeError):
return None
+158
View File
@@ -0,0 +1,158 @@
"""Operator query grammar (SPEC §8.2), engine-independent.
Tokenization: whitespace split respecting ``"quoted phrases"``. A ``key:value``
(or ``-key:value``) token with a known key becomes a filter; everything else is
free text. ``-`` negates. ``OR`` (uppercase) groups adjacent clauses into a
disjunction; the default combinator is AND. Unknown ``key:`` tokens are treated
as free text (forward compatibility). The parser never raises.
"""
from __future__ import annotations
from dataclasses import dataclass
from uuid import UUID
from app.search.base import SearchQuery
# Known operator keys (SPEC §8.2). Unknown keys fall through to free text.
KNOWN_KEYS: frozenset[str] = frozenset(
{
"from",
"to",
"cc",
"subject",
"account",
"in",
"is",
"has",
"filename",
"before",
"after",
"newer_than",
"older_than",
"larger",
"smaller",
}
)
@dataclass(frozen=True)
class Clause:
"""One parsed unit: a free-text term or a ``key:value`` filter."""
type: str # "text" | "filter"
key: str | None # operator key for filters, else None
value: str
negate: bool
phrase: bool # value was a quoted phrase
def as_dict(self) -> dict[str, object]:
return {
"type": self.type,
"key": self.key,
"value": self.value,
"negate": self.negate,
"phrase": self.phrase,
}
# A parsed query is an AND of groups; each group is an OR of clauses.
ParsedQuery = list[list[Clause]]
@dataclass(frozen=True)
class _Token:
text: str
negate: bool
starts_with_quote: bool # a bare quoted phrase -> never a key:value filter
def _tokenize(query: str) -> list[_Token]:
"""Split into tokens honouring quotes; never raises on unbalanced quotes.
Tracks a leading ``-`` (negation) and whether the token began with a quote
(a bare phrase), so ``key:"quoted value"`` is still recognised as a filter
while ``"key: value"`` stays free text.
"""
tokens: list[_Token] = []
i, n = 0, len(query)
while i < n:
while i < n and query[i].isspace():
i += 1
if i >= n:
break
negate = False
if query[i] == "-" and i + 1 < n and not query[i + 1].isspace():
negate = True
i += 1
buf: list[str] = []
starts_with_quote = i < n and query[i] == '"'
while i < n and not query[i].isspace():
if query[i] == '"':
i += 1
while i < n and query[i] != '"':
buf.append(query[i])
i += 1
if i < n and query[i] == '"':
i += 1 # consume closing quote
else:
buf.append(query[i])
i += 1
tokens.append(_Token(text="".join(buf), negate=negate, starts_with_quote=starts_with_quote))
return tokens
def _to_clause(token: _Token) -> Clause | None:
if not token.text and not token.starts_with_quote:
return None
if not token.starts_with_quote and ":" in token.text:
key, _, value = token.text.partition(":")
if key.lower() in KNOWN_KEYS and value:
return Clause(
type="filter",
key=key.lower(),
value=value,
negate=token.negate,
phrase=(" " in value),
)
return Clause(
type="text",
key=None,
value=token.text,
negate=token.negate,
phrase=token.starts_with_quote or (" " in token.text),
)
def parse(query: str) -> ParsedQuery:
"""Parse ``query`` into AND-of-OR groups of :class:`Clause`."""
groups: ParsedQuery = []
pending_or = False
for token in _tokenize(query):
if token.text == "OR" and not token.negate and not token.starts_with_quote:
pending_or = True
continue
clause = _to_clause(token)
if clause is None:
continue
if pending_or and groups:
groups[-1].append(clause)
else:
groups.append([clause])
pending_or = False
return groups
def free_text(groups: ParsedQuery) -> str | None:
"""Return the positive free-text remainder (standalone, non-negated text terms)."""
terms = [g[0].value for g in groups if len(g) == 1 and g[0].type == "text" and not g[0].negate]
return " ".join(terms) if terms else None
def to_search_query(query: str, user_id: UUID) -> SearchQuery:
"""Parse ``query`` into a :class:`SearchQuery` (I-7: ``user_id`` always set)."""
groups = parse(query)
filters: dict[str, object] = {
"groups": [[clause.as_dict() for clause in group] for group in groups]
}
return SearchQuery(text=free_text(groups), filters=filters, user_id=user_id)
+1
View File
@@ -0,0 +1 @@
"""Security primitives: credential vault, CSRF, secret types (SPEC §10)."""
+77
View File
@@ -0,0 +1,77 @@
"""Credential vault — envelope encryption (SPEC §10.2).
Mailbox secrets are encrypted with a fresh AES-256-GCM *data key*; the data key
is itself wrapped with the master key from ``MASTER_KEY`` (32 bytes, base64). A
key id is embedded to allow future master-key rotation.
Wire format of ``secret_ct`` (all lengths fixed unless noted)::
b"WMV1" # 4-byte magic + version
key_id # 1 byte (master key id, 0 for now)
wrap_nonce # 12 bytes (GCM nonce for the wrapped data key)
wrapped_key # 32 + 16 bytes (data key ciphertext + tag)
data_nonce # 12 bytes (GCM nonce for the payload)
payload_ciphertext # variable (payload + 16-byte tag)
The decryption path exists only in workers and ``accounts/test`` (SPEC §10.2);
this module enforces nothing about callers but is imported only from there.
"""
from __future__ import annotations
import base64
import os
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from app.config import get_settings
_MAGIC = b"WMV1"
_CURRENT_KEY_ID = 0
class VaultError(Exception):
"""Raised when a ciphertext is malformed or cannot be authenticated."""
def _master_key() -> bytes:
raw = base64.b64decode(get_settings().master_key)
if len(raw) != 32:
raise VaultError("MASTER_KEY must decode to exactly 32 bytes")
return raw
def encrypt_secret(plaintext: bytes) -> bytes:
"""Envelope-encrypt ``plaintext`` and return the ``secret_ct`` blob."""
data_key = AESGCM.generate_key(256)
wrap_nonce = os.urandom(12)
wrapped = AESGCM(_master_key()).encrypt(wrap_nonce, data_key, _MAGIC)
data_nonce = os.urandom(12)
ciphertext = AESGCM(data_key).encrypt(data_nonce, plaintext, _MAGIC)
return _MAGIC + bytes([_CURRENT_KEY_ID]) + wrap_nonce + wrapped + data_nonce + ciphertext
def decrypt_secret(blob: bytes) -> bytes:
"""Authenticate and decrypt a ``secret_ct`` blob back to plaintext bytes."""
if len(blob) < 4 + 1 + 12 + 48 + 12 or blob[:4] != _MAGIC:
raise VaultError("malformed secret ciphertext")
pos = 4
key_id = blob[pos]
pos += 1
if key_id != _CURRENT_KEY_ID:
raise VaultError(f"unknown master key id {key_id}")
wrap_nonce = blob[pos : pos + 12]
pos += 12
wrapped = blob[pos : pos + 48] # 32-byte key + 16-byte GCM tag
pos += 48
data_nonce = blob[pos : pos + 12]
pos += 12
ciphertext = blob[pos:]
try:
data_key = AESGCM(_master_key()).decrypt(wrap_nonce, wrapped, _MAGIC)
return AESGCM(data_key).decrypt(data_nonce, ciphertext, _MAGIC)
except Exception as exc: # cryptography raises InvalidTag etc.
raise VaultError("secret decryption failed") from exc
+1
View File
@@ -0,0 +1 @@
"""Sync engine: bootstrap, backfill, live IDLE/poll loops (SPEC §6.3)."""
+287
View File
@@ -0,0 +1,287 @@
"""Outbound user actions: flag changes and moves (SPEC §6.3).
The API writes the change to the DB first, then enqueues a job here to push it to
the mail server. On provider failure the job retries (arq, ×5 exponential) and, on
final failure, reverts the DB change and emits ``flags.changed`` with
``reverted=true`` so the UI can roll back.
"""
from __future__ import annotations
import logging
import uuid
from collections.abc import Iterable
from typing import Any
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.db import session_scope
from app.events import get_event_bus
from app.models import Account, Draft, Mailbox, Message, Thread
from app.sync.provider_factory import build_provider
from app.threading_.threader import denormalize_thread
logger = logging.getLogger(__name__)
# User-settable IMAP flags (SPEC §8.2 is:read/starred/answered). \Deleted/\Draft
# are managed by move/expunge and drafts, not this surface.
ALLOWED_FLAGS = frozenset({"\\Seen", "\\Flagged", "\\Answered"})
_MAX_TRIES = 5
# Folder names to create when a target special-use folder is missing on the server.
_DEFAULT_FOLDER_NAME = {"archive": "Archive", "trash": "Trash", "junk": "Spam"}
def sanitize_flags(flags: Iterable[str]) -> frozenset[str]:
return frozenset(f for f in flags if f in ALLOWED_FLAGS)
async def apply_flags_to_messages(
session: AsyncSession, messages: list[Message], add: frozenset[str], remove: frozenset[str]
) -> set[uuid.UUID]:
"""Apply a flag delta to messages in the DB and re-denormalize their threads."""
thread_ids: set[uuid.UUID] = set()
for message in messages:
message.flags = sorted((set(message.flags) | add) - remove)
if message.thread_id is not None:
thread_ids.add(message.thread_id)
await session.flush()
for tid in thread_ids:
await denormalize_thread(session, tid)
return thread_ids
# --- worker jobs -------------------------------------------------------------
async def push_flags_job(
ctx: dict[str, Any], message_id: str, add: list[str], remove: list[str]
) -> None:
"""Push a flag change to the mail server; revert the DB on final failure."""
mid = uuid.UUID(message_id)
async with session_scope() as session:
message = await session.get(Message, mid)
if message is None:
return
account = await session.get(Account, message.account_id)
mailbox = await session.get(Mailbox, message.mailbox_id)
if account is None or mailbox is None:
return
user_id, uid, path = message.user_id, message.uid, mailbox.path
provider = build_provider(account)
try:
await provider.push_flags(path, uid, frozenset(add), frozenset(remove))
except Exception:
if ctx.get("job_try", 1) >= _MAX_TRIES:
await _revert_flags(mid, user_id, add, remove)
return
raise # arq retries with backoff
finally:
await provider.close()
async def _revert_flags(
message_id: uuid.UUID, user_id: uuid.UUID, add: list[str], remove: list[str]
) -> None:
async with session_scope() as session:
message = await session.get(Message, message_id)
if message is None:
return
# Undo the change: remove what we added, add back what we removed.
message.flags = sorted((set(message.flags) - set(add)) | set(remove))
await session.flush()
if message.thread_id is not None:
await denormalize_thread(session, message.thread_id)
flags = list(message.flags)
await get_event_bus().publish(
"flags.changed",
{"user_id": str(user_id), "message_id": str(message_id), "flags": flags, "reverted": True},
)
logger.warning("reverted flag change for message %s after push failures", message_id)
async def move_job(
ctx: dict[str, Any], message_id: str, dst_mailbox_id: str, src_mailbox_id: str
) -> None:
"""Move a message on the server; update its uid, or revert the DB on failure."""
mid = uuid.UUID(message_id)
dst_id = uuid.UUID(dst_mailbox_id)
async with session_scope() as session:
message = await session.get(Message, mid)
if message is None:
return
account = await session.get(Account, message.account_id)
dst = await session.get(Mailbox, dst_id)
src = await session.get(Mailbox, uuid.UUID(src_mailbox_id))
if account is None or dst is None or src is None:
return
src_path = src.path
uid, dst_path, user_id = message.uid, dst.path, message.user_id
provider = build_provider(account)
try:
new_uid = await provider.move(uid, src_path, dst_path)
except Exception:
if ctx.get("job_try", 1) >= _MAX_TRIES:
logger.warning("move failed for message %s; leaving optimistic state", mid)
return
raise
finally:
await provider.close()
async with session_scope() as session:
message = await session.get(Message, mid)
if message is not None:
message.uid = new_uid
message.mailbox_id = dst_id
await get_event_bus().publish(
"message.changed",
{"user_id": str(user_id), "message_id": str(mid), "mailbox_id": str(dst_id)},
)
async def move_thread_to_folder_job(ctx: dict[str, Any], thread_id: str, special_use: str) -> None:
"""Move every message in a thread to each account's special-use folder.
Creates the target folder (e.g. Archive) if the account doesn't have one.
Used by the Archive and Delete (-> Trash) thread actions.
"""
tid = uuid.UUID(thread_id)
async with session_scope() as session:
messages = list(await session.scalars(select(Message).where(Message.thread_id == tid)))
if not messages:
return
user_id = messages[0].user_id
account_ids = list({m.account_id for m in messages})
accounts = {
a.id: a
for a in await session.scalars(select(Account).where(Account.id.in_(account_ids)))
}
mailboxes = list(
await session.scalars(select(Mailbox).where(Mailbox.account_id.in_(account_ids)))
)
path_by_id = {mb.id: mb.path for mb in mailboxes}
target_by_account = {mb.account_id: mb for mb in mailboxes if mb.special_use == special_use}
target_id_path = {mb.account_id: (mb.id, mb.path) for mb in target_by_account.values()}
snapshot = [(m.id, m.account_id, m.mailbox_id, m.uid) for m in messages]
# (message_id, account_id, target_path, new_uid) applied after the network moves.
moves: list[tuple[uuid.UUID, uuid.UUID, str, int]] = []
to_create: list[tuple[uuid.UUID, str, int]] = [] # account_id, path, uidvalidity
for account_id in account_ids:
provider = build_provider(accounts[account_id])
try:
if account_id in target_id_path:
target_id, target_path = target_id_path[account_id]
else:
target_id, target_path = None, _DEFAULT_FOLDER_NAME.get(special_use, special_use)
await provider.create_mailbox(target_path)
remote = await provider.list_mailboxes()
uidv = next((r.uidvalidity for r in remote if r.path == target_path), 0)
to_create.append((account_id, target_path, uidv))
for mid, m_account, m_mailbox, m_uid in snapshot:
if m_account != account_id or m_mailbox == target_id:
continue
src_path = path_by_id.get(m_mailbox, "INBOX")
try:
new_uid = await provider.move(m_uid, src_path, target_path)
except Exception:
logger.exception("move failed for message %s -> %s", mid, target_path)
continue
moves.append((mid, account_id, target_path, new_uid))
finally:
await provider.close()
async with session_scope() as session:
created: dict[tuple[uuid.UUID, str], uuid.UUID] = {}
for account_id, path, uidv in to_create:
mailbox = Mailbox(
account_id=account_id,
user_id=user_id,
path=path,
special_use=special_use,
uidvalidity=uidv,
)
session.add(mailbox)
await session.flush()
created[(account_id, path)] = mailbox.id
for mid, account_id, target_path, new_uid in moves:
resolved = target_id_path.get(account_id, (None, ""))[0] or created.get(
(account_id, target_path)
)
message = await session.get(Message, mid)
if message is not None and resolved is not None:
message.mailbox_id = resolved
message.uid = new_uid
await get_event_bus().publish(
"thread.updated", {"user_id": str(user_id), "thread_id": str(tid)}
)
async def delete_thread_job(ctx: dict[str, Any], thread_id: str) -> None:
"""Permanently delete every message in a thread (server EXPUNGE + DB rows).
Used by the Trash folder's Delete action. Best-effort per message on the
server; the DB rows (and cascaded attachments/body parts) are always removed.
"""
tid = uuid.UUID(thread_id)
async with session_scope() as session:
messages = list(await session.scalars(select(Message).where(Message.thread_id == tid)))
if not messages:
return
user_id = messages[0].user_id
account_ids = list({m.account_id for m in messages})
accounts = {
a.id: a
for a in await session.scalars(select(Account).where(Account.id.in_(account_ids)))
}
mailboxes = list(
await session.scalars(select(Mailbox).where(Mailbox.account_id.in_(account_ids)))
)
path_by_id = {mb.id: mb.path for mb in mailboxes}
# (account_id, mailbox_path) -> [uids] to expunge on the server.
by_box: dict[tuple[uuid.UUID, str], list[int]] = {}
for m in messages:
by_box.setdefault((m.account_id, path_by_id.get(m.mailbox_id, "INBOX")), []).append(
m.uid
)
for account_id in account_ids:
provider = build_provider(accounts[account_id])
try:
for (acct, path), uids in by_box.items():
if acct != account_id:
continue
try:
await provider.delete_messages(path, uids)
except Exception:
logger.exception("expunge failed for %s in %s", uids, path)
finally:
await provider.close()
async with session_scope() as session:
msgs = list(await session.scalars(select(Message).where(Message.thread_id == tid)))
msg_ids = [m.id for m in msgs]
# Drop draft references to the thread/messages (FKs lack ON DELETE CASCADE).
if msg_ids:
await session.execute(
update(Draft)
.where(Draft.reply_to_message_id.in_(msg_ids))
.values(reply_to_message_id=None)
)
await session.execute(
update(Draft).where(Draft.thread_id == tid).values(thread_id=None)
)
for m in msgs:
await session.delete(m)
thread = await session.get(Thread, tid)
if thread is not None:
await session.delete(thread)
await get_event_bus().publish(
"thread.updated", {"user_id": str(user_id), "thread_id": str(tid)}
)
+276
View File
@@ -0,0 +1,276 @@
"""Sync engine: bootstrap mailboxes, backfill, and the live IDLE/poll loop (SPEC §6.3)."""
from __future__ import annotations
import asyncio
import logging
import uuid
from sqlalchemy import delete, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import get_settings
from app.events import get_event_bus
from app.indexer.indexer import PersistResult, persist_message
from app.models import Account, Mailbox, Message
from app.providers.base import MailboxDelta, ProviderNotSupported
from app.providers.imap_smtp import ImapSmtpProvider
from app.search.factory import get_search_backend
from app.sync.folder_names import infer_special_use
from app.sync.provider_factory import build_provider
logger = logging.getLogger(__name__)
# Backfill priority: inbox, then sent/drafts, then everything else (SPEC §6.3).
_PRIORITY = {"inbox": 0, "sent": 1, "drafts": 2}
async def _load_account(session: AsyncSession, account_id: uuid.UUID) -> Account:
account = await session.get(Account, account_id)
if account is None:
raise ValueError(f"account {account_id} not found")
return account
async def _set_state(account_id: uuid.UUID, state: str, detail: str = "") -> None:
from app.db import session_scope
async with session_scope() as session:
await session.execute(
update(Account).where(Account.id == account_id).values(state=state, state_detail=detail)
)
bus = get_event_bus()
account = None
async with session_scope() as session:
account = await session.get(Account, account_id)
if account is not None:
await bus.publish(
"account.state_changed",
{"user_id": str(account.user_id), "account_id": str(account_id), "state": state},
)
async def bootstrap_mailboxes(
session: AsyncSession, provider: ImapSmtpProvider, account: Account
) -> list[Mailbox]:
"""List remote mailboxes and upsert local ``mailboxes`` rows (SPEC §6.3)."""
remote = await provider.list_mailboxes()
result: list[Mailbox] = []
for rm in remote:
special = rm.special_use or infer_special_use(rm.path)
existing = await session.scalar(
select(Mailbox).where(Mailbox.account_id == account.id, Mailbox.path == rm.path)
)
if existing is None:
mailbox = Mailbox(
account_id=account.id,
user_id=account.user_id,
path=rm.path,
special_use=special,
uidvalidity=rm.uidvalidity,
)
session.add(mailbox)
else:
existing.special_use = special
existing.uidvalidity = rm.uidvalidity
mailbox = existing
result.append(mailbox)
await session.flush()
return result
def _mailbox_order(mailboxes: list[Mailbox]) -> list[Mailbox]:
return sorted(mailboxes, key=lambda m: _PRIORITY.get(m.special_use or "", 99))
async def _emit_batch(
results: list[PersistResult], account: Account, mailbox_path: str, done: int
) -> None:
backend = get_search_backend()
bus = get_event_bus()
for r in results:
await backend.index(r.search_doc)
await bus.publish(
"message.new",
{
"user_id": str(account.user_id),
"account_id": str(account.id),
"message_id": str(r.message.id),
"thread_id": str(r.message.thread_id),
},
)
await bus.publish(
"sync.progress",
{
"user_id": str(account.user_id),
"account_id": str(account.id),
"mailbox": mailbox_path,
"done": done,
},
)
async def backfill_mailbox(provider: ImapSmtpProvider, account: Account, mailbox: Mailbox) -> int:
"""Backfill one mailbox newest-first, indexing inline per batch (FR-30)."""
from app.db import session_scope
account_id, user_id, mailbox_id = account.id, account.user_id, mailbox.id
special_use, path = mailbox.special_use, mailbox.path
done = 0
async for batch in provider.backfill(path, batch_size=200):
results: list[PersistResult] = []
max_uid = 0
async with session_scope() as session:
for remote in batch:
r = await persist_message(
session,
user_id=user_id,
account_id=account_id,
mailbox_id=mailbox_id,
mailbox_special_use=special_use,
remote=remote,
)
if r is not None:
results.append(r)
max_uid = max(max_uid, remote.uid)
await session.execute(
update(Mailbox)
.where(Mailbox.id == mailbox_id, Mailbox.last_seen_uid < max_uid)
.values(last_seen_uid=max_uid)
)
done += len(results)
await _emit_batch(results, account, path, done)
return done
async def bootstrap_and_backfill(account_id: uuid.UUID) -> int:
"""Full initial sync for an account: mailboxes + backfill; sets state=live."""
from app.db import session_scope
async with session_scope() as session:
account = await _load_account(session, account_id)
provider = build_provider(account)
mailboxes = _mailbox_order(await bootstrap_mailboxes(session, provider, account))
# Detach: reuse account/mailboxes values after the session closes.
account_obj = account
mailbox_list = list(mailboxes)
total = 0
try:
for mailbox in mailbox_list:
total += await backfill_mailbox(provider, account_obj, mailbox)
await _set_state(account_id, "live")
except Exception as exc: # noqa: BLE001 - surface provider errors as account state
logger.exception("backfill failed for account %s", account_id)
await _set_state(account_id, "error", str(exc)[:500])
raise
finally:
await provider.close()
return total
async def apply_delta(
provider: ImapSmtpProvider, account: Account, mailbox: Mailbox, delta: MailboxDelta
) -> None:
"""Apply a poll delta: new messages, flag changes, uidvalidity resets (SPEC §6.3)."""
from app.db import session_scope
bus = get_event_bus()
if delta.new_uidvalidity is not None:
# UIDVALIDITY changed: wipe this mailbox's rows and re-backfill (SPEC §6.3).
async with session_scope() as session:
await session.execute(delete(Message).where(Message.mailbox_id == mailbox.id))
await session.execute(
update(Mailbox)
.where(Mailbox.id == mailbox.id)
.values(uidvalidity=delta.new_uidvalidity, last_seen_uid=0)
)
await backfill_mailbox(provider, account, mailbox)
return
results: list[PersistResult] = []
max_uid = mailbox.last_seen_uid
async with session_scope() as session:
for remote in delta.new:
r = await persist_message(
session,
user_id=account.user_id,
account_id=account.id,
mailbox_id=mailbox.id,
mailbox_special_use=mailbox.special_use,
remote=remote,
)
if r is not None:
results.append(r)
max_uid = max(max_uid, remote.uid)
for change in delta.flag_changes:
await session.execute(
update(Message)
.where(Message.mailbox_id == mailbox.id, Message.uid == change.uid)
.values(flags=sorted(change.flags))
)
if delta.expunged_uids:
await session.execute(
delete(Message).where(
Message.mailbox_id == mailbox.id, Message.uid.in_(delta.expunged_uids)
)
)
updates = {"last_seen_uid": max_uid}
if delta.new_highestmodseq is not None:
updates["highestmodseq"] = delta.new_highestmodseq
await session.execute(update(Mailbox).where(Mailbox.id == mailbox.id).values(**updates))
await _emit_batch(results, account, mailbox.path, len(results))
for change in delta.flag_changes:
await bus.publish(
"flags.changed",
{
"user_id": str(account.user_id),
"account_id": str(account.id),
"mailbox": mailbox.path,
"uid": change.uid,
"flags": sorted(change.flags),
},
)
async def live_loop(
account_id: uuid.UUID, mailbox_path: str = "INBOX", stop: asyncio.Event | None = None
) -> None:
"""Maintain a live IDLE/poll loop for one mailbox (SPEC §6.3)."""
from app.db import session_scope
stop = stop or asyncio.Event()
poll_interval = get_settings().poll_interval
async with session_scope() as session:
account = await _load_account(session, account_id)
account_obj = account
provider = build_provider(account_obj)
try:
while not stop.is_set():
try:
await provider.idle(mailbox_path)
except ProviderNotSupported:
await asyncio.sleep(poll_interval)
if stop.is_set():
break
async with session_scope() as session:
mailbox = await session.scalar(
select(Mailbox).where(
Mailbox.account_id == account_id, Mailbox.path == mailbox_path
)
)
mailbox_obj = mailbox
if mailbox_obj is None:
break
delta = await provider.poll(
mailbox_path,
last_seen_uid=mailbox_obj.last_seen_uid,
highestmodseq=mailbox_obj.highestmodseq,
)
await apply_delta(provider, account_obj, mailbox_obj, delta)
finally:
await provider.close()
+48
View File
@@ -0,0 +1,48 @@
"""Special-use inference from folder names (SPEC §6.3 fallback heuristics).
Used only when the server does not advertise SPECIAL-USE. Includes Polish names
(Wysłane, Kosz, ...) alongside English/German, per the spec's i18n intent.
"""
from __future__ import annotations
_NAME_MAP: dict[str, str] = {
# sent
"sent": "sent",
"sent items": "sent",
"sent mail": "sent",
"wysłane": "sent",
"wyslane": "sent",
"elementy wysłane": "sent",
"gesendet": "sent",
# drafts
"drafts": "drafts",
"draft": "drafts",
"robocze": "drafts",
"kopie robocze": "drafts",
"entwürfe": "drafts",
# trash
"trash": "trash",
"deleted": "trash",
"deleted items": "trash",
"kosz": "trash",
"papierkorb": "trash",
# junk
"junk": "junk",
"spam": "junk",
"bulk mail": "junk",
"wiadomości-śmieci": "junk",
# archive
"archive": "archive",
"archiwum": "archive",
"all mail": "archive",
"archiv": "archive",
}
def infer_special_use(path: str) -> str | None:
"""Infer a special_use value from a mailbox path/name (case-insensitive)."""
if path.upper() == "INBOX":
return "inbox"
leaf = path.replace("\\", "/").split("/")[-1].strip().lower()
return _NAME_MAP.get(leaf) or _NAME_MAP.get(path.strip().lower())
+30
View File
@@ -0,0 +1,30 @@
"""Build a MailProvider from an account row (SPEC §10.2 decryption boundary).
This module runs only inside the worker/sync context; ``decrypt_secret`` (the
credential vault) is invoked here to obtain the plaintext mailbox password. It
must never be imported by API request code.
"""
from __future__ import annotations
from app.models import Account
from app.providers.imap_smtp import ImapSmtpProvider, ProviderConfig
from app.security.crypto import decrypt_secret
def build_provider(account: Account) -> ImapSmtpProvider:
"""Construct an :class:`ImapSmtpProvider` for ``account`` (password auth, M0)."""
imap = account.imap_config
smtp = account.smtp_config
password = decrypt_secret(account.secret_ct).decode("utf-8")
config = ProviderConfig(
imap_host=str(imap["host"]),
imap_port=int(str(imap["port"])),
imap_tls=str(imap.get("tls", "ssl")),
smtp_host=str(smtp["host"]),
smtp_port=int(str(smtp["port"])),
smtp_tls=str(smtp.get("tls", "ssl")),
username=account.email,
password=password,
)
return ImapSmtpProvider(config)
+61
View File
@@ -0,0 +1,61 @@
"""Live-sync supervisor (SPEC §6.3).
Runs inside the arq worker: on startup it launches one IDLE/poll loop per live
account so new mail and flag changes flow to the UI over WebSocket. Loops
self-heal (reconnect with backoff) and are cancelled on worker shutdown.
NOTE(spec): accounts that become live *after* worker startup are picked up on the
next worker restart; reacting to ``account.state_changed`` live is a later refinement.
"""
from __future__ import annotations
import asyncio
import contextlib
import logging
import uuid
from typing import Any
from sqlalchemy import select
from app.db import session_scope
from app.models import Account
from app.sync.engine import live_loop
logger = logging.getLogger(__name__)
_RECONNECT_BACKOFF = 30.0
async def _run_account(account_id: uuid.UUID, stop: asyncio.Event) -> None:
while not stop.is_set():
try:
await live_loop(account_id, "INBOX", stop)
except asyncio.CancelledError:
raise
except Exception:
logger.exception("live loop for account %s crashed; reconnecting", account_id)
with contextlib.suppress(TimeoutError):
await asyncio.wait_for(stop.wait(), timeout=_RECONNECT_BACKOFF)
async def start_supervisor(ctx: dict[str, Any]) -> None:
"""Launch a live loop per live account (called from the worker on_startup)."""
async with session_scope() as session:
account_ids = list(await session.scalars(select(Account.id).where(Account.state == "live")))
stop = asyncio.Event()
ctx["supervisor_stop"] = stop
ctx["supervisor_tasks"] = [asyncio.create_task(_run_account(aid, stop)) for aid in account_ids]
logger.info("live supervisor started for %d account(s)", len(account_ids))
async def stop_supervisor(ctx: dict[str, Any]) -> None:
"""Signal and cancel all live loops (called from the worker on_shutdown)."""
stop = ctx.get("supervisor_stop")
if stop is not None:
stop.set()
tasks = ctx.get("supervisor_tasks", [])
for task in tasks:
task.cancel()
if tasks:
await asyncio.gather(*tasks, return_exceptions=True)
+1
View File
@@ -0,0 +1 @@
"""JWZ threading (SPEC §7). Trailing underscore avoids the stdlib ``threading`` clash."""
+66
View File
@@ -0,0 +1,66 @@
"""Message-ID and subject normalization (SPEC §7.1, §7.3)."""
from __future__ import annotations
import hashlib
import re
# Repeated reply/forward prefixes to strip, case-insensitive (incl. Polish Odp:/PD:).
_PREFIX_RE = re.compile(r"^\s*(re|fw|fwd|odp|pd)\s*:\s*", re.IGNORECASE)
_WS_RE = re.compile(r"\s+")
_ANGLE_RE = re.compile(r"<([^>]*)>")
def normalize_message_id(raw: str | None) -> str | None:
"""Normalize a Message-ID per SPEC §7.1.
Strip angle brackets and whitespace, lowercase the domain part only.
Return None for obviously invalid ids (no ``@``).
"""
if raw is None:
return None
text = raw.strip()
m = _ANGLE_RE.search(text)
if m:
text = m.group(1)
text = text.strip().strip("<>").strip()
if "@" not in text:
return None
local, _, domain = text.rpartition("@")
if not local or not domain:
return None
return f"{local}@{domain.lower()}"
def normalize_reference_list(raw: str | None) -> list[str]:
"""Parse a References / In-Reply-To header value into normalized ids, in order."""
if not raw:
return []
out: list[str] = []
for token in _ANGLE_RE.findall(raw) or raw.split():
norm = normalize_message_id(token)
if norm and norm not in out:
out.append(norm)
return out
def synthesize_message_id(raw_message: bytes) -> str:
"""Synthesize a non-authoritative Message-ID for a message that lacks one (§7.1)."""
digest = hashlib.sha256(raw_message).hexdigest()[:32]
return f"{digest}@synthetic.local"
def is_synthetic(message_id: str | None) -> bool:
"""Return True if ``message_id`` is a synthesized (non-authoritative) id."""
return message_id is not None and message_id.endswith("@synthetic.local")
def normalize_subject(subject: str) -> str:
"""Normalize a subject for the §7.3 fallback: strip repeated Re:/Fw:/... prefixes."""
text = subject or ""
while True:
stripped = _PREFIX_RE.sub("", text, count=1)
if stripped == text:
break
text = stripped
return _WS_RE.sub(" ", text).strip()
+213
View File
@@ -0,0 +1,213 @@
"""JWZ threading — incremental per-message algorithm (SPEC §7.2, §7.3).
Cross-account by design: candidate lookup is scoped by ``user_id`` only (I-3).
Called after the new :class:`Message` row is flushed (so it has an ``id`` and its
normalized id columns); assigns ``thread_id`` and recomputes the thread's
denormalized fields.
"""
from __future__ import annotations
import uuid
from datetime import timedelta
from sqlalchemy import ColumnElement, delete, literal, select, update
from sqlalchemy import any_ as sa_any
from sqlalchemy import or_ as sa_or
from sqlalchemy.ext.asyncio import AsyncSession
from app.models import Message, Thread
from app.threading_.normalize import is_synthetic, normalize_subject
_SUBJECT_FALLBACK_WINDOW = timedelta(days=7)
def _recipient_emails(message: Message) -> set[str]:
"""Recipient (To/Cc) emails of a message, lowercased.
Subject-fallback joining keys on recipients rather than all participants so a
bulk sender that reuses one subject to many recipients (e.g. a newsletter or
a static welcome mail) does not collapse unrelated deliveries into one thread.
A genuine reply is always addressed to someone already in the thread.
"""
emails: set[str] = set()
for field in (message.to_addr, message.cc_addr):
for addr in field:
email = addr.get("email")
if email:
emails.add(email.lower())
return emails
def _candidate_ids(message: Message) -> list[str]:
candidates: list[str] = list(message.references_hdr)
if message.in_reply_to:
candidates.append(message.in_reply_to)
# Own id may let an earlier reply (that referenced us) join when we arrive late.
if message.message_id_hdr and not is_synthetic(message.message_id_hdr):
candidates.append(message.message_id_hdr)
# Dedupe, drop synthetic ids (never used to join, §7.1).
seen: list[str] = []
for c in candidates:
if c and not is_synthetic(c) and c not in seen:
seen.append(c)
return seen
async def _find_by_references(
session: AsyncSession,
user_id: uuid.UUID,
message_id: uuid.UUID,
candidates: list[str],
own_id: str | None,
) -> list[uuid.UUID]:
"""Threads joined by reference (SPEC §7.2.2).
Matches bidirectionally: existing messages whose id is one of my candidate ids
(I reference them / duplicate id), OR existing messages that reference *my* id
(handles a late-arriving parent). Synthetic own-ids are never used to join (§7.1).
"""
conditions: list[ColumnElement[bool]] = []
if candidates:
conditions.append(Message.message_id_hdr.in_(candidates))
if own_id is not None and not is_synthetic(own_id):
# own_id = ANY(references_hdr): an existing message that references me.
conditions.append(literal(own_id) == sa_any(Message.references_hdr))
conditions.append(Message.in_reply_to == own_id)
if not conditions:
return []
stmt = (
select(Message.thread_id)
.where(
Message.user_id == user_id,
Message.thread_id.is_not(None),
Message.id != message_id,
sa_or(*conditions),
)
.distinct()
)
rows = await session.scalars(stmt)
return [tid for tid in rows if tid is not None]
async def _find_by_subject(
session: AsyncSession, user_id: uuid.UUID, message: Message
) -> uuid.UUID | None:
subject_norm = normalize_subject(message.subject)
# Never join when this message both lacks references and has an empty subject (§7.3).
if not subject_norm:
return None
anchor = message.internal_date
lo = anchor - _SUBJECT_FALLBACK_WINDOW
hi = anchor + _SUBJECT_FALLBACK_WINDOW
stmt = (
select(Thread)
.where(
Thread.user_id == user_id,
Thread.subject_norm == subject_norm,
Thread.last_message_at >= lo,
Thread.last_message_at <= hi,
)
.order_by(Thread.last_message_at.desc())
)
my_recipients = _recipient_emails(message)
if not my_recipients:
return None
for thread in await session.scalars(stmt):
participant_emails = {
(p.get("email") or "").lower() for p in thread.participants if p.get("email")
}
if my_recipients & participant_emails:
return thread.id
return None
async def _merge_threads(
session: AsyncSession, thread_ids: list[uuid.UUID]
) -> tuple[uuid.UUID, list[uuid.UUID]]:
"""Merge ``thread_ids`` keeping the oldest ``last_message_at`` as canonical (§7.2.5)."""
threads = list(await session.scalars(select(Thread).where(Thread.id.in_(thread_ids))))
canonical = min(threads, key=lambda t: t.last_message_at)
others = [t.id for t in threads if t.id != canonical.id]
if others:
await session.execute(
update(Message).where(Message.thread_id.in_(others)).values(thread_id=canonical.id)
)
await session.execute(delete(Thread).where(Thread.id.in_(others)))
return canonical.id, others
async def _create_thread(session: AsyncSession, message: Message) -> uuid.UUID:
thread = Thread(
user_id=message.user_id,
subject_norm=normalize_subject(message.subject),
last_message_at=message.internal_date,
)
session.add(thread)
await session.flush()
return thread.id
async def denormalize_thread(session: AsyncSession, thread_id: uuid.UUID) -> None:
"""Recompute a thread's denormalized fields from its messages (SPEC §7.2.6)."""
messages = list(
await session.scalars(
select(Message)
.where(Message.thread_id == thread_id)
.order_by(Message.internal_date.desc())
)
)
thread = await session.get(Thread, thread_id)
if thread is None:
return
if not messages:
await session.execute(delete(Thread).where(Thread.id == thread_id))
return
newest = messages[0]
participants: list[dict[str, str]] = []
seen_emails: set[str] = set()
for msg in messages: # newest first => recency order
for addr in list(msg.from_addr) + list(msg.to_addr) + list(msg.cc_addr):
email = (addr.get("email") or "").lower()
if email and email not in seen_emails:
seen_emails.add(email)
participants.append({"name": addr.get("name", ""), "email": addr.get("email", "")})
thread.message_count = len(messages)
thread.unread_count = sum(1 for m in messages if "\\Seen" not in m.flags)
thread.has_attachments = any(m.has_attachments for m in messages)
thread.pinned = any("\\Flagged" in m.flags for m in messages)
thread.account_ids = sorted({m.account_id for m in messages})
thread.last_message_at = newest.internal_date
thread.snippet = newest.snippet
thread.subject_norm = normalize_subject(newest.subject) or thread.subject_norm
thread.participants = participants
async def thread_message(session: AsyncSession, message: Message) -> uuid.UUID:
"""Assign ``message`` to a thread (creating/attaching/merging) and denormalize.
Returns the resulting thread id and sets ``message.thread_id``.
"""
candidates = _candidate_ids(message)
own_id = message.message_id_hdr
thread_ids = await _find_by_references(session, message.user_id, message.id, candidates, own_id)
if not thread_ids:
subject_thread = await _find_by_subject(session, message.user_id, message)
if subject_thread is not None:
thread_ids = [subject_thread]
if not thread_ids:
thread_id = await _create_thread(session, message)
elif len(thread_ids) == 1:
thread_id = thread_ids[0]
else:
thread_id, _merged_away = await _merge_threads(session, thread_ids)
message.thread_id = thread_id
await session.flush()
await denormalize_thread(session, thread_id)
await session.flush()
return thread_id
+70
View File
@@ -0,0 +1,70 @@
"""WebSocket hub (SPEC §9.2).
Auth via session cookie. Relays every §8.3 event belonging to the user as
``{event, payload}`` frames; the client refetches via TanStack Query invalidation.
Client -> server: ``{"type":"ping"}`` only.
"""
from __future__ import annotations
import asyncio
import json
import logging
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
from app.identity.cookies import SESSION_COOKIE_INSECURE, SESSION_COOKIE_SECURE
from app.identity.sessions import resolve_session
from app.redis_client import get_redis
logger = logging.getLogger(__name__)
router = APIRouter()
@router.websocket("/ws")
async def ws_endpoint(websocket: WebSocket) -> None:
token = websocket.cookies.get(SESSION_COOKIE_SECURE) or websocket.cookies.get(
SESSION_COOKIE_INSECURE, ""
)
session = await resolve_session(token)
if session is None:
await websocket.close(code=1008) # policy violation
return
await websocket.accept()
user_id = str(session.user_id)
pubsub = get_redis().pubsub()
await pubsub.psubscribe("evt:*")
async def forward() -> None:
async for message in pubsub.listen():
if message.get("type") != "pmessage":
continue
try:
data = json.loads(message["data"])
except (ValueError, TypeError):
continue
payload = data.get("payload", {})
if str(payload.get("user_id")) == user_id:
await websocket.send_json(data)
async def receive() -> None:
while True:
raw = await websocket.receive_text()
try:
if json.loads(raw).get("type") == "ping":
await websocket.send_json({"type": "pong"})
except (ValueError, TypeError):
continue
forward_task = asyncio.create_task(forward())
receive_task = asyncio.create_task(receive())
try:
await asyncio.wait({forward_task, receive_task}, return_when=asyncio.FIRST_COMPLETED)
except WebSocketDisconnect:
pass
finally:
for task in (forward_task, receive_task):
task.cancel()
await pubsub.aclose() # type: ignore[no-untyped-call]