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
+13
View File
@@ -0,0 +1,13 @@
APP_DOMAIN=dev.webmail.local
DATABASE_URL=postgresql+asyncpg://webmail:webmail_dev@127.0.0.1:5433/webmail_dev
REDIS_URL=redis://127.0.0.1:6380/0
SEARCH_BACKEND=pg # switch to meili in M2 work
MEILI_URL=http://127.0.0.1:7700
MEILI_KEY=dev_master_key_change_me
MASTER_KEY=CHANGE_ME_base64_32_bytes # generate: openssl rand -base64 32
BLOB_ROOT=./var/blobs
UNDO_SECONDS=10
POLL_INTERVAL=120
SESSION_TTL_DAYS=30
MAX_ATTACHMENT_MB=25
LOG_LEVEL=DEBUG
+33
View File
@@ -0,0 +1,33 @@
# Secrets / local env
.env.dev
.env.*.local
gitea-server-credentials.md
# Local tooling
.claude/
# Blob store / runtime data (email content — never commit)
/var/
/backend/var/
**/var/blobs/
# Python
__pycache__/
*.py[cod]
.mypy_cache/
.ruff_cache/
.pytest_cache/
*.egg-info/
.eggs/
build/
dist/
# Node / frontend
node_modules/
frontend/dist/
.vite/
# Editor / OS
.DS_Store
.idea/
.vscode/
+19
View File
@@ -0,0 +1,19 @@
up: ## start services
docker compose -f docker/docker-compose.yml up -d postgres redis meilisearch
down: ## stop services (data kept)
docker compose -f docker/docker-compose.yml down
reset-db: ## DESTRUCTIVE: wipe db + search + blobs, re-migrate
docker compose -f docker/docker-compose.yml down -v
rm -rf var/blobs && mkdir -p var/blobs
docker compose -f docker/docker-compose.yml up -d --wait postgres redis meilisearch
cd backend && alembic upgrade head
api:
uvicorn app.main:create_app --factory --reload --port 8000 --app-dir backend
worker:
cd backend && arq workers.worker.WorkerSettings
web:
cd frontend && npm run dev
test:
cd backend && pytest -q && cd ../frontend && npx vitest run
mail-test: ## start throwaway IMAP/SMTP server
docker compose -f docker/docker-compose.yml up -d greenmail
+38
View File
@@ -0,0 +1,38 @@
[alembic]
script_location = migrations
prepend_sys_path = .
version_path_separator = os
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARNING
handlers = console
qualname =
[logger_sqlalchemy]
level = WARNING
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S
+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]
+57
View File
@@ -0,0 +1,57 @@
"""Alembic environment — async engine, config sourced from app settings."""
from __future__ import annotations
import asyncio
from logging.config import fileConfig
from alembic import context
from sqlalchemy.ext.asyncio import async_engine_from_config
from sqlalchemy.pool import NullPool
# Import models so Base.metadata is populated for autogenerate.
import app.models # noqa: F401
from app.config import get_settings
from app.db import Base
config = context.config
if config.config_file_name is not None:
fileConfig(config.config_file_name)
config.set_main_option("sqlalchemy.url", get_settings().database_url)
target_metadata = Base.metadata
def run_migrations_offline() -> None:
context.configure(
url=get_settings().database_url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def do_run_migrations(connection: object) -> None:
context.configure(connection=connection, target_metadata=target_metadata) # type: ignore[arg-type]
with context.begin_transaction():
context.run_migrations()
async def run_migrations_online() -> None:
connectable = async_engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=NullPool,
)
async with connectable.connect() as connection:
await connection.run_sync(do_run_migrations)
await connectable.dispose()
if context.is_offline_mode():
run_migrations_offline()
else:
asyncio.run(run_migrations_online())
+27
View File
@@ -0,0 +1,27 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from __future__ import annotations
from collections.abc import Sequence
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
revision: str = ${repr(up_revision)}
down_revision: str | None = ${repr(down_revision)}
branch_labels: str | Sequence[str] | None = ${repr(branch_labels)}
depends_on: str | Sequence[str] | None = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}
+200
View File
@@ -0,0 +1,200 @@
"""initial schema (SPEC §4)
Revision ID: 0001
Revises:
Create Date: 2026-07-16
The authoritative DDL lives in SPEC §4; this migration reproduces it verbatim.
"""
from __future__ import annotations
from collections.abc import Sequence
from alembic import op
revision: str = "0001"
down_revision: str | None = None
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
UPGRADE_STMTS: list[str] = [
"CREATE EXTENSION IF NOT EXISTS citext",
"""
CREATE TABLE users (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
handle citext UNIQUE NOT NULL,
password_hash text NOT NULL,
totp_secret bytea,
recovery_email citext,
settings jsonb NOT NULL DEFAULT '{}',
created_at timestamptz NOT NULL DEFAULT now()
)
""",
"""
CREATE TABLE accounts (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
email citext NOT NULL,
display_name text NOT NULL DEFAULT '',
color text NOT NULL DEFAULT '#888888',
imap_config jsonb NOT NULL,
smtp_config jsonb NOT NULL,
secret_ct bytea NOT NULL,
discovery_src text NOT NULL,
state text NOT NULL DEFAULT 'backfilling',
state_detail text NOT NULL DEFAULT '',
last_sync_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (user_id, email)
)
""",
"CREATE INDEX ix_accounts_user_id ON accounts (user_id)",
"""
CREATE TABLE mailboxes (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
account_id uuid NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
path text NOT NULL,
special_use text,
uidvalidity bigint NOT NULL DEFAULT 0,
highestmodseq bigint,
last_seen_uid bigint NOT NULL DEFAULT 0,
UNIQUE (account_id, path)
)
""",
"""
CREATE TABLE threads (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
subject_norm text NOT NULL,
snippet text NOT NULL DEFAULT '',
participants jsonb NOT NULL DEFAULT '[]',
account_ids uuid[] NOT NULL DEFAULT '{}',
message_count int NOT NULL DEFAULT 0,
unread_count int NOT NULL DEFAULT 0,
has_attachments boolean NOT NULL DEFAULT false,
last_message_at timestamptz NOT NULL
)
""",
"CREATE INDEX ix_threads_user_last ON threads (user_id, last_message_at DESC)",
"""
CREATE TABLE messages (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
account_id uuid NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
mailbox_id uuid NOT NULL REFERENCES mailboxes(id) ON DELETE CASCADE,
uid bigint NOT NULL,
thread_id uuid REFERENCES threads(id),
message_id_hdr text,
in_reply_to text,
references_hdr text[] NOT NULL DEFAULT '{}',
subject text NOT NULL DEFAULT '',
from_addr jsonb NOT NULL DEFAULT '[]',
to_addr jsonb NOT NULL DEFAULT '[]',
cc_addr jsonb NOT NULL DEFAULT '[]',
date_hdr timestamptz,
internal_date timestamptz NOT NULL,
flags text[] NOT NULL DEFAULT '{}',
size_bytes int NOT NULL DEFAULT 0,
has_attachments boolean NOT NULL DEFAULT false,
snippet text NOT NULL DEFAULT '',
blob_sha256 text NOT NULL,
body_indexed tsvector,
UNIQUE (mailbox_id, uid)
)
""",
"CREATE INDEX ix_messages_user_thread ON messages (user_id, thread_id)",
"CREATE INDEX ix_messages_account_msgid ON messages (account_id, message_id_hdr)",
"CREATE INDEX ix_messages_body_indexed ON messages USING gin (body_indexed)",
"""
CREATE TABLE body_parts (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
message_id uuid NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
kind text NOT NULL,
content text NOT NULL,
sanitizer_v int NOT NULL DEFAULT 0
)
""",
"""
CREATE TABLE attachments (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
message_id uuid NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
user_id uuid NOT NULL,
filename text NOT NULL,
mime_type text NOT NULL,
size_bytes int NOT NULL,
content_id text,
blob_sha256 text NOT NULL
)
""",
"""
CREATE TABLE drafts (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
account_id uuid NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
thread_id uuid REFERENCES threads(id),
reply_to_message_id uuid REFERENCES messages(id),
kind text NOT NULL DEFAULT 'reply',
payload jsonb NOT NULL,
updated_at timestamptz NOT NULL DEFAULT now()
)
""",
"""
CREATE TABLE outbox (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL,
draft_id uuid NOT NULL,
state text NOT NULL DEFAULT 'undo_window',
send_after timestamptz NOT NULL,
error text
)
""",
"""
CREATE TABLE saved_searches (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name text NOT NULL,
query text NOT NULL,
pinned boolean NOT NULL DEFAULT true,
UNIQUE (user_id, name)
)
""",
"""
CREATE TABLE known_recipients (
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
email citext NOT NULL,
name text NOT NULL DEFAULT '',
times_used int NOT NULL DEFAULT 0,
last_used_at timestamptz NOT NULL,
PRIMARY KEY (user_id, email)
)
""",
"CREATE INDEX ix_known_recipients_user_email "
"ON known_recipients (user_id, email text_pattern_ops)",
]
DOWNGRADE_STMTS: list[str] = [
"DROP TABLE IF EXISTS known_recipients",
"DROP TABLE IF EXISTS saved_searches",
"DROP TABLE IF EXISTS outbox",
"DROP TABLE IF EXISTS drafts",
"DROP TABLE IF EXISTS attachments",
"DROP TABLE IF EXISTS body_parts",
"DROP TABLE IF EXISTS messages",
"DROP TABLE IF EXISTS threads",
"DROP TABLE IF EXISTS mailboxes",
"DROP TABLE IF EXISTS accounts",
"DROP TABLE IF EXISTS users",
]
def upgrade() -> None:
for stmt in UPGRADE_STMTS:
op.execute(stmt)
def downgrade() -> None:
for stmt in DOWNGRADE_STMTS:
op.execute(stmt)
@@ -0,0 +1,28 @@
"""index messages(user_id, message_id_hdr)
Speeds the per-user Message-ID lookups used by JWZ threading (§7.2 candidate
set) and by the duplicate-Message-ID resolution in the indexer (§7.1).
Revision ID: 0002
Revises: 0001
Create Date: 2026-07-17
"""
from __future__ import annotations
from collections.abc import Sequence
from alembic import op
revision: str = "0002"
down_revision: str | None = "0001"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.execute("CREATE INDEX ix_messages_user_msgid ON messages (user_id, message_id_hdr)")
def downgrade() -> None:
op.execute("DROP INDEX IF EXISTS ix_messages_user_msgid")
@@ -0,0 +1,28 @@
"""add messages.has_remote_images
Surfaces whether the sanitizer blocked remote images so the UI can show the
"load images" bar (SPEC §9.4).
Revision ID: 0003
Revises: 0002
Create Date: 2026-07-17
"""
from __future__ import annotations
from collections.abc import Sequence
from alembic import op
revision: str = "0003"
down_revision: str | None = "0002"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.execute("ALTER TABLE messages ADD COLUMN has_remote_images boolean NOT NULL DEFAULT false")
def downgrade() -> None:
op.execute("ALTER TABLE messages DROP COLUMN IF EXISTS has_remote_images")
@@ -0,0 +1,28 @@
"""add threads.pinned
Denormalized "pinned" state for a thread (SPEC extension for the UI pin action):
true when any message in the thread carries the \\Flagged flag.
Revision ID: 0004
Revises: 0003
Create Date: 2026-07-17
"""
from __future__ import annotations
from collections.abc import Sequence
from alembic import op
revision: str = "0004"
down_revision: str | None = "0003"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.execute("ALTER TABLE threads ADD COLUMN pinned boolean NOT NULL DEFAULT false")
def downgrade() -> None:
op.execute("ALTER TABLE threads DROP COLUMN IF EXISTS pinned")
@@ -0,0 +1,53 @@
"""add compose_attachments
Files uploaded for outgoing mail (the compose "Attach" action). Referenced by a
draft's payload.attachment_ids and assembled into the MIME at send time.
Revision ID: 0005
Revises: 0004
Create Date: 2026-07-17
"""
from __future__ import annotations
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "0005"
down_revision: str | None = "0004"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.create_table(
"compose_attachments",
sa.Column(
"id",
sa.dialects.postgresql.UUID(as_uuid=True),
primary_key=True,
server_default=sa.text("gen_random_uuid()"),
),
sa.Column(
"user_id",
sa.dialects.postgresql.UUID(as_uuid=True),
sa.ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("filename", sa.Text(), nullable=False),
sa.Column("mime_type", sa.Text(), nullable=False),
sa.Column("size_bytes", sa.Integer(), nullable=False),
sa.Column("blob_sha256", sa.Text(), nullable=False),
sa.Column(
"created_at",
sa.TIMESTAMP(timezone=True),
nullable=False,
server_default=sa.text("now()"),
),
)
def downgrade() -> None:
op.drop_table("compose_attachments")
+77
View File
@@ -0,0 +1,77 @@
[build-system]
requires = ["setuptools>=68", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "webmail-backend"
version = "0.1.0"
description = "Webmail client backend (sync-first, see webmail-client-spec.md)"
requires-python = "==3.11.*"
# Pinned per SPEC §3. Do not swap libraries or add deps without a `# DEPENDENCY:` comment.
dependencies = [
"fastapi~=0.115",
"uvicorn[standard]~=0.30",
"sqlalchemy[asyncio]~=2.0",
"asyncpg~=0.29",
"alembic~=1.13",
"pydantic~=2.8",
"pydantic-settings~=2.4",
"arq~=0.26",
"redis~=5.0",
"aioimaplib~=1.1",
"imapclient~=3.0",
"aiosmtplib~=3.0",
"argon2-cffi~=23.1",
"pyotp~=2.9",
"dnspython~=2.6",
"httpx~=0.27",
"nh3~=0.2",
"cryptography~=43.0",
"mail-parser~=4.0",
"python-multipart~=0.0.9",
# DEPENDENCY: SPEC §6.2 mandates accounts/presets.yaml (config, not code); the
# stdlib has no YAML parser, so PyYAML is required to load it.
"pyyaml~=6.0",
# DEPENDENCY: autoconfig/ISPDB return XML from untrusted servers; defusedxml
# blocks XXE/billion-laughs that stdlib ElementTree is vulnerable to (§10 hostile input).
"defusedxml~=0.7",
]
[project.optional-dependencies]
dev = [
"ruff~=0.6",
"mypy~=1.11",
"pytest~=8.3",
"pytest-asyncio~=0.24",
"respx~=0.21",
"aiosqlite~=0.20",
]
[tool.setuptools.packages.find]
where = ["."]
include = ["app*", "workers*"]
[tool.ruff]
line-length = 100
target-version = "py311"
[tool.ruff.lint]
select = ["E", "F", "I", "UP", "B", "ASYNC", "C4", "SIM"]
ignore = ["B008"] # FastAPI Depends() in defaults is idiomatic
[tool.mypy]
python_version = "3.11"
strict = true
plugins = ["pydantic.mypy"]
mypy_path = "."
explicit_package_bases = true
namespace_packages = true
[[tool.mypy.overrides]]
module = ["aioimaplib.*", "mailparser.*", "imapclient.*", "arq.*", "yaml", "defusedxml.*"]
ignore_missing_imports = true
[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["tests"]
pythonpath = ["."]
+75
View File
@@ -0,0 +1,75 @@
"""Dev-only: seed a demo user with a synced test mailbox so the UI has data.
Run from backend/ with the conda env active and services up:
python scripts/seed_demo.py
Idempotent: reuses the demo user/account if they already exist and re-syncs.
"""
from __future__ import annotations
import asyncio
from sqlalchemy import select
from app.db import session_scope
from app.identity.passwords import hash_password
from app.models import Account, User
from app.security.crypto import encrypt_secret
from app.sync.engine import bootstrap_and_backfill
DEMO_LOCALPART = "demo"
DEMO_PASSWORD = "webmailDemo1"
MAILBOX_EMAIL = "test@imaginary-pixels.com"
MAILBOX_PASSWORD = b"webmail!@A"
HOST = "serwer2295308.home.pl"
async def main() -> None:
from app.config import get_settings
domain = get_settings().app_domain
handle = f"{DEMO_LOCALPART}@{domain}"
async with session_scope() as session:
user = await session.scalar(select(User).where(User.handle == handle))
if user is None:
user = User(handle=handle, password_hash=hash_password(DEMO_PASSWORD))
session.add(user)
await session.flush()
print(f"created user {handle}")
else:
# Reset password so the printed credentials always work.
user.password_hash = hash_password(DEMO_PASSWORD)
print(f"reusing user {handle}")
account = await session.scalar(
select(Account).where(Account.user_id == user.id, Account.email == MAILBOX_EMAIL)
)
if account is None:
account = Account(
user_id=user.id,
email=MAILBOX_EMAIL,
display_name="Test Mailbox",
color="#22aa77",
imap_config={"host": HOST, "port": 993, "tls": "ssl", "auth": "password"},
smtp_config={"host": HOST, "port": 465, "tls": "ssl", "auth": "password"},
secret_ct=encrypt_secret(MAILBOX_PASSWORD),
discovery_src="manual",
state="backfilling",
)
session.add(account)
await session.flush()
print(f"created account {MAILBOX_EMAIL}")
account_id = account.id
print("syncing mailbox (this hits the real IMAP server)…")
count = await bootstrap_and_backfill(account_id)
print(f"synced {count} messages")
print("\n=== Log in at http://localhost:5173 ===")
print(f" handle: {DEMO_LOCALPART}")
print(f" password: {DEMO_PASSWORD}")
if __name__ == "__main__":
asyncio.run(main())
+174
View File
@@ -0,0 +1,174 @@
"""Account CRUD API (SPEC §9.1) driven through the ASGI app."""
from __future__ import annotations
import uuid
from collections.abc import AsyncIterator
from dataclasses import dataclass
import httpx
import pytest_asyncio
from httpx import ASGITransport
from sqlalchemy import delete, select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.pool import NullPool
from app.api import accounts as accounts_api
from app.config import get_settings
from app.identity.passwords import hash_password
from app.identity.sessions import create_session
from app.main import create_app
from app.models import Account, User
CSRF = "test-csrf-token"
GOOD_CFG = {
"imap": {"host": "imap.example.com", "port": 993, "tls": "ssl"},
"smtp": {"host": "smtp.example.com", "port": 465, "tls": "ssl"},
}
@dataclass
class Ctx:
client: httpx.AsyncClient
user_id: uuid.UUID
session: AsyncSession
def _hdr() -> dict[str, str]:
return {"X-CSRF": CSRF}
@pytest_asyncio.fixture
async def ctx() -> AsyncIterator[Ctx]:
engine = create_async_engine(get_settings().database_url, poolclass=NullPool)
sm = async_sessionmaker(engine, expire_on_commit=False)
session = sm()
user = User(handle=f"acc{uuid.uuid4().hex[:8]}@test.local", password_hash=hash_password("pw"))
session.add(user)
await session.commit()
token = await create_session(user.id, "pytest")
transport = ASGITransport(app=create_app())
client = httpx.AsyncClient(
transport=transport,
base_url="http://testserver",
cookies={"session": token, "csrf": CSRF},
)
try:
yield Ctx(client=client, user_id=user.id, session=session)
finally:
await client.aclose()
await session.execute(delete(User).where(User.id == user.id))
await session.commit()
await session.close()
await engine.dispose()
async def test_discover_gmail_preset(ctx: Ctx) -> None:
resp = await ctx.client.post(
"/api/accounts/discover", json={"email": "someone@gmail.com"}, headers=_hdr()
)
assert resp.status_code == 200
data = resp.json()
assert data["candidates"][0]["imap"]["host"] == "imap.gmail.com"
assert data["candidates"][0]["source"] == "preset"
assert data["steps"][0]["method"] == "preset"
async def test_discover_requires_csrf(ctx: Ctx) -> None:
resp = await ctx.client.post(
"/api/accounts/discover", json={"email": "someone@gmail.com"}
) # no X-CSRF header
assert resp.status_code == 403
assert resp.json()["error"]["code"] == "csrf_failed"
async def test_discover_rate_limited(ctx: Ctx) -> None:
last = None
for _ in range(11):
last = await ctx.client.post(
"/api/accounts/discover", json={"email": "x@gmail.com"}, headers=_hdr()
)
assert last is not None and last.status_code == 429
async def test_test_endpoint_maps_failure(ctx: Ctx, monkeypatch) -> None: # type: ignore[no-untyped-def]
from app.accounts.testing import AccountTestError
async def fake_test(**_kw: object) -> None:
raise AccountTestError("auth_failed", "bad password")
monkeypatch.setattr(accounts_api, "test_account", fake_test)
resp = await ctx.client.post(
"/api/accounts/test",
json={**GOOD_CFG, "email": "me@example.com", "password": "x"},
headers=_hdr(),
)
assert resp.status_code == 400
assert resp.json()["error"]["code"] == "auth_failed"
async def test_create_account_enqueues_sync(ctx: Ctx, monkeypatch) -> None: # type: ignore[no-untyped-def]
async def fake_test(**_kw: object) -> None:
return None
enqueued: list[tuple[str, str]] = []
class FakePool:
async def enqueue_job(self, name: str, arg: str) -> None:
enqueued.append((name, arg))
async def fake_pool() -> FakePool:
return FakePool()
monkeypatch.setattr(accounts_api, "test_account", fake_test)
monkeypatch.setattr(accounts_api, "get_arq_pool", fake_pool)
resp = await ctx.client.post(
"/api/accounts",
json={
**GOOD_CFG,
"email": "me@example.com",
"display_name": "Me",
"color": "#ff0000",
"password": "secret",
},
headers=_hdr(),
)
assert resp.status_code == 200, resp.text
body = resp.json()
assert body["email"] == "me@example.com"
assert body["state"] == "backfilling"
account = await ctx.session.scalar(select(Account).where(Account.id == uuid.UUID(body["id"])))
assert account is not None
assert account.secret_ct # credentials stored encrypted, non-empty
assert account.imap_config["host"] == "imap.example.com"
assert enqueued == [("sync_bootstrap_account", body["id"])]
async def test_delete_account(ctx: Ctx, monkeypatch) -> None: # type: ignore[no-untyped-def]
async def fake_test(**_kw: object) -> None:
return None
class FakePool:
async def enqueue_job(self, name: str, arg: str) -> None:
return None
async def fake_pool() -> FakePool:
return FakePool()
monkeypatch.setattr(accounts_api, "test_account", fake_test)
monkeypatch.setattr(accounts_api, "get_arq_pool", fake_pool)
created = await ctx.client.post(
"/api/accounts",
json={**GOOD_CFG, "email": "del@example.com", "password": "secret"},
headers=_hdr(),
)
account_id = created.json()["id"]
resp = await ctx.client.request("DELETE", f"/api/accounts/{account_id}", headers=_hdr())
assert resp.status_code == 200
gone = await ctx.session.scalar(select(Account).where(Account.id == uuid.UUID(account_id)))
assert gone is None
+185
View File
@@ -0,0 +1,185 @@
"""Autodiscovery resolver (SPEC §6.2), with respx-mocked HTTP and a DNS stub."""
from __future__ import annotations
import httpx
import pytest
import respx
from app.accounts import discovery, ssrf
from app.accounts.discovery import discover, domain_of, parse_autoconfig_xml
from app.accounts.types import ServerConfig
AUTOCONFIG_XML = """<?xml version="1.0"?>
<clientConfig version="1.1">
<emailProvider id="example.com">
<domain>example.com</domain>
<incomingServer type="imap">
<hostname>imap.example.com</hostname>
<port>993</port>
<socketType>SSL</socketType>
<authentication>password-cleartext</authentication>
<username>%EMAILADDRESS%</username>
</incomingServer>
<outgoingServer type="smtp">
<hostname>smtp.example.com</hostname>
<port>587</port>
<socketType>STARTTLS</socketType>
<authentication>password-cleartext</authentication>
<username>%EMAILADDRESS%</username>
</outgoingServer>
</emailProvider>
</clientConfig>
"""
@pytest.fixture(autouse=True)
def _skip_ssrf(monkeypatch) -> None: # type: ignore[no-untyped-def]
"""Bypass the real DNS/public-IP check so respx can intercept the fetch."""
async def ok(_host: str) -> None:
return None
monkeypatch.setattr(ssrf, "assert_host_is_public", ok)
# --- XML parsing -------------------------------------------------------------
def test_parse_autoconfig_xml() -> None:
cand = parse_autoconfig_xml(AUTOCONFIG_XML)
assert cand is not None
assert cand.imap == ServerConfig("imap.example.com", 993, "ssl", "password")
assert cand.smtp == ServerConfig("smtp.example.com", 587, "starttls", "password")
def test_parse_autoconfig_xml_rejects_garbage() -> None:
assert parse_autoconfig_xml("not xml <<<") is None
assert parse_autoconfig_xml("<clientConfig></clientConfig>") is None
def test_domain_of() -> None:
assert domain_of("Alice@Example.COM") == "example.com"
# --- preset step -------------------------------------------------------------
async def test_preset_hit_short_circuits() -> None:
result = await discover("someone@gmail.com")
assert len(result.candidates) == 1
c = result.candidates[0]
assert c.source == "preset"
assert c.imap.host == "imap.gmail.com"
assert c.oauth_provider == "google"
assert result.steps[0].method == "preset" and result.steps[0].status == "found"
async def test_preset_polish_provider() -> None:
result = await discover("jan@wp.pl")
assert result.candidates[0].imap.host == "imap.wp.pl"
assert result.candidates[0].source == "preset"
# --- autoconfig / ispdb via HTTP --------------------------------------------
@respx.mock
async def test_autoconfig_found() -> None:
respx.get(url__regex=r"https://autoconfig\.example\.com/mail/.*").mock(
return_value=httpx.Response(200, text=AUTOCONFIG_XML)
)
result = await discover("user@example.com")
assert result.candidates[0].source == "autoconfig"
assert result.candidates[0].imap.host == "imap.example.com"
# preset step was recorded as not_found before autoconfig succeeded
assert result.steps[0].status == "not_found"
@respx.mock
async def test_ispdb_found_after_autoconfig_misses() -> None:
respx.get(url__regex=r"https://autoconfig\.example\.com/.*").mock(
return_value=httpx.Response(404)
)
respx.get(url__regex=r"https://example\.com/\.well-known/.*").mock(
return_value=httpx.Response(404)
)
respx.get(url__regex=r"https://autoconfig\.thunderbird\.net/v1\.1/example\.com").mock(
return_value=httpx.Response(200, text=AUTOCONFIG_XML)
)
result = await discover("user@example.com")
assert result.candidates[0].source == "ispdb"
methods = {s.method for s in result.steps}
assert {"preset", "autoconfig", "ispdb"} <= methods
# --- SRV via DNS stub --------------------------------------------------------
class _FakeSrv:
def __init__(self, priority: int, weight: int, port: int, target: str) -> None:
self.priority = priority
self.weight = weight
self.port = port
self.target = target
@respx.mock
async def test_srv_found(monkeypatch) -> None: # type: ignore[no-untyped-def]
# autoconfig + ispdb all miss
respx.get(url__regex=r"https://.*").mock(return_value=httpx.Response(404))
async def fake_srv_lookup(name: str, tls: str) -> ServerConfig | None:
if name.startswith("_imaps._tcp"):
return ServerConfig("imap.example.com", 993, "ssl")
if name.startswith("_submissions._tcp"):
return ServerConfig("smtp.example.com", 465, "ssl")
return None
monkeypatch.setattr(discovery, "_srv_lookup", fake_srv_lookup)
result = await discover("user@example.com")
assert result.candidates[0].source == "srv"
assert result.candidates[0].imap.port == 993
# --- probe fallback ----------------------------------------------------------
@respx.mock
async def test_probe_found_when_all_else_misses(monkeypatch) -> None: # type: ignore[no-untyped-def]
respx.get(url__regex=r"https://.*").mock(return_value=httpx.Response(404))
async def no_srv(_name: str, _tls: str) -> ServerConfig | None:
return None
async def fake_probe(host: str, port: int, tls: str, kind: str) -> ServerConfig | None:
# only imap.example.com:993 and smtp.example.com:465 "answer"
if (host, port) in {("imap.example.com", 993), ("smtp.example.com", 465)}:
return ServerConfig(host, port, tls)
return None
monkeypatch.setattr(discovery, "_srv_lookup", no_srv)
monkeypatch.setattr(discovery, "_probe_one", fake_probe)
result = await discover("user@example.com")
assert result.candidates[0].source == "probe"
assert result.candidates[0].imap.host == "imap.example.com"
assert result.candidates[0].smtp.host == "smtp.example.com"
@respx.mock
async def test_all_steps_fail_records_every_step(monkeypatch) -> None: # type: ignore[no-untyped-def]
respx.get(url__regex=r"https://.*").mock(return_value=httpx.Response(404))
async def no_srv(_name: str, _tls: str) -> ServerConfig | None:
return None
async def no_probe(_h: str, _p: int, _t: str, _k: str) -> ServerConfig | None:
return None
monkeypatch.setattr(discovery, "_srv_lookup", no_srv)
monkeypatch.setattr(discovery, "_probe_one", no_probe)
result = await discover("user@nowhere.invalid")
assert result.candidates == []
methods = [s.method for s in result.steps]
assert methods == ["preset", "autoconfig", "ispdb", "srv", "probe"]
assert all(s.status in ("not_found", "error") for s in result.steps)
+55
View File
@@ -0,0 +1,55 @@
"""SSRF guard for autodiscovery (SPEC §10.6)."""
from __future__ import annotations
import pytest
from app.accounts import ssrf
from app.accounts.ssrf import SsrfError, _is_public_ip, assert_host_is_public, guarded_get
@pytest.mark.parametrize(
"ip,public",
[
("8.8.8.8", True),
("1.1.1.1", True),
("2606:4700:4700::1111", True),
("127.0.0.1", False),
("10.0.0.1", False),
("192.168.1.1", False),
("172.16.5.4", False),
("169.254.1.1", False), # link-local
("::1", False),
("fe80::1", False), # link-local v6
("0.0.0.0", False),
],
)
def test_is_public_ip(ip: str, public: bool) -> None:
assert _is_public_ip(ip) is public
async def test_assert_host_public_accepts_public(monkeypatch) -> None: # type: ignore[no-untyped-def]
async def fake_resolve(_host: str) -> list[str]:
return ["8.8.8.8"]
monkeypatch.setattr(ssrf, "_resolve_all", fake_resolve)
await assert_host_is_public("mail.example.com") # no raise
async def test_assert_host_rejects_private(monkeypatch) -> None: # type: ignore[no-untyped-def]
async def fake_resolve(_host: str) -> list[str]:
return ["8.8.8.8", "127.0.0.1"] # one private address is enough to reject
monkeypatch.setattr(ssrf, "_resolve_all", fake_resolve)
with pytest.raises(SsrfError):
await assert_host_is_public("evil.example.com")
async def test_guarded_get_rejects_non_https() -> None:
with pytest.raises(SsrfError):
await guarded_get("http://example.com/config.xml")
async def test_guarded_get_rejects_missing_host() -> None:
with pytest.raises(SsrfError):
await guarded_get("https:///config.xml")
+283
View File
@@ -0,0 +1,283 @@
"""REST API integration tests (SPEC §9.1) driving the ASGI app with seeded data."""
from __future__ import annotations
import uuid
from collections.abc import AsyncIterator
from dataclasses import dataclass, field
from datetime import UTC, datetime
from email.message import EmailMessage
import httpx
import pytest_asyncio
from httpx import ASGITransport
from sqlalchemy import delete, select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.pool import NullPool
from app.config import get_settings
from app.identity.passwords import hash_password
from app.identity.sessions import create_session
from app.indexer.indexer import persist_message
from app.main import create_app
from app.models import Account, Mailbox, Message, User
from app.providers.base import RemoteMessage
from app.search.pg import PgSearchBackend
@dataclass
class ApiCtx:
client: httpx.AsyncClient
user_id: uuid.UUID
account_id: uuid.UUID | None = None
archive_id: uuid.UUID | None = None
session: AsyncSession | None = None
thread_ids: list[uuid.UUID] = field(default_factory=list)
message_ids: list[uuid.UUID] = field(default_factory=list)
def _raw(
subject: str, body: str, *, mid: str, refs: str | None = None, html: str | None = None
) -> bytes:
m = EmailMessage()
m["Subject"] = subject
m["From"] = "Alice <alice@example.com>"
m["To"] = "me@example.com"
m["Message-ID"] = mid
m["Date"] = "Mon, 09 Mar 2026 08:36:37 +0100"
if refs:
m["References"] = refs
m["In-Reply-To"] = refs
m.set_content(body)
if html:
m.add_alternative(html, subtype="html")
return m.as_bytes()
@pytest_asyncio.fixture
async def api() -> AsyncIterator[ApiCtx]:
engine = create_async_engine(get_settings().database_url, poolclass=NullPool)
sm = async_sessionmaker(engine, expire_on_commit=False)
session = sm()
user = User(handle=f"api{uuid.uuid4().hex[:8]}@test.local", password_hash=hash_password("pw"))
session.add(user)
await session.flush()
account = Account(
user_id=user.id,
email="me@example.com",
imap_config={},
smtp_config={},
secret_ct=b"",
discovery_src="manual",
)
session.add(account)
await session.flush()
mailbox = Mailbox(account_id=account.id, user_id=user.id, path="INBOX", special_use="inbox")
archive = Mailbox(account_id=account.id, user_id=user.id, path="Archive", special_use="archive")
session.add_all([mailbox, archive])
await session.commit()
backend = PgSearchBackend()
ctx = ApiCtx(
client=None, # type: ignore[arg-type]
user_id=user.id,
account_id=account.id,
archive_id=archive.id,
session=session,
)
specs = [
(_raw("Project kickoff", "kickoff meeting notes and revenue plan", mid="<a@x.com>"), 1),
(
_raw(
"Re: Project kickoff",
"sounds good, revenue looks strong",
mid="<b@x.com>",
refs="<a@x.com>",
),
2,
),
(
_raw(
"Lunch",
"grab lunch tomorrow?",
mid="<c@x.com>",
html="<p>grab <b>lunch</b> <script>x()</script>tomorrow?</p>",
),
3,
),
]
for raw, u in specs:
remote = RemoteMessage(uid=u, raw=raw, flags=frozenset(), internal_date=datetime.now(UTC))
result = await persist_message(
session,
user_id=user.id,
account_id=account.id,
mailbox_id=mailbox.id,
mailbox_special_use="inbox",
remote=remote,
)
await session.commit()
assert result is not None
ctx.message_ids.append(result.message.id)
if result.message.thread_id not in ctx.thread_ids:
ctx.thread_ids.append(result.message.thread_id)
await backend.index(result.search_doc)
token = await create_session(user.id, "pytest")
transport = ASGITransport(app=create_app())
ctx.client = httpx.AsyncClient(
transport=transport, base_url="http://testserver", cookies={"session": token}
)
try:
yield ctx
finally:
await ctx.client.aclose()
await session.execute(delete(User).where(User.id == user.id))
await session.commit()
await session.close()
await engine.dispose()
async def test_requires_auth() -> None:
transport = ASGITransport(app=create_app())
async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as client:
resp = await client.get("/api/threads")
assert resp.status_code == 401
assert resp.json()["error"]["code"] == "not_authenticated"
async def test_list_threads(api: ApiCtx) -> None:
resp = await api.client.get("/api/threads")
assert resp.status_code == 200
data = resp.json()
# 2 threads: the kickoff reply-chain + lunch
assert len(data["threads"]) == 2
# newest first
subjects = [t["subject"] for t in data["threads"]]
assert "Lunch" in subjects
kickoff = next(t for t in data["threads"] if "kickoff" in t["subject"].lower())
assert kickoff["message_count"] == 2
async def test_folder_view(api: ApiCtx) -> None:
# all seeded mail is in an inbox mailbox
inbox = await api.client.get("/api/threads", params={"view": "folder:inbox"})
assert inbox.status_code == 200
assert len(inbox.json()["threads"]) == 2 # kickoff chain + lunch
# no messages are in a sent folder
sent = await api.client.get("/api/threads", params={"view": "folder:sent"})
assert sent.status_code == 200
assert sent.json()["threads"] == []
async def test_account_view_shows_only_that_accounts_inbox(api: ApiCtx) -> None:
assert api.session is not None
# The account view starts equal to the inbox (all seeded mail is in inbox).
resp = await api.client.get("/api/threads", params={"view": f"account:{api.account_id}"})
assert resp.status_code == 200
assert len(resp.json()["threads"]) == 2
# Move the whole "Lunch" thread out of the inbox into Archive.
lunch_id = next(t["id"] for t in resp.json()["threads"] if t["subject"] == "Lunch")
msgs = list(
await api.session.scalars(select(Message).where(Message.thread_id == uuid.UUID(lunch_id)))
)
for m in msgs:
m.mailbox_id = api.archive_id
await api.session.commit()
# It must drop out of both the unified inbox and the account view...
inbox = await api.client.get("/api/threads", params={"view": "folder:inbox"})
assert [t["subject"] for t in inbox.json()["threads"]] == ["Project kickoff"]
account = await api.client.get("/api/threads", params={"view": f"account:{api.account_id}"})
assert [t["subject"] for t in account.json()["threads"]] == ["Project kickoff"]
# ...but still be reachable under the Archive folder.
archive = await api.client.get("/api/threads", params={"view": "folder:archive"})
assert [t["subject"] for t in archive.json()["threads"]] == ["Lunch"]
async def test_attachments_list_and_zip(api: ApiCtx) -> None:
import io
import zipfile
from app.indexer import blobstore
from app.models import Attachment
assert api.session is not None
mid = api.message_ids[-1] # the "Lunch" message
sha = blobstore.store(api.user_id, b"hello attachment")
api.session.add(
Attachment(
message_id=mid,
user_id=api.user_id,
filename="notes.txt",
mime_type="text/plain",
size_bytes=16,
content_id=None,
blob_sha256=sha,
)
)
await api.session.commit()
listing = await api.client.get(f"/api/messages/{mid}/attachments")
assert listing.status_code == 200
body = listing.json()
assert [a["filename"] for a in body] == ["notes.txt"]
assert body[0]["size_bytes"] == 16
zipped = await api.client.get(f"/api/messages/{mid}/attachments.zip")
assert zipped.status_code == 200
assert zipped.headers["content-type"] == "application/zip"
assert "Attachments.zip" in zipped.headers["content-disposition"]
archive = zipfile.ZipFile(io.BytesIO(zipped.content))
assert archive.namelist() == ["notes.txt"]
assert archive.read("notes.txt") == b"hello attachment"
async def test_thread_detail_messages_ascending(api: ApiCtx) -> None:
# find the kickoff thread (2 messages)
resp = await api.client.get("/api/threads")
kickoff = next(t for t in resp.json()["threads"] if t["message_count"] == 2)
detail = await api.client.get(f"/api/threads/{kickoff['id']}")
assert detail.status_code == 200
body = detail.json()
assert body["order"] == "asc"
dates = [m["date"] for m in body["messages"]]
assert dates == sorted(dates) # ascending
async def test_message_html_is_sanitized(api: ApiCtx) -> None:
# the lunch message has HTML with a script
resp = await api.client.get("/api/threads")
lunch = next(t for t in resp.json()["threads"] if t["subject"] == "Lunch")
detail = (await api.client.get(f"/api/threads/{lunch['id']}")).json()
mid = detail["messages"][0]["id"]
html = await api.client.get(f"/api/messages/{mid}/html")
assert html.status_code == 200
assert "<script" not in html.text.lower()
assert "lunch" in html.text.lower()
async def test_search_grouped_by_thread(api: ApiCtx) -> None:
resp = await api.client.get("/api/search", params={"q": "revenue"})
assert resp.status_code == 200
data = resp.json()
assert len(data["threads"]) == 1 # both kickoff messages -> one thread
assert data["threads"][0]["thread"]["message_count"] == 2
async def test_accounts_list(api: ApiCtx) -> None:
resp = await api.client.get("/api/accounts")
assert resp.status_code == 200
assert resp.json()[0]["email"] == "me@example.com"
async def test_thread_pagination_cursor(api: ApiCtx) -> None:
first = await api.client.get("/api/threads", params={"limit": 1})
body = first.json()
assert len(body["threads"]) == 1
assert body["cursor"] is not None
second = await api.client.get("/api/threads", params={"limit": 1, "cursor": body["cursor"]})
body2 = second.json()
assert len(body2["threads"]) == 1
assert body2["threads"][0]["id"] != body["threads"][0]["id"]
+65
View File
@@ -0,0 +1,65 @@
"""Shared test fixtures and environment defaults.
Ensures the SPEC §14 env vars exist even when tests run outside the conda
activate hook (e.g. CI), so ``app.config`` imports cleanly.
"""
from __future__ import annotations
import base64
import os
os.environ.setdefault("APP_DOMAIN", "test.webmail.local")
os.environ.setdefault(
"DATABASE_URL", "postgresql+asyncpg://webmail:webmail_dev@127.0.0.1:5433/webmail_dev"
)
# Force a dedicated Redis DB for tests (index 15) so the suite is isolated from the
# dev app's Redis (DB 0) and can be flushed between tests without touching real data.
os.environ["REDIS_URL"] = "redis://127.0.0.1:6380/15"
os.environ.setdefault("MASTER_KEY", base64.b64encode(b"\x00" * 32).decode())
os.environ.setdefault("BLOB_ROOT", "./var/blobs-test")
from collections.abc import AsyncIterator # noqa: E402
import pytest_asyncio # noqa: E402
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine # noqa: E402
from sqlalchemy.pool import NullPool # noqa: E402
from app.config import get_settings # noqa: E402
@pytest_asyncio.fixture(autouse=True)
async def _reset_global_engine() -> AsyncIterator[None]:
"""Dispose process-wide async resources after each test.
Each test runs on its own event loop; disposing here ensures the next test
(and any code using the global engine/redis, e.g. the search backend) rebinds
to its own loop rather than reusing a closed one.
"""
from app.redis_client import get_redis
# Flush the dedicated test Redis DB so rate-limit / session / token state does
# not leak between tests (each test starts from a clean slate).
await get_redis().flushdb()
yield
from app.db import dispose_engine
from app.redis_client import close_redis
await dispose_engine()
await close_redis()
@pytest_asyncio.fixture
async def db_session() -> AsyncIterator[AsyncSession]:
"""Rollback-per-test session (nothing persists to the dev database)."""
engine = create_async_engine(get_settings().database_url, poolclass=NullPool)
conn = await engine.connect()
trans = await conn.begin()
session = AsyncSession(bind=conn, expire_on_commit=False)
try:
yield session
finally:
await session.close()
await trans.rollback()
await conn.close()
await engine.dispose()
+67
View File
@@ -0,0 +1,67 @@
"""Handle validation rules (SPEC §4)."""
from __future__ import annotations
import pytest
from app.config import get_settings
from app.identity.handles import InvalidHandle, full_handle, validate_localpart
from app.identity.reserved import is_reserved
from app.identity.service import resolve_handle
_DOMAIN = get_settings().app_domain
@pytest.mark.parametrize(
"raw,expected",
[
("alice", "alice"),
("Alice", "alice"), # case-insensitive
(" bob ", "bob"), # trimmed
("a.b_c-d", "a.b_c-d"),
("user123", "user123"),
("abc", "abc"), # min length
("a" * 30, "a" * 30), # max length
],
)
def test_valid_localparts(raw: str, expected: str) -> None:
assert validate_localpart(raw) == expected
@pytest.mark.parametrize(
"raw,code",
[
("ab", "length"), # too short
("a" * 31, "length"), # too long
(".alice", "dot_edge"),
("alice.", "dot_edge"),
("al..ice", "double_dot"),
("al ice", "charset"), # space
("aliçe", "charset"), # non-ascii
("al@ice", "charset"), # '@'
("UPPER!", "charset"),
("postmaster", "reserved"),
("admin", "reserved"),
("root", "reserved"),
("NoReply", "reserved"), # reserved, case-insensitive
],
)
def test_invalid_localparts(raw: str, code: str) -> None:
with pytest.raises(InvalidHandle) as exc:
validate_localpart(raw)
assert exc.value.code == code
def test_full_handle_appends_domain() -> None:
assert full_handle("alice") == f"alice@{_DOMAIN}"
def test_reserved_is_case_insensitive() -> None:
assert is_reserved("ADMIN")
assert is_reserved("admin")
assert not is_reserved("alice")
def test_resolve_handle_accepts_localpart_or_full() -> None:
assert resolve_handle("Alice") == f"alice@{_DOMAIN}"
assert resolve_handle("alice@other.example") == "alice@other.example"
+188
View File
@@ -0,0 +1,188 @@
"""M1 identity: 2FA (TOTP), session list/revoke, password reset (SPEC §6.1/§9.1)."""
from __future__ import annotations
import uuid
from collections.abc import AsyncIterator
from dataclasses import dataclass
import httpx
import pyotp
import pytest_asyncio
from httpx import ASGITransport
from sqlalchemy import delete
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.pool import NullPool
from app.config import get_settings
from app.identity.passwords import hash_password
from app.identity.sessions import create_session
from app.main import create_app
from app.models import User
from app.redis_client import get_redis
CSRF = "csrf-token"
PASSWORD = "hunter2pass"
@dataclass
class Ctx:
app: object
client: httpx.AsyncClient
user_id: uuid.UUID
handle: str
session: AsyncSession
def _hdr() -> dict[str, str]:
return {"X-CSRF": CSRF}
@pytest_asyncio.fixture
async def ctx() -> AsyncIterator[Ctx]:
engine = create_async_engine(get_settings().database_url, poolclass=NullPool)
sm = async_sessionmaker(engine, expire_on_commit=False)
session = sm()
handle = f"id{uuid.uuid4().hex[:8]}@test.local"
user = User(handle=handle, password_hash=hash_password(PASSWORD), recovery_email="r@x.com")
session.add(user)
await session.commit()
token = await create_session(user.id, "pytest")
app = create_app()
client = httpx.AsyncClient(
transport=ASGITransport(app=app),
base_url="http://testserver",
cookies={"session": token, "csrf": CSRF},
)
try:
yield Ctx(app=app, client=client, user_id=user.id, handle=handle, session=session)
finally:
await client.aclose()
await session.execute(delete(User).where(User.id == user.id))
await session.commit()
await session.close()
await engine.dispose()
def _fresh_client(app: object) -> httpx.AsyncClient:
return httpx.AsyncClient(transport=ASGITransport(app=app), base_url="http://testserver")
async def test_totp_setup_enable_and_login(ctx: Ctx) -> None:
setup = await ctx.client.post("/api/auth/totp/setup", headers=_hdr())
assert setup.status_code == 200
secret = setup.json()["secret"]
assert setup.json()["provisioning_uri"].startswith("otpauth://")
# wrong code rejected
bad = await ctx.client.post("/api/auth/totp/enable", json={"code": "000000"}, headers=_hdr())
assert bad.status_code == 400
code = pyotp.TOTP(secret).now()
ok = await ctx.client.post("/api/auth/totp/enable", json={"code": code}, headers=_hdr())
assert ok.status_code == 200
# login now requires the 2FA code
async with _fresh_client(ctx.app) as anon:
no_totp = await anon.post(
"/api/auth/login", json={"handle": ctx.handle, "password": PASSWORD}
)
assert no_totp.status_code == 401
assert no_totp.json()["error"]["code"] == "totp_required"
with_totp = await anon.post(
"/api/auth/login",
json={"handle": ctx.handle, "password": PASSWORD, "totp": pyotp.TOTP(secret).now()},
)
assert with_totp.status_code == 200
async def test_totp_disable(ctx: Ctx) -> None:
# enable first
secret = (await ctx.client.post("/api/auth/totp/setup", headers=_hdr())).json()["secret"]
await ctx.client.post(
"/api/auth/totp/enable", json={"code": pyotp.TOTP(secret).now()}, headers=_hdr()
)
assert (await ctx.client.get("/api/auth/me")).json()["totp_enabled"] is True
# wrong password is rejected
bad = await ctx.client.post("/api/auth/totp/disable", json={"password": "nope"}, headers=_hdr())
assert bad.status_code == 401
assert (await ctx.client.get("/api/auth/me")).json()["totp_enabled"] is True
# correct password disables it
ok = await ctx.client.post(
"/api/auth/totp/disable", json={"password": PASSWORD}, headers=_hdr()
)
assert ok.status_code == 200
assert (await ctx.client.get("/api/auth/me")).json()["totp_enabled"] is False
# login now works without a code
async with _fresh_client(ctx.app) as anon:
resp = await anon.post("/api/auth/login", json={"handle": ctx.handle, "password": PASSWORD})
assert resp.status_code == 200
async def test_sessions_list_and_revoke(ctx: Ctx) -> None:
# create a second session for the same user (a different device)
other_token = await create_session(ctx.user_id, "other-device")
listing = await ctx.client.get("/api/auth/sessions")
assert listing.status_code == 200
rows = listing.json()
assert len(rows) == 2
current = [r for r in rows if r["current"]]
assert len(current) == 1
other_sid = next(r["sid"] for r in rows if not r["current"])
revoked = await ctx.client.request("DELETE", f"/api/auth/sessions/{other_sid}", headers=_hdr())
assert revoked.status_code == 200
# the revoked token no longer resolves
async with _fresh_client(ctx.app) as anon:
anon.cookies.set("session", other_token)
me = await anon.get("/api/auth/me")
assert me.status_code == 401
async def test_password_reset_flow(ctx: Ctx) -> None:
# request a reset; token is stored in redis (not returned)
req = await ctx.client.post("/api/auth/password/reset-request", json={"handle": ctx.handle})
assert req.status_code == 200
redis = get_redis()
keys = await redis.keys("pwreset:*")
# Pick the token bound to THIS user (redis DB may hold others from prior runs).
token = None
for key in keys:
if await redis.get(key) == str(ctx.user_id):
token = key.split(":", 1)[1]
break
assert token is not None, "a reset token should have been issued for this user"
new_password = "brandNewPass9"
reset = await ctx.client.post(
"/api/auth/password/reset", json={"token": token, "password": new_password}
)
assert reset.status_code == 200
# token is single-use
again = await ctx.client.post(
"/api/auth/password/reset", json={"token": token, "password": new_password}
)
assert again.status_code == 400
# new password works, old one does not
async with _fresh_client(ctx.app) as anon:
good = await anon.post(
"/api/auth/login", json={"handle": ctx.handle, "password": new_password}
)
assert good.status_code == 200
bad = await anon.post("/api/auth/login", json={"handle": ctx.handle, "password": PASSWORD})
assert bad.status_code == 401
async def test_reset_request_unknown_handle_still_ok(ctx: Ctx) -> None:
# must not leak whether a handle exists
resp = await ctx.client.post("/api/auth/password/reset-request", json={"handle": "nobody-here"})
assert resp.status_code == 200
+43
View File
@@ -0,0 +1,43 @@
"""Content-addressed blob store (SPEC §4, §6.4)."""
from __future__ import annotations
import uuid
import pytest
from app.indexer import blobstore
@pytest.fixture(autouse=True)
def _blob_root(tmp_path, monkeypatch) -> None: # type: ignore[no-untyped-def]
from app.config import get_settings
settings = get_settings()
monkeypatch.setattr(settings, "blob_root", str(tmp_path))
def test_store_and_load_roundtrip() -> None:
uid = uuid.uuid4()
data = b"hello world"
sha = blobstore.store(uid, data)
assert blobstore.load(uid, sha) == data
assert blobstore.exists(uid, sha)
def test_store_is_content_addressed_and_dedupes() -> None:
uid = uuid.uuid4()
sha1 = blobstore.store(uid, b"same payload")
sha2 = blobstore.store(uid, b"same payload")
assert sha1 == sha2
# path layout: {user}/{sha[:2]}/{sha}
path = blobstore.blob_path(uid, sha1)
assert path.parent.name == sha1[:2]
assert path.parent.parent.name == str(uid)
def test_different_users_isolated() -> None:
u1, u2 = uuid.uuid4(), uuid.uuid4()
sha = blobstore.store(u1, b"payload")
assert blobstore.exists(u1, sha)
assert not blobstore.exists(u2, sha)
@@ -0,0 +1,124 @@
"""Duplicate Message-ID handling in the indexer (SPEC §7.1).
Real-world trigger: home.pl's welcome mailer reuses one static Message-ID for
every recipient, so the same id lands in different accounts as different messages.
"""
from __future__ import annotations
import uuid
from collections.abc import AsyncIterator
from datetime import UTC, datetime, timedelta
from email.message import EmailMessage
import pytest_asyncio
from sqlalchemy import delete
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.pool import NullPool
from app.config import get_settings
from app.indexer.indexer import persist_message
from app.models import Account, Mailbox, Message, User
from app.providers.base import RemoteMessage
class Dupenv:
def __init__(self, session: AsyncSession, user_id: uuid.UUID) -> None:
self.session = session
self.user_id = user_id
self.accounts: dict[str, uuid.UUID] = {}
self.mailboxes: dict[str, uuid.UUID] = {}
self._uid = 0
async def add_account(self, label: str) -> None:
acc = Account(
user_id=self.user_id,
email=f"{label}@example.com",
imap_config={},
smtp_config={},
secret_ct=b"",
discovery_src="manual",
)
self.session.add(acc)
await self.session.flush()
self.accounts[label] = acc.id
async def add_mailbox(self, account_label: str, path: str) -> None:
mb = Mailbox(
account_id=self.accounts[account_label],
user_id=self.user_id,
path=path,
special_use="inbox",
)
self.session.add(mb)
await self.session.flush()
self.mailboxes[f"{account_label}/{path}"] = mb.id
async def ingest(
self, account_label: str, path: str, *, mid: str, minutes: int = 0
) -> Message | None:
self._uid += 1
m = EmailMessage()
m["Subject"] = "Pierwsze kroki z pocztą w home.pl"
m["From"] = "info@home.pl"
m["Message-ID"] = mid
m["Date"] = "Sun, 23 Nov 2025 12:47:30 +0000"
m.set_content("welcome template body")
remote = RemoteMessage(
uid=self._uid,
raw=m.as_bytes(),
flags=frozenset(),
internal_date=datetime(2026, 1, 1, tzinfo=UTC) + timedelta(minutes=minutes),
)
result = await persist_message(
self.session,
user_id=self.user_id,
account_id=self.accounts[account_label],
mailbox_id=self.mailboxes[f"{account_label}/{path}"],
mailbox_special_use="inbox",
remote=remote,
)
return result.message if result else None
@pytest_asyncio.fixture
async def dup() -> AsyncIterator[Dupenv]:
engine = create_async_engine(get_settings().database_url, poolclass=NullPool)
sm = async_sessionmaker(engine, expire_on_commit=False)
session = sm()
user = User(handle=f"dup{uuid.uuid4().hex[:8]}@test.local", password_hash="x")
session.add(user)
await session.flush()
env = Dupenv(session, user.id)
await env.add_account("a")
await env.add_account("b")
await env.add_mailbox("a", "INBOX")
await env.add_mailbox("a", "Archive")
await env.add_mailbox("b", "INBOX")
try:
yield env
finally:
await session.execute(delete(User).where(User.id == user.id))
await session.commit()
await session.close()
await engine.dispose()
async def test_cross_account_duplicate_isolates(dup: Dupenv) -> None:
# Same static Message-ID delivered to two different accounts -> two threads.
a = await dup.ingest("a", "INBOX", mid="<static@home.pl>", minutes=0)
b = await dup.ingest("b", "INBOX", mid="<static@home.pl>", minutes=60)
assert a is not None and b is not None
assert a.thread_id != b.thread_id
# The second one was stored with a synthesized, non-authoritative id.
assert a.message_id_hdr == "static@home.pl"
assert b.message_id_hdr.endswith("@synthetic.local")
async def test_same_account_duplicate_merges(dup: Dupenv) -> None:
# Same message in INBOX + Archive of ONE account (Gmail-style) -> one thread.
a = await dup.ingest("a", "INBOX", mid="<msg@x.com>", minutes=0)
b = await dup.ingest("a", "Archive", mid="<msg@x.com>", minutes=1)
assert a is not None and b is not None
assert a.thread_id == b.thread_id
assert b.message_id_hdr == "msg@x.com"
+103
View File
@@ -0,0 +1,103 @@
"""MIME parsing (SPEC §6.4)."""
from __future__ import annotations
from email.message import EmailMessage
from app.indexer.mime import parse_message
def _build(
*,
subject: str = "Hello",
from_addr: str = "Alice <alice@example.com>",
to_addr: str = "bob@example.com",
message_id: str = "<m1@example.com>",
references: str | None = None,
in_reply_to: str | None = None,
text: str | None = "plain body",
html: str | None = None,
) -> bytes:
msg = EmailMessage()
msg["Subject"] = subject
msg["From"] = from_addr
msg["To"] = to_addr
msg["Message-ID"] = message_id
msg["Date"] = "Mon, 09 Mar 2026 08:36:37 +0100"
if references:
msg["References"] = references
if in_reply_to:
msg["In-Reply-To"] = in_reply_to
if text is not None:
msg.set_content(text)
if html is not None:
if text is not None:
msg.add_alternative(html, subtype="html")
else:
msg.set_content(html, subtype="html")
return msg.as_bytes()
def test_parse_basic_headers_and_body() -> None:
parsed = parse_message(_build())
assert parsed.subject == "Hello"
assert parsed.message_id == "m1@example.com"
assert parsed.from_addr == [{"name": "Alice", "email": "alice@example.com"}]
assert parsed.to_addr == [{"name": "", "email": "bob@example.com"}]
assert "plain body" in parsed.text_body
assert parsed.date is not None
assert parsed.snippet == "plain body"
def test_references_include_in_reply_to_in_order() -> None:
parsed = parse_message(_build(references="<a@x.com> <b@x.com>", in_reply_to="<b@x.com>"))
# References first, then In-Reply-To, deduped
assert parsed.references == ["a@x.com", "b@x.com"]
assert parsed.in_reply_to == "b@x.com"
def test_html_only_derives_text() -> None:
parsed = parse_message(_build(text=None, html="<p>Hello <b>world</b></p>"))
assert parsed.html_body is not None
assert "Hello" in parsed.text_body
assert "world" in parsed.text_body
assert "<b>" not in parsed.text_body # tags stripped for indexing text
def test_multipart_alternative_prefers_html_for_display() -> None:
parsed = parse_message(_build(text="text version", html="<p>html version</p>"))
assert parsed.html_body is not None
assert "html version" in parsed.html_body
assert "text version" in parsed.text_body
def test_rfc2047_encoded_subject_is_decoded() -> None:
# "Próba" base64-encoded UTF-8
parsed = parse_message(_build(subject="=?UTF-8?B?UHLDs2Jh?="))
assert parsed.subject == "Próba"
def test_attachment_extracted() -> None:
msg = EmailMessage()
msg["Subject"] = "with attachment"
msg["From"] = "a@b.com"
msg["To"] = "c@d.com"
msg["Message-ID"] = "<att@example.com>"
msg.set_content("see attached")
msg.add_attachment(
b"\x00\x01\x02data", maintype="application", subtype="octet-stream", filename="file.bin"
)
parsed = parse_message(msg.as_bytes())
assert parsed.has_attachments
assert parsed.attachments[0].filename == "file.bin"
assert parsed.attachments[0].payload == b"\x00\x01\x02data"
def test_missing_message_id_returns_none() -> None:
msg = EmailMessage()
msg["Subject"] = "no id"
msg["From"] = "a@b.com"
msg["To"] = "c@d.com"
msg.set_content("body")
parsed = parse_message(msg.as_bytes())
assert parsed.message_id is None
+186
View File
@@ -0,0 +1,186 @@
"""End-to-end indexer pipeline + Postgres search (SPEC §6.4, §8).
Uses a committed session (search reads through its own connection) and deletes
the user on teardown; the user FK cascade removes all created rows.
"""
from __future__ import annotations
import uuid
from collections.abc import AsyncIterator
from datetime import UTC, datetime
from email.message import EmailMessage
import pytest_asyncio
from sqlalchemy import delete, select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.pool import NullPool
from app.config import get_settings
from app.indexer.indexer import persist_message
from app.models import Account, BodyPart, KnownRecipient, Mailbox, Message, User
from app.providers.base import RemoteMessage
from app.search.pg import PgSearchBackend
from app.search.queryparser import to_search_query
class Fixture:
def __init__(
self,
session: AsyncSession,
user_id: uuid.UUID,
account_id: uuid.UUID,
mailbox_id: uuid.UUID,
) -> None:
self.session = session
self.user_id = user_id
self.account_id = account_id
self.mailbox_id = mailbox_id
self.backend = PgSearchBackend()
self._uid = 0
async def ingest(self, raw: bytes, *, flags: frozenset[str] = frozenset()) -> Message | None:
self._uid += 1
remote = RemoteMessage(uid=self._uid, raw=raw, flags=flags, internal_date=datetime.now(UTC))
result = await persist_message(
self.session,
user_id=self.user_id,
account_id=self.account_id,
mailbox_id=self.mailbox_id,
mailbox_special_use="inbox",
remote=remote,
)
await self.session.commit()
if result is not None:
await self.backend.index(result.search_doc)
return result.message
return None
@pytest_asyncio.fixture
async def fx() -> AsyncIterator[Fixture]:
engine = create_async_engine(get_settings().database_url, poolclass=NullPool)
sm = async_sessionmaker(engine, expire_on_commit=False)
session = sm()
user = User(handle=f"pipe{uuid.uuid4().hex[:8]}@test.local", password_hash="x")
session.add(user)
await session.flush()
account = Account(
user_id=user.id,
email="me@example.com",
imap_config={},
smtp_config={},
secret_ct=b"",
discovery_src="manual",
)
session.add(account)
await session.flush()
mailbox = Mailbox(account_id=account.id, user_id=user.id, path="INBOX", special_use="inbox")
session.add(mailbox)
await session.commit()
try:
yield Fixture(session, user.id, account.id, mailbox.id)
finally:
await session.execute(delete(User).where(User.id == user.id))
await session.commit()
await session.close()
await engine.dispose()
def _msg(
subject: str,
body: str,
*,
message_id: str,
frm: str = "Alice <alice@example.com>",
html: str | None = None,
) -> bytes:
m = EmailMessage()
m["Subject"] = subject
m["From"] = frm
m["To"] = "me@example.com"
m["Message-ID"] = message_id
m["Date"] = "Mon, 09 Mar 2026 08:36:37 +0100"
m.set_content(body)
if html:
m.add_alternative(html, subtype="html")
return m.as_bytes()
async def test_ingest_creates_rows_and_thread(fx: Fixture) -> None:
msg = await fx.ingest(
_msg("Invoice 2026", "Please find the invoice attached.", message_id="<a@example.com>")
)
assert msg is not None
assert msg.thread_id is not None
body = (await fx.session.scalars(select(BodyPart).where(BodyPart.message_id == msg.id))).all()
kinds = {b.kind for b in body}
assert "text" in kinds
# known_recipients harvested the sender
kr = await fx.session.scalar(
select(KnownRecipient).where(KnownRecipient.email == "alice@example.com")
)
assert kr is not None and kr.times_used == 1
async def test_idempotent_reingest(fx: Fixture) -> None:
raw = _msg("Dup", "body", message_id="<dup@example.com>")
first = await fx.ingest(raw)
assert first is not None
# Same (mailbox_id, uid) — but ingest increments uid; force same uid path:
remote = RemoteMessage(uid=1, raw=raw, flags=frozenset(), internal_date=datetime.now(UTC))
dup = await persist_message(
fx.session,
user_id=fx.user_id,
account_id=fx.account_id,
mailbox_id=fx.mailbox_id,
mailbox_special_use="inbox",
remote=remote,
)
assert dup is None # (mailbox_id, uid=1) already exists
async def test_search_finds_indexed_message(fx: Fixture) -> None:
await fx.ingest(
_msg("Quarterly report", "revenue numbers and projections", message_id="<r@example.com>")
)
await fx.ingest(_msg("Lunch plans", "let us grab lunch tomorrow", message_id="<l@example.com>"))
q = to_search_query("revenue", fx.user_id)
hits, cursor = await fx.backend.query(q, limit=50, cursor=None)
assert len(hits) == 1
assert hits[0].highlights # revenue highlighted
q2 = to_search_query("lunch", fx.user_id)
hits2, _ = await fx.backend.query(q2, limit=50, cursor=None)
assert len(hits2) == 1
async def test_search_operator_from_filter(fx: Fixture) -> None:
await fx.ingest(
_msg("Hi", "hello there", message_id="<h1@example.com>", frm="Bob <bob@example.com>")
)
await fx.ingest(
_msg("Yo", "hello again", message_id="<h2@example.com>", frm="Carol <carol@example.com>")
)
q = to_search_query("hello from:bob", fx.user_id)
hits, _ = await fx.backend.query(q, limit=50, cursor=None)
assert len(hits) == 1
async def test_html_sanitized_stored(fx: Fixture) -> None:
msg = await fx.ingest(
_msg(
"HTML",
"text fallback",
message_id="<html@example.com>",
html="<p>Hello <script>evil()</script><b>world</b></p>",
)
)
assert msg is not None
html_part = await fx.session.scalar(
select(BodyPart).where(BodyPart.message_id == msg.id, BodyPart.kind == "html_sanitized")
)
assert html_part is not None
assert "<script" not in html_part.content.lower()
assert "world" in html_part.content
+56
View File
@@ -0,0 +1,56 @@
"""HTML sanitization (SPEC §6.4, §10.4, I-8)."""
from __future__ import annotations
import uuid
from app.indexer.sanitize import sanitize_html
MID = uuid.uuid4()
def test_scripts_and_handlers_are_removed() -> None:
html = '<div onclick="evil()">hi<script>alert(1)</script></div>'
result = sanitize_html(html, MID)
assert "<script" not in result.html.lower()
assert "onclick" not in result.html.lower()
assert "hi" in result.html
def test_remote_image_is_blocked_and_flagged() -> None:
html = '<p>see <img src="http://tracker.example/pixel.gif"> this</p>'
result = sanitize_html(html, MID)
assert result.has_remote_images
assert "tracker.example" not in result.html
def test_cid_image_is_rewritten_to_endpoint() -> None:
html = '<img src="cid:logo123">'
result = sanitize_html(html, MID)
assert f"/api/messages/{MID}/cid/logo123" in result.html
assert not result.has_remote_images
def test_data_image_is_preserved() -> None:
html = '<img src="data:image/png;base64,iVBORw0KGgo=">'
result = sanitize_html(html, MID)
assert "data:image/png" in result.html
assert not result.has_remote_images
def test_safe_links_survive_with_rel() -> None:
html = '<a href="https://example.com">link</a>'
result = sanitize_html(html, MID)
assert "https://example.com" in result.html
assert "javascript:" not in result.html.lower()
def test_javascript_uri_is_stripped() -> None:
html = '<a href="javascript:alert(1)">x</a>'
result = sanitize_html(html, MID)
assert "javascript:" not in result.html.lower()
def test_sanitizer_version_reported() -> None:
result = sanitize_html("<p>hi</p>", MID)
assert result.sanitizer_v >= 1
+303
View File
@@ -0,0 +1,303 @@
"""Flag / move actions (SPEC §6.3, §9.1): DB-first + enqueue + revert."""
from __future__ import annotations
import uuid
from collections.abc import AsyncIterator
from dataclasses import dataclass, field
from datetime import UTC, datetime
from email.message import EmailMessage
import httpx
import pytest_asyncio
from httpx import ASGITransport
from sqlalchemy import delete, func, select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.pool import NullPool
from app.api import actions as actions_api
from app.config import get_settings
from app.db import session_scope
from app.identity.passwords import hash_password
from app.identity.sessions import create_session
from app.indexer.indexer import persist_message
from app.main import create_app
from app.models import Account, Mailbox, Message, Thread, User
from app.providers.base import RemoteMessage
from app.sync import actions as sync_actions
CSRF = "csrf"
@dataclass
class Ctx:
client: httpx.AsyncClient
session: AsyncSession
user_id: uuid.UUID
inbox_id: uuid.UUID
archive_id: uuid.UUID
message_ids: list[uuid.UUID] = field(default_factory=list)
thread_id: uuid.UUID | None = None
enqueued: list[tuple] = field(default_factory=list)
def _hdr() -> dict[str, str]:
return {"X-CSRF": CSRF}
def _raw(subject: str, mid: str, refs: str | None = None) -> bytes:
m = EmailMessage()
m["Subject"] = subject
m["From"] = "a@x.com"
m["To"] = "me@example.com"
m["Message-ID"] = mid
m["Date"] = "Mon, 09 Mar 2026 08:36:37 +0100"
if refs:
m["References"] = refs
m.set_content("body")
return m.as_bytes()
@pytest_asyncio.fixture
async def ctx(monkeypatch) -> AsyncIterator[Ctx]: # type: ignore[no-untyped-def]
engine = create_async_engine(get_settings().database_url, poolclass=NullPool)
sm = async_sessionmaker(engine, expire_on_commit=False)
session = sm()
user = User(handle=f"act{uuid.uuid4().hex[:8]}@test.local", password_hash=hash_password("pw"))
session.add(user)
await session.flush()
account = Account(
user_id=user.id,
email="me@example.com",
imap_config={},
smtp_config={},
secret_ct=b"",
discovery_src="manual",
)
session.add(account)
await session.flush()
inbox = Mailbox(account_id=account.id, user_id=user.id, path="INBOX", special_use="inbox")
archive = Mailbox(account_id=account.id, user_id=user.id, path="Archive", special_use="archive")
session.add_all([inbox, archive])
await session.commit()
c = Ctx(
client=None,
session=session,
user_id=user.id,
inbox_id=inbox.id, # type: ignore[arg-type]
archive_id=archive.id,
)
for i, (subj, mid, refs) in enumerate(
[
("Topic", "<a@x.com>", None),
("Re: Topic", "<b@x.com>", "<a@x.com>"),
]
):
remote = RemoteMessage(
uid=i + 1, raw=_raw(subj, mid, refs), flags=frozenset(), internal_date=datetime.now(UTC)
)
result = await persist_message(
session,
user_id=user.id,
account_id=account.id,
mailbox_id=inbox.id,
mailbox_special_use="inbox",
remote=remote,
)
await session.commit()
assert result is not None
c.message_ids.append(result.message.id)
c.thread_id = result.message.thread_id
# capture enqueues instead of hitting a real arq pool
class FakePool:
async def enqueue_job(self, *args: object) -> None:
c.enqueued.append(args)
async def fake_pool() -> FakePool:
return FakePool()
monkeypatch.setattr(actions_api, "get_arq_pool", fake_pool)
token = await create_session(user.id, "pytest")
c.client = httpx.AsyncClient(
transport=ASGITransport(app=create_app()),
base_url="http://testserver",
cookies={"session": token, "csrf": CSRF},
)
try:
yield c
finally:
await c.client.aclose()
await session.execute(delete(User).where(User.id == user.id))
await session.commit()
await session.close()
await engine.dispose()
async def test_mark_message_read_enqueues_push(ctx: Ctx) -> None:
mid = ctx.message_ids[0]
resp = await ctx.client.post(
f"/api/messages/{mid}/flags", json={"add": ["\\Seen"]}, headers=_hdr()
)
assert resp.status_code == 200
assert "\\Seen" in resp.json()["flags"]
await ctx.session.refresh(await ctx.session.get(Message, mid))
msg = await ctx.session.get(Message, mid)
assert msg is not None and "\\Seen" in msg.flags
assert ctx.enqueued == [("push_flags_job", str(mid), ["\\Seen"], [])]
async def test_noop_flag_change_does_not_enqueue(ctx: Ctx) -> None:
mid = ctx.message_ids[0]
await ctx.client.post(f"/api/messages/{mid}/flags", json={"add": ["\\Seen"]}, headers=_hdr())
ctx.enqueued.clear()
# already \Seen -> no-op
resp = await ctx.client.post(
f"/api/messages/{mid}/flags", json={"add": ["\\Seen"]}, headers=_hdr()
)
assert resp.status_code == 200
assert ctx.enqueued == []
async def test_thread_flags_mark_all_read(ctx: Ctx) -> None:
resp = await ctx.client.post(
f"/api/threads/{ctx.thread_id}/flags", json={"add": ["\\Seen"]}, headers=_hdr()
)
assert resp.status_code == 200
thread = await ctx.session.get(Thread, ctx.thread_id)
await ctx.session.refresh(thread)
assert thread is not None and thread.unread_count == 0
assert len(ctx.enqueued) == 2 # both messages pushed
async def test_thread_flag_sets_pinned(ctx: Ctx) -> None:
resp = await ctx.client.post(
f"/api/threads/{ctx.thread_id}/flags", json={"add": ["\\Flagged"]}, headers=_hdr()
)
assert resp.status_code == 200
thread = await ctx.session.get(Thread, ctx.thread_id)
await ctx.session.refresh(thread)
assert thread is not None and thread.pinned is True
# Unpinning clears it again.
await ctx.client.post(
f"/api/threads/{ctx.thread_id}/flags", json={"remove": ["\\Flagged"]}, headers=_hdr()
)
await ctx.session.refresh(thread)
assert thread.pinned is False
async def test_move_message(ctx: Ctx) -> None:
mid = ctx.message_ids[0]
resp = await ctx.client.post(
f"/api/messages/{mid}/move", json={"mailbox_id": str(ctx.archive_id)}, headers=_hdr()
)
assert resp.status_code == 200
msg = await ctx.session.get(Message, mid)
await ctx.session.refresh(msg)
assert msg is not None and msg.mailbox_id == ctx.archive_id
assert ctx.enqueued[0][0] == "move_job"
async def test_archive_thread_enqueues_move(ctx: Ctx) -> None:
resp = await ctx.client.post(f"/api/threads/{ctx.thread_id}/archive", headers=_hdr())
assert resp.status_code == 200
assert resp.json() == {"status": "queued"}
assert ctx.enqueued == [
("move_thread_to_folder_job", str(ctx.thread_id), "archive"),
]
async def test_trash_thread_enqueues_move(ctx: Ctx) -> None:
resp = await ctx.client.post(f"/api/threads/{ctx.thread_id}/trash", headers=_hdr())
assert resp.status_code == 200
assert ctx.enqueued == [
("move_thread_to_folder_job", str(ctx.thread_id), "trash"),
]
async def test_archive_unknown_thread_404(ctx: Ctx) -> None:
resp = await ctx.client.post(f"/api/threads/{uuid.uuid4()}/archive", headers=_hdr())
assert resp.status_code == 404
assert ctx.enqueued == []
async def test_restore_thread_enqueues_move_to_inbox(ctx: Ctx) -> None:
resp = await ctx.client.post(f"/api/threads/{ctx.thread_id}/restore", headers=_hdr())
assert resp.status_code == 200
assert ctx.enqueued == [
("move_thread_to_folder_job", str(ctx.thread_id), "inbox"),
]
async def test_delete_thread_enqueues_permanent_delete(ctx: Ctx) -> None:
resp = await ctx.client.post(f"/api/threads/{ctx.thread_id}/delete", headers=_hdr())
assert resp.status_code == 200
assert ctx.enqueued == [("delete_thread_job", str(ctx.thread_id))]
async def test_ignores_unknown_flags(ctx: Ctx) -> None:
mid = ctx.message_ids[0]
resp = await ctx.client.post(
f"/api/messages/{mid}/flags", json={"add": ["\\Deleted", "bogus"]}, headers=_hdr()
)
assert resp.status_code == 200
msg = await ctx.session.get(Message, mid)
await ctx.session.refresh(msg)
assert msg is not None and "\\Deleted" not in msg.flags # not user-settable here
async def test_delete_thread_job_expunges_and_removes_rows(ctx: Ctx, monkeypatch) -> None: # type: ignore[no-untyped-def]
tid = ctx.thread_id
assert tid is not None
deleted: list[tuple[str, list[int]]] = []
class FakeProvider:
async def delete_messages(self, mailbox: str, uids: list[int]) -> None:
deleted.append((mailbox, uids))
async def close(self) -> None:
return None
monkeypatch.setattr(sync_actions, "build_provider", lambda _account: FakeProvider())
await sync_actions.delete_thread_job({}, str(tid))
# server expunge happened for the thread's INBOX uids
assert deleted and deleted[0][0] == "INBOX"
# DB rows for the thread's messages are gone (verify in a fresh session; the
# job deleted them in its own session, so drop ctx's stale identity map).
ctx.session.expunge_all()
async with session_scope() as verify:
remaining = await verify.scalar(
select(func.count()).select_from(Message).where(Message.thread_id == tid)
)
assert remaining == 0
assert await verify.scalar(select(Thread).where(Thread.id == tid)) is None
async def test_push_flags_job_reverts_on_failure(ctx: Ctx, monkeypatch) -> None: # type: ignore[no-untyped-def]
mid = ctx.message_ids[0]
# mark read in DB first (as the endpoint would)
msg = await ctx.session.get(Message, mid)
assert msg is not None
msg.flags = ["\\Seen"]
await ctx.session.commit()
class FailingProvider:
async def push_flags(self, *a: object, **k: object) -> None:
raise RuntimeError("imap down")
async def close(self) -> None:
return None
monkeypatch.setattr(sync_actions, "build_provider", lambda _account: FailingProvider())
# final try -> should revert (remove the \Seen we added)
await sync_actions.push_flags_job({"job_try": 5}, str(mid), ["\\Seen"], [])
reverted = await ctx.session.get(Message, mid)
await ctx.session.refresh(reverted)
assert reverted is not None and "\\Seen" not in reverted.flags
@@ -0,0 +1,188 @@
"""Recipient suggest, settings, and per-sender remote-image control (SPEC §9.1/§9.4)."""
from __future__ import annotations
import uuid
from collections.abc import AsyncIterator
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from email.message import EmailMessage
import httpx
import pytest_asyncio
from httpx import ASGITransport
from sqlalchemy import delete
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.pool import NullPool
from app.config import get_settings
from app.identity.passwords import hash_password
from app.identity.sessions import create_session
from app.indexer.indexer import persist_message
from app.main import create_app
from app.models import Account, KnownRecipient, Mailbox, User
from app.providers.base import RemoteMessage
from app.search.pg import PgSearchBackend
CSRF = "csrf"
@dataclass
class Ctx:
client: httpx.AsyncClient
session: AsyncSession
user_id: uuid.UUID
account_id: uuid.UUID
mailbox_id: uuid.UUID
def _hdr() -> dict[str, str]:
return {"X-CSRF": CSRF}
@pytest_asyncio.fixture
async def ctx() -> AsyncIterator[Ctx]:
engine = create_async_engine(get_settings().database_url, poolclass=NullPool)
sm = async_sessionmaker(engine, expire_on_commit=False)
session = sm()
user = User(handle=f"rs{uuid.uuid4().hex[:8]}@test.local", password_hash=hash_password("pw"))
session.add(user)
await session.flush()
account = Account(
user_id=user.id,
email="me@example.com",
imap_config={},
smtp_config={},
secret_ct=b"",
discovery_src="manual",
)
session.add(account)
await session.flush()
mailbox = Mailbox(account_id=account.id, user_id=user.id, path="INBOX", special_use="inbox")
session.add(mailbox)
await session.commit()
token = await create_session(user.id, "pytest")
client = httpx.AsyncClient(
transport=ASGITransport(app=create_app()),
base_url="http://testserver",
cookies={"session": token, "csrf": CSRF},
)
try:
yield Ctx(
client=client,
session=session,
user_id=user.id,
account_id=account.id,
mailbox_id=mailbox.id,
)
finally:
await client.aclose()
await session.execute(delete(User).where(User.id == user.id))
await session.commit()
await session.close()
await engine.dispose()
async def test_recipient_suggest_prefix_and_ranking(ctx: Ctx) -> None:
now = datetime.now(UTC)
ctx.session.add_all(
[
KnownRecipient(
user_id=ctx.user_id,
email="alice@example.com",
name="Alice",
times_used=10,
last_used_at=now,
),
KnownRecipient(
user_id=ctx.user_id,
email="alan@example.com",
name="Alan",
times_used=1,
last_used_at=now - timedelta(days=200),
),
KnownRecipient(
user_id=ctx.user_id,
email="bob@example.com",
name="Bob",
times_used=99,
last_used_at=now,
),
]
)
await ctx.session.commit()
resp = await ctx.client.get("/api/recipients/suggest", params={"q": "al"})
assert resp.status_code == 200
emails = [r["email"] for r in resp.json()]
assert set(emails) == {"alice@example.com", "alan@example.com"} # prefix match, bob excluded
assert emails[0] == "alice@example.com" # higher score (recent + frequent) ranks first
async def test_recipient_suggest_empty_query(ctx: Ctx) -> None:
resp = await ctx.client.get("/api/recipients/suggest", params={"q": ""})
assert resp.status_code == 200 and resp.json() == []
async def test_settings_get_and_patch(ctx: Ctx) -> None:
got = await ctx.client.get("/api/settings")
assert got.status_code == 200
assert got.json()["thread_order"] == "asc"
patched = await ctx.client.patch(
"/api/settings", json={"thread_order": "desc", "tz": "Europe/Warsaw"}, headers=_hdr()
)
assert patched.status_code == 200
assert patched.json()["thread_order"] == "desc"
assert patched.json()["tz"] == "Europe/Warsaw"
# invalid order is ignored
patched2 = await ctx.client.patch(
"/api/settings", json={"thread_order": "sideways"}, headers=_hdr()
)
assert patched2.json()["thread_order"] == "desc"
async def _ingest_html(ctx: Ctx, *, sender: str, html: str) -> uuid.UUID:
m = EmailMessage()
m["Subject"] = "Newsletter"
m["From"] = sender
m["To"] = "me@example.com"
m["Message-ID"] = f"<{uuid.uuid4().hex}@x.com>"
m["Date"] = "Mon, 09 Mar 2026 08:36:37 +0100"
m.set_content("text fallback")
m.add_alternative(html, subtype="html")
result = await persist_message(
ctx.session,
user_id=ctx.user_id,
account_id=ctx.account_id,
mailbox_id=ctx.mailbox_id,
mailbox_special_use="inbox",
remote=RemoteMessage(
uid=1, raw=m.as_bytes(), flags=frozenset(), internal_date=datetime.now(UTC)
),
)
await ctx.session.commit()
assert result is not None
await PgSearchBackend().index(result.search_doc)
return result.message.id
async def test_remote_images_blocked_then_allowed(ctx: Ctx) -> None:
mid = await _ingest_html(
ctx,
sender="News <news@sender.com>",
html='<p>Hi<img src="http://tracker.example/pixel.gif"></p>',
)
# blocked by default
before = await ctx.client.get(f"/api/messages/{mid}/html")
assert "tracker.example" not in before.text
# approve the sender
allow = await ctx.client.post(f"/api/messages/{mid}/allow-remote-images", headers=_hdr())
assert allow.status_code == 200
assert allow.json()["sender"] == "news@sender.com"
# now the remote image is rendered
after = await ctx.client.get(f"/api/messages/{mid}/html")
assert "http://tracker.example/pixel.gif" in after.text

Some files were not shown because too many files have changed in this diff Show More