From a5ec0d21cb62e6266ef18967bb2df600aadbc4b9 Mon Sep 17 00:00:00 2001
From: Lukas Remis
Date: Fri, 17 Jul 2026 23:20:34 +0200
Subject: [PATCH] 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
---
.env.example | 13 +
.gitignore | 33 +
Makefile | 19 +
backend/alembic.ini | 38 +
backend/app/__init__.py | 1 +
backend/app/accounts/__init__.py | 1 +
backend/app/accounts/discovery.py | 255 +
backend/app/accounts/presets.yaml | 68 +
backend/app/accounts/schemas.py | 74 +
backend/app/accounts/service.py | 99 +
backend/app/accounts/ssrf.py | 89 +
backend/app/accounts/testing.py | 104 +
backend/app/accounts/types.py | 34 +
backend/app/api/__init__.py | 39 +
backend/app/api/accounts.py | 176 +
backend/app/api/actions.py | 159 +
backend/app/api/compose.py | 163 +
backend/app/api/cursors.py | 23 +
backend/app/api/errors.py | 35 +
backend/app/api/mailboxes.py | 37 +
backend/app/api/messages.py | 231 +
backend/app/api/recipients.py | 52 +
backend/app/api/schemas.py | 91 +
backend/app/api/search.py | 64 +
backend/app/api/settings.py | 71 +
backend/app/api/threads.py | 160 +
backend/app/config.py | 46 +
backend/app/db.py | 72 +
backend/app/events.py | 90 +
backend/app/identity/__init__.py | 1 +
backend/app/identity/cookies.py | 76 +
backend/app/identity/deps.py | 50 +
backend/app/identity/handles.py | 56 +
backend/app/identity/passwords.py | 34 +
backend/app/identity/reserved.py | 50 +
backend/app/identity/reset.py | 43 +
backend/app/identity/routes.py | 213 +
backend/app/identity/schemas.py | 61 +
backend/app/identity/service.py | 124 +
backend/app/identity/sessions.py | 141 +
backend/app/identity/totp.py | 59 +
backend/app/indexer/__init__.py | 1 +
backend/app/indexer/blobstore.py | 55 +
backend/app/indexer/indexer.py | 202 +
backend/app/indexer/mime.py | 147 +
backend/app/indexer/sanitize.py | 195 +
backend/app/main.py | 45 +
backend/app/models.py | 296 +
backend/app/outbox/__init__.py | 1 +
backend/app/outbox/jobs.py | 161 +
backend/app/outbox/mime_build.py | 102 +
backend/app/outbox/schemas.py | 49 +
backend/app/outbox/service.py | 76 +
backend/app/providers/__init__.py | 2 +
backend/app/providers/base.py | 111 +
backend/app/providers/imap_smtp/__init__.py | 7 +
backend/app/providers/imap_smtp/parsing.py | 165 +
backend/app/providers/imap_smtp/provider.py | 312 +
backend/app/queue.py | 31 +
backend/app/redis_client.py | 42 +
backend/app/search/__init__.py | 1 +
backend/app/search/base.py | 58 +
backend/app/search/factory.py | 21 +
backend/app/search/pg.py | 271 +
backend/app/search/queryparser.py | 158 +
backend/app/security/__init__.py | 1 +
backend/app/security/crypto.py | 77 +
backend/app/sync/__init__.py | 1 +
backend/app/sync/actions.py | 287 +
backend/app/sync/engine.py | 276 +
backend/app/sync/folder_names.py | 48 +
backend/app/sync/provider_factory.py | 30 +
backend/app/sync/supervisor.py | 61 +
backend/app/threading_/__init__.py | 1 +
backend/app/threading_/normalize.py | 66 +
backend/app/threading_/threader.py | 213 +
backend/app/ws.py | 70 +
backend/migrations/env.py | 57 +
backend/migrations/script.py.mako | 27 +
backend/migrations/versions/0001_initial.py | 200 +
.../versions/0002_user_msgid_index.py | 28 +
.../versions/0003_has_remote_images.py | 28 +
.../migrations/versions/0004_thread_pinned.py | 28 +
.../versions/0005_compose_attachments.py | 53 +
backend/pyproject.toml | 77 +
backend/scripts/seed_demo.py | 75 +
backend/tests/accounts/test_account_api.py | 174 +
backend/tests/accounts/test_discovery.py | 185 +
backend/tests/accounts/test_ssrf.py | 55 +
backend/tests/api/test_api.py | 283 +
backend/tests/conftest.py | 65 +
backend/tests/identity/test_handles.py | 67 +
backend/tests/identity/test_identity_m1.py | 188 +
backend/tests/indexer/test_blobstore.py | 43 +
backend/tests/indexer/test_duplicate_msgid.py | 124 +
backend/tests/indexer/test_mime.py | 103 +
backend/tests/indexer/test_pipeline.py | 186 +
backend/tests/indexer/test_sanitize.py | 56 +
backend/tests/mail/test_actions.py | 303 +
.../tests/mail/test_recipients_settings.py | 188 +
backend/tests/outbox/test_outbox.py | 361 +
backend/tests/providers/test_parsing.py | 122 +
backend/tests/search/test_queryparser.py | 109 +
backend/tests/search/test_search_api.py | 178 +
backend/tests/sync/test_folder_names.py | 34 +
backend/tests/threading/conftest.py | 15 +
backend/tests/threading/env.py | 87 +
backend/tests/threading/test_normalize.py | 55 +
backend/tests/threading/test_threading.py | 137 +
backend/workers/__init__.py | 1 +
backend/workers/worker.py | 68 +
dev-environment-setup.md | 227 +
docker/docker-compose.yml | 53 +
docs/open-questions.md | 102 +
environment.yml | 7 +
frontend/.eslintrc.cjs | 19 +
frontend/icons/archive.svg | 1 +
frontend/icons/compose.svg | 1 +
frontend/icons/drafts-28-regular.svg | 1 +
frontend/icons/forward.svg | 1 +
frontend/icons/inbox.svg | 1 +
frontend/icons/log-out.svg | 1 +
frontend/icons/pin.svg | 1 +
frontend/icons/reply-all-line.svg | 1 +
frontend/icons/reply.svg | 1 +
frontend/icons/sent-02.svg | 1 +
frontend/icons/settings.svg | 1 +
frontend/icons/spam.svg | 1 +
frontend/icons/trash.svg | 1 +
frontend/index.html | 12 +
frontend/package-lock.json | 6094 +++++++++++++++++
frontend/package.json | 45 +
frontend/postcss.config.js | 6 +
frontend/src/App.tsx | 73 +
frontend/src/components/FolderIcon.tsx | 6 +
frontend/src/components/Icon.tsx | 62 +
frontend/src/components/Resizer.tsx | 32 +
frontend/src/components/ThemeToggle.tsx | 18 +
frontend/src/components/compose/Composer.tsx | 549 ++
.../src/components/compose/RecipientChips.tsx | 149 +
.../src/components/compose/RichTextEditor.tsx | 426 ++
frontend/src/components/list/DraftsList.tsx | 49 +
frontend/src/components/list/MessageList.tsx | 109 +
.../src/components/thread/Attachments.tsx | 119 +
frontend/src/components/thread/Composer.tsx | 14 +
.../src/components/thread/MessageBody.tsx | 90 +
frontend/src/components/thread/ThreadView.tsx | 230 +
frontend/src/i18n/en.ts | 183 +
frontend/src/i18n/index.tsx | 62 +
frontend/src/index.css | 105 +
frontend/src/lib/api.ts | 203 +
frontend/src/lib/auth.ts | 15 +
frontend/src/lib/theme.tsx | 57 +
frontend/src/lib/types.ts | 184 +
frontend/src/lib/useKeyboardShortcuts.ts | 83 +
frontend/src/lib/ws.ts | 65 +
frontend/src/main.tsx | 27 +
frontend/src/routes/ComposeWindow.tsx | 39 +
frontend/src/routes/Login.tsx | 91 +
frontend/src/routes/MailLayout.tsx | 454 ++
frontend/src/routes/Register.tsx | 64 +
frontend/src/routes/settings/Settings.tsx | 302 +
frontend/src/routes/wizard/AccountWizard.tsx | 224 +
frontend/src/test/ThreadView.test.tsx | 100 +
frontend/src/test/setup.ts | 1 +
frontend/src/vite-env.d.ts | 1 +
frontend/tailwind.config.js | 35 +
frontend/tsconfig.json | 22 +
frontend/tsconfig.node.json | 11 +
frontend/vite.config.ts | 23 +
screenshots/UI_screenshot.jpg | Bin 0 -> 214163 bytes
screenshots/UI_screenshot.jpg~ | Bin 0 -> 214293 bytes
screenshots/palette.png | Bin 0 -> 1877 bytes
webmail-client-spec.md | 739 ++
174 files changed, 22239 insertions(+)
create mode 100644 .env.example
create mode 100644 .gitignore
create mode 100644 Makefile
create mode 100644 backend/alembic.ini
create mode 100644 backend/app/__init__.py
create mode 100644 backend/app/accounts/__init__.py
create mode 100644 backend/app/accounts/discovery.py
create mode 100644 backend/app/accounts/presets.yaml
create mode 100644 backend/app/accounts/schemas.py
create mode 100644 backend/app/accounts/service.py
create mode 100644 backend/app/accounts/ssrf.py
create mode 100644 backend/app/accounts/testing.py
create mode 100644 backend/app/accounts/types.py
create mode 100644 backend/app/api/__init__.py
create mode 100644 backend/app/api/accounts.py
create mode 100644 backend/app/api/actions.py
create mode 100644 backend/app/api/compose.py
create mode 100644 backend/app/api/cursors.py
create mode 100644 backend/app/api/errors.py
create mode 100644 backend/app/api/mailboxes.py
create mode 100644 backend/app/api/messages.py
create mode 100644 backend/app/api/recipients.py
create mode 100644 backend/app/api/schemas.py
create mode 100644 backend/app/api/search.py
create mode 100644 backend/app/api/settings.py
create mode 100644 backend/app/api/threads.py
create mode 100644 backend/app/config.py
create mode 100644 backend/app/db.py
create mode 100644 backend/app/events.py
create mode 100644 backend/app/identity/__init__.py
create mode 100644 backend/app/identity/cookies.py
create mode 100644 backend/app/identity/deps.py
create mode 100644 backend/app/identity/handles.py
create mode 100644 backend/app/identity/passwords.py
create mode 100644 backend/app/identity/reserved.py
create mode 100644 backend/app/identity/reset.py
create mode 100644 backend/app/identity/routes.py
create mode 100644 backend/app/identity/schemas.py
create mode 100644 backend/app/identity/service.py
create mode 100644 backend/app/identity/sessions.py
create mode 100644 backend/app/identity/totp.py
create mode 100644 backend/app/indexer/__init__.py
create mode 100644 backend/app/indexer/blobstore.py
create mode 100644 backend/app/indexer/indexer.py
create mode 100644 backend/app/indexer/mime.py
create mode 100644 backend/app/indexer/sanitize.py
create mode 100644 backend/app/main.py
create mode 100644 backend/app/models.py
create mode 100644 backend/app/outbox/__init__.py
create mode 100644 backend/app/outbox/jobs.py
create mode 100644 backend/app/outbox/mime_build.py
create mode 100644 backend/app/outbox/schemas.py
create mode 100644 backend/app/outbox/service.py
create mode 100644 backend/app/providers/__init__.py
create mode 100644 backend/app/providers/base.py
create mode 100644 backend/app/providers/imap_smtp/__init__.py
create mode 100644 backend/app/providers/imap_smtp/parsing.py
create mode 100644 backend/app/providers/imap_smtp/provider.py
create mode 100644 backend/app/queue.py
create mode 100644 backend/app/redis_client.py
create mode 100644 backend/app/search/__init__.py
create mode 100644 backend/app/search/base.py
create mode 100644 backend/app/search/factory.py
create mode 100644 backend/app/search/pg.py
create mode 100644 backend/app/search/queryparser.py
create mode 100644 backend/app/security/__init__.py
create mode 100644 backend/app/security/crypto.py
create mode 100644 backend/app/sync/__init__.py
create mode 100644 backend/app/sync/actions.py
create mode 100644 backend/app/sync/engine.py
create mode 100644 backend/app/sync/folder_names.py
create mode 100644 backend/app/sync/provider_factory.py
create mode 100644 backend/app/sync/supervisor.py
create mode 100644 backend/app/threading_/__init__.py
create mode 100644 backend/app/threading_/normalize.py
create mode 100644 backend/app/threading_/threader.py
create mode 100644 backend/app/ws.py
create mode 100644 backend/migrations/env.py
create mode 100644 backend/migrations/script.py.mako
create mode 100644 backend/migrations/versions/0001_initial.py
create mode 100644 backend/migrations/versions/0002_user_msgid_index.py
create mode 100644 backend/migrations/versions/0003_has_remote_images.py
create mode 100644 backend/migrations/versions/0004_thread_pinned.py
create mode 100644 backend/migrations/versions/0005_compose_attachments.py
create mode 100644 backend/pyproject.toml
create mode 100644 backend/scripts/seed_demo.py
create mode 100644 backend/tests/accounts/test_account_api.py
create mode 100644 backend/tests/accounts/test_discovery.py
create mode 100644 backend/tests/accounts/test_ssrf.py
create mode 100644 backend/tests/api/test_api.py
create mode 100644 backend/tests/conftest.py
create mode 100644 backend/tests/identity/test_handles.py
create mode 100644 backend/tests/identity/test_identity_m1.py
create mode 100644 backend/tests/indexer/test_blobstore.py
create mode 100644 backend/tests/indexer/test_duplicate_msgid.py
create mode 100644 backend/tests/indexer/test_mime.py
create mode 100644 backend/tests/indexer/test_pipeline.py
create mode 100644 backend/tests/indexer/test_sanitize.py
create mode 100644 backend/tests/mail/test_actions.py
create mode 100644 backend/tests/mail/test_recipients_settings.py
create mode 100644 backend/tests/outbox/test_outbox.py
create mode 100644 backend/tests/providers/test_parsing.py
create mode 100644 backend/tests/search/test_queryparser.py
create mode 100644 backend/tests/search/test_search_api.py
create mode 100644 backend/tests/sync/test_folder_names.py
create mode 100644 backend/tests/threading/conftest.py
create mode 100644 backend/tests/threading/env.py
create mode 100644 backend/tests/threading/test_normalize.py
create mode 100644 backend/tests/threading/test_threading.py
create mode 100644 backend/workers/__init__.py
create mode 100644 backend/workers/worker.py
create mode 100644 dev-environment-setup.md
create mode 100644 docker/docker-compose.yml
create mode 100644 docs/open-questions.md
create mode 100644 environment.yml
create mode 100644 frontend/.eslintrc.cjs
create mode 100644 frontend/icons/archive.svg
create mode 100644 frontend/icons/compose.svg
create mode 100644 frontend/icons/drafts-28-regular.svg
create mode 100644 frontend/icons/forward.svg
create mode 100644 frontend/icons/inbox.svg
create mode 100644 frontend/icons/log-out.svg
create mode 100644 frontend/icons/pin.svg
create mode 100644 frontend/icons/reply-all-line.svg
create mode 100644 frontend/icons/reply.svg
create mode 100644 frontend/icons/sent-02.svg
create mode 100644 frontend/icons/settings.svg
create mode 100644 frontend/icons/spam.svg
create mode 100644 frontend/icons/trash.svg
create mode 100644 frontend/index.html
create mode 100644 frontend/package-lock.json
create mode 100644 frontend/package.json
create mode 100644 frontend/postcss.config.js
create mode 100644 frontend/src/App.tsx
create mode 100644 frontend/src/components/FolderIcon.tsx
create mode 100644 frontend/src/components/Icon.tsx
create mode 100644 frontend/src/components/Resizer.tsx
create mode 100644 frontend/src/components/ThemeToggle.tsx
create mode 100644 frontend/src/components/compose/Composer.tsx
create mode 100644 frontend/src/components/compose/RecipientChips.tsx
create mode 100644 frontend/src/components/compose/RichTextEditor.tsx
create mode 100644 frontend/src/components/list/DraftsList.tsx
create mode 100644 frontend/src/components/list/MessageList.tsx
create mode 100644 frontend/src/components/thread/Attachments.tsx
create mode 100644 frontend/src/components/thread/Composer.tsx
create mode 100644 frontend/src/components/thread/MessageBody.tsx
create mode 100644 frontend/src/components/thread/ThreadView.tsx
create mode 100644 frontend/src/i18n/en.ts
create mode 100644 frontend/src/i18n/index.tsx
create mode 100644 frontend/src/index.css
create mode 100644 frontend/src/lib/api.ts
create mode 100644 frontend/src/lib/auth.ts
create mode 100644 frontend/src/lib/theme.tsx
create mode 100644 frontend/src/lib/types.ts
create mode 100644 frontend/src/lib/useKeyboardShortcuts.ts
create mode 100644 frontend/src/lib/ws.ts
create mode 100644 frontend/src/main.tsx
create mode 100644 frontend/src/routes/ComposeWindow.tsx
create mode 100644 frontend/src/routes/Login.tsx
create mode 100644 frontend/src/routes/MailLayout.tsx
create mode 100644 frontend/src/routes/Register.tsx
create mode 100644 frontend/src/routes/settings/Settings.tsx
create mode 100644 frontend/src/routes/wizard/AccountWizard.tsx
create mode 100644 frontend/src/test/ThreadView.test.tsx
create mode 100644 frontend/src/test/setup.ts
create mode 100644 frontend/src/vite-env.d.ts
create mode 100644 frontend/tailwind.config.js
create mode 100644 frontend/tsconfig.json
create mode 100644 frontend/tsconfig.node.json
create mode 100644 frontend/vite.config.ts
create mode 100644 screenshots/UI_screenshot.jpg
create mode 100644 screenshots/UI_screenshot.jpg~
create mode 100644 screenshots/palette.png
create mode 100644 webmail-client-spec.md
diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..7579898
--- /dev/null
+++ b/.env.example
@@ -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
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..42cd1f5
--- /dev/null
+++ b/.gitignore
@@ -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/
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..90652bc
--- /dev/null
+++ b/Makefile
@@ -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
diff --git a/backend/alembic.ini b/backend/alembic.ini
new file mode 100644
index 0000000..003e661
--- /dev/null
+++ b/backend/alembic.ini
@@ -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
diff --git a/backend/app/__init__.py b/backend/app/__init__.py
new file mode 100644
index 0000000..d7de678
--- /dev/null
+++ b/backend/app/__init__.py
@@ -0,0 +1 @@
+"""Webmail backend application package."""
diff --git a/backend/app/accounts/__init__.py b/backend/app/accounts/__init__.py
new file mode 100644
index 0000000..2aa4468
--- /dev/null
+++ b/backend/app/accounts/__init__.py
@@ -0,0 +1 @@
+"""Accounts: autodiscovery, credential testing, account lifecycle (SPEC §6.2)."""
diff --git a/backend/app/accounts/discovery.py b/backend/app/accounts/discovery.py
new file mode 100644
index 0000000..04b7055
--- /dev/null
+++ b/backend/app/accounts/discovery.py
@@ -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., then /.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
diff --git a/backend/app/accounts/presets.yaml b/backend/app/accounts/presets.yaml
new file mode 100644
index 0000000..bd78330
--- /dev/null
+++ b/backend/app/accounts/presets.yaml
@@ -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
diff --git a/backend/app/accounts/schemas.py b/backend/app/accounts/schemas.py
new file mode 100644
index 0000000..c4a9fc6
--- /dev/null
+++ b/backend/app/accounts/schemas.py
@@ -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]
diff --git a/backend/app/accounts/service.py b/backend/app/accounts/service.py
new file mode 100644
index 0000000..3ca5b5f
--- /dev/null
+++ b/backend/app/accounts/service.py
@@ -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
diff --git a/backend/app/accounts/ssrf.py b/backend/app/accounts/ssrf.py
new file mode 100644
index 0000000..a0198ea
--- /dev/null
+++ b/backend/app/accounts/ssrf.py
@@ -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")
diff --git a/backend/app/accounts/testing.py b/backend/app/accounts/testing.py
new file mode 100644
index 0000000..969cef1
--- /dev/null
+++ b/backend/app/accounts/testing.py
@@ -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)
diff --git a/backend/app/accounts/types.py b/backend/app/accounts/types.py
new file mode 100644
index 0000000..d51bd91
--- /dev/null
+++ b/backend/app/accounts/types.py
@@ -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)
diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py
new file mode 100644
index 0000000..492a530
--- /dev/null
+++ b/backend/app/api/__init__.py
@@ -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")
diff --git a/backend/app/api/accounts.py b/backend/app/api/accounts.py
new file mode 100644
index 0000000..f8304f2
--- /dev/null
+++ b/backend/app/api/accounts.py
@@ -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,
+ )
diff --git a/backend/app/api/actions.py b/backend/app/api/actions.py
new file mode 100644
index 0000000..277c0fb
--- /dev/null
+++ b/backend/app/api/actions.py
@@ -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)}
diff --git a/backend/app/api/compose.py b/backend/app/api/compose.py
new file mode 100644
index 0000000..dbcbdef
--- /dev/null
+++ b/backend/app/api/compose.py
@@ -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"}
diff --git a/backend/app/api/cursors.py b/backend/app/api/cursors.py
new file mode 100644
index 0000000..4ef0937
--- /dev/null
+++ b/backend/app/api/cursors.py
@@ -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
diff --git a/backend/app/api/errors.py b/backend/app/api/errors.py
new file mode 100644
index 0000000..13ae5b1
--- /dev/null
+++ b/backend/app/api/errors.py
@@ -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]
diff --git a/backend/app/api/mailboxes.py b/backend/app/api/mailboxes.py
new file mode 100644
index 0000000..fe75138
--- /dev/null
+++ b/backend/app/api/mailboxes.py
@@ -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
+ ]
diff --git a/backend/app/api/messages.py b/backend/app/api/messages.py
new file mode 100644
index 0000000..05b9ef5
--- /dev/null
+++ b/backend/app/api/messages.py
@@ -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 = """
+
+{body}"""
+
+
+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"{body}"))
+
+
+@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}"'},
+ )
diff --git a/backend/app/api/recipients.py b/backend/app/api/recipients.py
new file mode 100644
index 0000000..7f45a2a
--- /dev/null
+++ b/backend/app/api/recipients.py
@@ -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]
diff --git a/backend/app/api/schemas.py b/backend/app/api/schemas.py
new file mode 100644
index 0000000..1e90cd7
--- /dev/null
+++ b/backend/app/api/schemas.py
@@ -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
diff --git a/backend/app/api/search.py b/backend/app/api/search.py
new file mode 100644
index 0000000..b9c1f74
--- /dev/null
+++ b/backend/app/api/search.py
@@ -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)
diff --git a/backend/app/api/settings.py b/backend/app/api/settings.py
new file mode 100644
index 0000000..62383e9
--- /dev/null
+++ b/backend/app/api/settings.py
@@ -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)
diff --git a/backend/app/api/threads.py b/backend/app/api/threads.py
new file mode 100644
index 0000000..b4c1c6b
--- /dev/null
+++ b/backend/app/api/threads.py
@@ -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],
+ )
diff --git a/backend/app/config.py b/backend/app/config.py
new file mode 100644
index 0000000..49f5574
--- /dev/null
+++ b/backend/app/config.py
@@ -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
diff --git a/backend/app/db.py b/backend/app/db.py
new file mode 100644
index 0000000..9204cfc
--- /dev/null
+++ b/backend/app/db.py
@@ -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
diff --git a/backend/app/events.py b/backend/app/events.py
new file mode 100644
index 0000000..f080534
--- /dev/null
+++ b/backend/app/events.py
@@ -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
diff --git a/backend/app/identity/__init__.py b/backend/app/identity/__init__.py
new file mode 100644
index 0000000..edfeba0
--- /dev/null
+++ b/backend/app/identity/__init__.py
@@ -0,0 +1 @@
+"""Identity: app accounts, auth, sessions, handles (SPEC §6.1)."""
diff --git a/backend/app/identity/cookies.py b/backend/app/identity/cookies.py
new file mode 100644
index 0000000..5a84682
--- /dev/null
+++ b/backend/app/identity/cookies.py
@@ -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="/")
diff --git a/backend/app/identity/deps.py b/backend/app/identity/deps.py
new file mode 100644
index 0000000..2ea3fd9
--- /dev/null
+++ b/backend/app/identity/deps.py
@@ -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)]
diff --git a/backend/app/identity/handles.py b/backend/app/identity/handles.py
new file mode 100644
index 0000000..637a891
--- /dev/null
+++ b/backend/app/identity/handles.py
@@ -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}"
diff --git a/backend/app/identity/passwords.py b/backend/app/identity/passwords.py
new file mode 100644
index 0000000..4ade7eb
--- /dev/null
+++ b/backend/app/identity/passwords.py
@@ -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)
diff --git a/backend/app/identity/reserved.py b/backend/app/identity/reserved.py
new file mode 100644
index 0000000..d3b2a6e
--- /dev/null
+++ b/backend/app/identity/reserved.py
@@ -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
diff --git a/backend/app/identity/reset.py b/backend/app/identity/reset.py
new file mode 100644
index 0000000..07dac71
--- /dev/null
+++ b/backend/app/identity/reset.py
@@ -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
diff --git a/backend/app/identity/routes.py b/backend/app/identity/routes.py
new file mode 100644
index 0000000..18dc8ef
--- /dev/null
+++ b/backend/app/identity/routes.py
@@ -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()
diff --git a/backend/app/identity/schemas.py b/backend/app/identity/schemas.py
new file mode 100644
index 0000000..0cd6d11
--- /dev/null
+++ b/backend/app/identity/schemas.py
@@ -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"
diff --git a/backend/app/identity/service.py b/backend/app/identity/service.py
new file mode 100644
index 0000000..187023f
--- /dev/null
+++ b/backend/app/identity/service.py
@@ -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)
diff --git a/backend/app/identity/sessions.py b/backend/app/identity/sessions.py
new file mode 100644
index 0000000..085dfe8
--- /dev/null
+++ b/backend/app/identity/sessions.py
@@ -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))
diff --git a/backend/app/identity/totp.py b/backend/app/identity/totp.py
new file mode 100644
index 0000000..8db9078
--- /dev/null
+++ b/backend/app/identity/totp.py
@@ -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")
diff --git a/backend/app/indexer/__init__.py b/backend/app/indexer/__init__.py
new file mode 100644
index 0000000..36b6db4
--- /dev/null
+++ b/backend/app/indexer/__init__.py
@@ -0,0 +1 @@
+"""Indexer: MIME parsing, sanitization, blob storage, DB rows (SPEC §6.4)."""
diff --git a/backend/app/indexer/blobstore.py b/backend/app/indexer/blobstore.py
new file mode 100644
index 0000000..212e271
--- /dev/null
+++ b/backend/app/indexer/blobstore.py
@@ -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)
diff --git a/backend/app/indexer/indexer.py b/backend/app/indexer/indexer.py
new file mode 100644
index 0000000..d66e0aa
--- /dev/null
+++ b/backend/app/indexer/indexer.py
@@ -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)
diff --git a/backend/app/indexer/mime.py b/backend/app/indexer/mime.py
new file mode 100644
index 0000000..f7030a6
--- /dev/null
+++ b/backend/app/indexer/mime.py
@@ -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),
+ )
diff --git a/backend/app/indexer/sanitize.py b/backend/app/indexer/sanitize.py
new file mode 100644
index 0000000..e7406af
--- /dev/null
+++ b/backend/app/indexer/sanitize.py
@@ -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,
+ )
diff --git a/backend/app/main.py b/backend/app/main.py
new file mode 100644
index 0000000..d998e9f
--- /dev/null
+++ b/backend/app/main.py
@@ -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
diff --git a/backend/app/models.py b/backend/app/models.py
new file mode 100644
index 0000000..2081091
--- /dev/null
+++ b/backend/app/models.py
@@ -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",
+]
diff --git a/backend/app/outbox/__init__.py b/backend/app/outbox/__init__.py
new file mode 100644
index 0000000..5dbcf92
--- /dev/null
+++ b/backend/app/outbox/__init__.py
@@ -0,0 +1 @@
+"""Outbox: drafts, MIME assembly, send queue, undo-send (SPEC §6.5)."""
diff --git a/backend/app/outbox/jobs.py b/backend/app/outbox/jobs.py
new file mode 100644
index 0000000..e9504d0
--- /dev/null
+++ b/backend/app/outbox/jobs.py
@@ -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"},
+ )
diff --git a/backend/app/outbox/mime_build.py b/backend/app/outbox/mime_build.py
new file mode 100644
index 0000000..942cde9
--- /dev/null
+++ b/backend/app/outbox/mime_build.py
@@ -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
diff --git a/backend/app/outbox/schemas.py b/backend/app/outbox/schemas.py
new file mode 100644
index 0000000..144f8a3
--- /dev/null
+++ b/backend/app/outbox/schemas.py
@@ -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
diff --git a/backend/app/outbox/service.py b/backend/app/outbox/service.py
new file mode 100644
index 0000000..326bb65
--- /dev/null
+++ b/backend/app/outbox/service.py
@@ -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
diff --git a/backend/app/providers/__init__.py b/backend/app/providers/__init__.py
new file mode 100644
index 0000000..8f57ac2
--- /dev/null
+++ b/backend/app/providers/__init__.py
@@ -0,0 +1,2 @@
+"""Mail provider abstraction (SPEC §5.1). Nothing above this package may
+import IMAP/SMTP libraries — only concrete providers do."""
diff --git a/backend/app/providers/base.py b/backend/app/providers/base.py
new file mode 100644
index 0000000..7b2fea0
--- /dev/null
+++ b/backend/app/providers/base.py
@@ -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
+ ...
diff --git a/backend/app/providers/imap_smtp/__init__.py b/backend/app/providers/imap_smtp/__init__.py
new file mode 100644
index 0000000..48ca8a3
--- /dev/null
+++ b/backend/app/providers/imap_smtp/__init__.py
@@ -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"]
diff --git a/backend/app/providers/imap_smtp/parsing.py b/backend/app/providers/imap_smtp/parsing.py
new file mode 100644
index 0000000..f89bc4c
--- /dev/null
+++ b/backend/app/providers/imap_smtp/parsing.py
@@ -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 ``... {}`` 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
diff --git a/backend/app/providers/imap_smtp/provider.py b/backend/app/providers/imap_smtp/provider.py
new file mode 100644
index 0000000..856295f
--- /dev/null
+++ b/backend/app/providers/imap_smtp/provider.py
@@ -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
diff --git a/backend/app/queue.py b/backend/app/queue.py
new file mode 100644
index 0000000..ca99526
--- /dev/null
+++ b/backend/app/queue.py
@@ -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
diff --git a/backend/app/redis_client.py b/backend/app/redis_client.py
new file mode 100644
index 0000000..b78025c
--- /dev/null
+++ b/backend/app/redis_client.py
@@ -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
diff --git a/backend/app/search/__init__.py b/backend/app/search/__init__.py
new file mode 100644
index 0000000..f9b455d
--- /dev/null
+++ b/backend/app/search/__init__.py
@@ -0,0 +1 @@
+"""Search: engine-independent query grammar + pluggable backends (SPEC §5.2, §8)."""
diff --git a/backend/app/search/base.py b/backend/app/search/base.py
new file mode 100644
index 0000000..def5e52
--- /dev/null
+++ b/backend/app/search/base.py
@@ -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
+
+
+@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]: ...
diff --git a/backend/app/search/factory.py b/backend/app/search/factory.py
new file mode 100644
index 0000000..66e50f0
--- /dev/null
+++ b/backend/app/search/factory.py
@@ -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
diff --git a/backend/app/search/pg.py b/backend/app/search/pg.py
new file mode 100644
index 0000000..7c3805f
--- /dev/null
+++ b/backend/app/search/pg.py
@@ -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 "" in marked_subject:
+ out["subject"] = marked_subject
+ marked_body = _mark(snippet, terms)
+ if "" 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 (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"{m.group(0)}", 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
diff --git a/backend/app/search/queryparser.py b/backend/app/search/queryparser.py
new file mode 100644
index 0000000..e327fc9
--- /dev/null
+++ b/backend/app/search/queryparser.py
@@ -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)
diff --git a/backend/app/security/__init__.py b/backend/app/security/__init__.py
new file mode 100644
index 0000000..d8d2e71
--- /dev/null
+++ b/backend/app/security/__init__.py
@@ -0,0 +1 @@
+"""Security primitives: credential vault, CSRF, secret types (SPEC §10)."""
diff --git a/backend/app/security/crypto.py b/backend/app/security/crypto.py
new file mode 100644
index 0000000..3aa8c2b
--- /dev/null
+++ b/backend/app/security/crypto.py
@@ -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
diff --git a/backend/app/sync/__init__.py b/backend/app/sync/__init__.py
new file mode 100644
index 0000000..5ed92ac
--- /dev/null
+++ b/backend/app/sync/__init__.py
@@ -0,0 +1 @@
+"""Sync engine: bootstrap, backfill, live IDLE/poll loops (SPEC §6.3)."""
diff --git a/backend/app/sync/actions.py b/backend/app/sync/actions.py
new file mode 100644
index 0000000..f7d2028
--- /dev/null
+++ b/backend/app/sync/actions.py
@@ -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)}
+ )
diff --git a/backend/app/sync/engine.py b/backend/app/sync/engine.py
new file mode 100644
index 0000000..e81578b
--- /dev/null
+++ b/backend/app/sync/engine.py
@@ -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()
diff --git a/backend/app/sync/folder_names.py b/backend/app/sync/folder_names.py
new file mode 100644
index 0000000..0571e24
--- /dev/null
+++ b/backend/app/sync/folder_names.py
@@ -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())
diff --git a/backend/app/sync/provider_factory.py b/backend/app/sync/provider_factory.py
new file mode 100644
index 0000000..ccb48fd
--- /dev/null
+++ b/backend/app/sync/provider_factory.py
@@ -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)
diff --git a/backend/app/sync/supervisor.py b/backend/app/sync/supervisor.py
new file mode 100644
index 0000000..b4ba0ec
--- /dev/null
+++ b/backend/app/sync/supervisor.py
@@ -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)
diff --git a/backend/app/threading_/__init__.py b/backend/app/threading_/__init__.py
new file mode 100644
index 0000000..92796f6
--- /dev/null
+++ b/backend/app/threading_/__init__.py
@@ -0,0 +1 @@
+"""JWZ threading (SPEC §7). Trailing underscore avoids the stdlib ``threading`` clash."""
diff --git a/backend/app/threading_/normalize.py b/backend/app/threading_/normalize.py
new file mode 100644
index 0000000..64750c9
--- /dev/null
+++ b/backend/app/threading_/normalize.py
@@ -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()
diff --git a/backend/app/threading_/threader.py b/backend/app/threading_/threader.py
new file mode 100644
index 0000000..1857b61
--- /dev/null
+++ b/backend/app/threading_/threader.py
@@ -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
diff --git a/backend/app/ws.py b/backend/app/ws.py
new file mode 100644
index 0000000..4731835
--- /dev/null
+++ b/backend/app/ws.py
@@ -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]
diff --git a/backend/migrations/env.py b/backend/migrations/env.py
new file mode 100644
index 0000000..f8fe313
--- /dev/null
+++ b/backend/migrations/env.py
@@ -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())
diff --git a/backend/migrations/script.py.mako b/backend/migrations/script.py.mako
new file mode 100644
index 0000000..948ea71
--- /dev/null
+++ b/backend/migrations/script.py.mako
@@ -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"}
diff --git a/backend/migrations/versions/0001_initial.py b/backend/migrations/versions/0001_initial.py
new file mode 100644
index 0000000..53834bd
--- /dev/null
+++ b/backend/migrations/versions/0001_initial.py
@@ -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)
diff --git a/backend/migrations/versions/0002_user_msgid_index.py b/backend/migrations/versions/0002_user_msgid_index.py
new file mode 100644
index 0000000..ad70bf1
--- /dev/null
+++ b/backend/migrations/versions/0002_user_msgid_index.py
@@ -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")
diff --git a/backend/migrations/versions/0003_has_remote_images.py b/backend/migrations/versions/0003_has_remote_images.py
new file mode 100644
index 0000000..8172146
--- /dev/null
+++ b/backend/migrations/versions/0003_has_remote_images.py
@@ -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")
diff --git a/backend/migrations/versions/0004_thread_pinned.py b/backend/migrations/versions/0004_thread_pinned.py
new file mode 100644
index 0000000..2fdcdde
--- /dev/null
+++ b/backend/migrations/versions/0004_thread_pinned.py
@@ -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")
diff --git a/backend/migrations/versions/0005_compose_attachments.py b/backend/migrations/versions/0005_compose_attachments.py
new file mode 100644
index 0000000..b22d1e7
--- /dev/null
+++ b/backend/migrations/versions/0005_compose_attachments.py
@@ -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")
diff --git a/backend/pyproject.toml b/backend/pyproject.toml
new file mode 100644
index 0000000..39703e8
--- /dev/null
+++ b/backend/pyproject.toml
@@ -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 = ["."]
diff --git a/backend/scripts/seed_demo.py b/backend/scripts/seed_demo.py
new file mode 100644
index 0000000..b0e5cb6
--- /dev/null
+++ b/backend/scripts/seed_demo.py
@@ -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())
diff --git a/backend/tests/accounts/test_account_api.py b/backend/tests/accounts/test_account_api.py
new file mode 100644
index 0000000..aa3963b
--- /dev/null
+++ b/backend/tests/accounts/test_account_api.py
@@ -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
diff --git a/backend/tests/accounts/test_discovery.py b/backend/tests/accounts/test_discovery.py
new file mode 100644
index 0000000..4840852
--- /dev/null
+++ b/backend/tests/accounts/test_discovery.py
@@ -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 = """
+
+
+ example.com
+
+ imap.example.com
+ 993
+ SSL
+ password-cleartext
+ %EMAILADDRESS%
+
+
+ smtp.example.com
+ 587
+ STARTTLS
+ password-cleartext
+ %EMAILADDRESS%
+
+
+
+"""
+
+
+@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("") 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)
diff --git a/backend/tests/accounts/test_ssrf.py b/backend/tests/accounts/test_ssrf.py
new file mode 100644
index 0000000..cf07dfa
--- /dev/null
+++ b/backend/tests/accounts/test_ssrf.py
@@ -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")
diff --git a/backend/tests/api/test_api.py b/backend/tests/api/test_api.py
new file mode 100644
index 0000000..b6b3a4e
--- /dev/null
+++ b/backend/tests/api/test_api.py
@@ -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 "
+ 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=""), 1),
+ (
+ _raw(
+ "Re: Project kickoff",
+ "sounds good, revenue looks strong",
+ mid="",
+ refs="",
+ ),
+ 2,
+ ),
+ (
+ _raw(
+ "Lunch",
+ "grab lunch tomorrow?",
+ mid="",
+ html="grab lunch tomorrow?
",
+ ),
+ 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 "world
",
+ )
+ )
+ 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 "'
+ result = sanitize_html(html, MID)
+ assert "
+