Initial commit: webmail client (M0/M1)
Three-pane webmail client. - Backend: FastAPI + async SQLAlchemy + arq worker (Postgres/Redis), IMAP/SMTP sync, threading, search, drafts/outbox with undo, attachments, 2FA. - Frontend: React 18 + Vite + TypeScript + TanStack Query + Tailwind; three-pane UI with rich-text composer, light/dark themes, resizable panels. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
APP_DOMAIN=dev.webmail.local
|
||||
DATABASE_URL=postgresql+asyncpg://webmail:webmail_dev@127.0.0.1:5433/webmail_dev
|
||||
REDIS_URL=redis://127.0.0.1:6380/0
|
||||
SEARCH_BACKEND=pg # switch to meili in M2 work
|
||||
MEILI_URL=http://127.0.0.1:7700
|
||||
MEILI_KEY=dev_master_key_change_me
|
||||
MASTER_KEY=CHANGE_ME_base64_32_bytes # generate: openssl rand -base64 32
|
||||
BLOB_ROOT=./var/blobs
|
||||
UNDO_SECONDS=10
|
||||
POLL_INTERVAL=120
|
||||
SESSION_TTL_DAYS=30
|
||||
MAX_ATTACHMENT_MB=25
|
||||
LOG_LEVEL=DEBUG
|
||||
+33
@@ -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/
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
"""Webmail backend application package."""
|
||||
@@ -0,0 +1 @@
|
||||
"""Accounts: autodiscovery, credential testing, account lifecycle (SPEC §6.2)."""
|
||||
@@ -0,0 +1,255 @@
|
||||
"""Autodiscovery resolver (SPEC §6.2).
|
||||
|
||||
``discover(email)`` runs the chain — presets, Thunderbird autoconfig, ISPDB, DNS
|
||||
SRV (RFC 6186), then a live probe — returning on the first success but recording
|
||||
every step's outcome for wizard UX. All HTTP is SSRF-guarded (§10.6).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import ssl
|
||||
from pathlib import Path
|
||||
|
||||
import dns.asyncresolver
|
||||
import dns.exception
|
||||
import httpx
|
||||
import yaml
|
||||
from defusedxml import ElementTree as SafeET
|
||||
|
||||
from app.accounts.ssrf import SsrfError, assert_host_is_public, guarded_get
|
||||
from app.accounts.types import CandidateConfig, DiscoveryResult, DiscoveryStep, ServerConfig
|
||||
|
||||
_PROBE_TIMEOUT = 5.0
|
||||
|
||||
_presets_cache: dict[str, CandidateConfig] | None = None
|
||||
|
||||
|
||||
# --- presets -----------------------------------------------------------------
|
||||
|
||||
|
||||
def _load_presets() -> dict[str, CandidateConfig]:
|
||||
global _presets_cache
|
||||
if _presets_cache is None:
|
||||
raw = yaml.safe_load((Path(__file__).parent / "presets.yaml").read_text())
|
||||
mapping: dict[str, CandidateConfig] = {}
|
||||
for prov in raw["providers"].values():
|
||||
auth = prov.get("auth", "password")
|
||||
imap = ServerConfig(
|
||||
prov["imap"]["host"], prov["imap"]["port"], prov["imap"]["tls"], auth
|
||||
)
|
||||
smtp = ServerConfig(
|
||||
prov["smtp"]["host"], prov["smtp"]["port"], prov["smtp"]["tls"], auth
|
||||
)
|
||||
candidate = CandidateConfig(imap, smtp, "preset", prov.get("oauth_provider"))
|
||||
for domain in prov["domains"]:
|
||||
mapping[domain.lower()] = candidate
|
||||
_presets_cache = mapping
|
||||
return _presets_cache
|
||||
|
||||
|
||||
def domain_of(email: str) -> str:
|
||||
return email.rsplit("@", 1)[-1].strip().lower()
|
||||
|
||||
|
||||
# --- Thunderbird autoconfig / ISPDB XML --------------------------------------
|
||||
|
||||
|
||||
def _socket_type(text: str | None) -> str:
|
||||
return {"ssl": "ssl", "starttls": "starttls", "plain": "plain"}.get(
|
||||
(text or "").strip().lower(), "ssl"
|
||||
)
|
||||
|
||||
|
||||
def parse_autoconfig_xml(xml_text: str) -> CandidateConfig | None:
|
||||
"""Parse Thunderbird ``clientConfig`` XML into a CandidateConfig (or None)."""
|
||||
try:
|
||||
root = SafeET.fromstring(xml_text)
|
||||
except Exception:
|
||||
return None
|
||||
provider = root.find("emailProvider")
|
||||
if provider is None:
|
||||
return None
|
||||
|
||||
def _server(kind: str, server_type: str) -> ServerConfig | None:
|
||||
for node in provider.findall(kind):
|
||||
if node.get("type") == server_type:
|
||||
host = node.findtext("hostname")
|
||||
port = node.findtext("port")
|
||||
if host and port and port.isdigit():
|
||||
auth = node.findtext("authentication") or ""
|
||||
is_oauth = "oauth" in auth.lower()
|
||||
return ServerConfig(
|
||||
host.strip(),
|
||||
int(port),
|
||||
_socket_type(node.findtext("socketType")),
|
||||
"oauth2" if is_oauth else "password",
|
||||
)
|
||||
return None
|
||||
|
||||
imap = _server("incomingServer", "imap")
|
||||
smtp = _server("outgoingServer", "smtp")
|
||||
if imap is None or smtp is None:
|
||||
return None
|
||||
return CandidateConfig(imap, smtp, "autoconfig")
|
||||
|
||||
|
||||
# --- DNS SRV (RFC 6186) ------------------------------------------------------
|
||||
|
||||
|
||||
async def _srv_lookup(name: str, tls: str) -> ServerConfig | None:
|
||||
resolver = dns.asyncresolver.Resolver()
|
||||
resolver.lifetime = 5.0
|
||||
try:
|
||||
answer = await resolver.resolve(name, "SRV")
|
||||
except (dns.exception.DNSException, OSError):
|
||||
return None
|
||||
for record in sorted(answer, key=lambda r: (r.priority, r.weight)):
|
||||
target = str(record.target).rstrip(".")
|
||||
if target and target != ".": # "." target => service explicitly unavailable
|
||||
return ServerConfig(target, record.port, tls)
|
||||
return None
|
||||
|
||||
|
||||
async def _discover_srv(domain: str) -> CandidateConfig | None:
|
||||
imap = await _srv_lookup(f"_imaps._tcp.{domain}", "ssl") or await _srv_lookup(
|
||||
f"_imap._tcp.{domain}", "starttls"
|
||||
)
|
||||
smtp = await _srv_lookup(f"_submissions._tcp.{domain}", "ssl") or await _srv_lookup(
|
||||
f"_submission._tcp.{domain}", "starttls"
|
||||
)
|
||||
if imap and smtp:
|
||||
return CandidateConfig(imap, smtp, "srv")
|
||||
return None
|
||||
|
||||
|
||||
# --- live probe --------------------------------------------------------------
|
||||
|
||||
|
||||
async def _probe_one(host: str, port: int, tls: str, kind: str) -> ServerConfig | None:
|
||||
try:
|
||||
await assert_host_is_public(host) # SSRF: never probe internal hosts
|
||||
except SsrfError:
|
||||
return None
|
||||
try:
|
||||
if tls == "ssl":
|
||||
ctx = ssl.create_default_context()
|
||||
conn = asyncio.open_connection(host, port, ssl=ctx)
|
||||
else:
|
||||
conn = asyncio.open_connection(host, port)
|
||||
reader, writer = await asyncio.wait_for(conn, _PROBE_TIMEOUT)
|
||||
greeting = await asyncio.wait_for(reader.readline(), _PROBE_TIMEOUT)
|
||||
writer.close()
|
||||
except (TimeoutError, OSError, ssl.SSLError):
|
||||
return None
|
||||
text = greeting.decode("ascii", "ignore")
|
||||
if kind == "imap" and text.startswith("* OK"):
|
||||
return ServerConfig(host, port, tls)
|
||||
if kind == "smtp" and text.startswith("220"):
|
||||
return ServerConfig(host, port, tls)
|
||||
return None
|
||||
|
||||
|
||||
async def _first_working(candidates: list[tuple[str, int, str]], kind: str) -> ServerConfig | None:
|
||||
results = await asyncio.gather(
|
||||
*(_probe_one(host, port, tls, kind) for host, port, tls in candidates)
|
||||
)
|
||||
for result in results: # candidates are in priority order
|
||||
if result is not None:
|
||||
return result
|
||||
return None
|
||||
|
||||
|
||||
async def _discover_probe(domain: str) -> CandidateConfig | None:
|
||||
imap_hosts = [
|
||||
(f"imap.{domain}", 993, "ssl"),
|
||||
(f"mail.{domain}", 993, "ssl"),
|
||||
(f"imap.{domain}", 143, "starttls"),
|
||||
(f"mail.{domain}", 143, "starttls"),
|
||||
]
|
||||
smtp_hosts = [
|
||||
(f"smtp.{domain}", 465, "ssl"),
|
||||
(f"mail.{domain}", 465, "ssl"),
|
||||
(f"smtp.{domain}", 587, "starttls"),
|
||||
(f"mail.{domain}", 587, "starttls"),
|
||||
]
|
||||
imap, smtp = await asyncio.gather(
|
||||
_first_working(imap_hosts, "imap"), _first_working(smtp_hosts, "smtp")
|
||||
)
|
||||
if imap and smtp:
|
||||
return CandidateConfig(imap, smtp, "probe")
|
||||
return None
|
||||
|
||||
|
||||
# --- orchestration -----------------------------------------------------------
|
||||
|
||||
|
||||
async def _try_http_config(url: str, source: str) -> CandidateConfig | None:
|
||||
xml_text = await guarded_get(url)
|
||||
if xml_text is None:
|
||||
return None
|
||||
candidate = parse_autoconfig_xml(xml_text)
|
||||
if candidate is None:
|
||||
return None
|
||||
return CandidateConfig(candidate.imap, candidate.smtp, source, candidate.oauth_provider)
|
||||
|
||||
|
||||
async def discover(email: str) -> DiscoveryResult:
|
||||
"""Run the autodiscovery chain for ``email`` (SPEC §6.2)."""
|
||||
result = DiscoveryResult()
|
||||
domain = domain_of(email)
|
||||
|
||||
# 1. Presets.
|
||||
presets = _load_presets()
|
||||
if domain in presets:
|
||||
result.candidates.append(presets[domain])
|
||||
result.steps.append(DiscoveryStep("preset", "found", domain))
|
||||
return result
|
||||
result.steps.append(DiscoveryStep("preset", "not_found", domain))
|
||||
|
||||
# 2. Autoconfig (autoconfig.<domain>, then <domain>/.well-known).
|
||||
autoconfig_urls = [
|
||||
f"https://autoconfig.{domain}/mail/config-v1.1.xml?emailaddress={email}",
|
||||
f"https://{domain}/.well-known/autoconfig/mail/config-v1.1.xml?emailaddress={email}",
|
||||
]
|
||||
for url in autoconfig_urls:
|
||||
try:
|
||||
candidate = await _try_http_config(url, "autoconfig")
|
||||
except (SsrfError, httpx.HTTPError) as exc:
|
||||
result.steps.append(DiscoveryStep("autoconfig", "error", str(exc)[:200]))
|
||||
continue
|
||||
if candidate is not None:
|
||||
result.candidates.append(candidate)
|
||||
result.steps.append(DiscoveryStep("autoconfig", "found", url))
|
||||
return result
|
||||
result.steps.append(DiscoveryStep("autoconfig", "not_found", domain))
|
||||
|
||||
# 3. ISPDB (Thunderbird's database).
|
||||
ispdb_url = f"https://autoconfig.thunderbird.net/v1.1/{domain}"
|
||||
try:
|
||||
candidate = await _try_http_config(ispdb_url, "ispdb")
|
||||
if candidate is not None:
|
||||
result.candidates.append(candidate)
|
||||
result.steps.append(DiscoveryStep("ispdb", "found", ispdb_url))
|
||||
return result
|
||||
result.steps.append(DiscoveryStep("ispdb", "not_found", domain))
|
||||
except (SsrfError, httpx.HTTPError) as exc:
|
||||
result.steps.append(DiscoveryStep("ispdb", "error", str(exc)[:200]))
|
||||
|
||||
# 4. DNS SRV (RFC 6186).
|
||||
srv = await _discover_srv(domain)
|
||||
if srv is not None:
|
||||
result.candidates.append(srv)
|
||||
result.steps.append(DiscoveryStep("srv", "found", domain))
|
||||
return result
|
||||
result.steps.append(DiscoveryStep("srv", "not_found", domain))
|
||||
|
||||
# 5. Live probe.
|
||||
probe = await _discover_probe(domain)
|
||||
if probe is not None:
|
||||
result.candidates.append(probe)
|
||||
result.steps.append(DiscoveryStep("probe", "found", domain))
|
||||
return result
|
||||
result.steps.append(DiscoveryStep("probe", "not_found", domain))
|
||||
|
||||
return result
|
||||
@@ -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
|
||||
@@ -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]
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -0,0 +1,39 @@
|
||||
"""REST API routers (SPEC §9.1). One module per route group."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import FastAPI
|
||||
|
||||
|
||||
def register_routers(app: FastAPI) -> None:
|
||||
"""Mount all API routers under the ``/api`` prefix.
|
||||
|
||||
Routers are added milestone by milestone; imports are local so the app
|
||||
factory stays importable while modules are still being built out.
|
||||
"""
|
||||
from app.api.accounts import router as accounts_router
|
||||
from app.api.actions import router as actions_router
|
||||
from app.api.compose import router as compose_router
|
||||
from app.api.mailboxes import router as mailboxes_router
|
||||
from app.api.messages import router as messages_router
|
||||
from app.api.recipients import router as recipients_router
|
||||
from app.api.search import router as search_router
|
||||
from app.api.settings import router as settings_router
|
||||
from app.api.threads import router as threads_router
|
||||
from app.identity.routes import router as auth_router
|
||||
from app.ws import router as ws_router
|
||||
|
||||
for router in (
|
||||
auth_router,
|
||||
accounts_router,
|
||||
threads_router,
|
||||
messages_router,
|
||||
search_router,
|
||||
actions_router,
|
||||
compose_router,
|
||||
recipients_router,
|
||||
settings_router,
|
||||
mailboxes_router,
|
||||
):
|
||||
app.include_router(router, prefix="/api")
|
||||
app.include_router(ws_router, prefix="/api")
|
||||
@@ -0,0 +1,176 @@
|
||||
"""Accounts API (SPEC §9.1): discover, test, create, list, update, delete."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.accounts.discovery import discover
|
||||
from app.accounts.schemas import (
|
||||
AccountDetailOut,
|
||||
CandidateOut,
|
||||
CreateAccountRequest,
|
||||
DiscoverRequest,
|
||||
DiscoverResponse,
|
||||
DiscoveryStepOut,
|
||||
ServerConfigIn,
|
||||
TestRequest,
|
||||
TestResponse,
|
||||
UpdateAccountRequest,
|
||||
)
|
||||
from app.accounts.service import AccountExistsError, create_account, delete_account
|
||||
from app.accounts.testing import AccountTestError, test_account
|
||||
from app.accounts.types import CandidateConfig, ServerConfig
|
||||
from app.api.errors import raise_api_error
|
||||
from app.api.schemas import AccountOut
|
||||
from app.db import get_session
|
||||
from app.identity.deps import CurrentUser, require_csrf
|
||||
from app.models import Account
|
||||
from app.queue import get_arq_pool
|
||||
from app.redis_client import get_redis
|
||||
|
||||
router = APIRouter(prefix="/accounts", tags=["accounts"])
|
||||
|
||||
DbSession = Annotated[AsyncSession, Depends(get_session)]
|
||||
|
||||
_DISCOVER_LIMIT = 10 # per minute per user (SPEC §10.5)
|
||||
_DISCOVER_WINDOW = 60
|
||||
|
||||
|
||||
async def _rate_limit_discover(user_id: uuid.UUID) -> None:
|
||||
redis = get_redis()
|
||||
key = f"rl:discover:{user_id}"
|
||||
count = await redis.incr(key)
|
||||
if count == 1:
|
||||
await redis.expire(key, _DISCOVER_WINDOW)
|
||||
if count > _DISCOVER_LIMIT:
|
||||
raise_api_error(429, "rate_limited", "too many discovery requests; slow down")
|
||||
|
||||
|
||||
def _server_in(cfg: ServerConfig) -> ServerConfigIn:
|
||||
return ServerConfigIn(host=cfg.host, port=cfg.port, tls=cfg.tls, auth=cfg.auth)
|
||||
|
||||
|
||||
def _candidate_out(c: CandidateConfig) -> CandidateOut:
|
||||
return CandidateOut(
|
||||
imap=_server_in(c.imap),
|
||||
smtp=_server_in(c.smtp),
|
||||
source=c.source,
|
||||
oauth_provider=c.oauth_provider,
|
||||
)
|
||||
|
||||
|
||||
def _to_server_config(cfg: ServerConfigIn) -> ServerConfig:
|
||||
return ServerConfig(cfg.host, cfg.port, cfg.tls, cfg.auth)
|
||||
|
||||
|
||||
@router.get("", response_model=list[AccountOut])
|
||||
async def list_accounts(user: CurrentUser, db: DbSession) -> list[AccountOut]:
|
||||
accounts = await db.scalars(
|
||||
select(Account).where(Account.user_id == user.id).order_by(Account.created_at)
|
||||
)
|
||||
return [
|
||||
AccountOut(
|
||||
id=a.id, email=a.email, display_name=a.display_name, color=a.color, state=a.state
|
||||
)
|
||||
for a in accounts
|
||||
]
|
||||
|
||||
|
||||
@router.post("/discover", response_model=DiscoverResponse, dependencies=[Depends(require_csrf)])
|
||||
async def discover_account(body: DiscoverRequest, user: CurrentUser) -> DiscoverResponse:
|
||||
await _rate_limit_discover(user.id)
|
||||
result = await discover(str(body.email))
|
||||
return DiscoverResponse(
|
||||
candidates=[_candidate_out(c) for c in result.candidates],
|
||||
steps=[
|
||||
DiscoveryStepOut(method=s.method, status=s.status, detail=s.detail)
|
||||
for s in result.steps
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@router.post("/test", response_model=TestResponse, dependencies=[Depends(require_csrf)])
|
||||
async def test_account_credentials(body: TestRequest, _user: CurrentUser) -> TestResponse:
|
||||
try:
|
||||
await test_account(
|
||||
imap=_to_server_config(body.imap),
|
||||
smtp=_to_server_config(body.smtp),
|
||||
username=body.username or str(body.email),
|
||||
password=body.password,
|
||||
)
|
||||
except AccountTestError as exc:
|
||||
raise_api_error(400, exc.code, exc.detail)
|
||||
return TestResponse(ok=True)
|
||||
|
||||
|
||||
@router.post("", response_model=AccountDetailOut, dependencies=[Depends(require_csrf)])
|
||||
async def create_new_account(
|
||||
body: CreateAccountRequest, user: CurrentUser, db: DbSession
|
||||
) -> AccountDetailOut:
|
||||
# Validate credentials before persisting (also the §10.2 decryption sibling).
|
||||
try:
|
||||
await test_account(
|
||||
imap=_to_server_config(body.imap),
|
||||
smtp=_to_server_config(body.smtp),
|
||||
username=body.username or str(body.email),
|
||||
password=body.password,
|
||||
)
|
||||
except AccountTestError as exc:
|
||||
raise_api_error(400, exc.code, exc.detail)
|
||||
|
||||
try:
|
||||
account = await create_account(db, user_id=user.id, req=body)
|
||||
except AccountExistsError:
|
||||
raise_api_error(409, "account_exists", "an account for this email already exists")
|
||||
await db.commit()
|
||||
|
||||
pool = await get_arq_pool()
|
||||
await pool.enqueue_job("sync_bootstrap_account", str(account.id))
|
||||
|
||||
return _detail(account)
|
||||
|
||||
|
||||
@router.patch(
|
||||
"/{account_id}", response_model=AccountDetailOut, dependencies=[Depends(require_csrf)]
|
||||
)
|
||||
async def update_account(
|
||||
account_id: uuid.UUID, body: UpdateAccountRequest, user: CurrentUser, db: DbSession
|
||||
) -> AccountDetailOut:
|
||||
account = await db.scalar(
|
||||
select(Account).where(Account.id == account_id, Account.user_id == user.id)
|
||||
)
|
||||
if account is None:
|
||||
raise_api_error(404, "not_found", "account not found")
|
||||
if body.display_name is not None:
|
||||
account.display_name = body.display_name
|
||||
if body.color is not None:
|
||||
account.color = body.color
|
||||
await db.commit()
|
||||
return _detail(account)
|
||||
|
||||
|
||||
@router.delete("/{account_id}", dependencies=[Depends(require_csrf)])
|
||||
async def remove_account(account_id: uuid.UUID, user: CurrentUser, db: DbSession) -> dict[str, str]:
|
||||
ok = await delete_account(db, user_id=user.id, account_id=account_id)
|
||||
if not ok:
|
||||
raise_api_error(404, "not_found", "account not found")
|
||||
await db.commit()
|
||||
return {"status": "deleted"}
|
||||
|
||||
|
||||
def _detail(account: Account) -> AccountDetailOut:
|
||||
return AccountDetailOut(
|
||||
id=account.id,
|
||||
email=account.email,
|
||||
display_name=account.display_name,
|
||||
color=account.color,
|
||||
state=account.state,
|
||||
state_detail=account.state_detail,
|
||||
imap_config=account.imap_config,
|
||||
smtp_config=account.smtp_config,
|
||||
)
|
||||
@@ -0,0 +1,159 @@
|
||||
"""Flag / move / archive endpoints (SPEC §9.1). DB-first, then push via worker."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.errors import raise_api_error
|
||||
from app.api.schemas import FlagChangeRequest, MoveRequest
|
||||
from app.db import get_session
|
||||
from app.events import get_event_bus
|
||||
from app.identity.deps import CurrentUser, require_csrf
|
||||
from app.models import Mailbox, Message, Thread
|
||||
from app.queue import get_arq_pool
|
||||
from app.sync.actions import apply_flags_to_messages, sanitize_flags
|
||||
|
||||
router = APIRouter(tags=["mail"], dependencies=[Depends(require_csrf)])
|
||||
|
||||
DbSession = Annotated[AsyncSession, Depends(get_session)]
|
||||
|
||||
|
||||
async def _push_and_publish(
|
||||
user_id: uuid.UUID, changed: list[Message], add: frozenset[str], remove: frozenset[str]
|
||||
) -> None:
|
||||
pool = await get_arq_pool()
|
||||
bus = get_event_bus()
|
||||
for message in changed:
|
||||
await pool.enqueue_job("push_flags_job", str(message.id), sorted(add), sorted(remove))
|
||||
await bus.publish(
|
||||
"flags.changed",
|
||||
{
|
||||
"user_id": str(user_id),
|
||||
"message_id": str(message.id),
|
||||
"flags": list(message.flags),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _would_change(message: Message, add: frozenset[str], remove: frozenset[str]) -> bool:
|
||||
current = set(message.flags)
|
||||
return bool((add - current) or (remove & current))
|
||||
|
||||
|
||||
@router.post("/messages/{message_id}/flags")
|
||||
async def message_flags(
|
||||
message_id: uuid.UUID, body: FlagChangeRequest, user: CurrentUser, db: DbSession
|
||||
) -> dict[str, object]:
|
||||
message = await db.scalar(
|
||||
select(Message).where(Message.id == message_id, Message.user_id == user.id)
|
||||
)
|
||||
if message is None:
|
||||
raise_api_error(404, "not_found", "message not found")
|
||||
add, remove = sanitize_flags(body.add), sanitize_flags(body.remove)
|
||||
changed = [message] if _would_change(message, add, remove) else []
|
||||
await apply_flags_to_messages(db, [message], add, remove)
|
||||
await db.commit()
|
||||
await _push_and_publish(user.id, changed, add, remove)
|
||||
return {"id": str(message.id), "flags": list(message.flags)}
|
||||
|
||||
|
||||
@router.post("/threads/{thread_id}/flags")
|
||||
async def thread_flags(
|
||||
thread_id: uuid.UUID, body: FlagChangeRequest, user: CurrentUser, db: DbSession
|
||||
) -> dict[str, object]:
|
||||
thread = await db.scalar(
|
||||
select(Thread).where(Thread.id == thread_id, Thread.user_id == user.id)
|
||||
)
|
||||
if thread is None:
|
||||
raise_api_error(404, "not_found", "thread not found")
|
||||
messages = list(
|
||||
await db.scalars(
|
||||
select(Message).where(Message.thread_id == thread_id, Message.user_id == user.id)
|
||||
)
|
||||
)
|
||||
add, remove = sanitize_flags(body.add), sanitize_flags(body.remove)
|
||||
changed = [m for m in messages if _would_change(m, add, remove)]
|
||||
await apply_flags_to_messages(db, messages, add, remove)
|
||||
await db.commit()
|
||||
await _push_and_publish(user.id, changed, add, remove)
|
||||
await get_event_bus().publish(
|
||||
"thread.updated", {"user_id": str(user.id), "thread_id": str(thread_id)}
|
||||
)
|
||||
return {"thread_id": str(thread_id), "changed": len(changed)}
|
||||
|
||||
|
||||
async def _move_thread(
|
||||
user_id: uuid.UUID, thread_id: uuid.UUID, db: AsyncSession, special: str
|
||||
) -> None:
|
||||
thread = await db.scalar(
|
||||
select(Thread).where(Thread.id == thread_id, Thread.user_id == user_id)
|
||||
)
|
||||
if thread is None:
|
||||
raise_api_error(404, "not_found", "thread not found")
|
||||
pool = await get_arq_pool()
|
||||
await pool.enqueue_job("move_thread_to_folder_job", str(thread_id), special)
|
||||
|
||||
|
||||
@router.post("/threads/{thread_id}/archive")
|
||||
async def archive_thread(thread_id: uuid.UUID, user: CurrentUser, db: DbSession) -> dict[str, str]:
|
||||
await _move_thread(user.id, thread_id, db, "archive")
|
||||
return {"status": "queued"}
|
||||
|
||||
|
||||
@router.post("/threads/{thread_id}/trash")
|
||||
async def trash_thread(thread_id: uuid.UUID, user: CurrentUser, db: DbSession) -> dict[str, str]:
|
||||
await _move_thread(user.id, thread_id, db, "trash")
|
||||
return {"status": "queued"}
|
||||
|
||||
|
||||
@router.post("/threads/{thread_id}/restore")
|
||||
async def restore_thread(thread_id: uuid.UUID, user: CurrentUser, db: DbSession) -> dict[str, str]:
|
||||
"""Move a thread back to the Inbox (from Archive or Trash)."""
|
||||
await _move_thread(user.id, thread_id, db, "inbox")
|
||||
return {"status": "queued"}
|
||||
|
||||
|
||||
@router.post("/threads/{thread_id}/delete")
|
||||
async def delete_thread(thread_id: uuid.UUID, user: CurrentUser, db: DbSession) -> dict[str, str]:
|
||||
"""Permanently delete a thread (server EXPUNGE + DB rows)."""
|
||||
thread = await db.scalar(
|
||||
select(Thread).where(Thread.id == thread_id, Thread.user_id == user.id)
|
||||
)
|
||||
if thread is None:
|
||||
raise_api_error(404, "not_found", "thread not found")
|
||||
pool = await get_arq_pool()
|
||||
await pool.enqueue_job("delete_thread_job", str(thread_id))
|
||||
return {"status": "queued"}
|
||||
|
||||
|
||||
@router.post("/messages/{message_id}/move")
|
||||
async def move_message(
|
||||
message_id: uuid.UUID, body: MoveRequest, user: CurrentUser, db: DbSession
|
||||
) -> dict[str, object]:
|
||||
message = await db.scalar(
|
||||
select(Message).where(Message.id == message_id, Message.user_id == user.id)
|
||||
)
|
||||
if message is None:
|
||||
raise_api_error(404, "not_found", "message not found")
|
||||
dst = await db.scalar(
|
||||
select(Mailbox).where(
|
||||
Mailbox.id == body.mailbox_id,
|
||||
Mailbox.user_id == user.id,
|
||||
Mailbox.account_id == message.account_id, # moves stay within an account
|
||||
)
|
||||
)
|
||||
if dst is None:
|
||||
raise_api_error(400, "bad_mailbox", "target mailbox not found in this account")
|
||||
|
||||
src_mailbox_id = message.mailbox_id
|
||||
message.mailbox_id = dst.id # optimistic
|
||||
await db.commit()
|
||||
|
||||
pool = await get_arq_pool()
|
||||
await pool.enqueue_job("move_job", str(message.id), str(dst.id), str(src_mailbox_id))
|
||||
return {"id": str(message.id), "mailbox_id": str(dst.id)}
|
||||
@@ -0,0 +1,163 @@
|
||||
"""Compose endpoints: drafts + send + undo (SPEC §9.1)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import timedelta
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, File, Query, UploadFile
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.errors import raise_api_error
|
||||
from app.api.schemas import AttachmentOut
|
||||
from app.config import get_settings
|
||||
from app.db import get_session
|
||||
from app.identity.deps import CurrentUser, require_csrf
|
||||
from app.indexer import blobstore
|
||||
from app.models import ComposeAttachment, Draft
|
||||
from app.outbox.schemas import DraftIn, DraftOut, SendRequest, SendResponse
|
||||
from app.outbox.service import DraftError, cancel_outbox, create_outbox, upsert_draft
|
||||
from app.queue import get_arq_pool
|
||||
|
||||
router = APIRouter(tags=["compose"], dependencies=[Depends(require_csrf)])
|
||||
|
||||
DbSession = Annotated[AsyncSession, Depends(get_session)]
|
||||
|
||||
MAX_ATTACHMENT_BYTES = 25 * 1024 * 1024 # 25 MB per file (compose Attach limit)
|
||||
|
||||
|
||||
def _att_out(att: ComposeAttachment) -> AttachmentOut:
|
||||
return AttachmentOut(
|
||||
id=att.id, filename=att.filename, mime_type=att.mime_type, size_bytes=att.size_bytes
|
||||
)
|
||||
|
||||
|
||||
@router.post("/attachments", response_model=AttachmentOut)
|
||||
async def upload_attachment(
|
||||
user: CurrentUser, db: DbSession, file: UploadFile = File(...)
|
||||
) -> AttachmentOut:
|
||||
"""Store a file for an outgoing message (≤ 25 MB) and return its metadata."""
|
||||
data = await file.read()
|
||||
if not data:
|
||||
raise_api_error(400, "empty_file", "the file is empty")
|
||||
if len(data) > MAX_ATTACHMENT_BYTES:
|
||||
raise_api_error(413, "too_large", "attachment exceeds the 25 MB limit")
|
||||
sha = blobstore.store(user.id, data)
|
||||
att = ComposeAttachment(
|
||||
user_id=user.id,
|
||||
filename=file.filename or "attachment",
|
||||
mime_type=file.content_type or "application/octet-stream",
|
||||
size_bytes=len(data),
|
||||
blob_sha256=sha,
|
||||
)
|
||||
db.add(att)
|
||||
await db.commit()
|
||||
return _att_out(att)
|
||||
|
||||
|
||||
@router.get("/attachments", response_model=list[AttachmentOut])
|
||||
async def list_compose_attachments(
|
||||
user: CurrentUser, db: DbSession, ids: str = Query("")
|
||||
) -> list[AttachmentOut]:
|
||||
"""Resolve metadata for the given compose-attachment ids (reopening a draft)."""
|
||||
id_list: list[uuid.UUID] = []
|
||||
for value in ids.split(","):
|
||||
value = value.strip()
|
||||
if not value:
|
||||
continue
|
||||
try:
|
||||
id_list.append(uuid.UUID(value))
|
||||
except ValueError:
|
||||
continue
|
||||
if not id_list:
|
||||
return []
|
||||
rows = await db.scalars(
|
||||
select(ComposeAttachment).where(
|
||||
ComposeAttachment.user_id == user.id, ComposeAttachment.id.in_(id_list)
|
||||
)
|
||||
)
|
||||
by_id = {r.id: r for r in rows}
|
||||
return [_att_out(by_id[i]) for i in id_list if i in by_id]
|
||||
|
||||
|
||||
def _draft_out(draft: Draft) -> DraftOut:
|
||||
return DraftOut(
|
||||
id=draft.id,
|
||||
account_id=draft.account_id,
|
||||
thread_id=draft.thread_id,
|
||||
reply_to_message_id=draft.reply_to_message_id,
|
||||
kind=draft.kind,
|
||||
payload=draft.payload,
|
||||
updated_at=draft.updated_at.isoformat(),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/drafts", response_model=list[DraftOut])
|
||||
async def list_drafts(user: CurrentUser, db: DbSession) -> list[DraftOut]:
|
||||
drafts = await db.scalars(
|
||||
select(Draft).where(Draft.user_id == user.id).order_by(Draft.updated_at.desc())
|
||||
)
|
||||
return [_draft_out(d) for d in drafts]
|
||||
|
||||
|
||||
@router.post("/drafts", response_model=DraftOut)
|
||||
async def create_draft(body: DraftIn, user: CurrentUser, db: DbSession) -> DraftOut:
|
||||
try:
|
||||
draft = await upsert_draft(db, user_id=user.id, body=body, draft_id=None)
|
||||
except DraftError as exc:
|
||||
raise_api_error(400, "draft_error", str(exc))
|
||||
await db.commit()
|
||||
return _draft_out(draft)
|
||||
|
||||
|
||||
@router.put("/drafts/{draft_id}", response_model=DraftOut)
|
||||
async def update_draft(
|
||||
draft_id: uuid.UUID, body: DraftIn, user: CurrentUser, db: DbSession
|
||||
) -> DraftOut:
|
||||
try:
|
||||
draft = await upsert_draft(db, user_id=user.id, body=body, draft_id=draft_id)
|
||||
except DraftError as exc:
|
||||
code = 404 if "not found" in str(exc) else 400
|
||||
raise_api_error(code, "draft_error", str(exc))
|
||||
await db.commit()
|
||||
return _draft_out(draft)
|
||||
|
||||
|
||||
@router.delete("/drafts/{draft_id}")
|
||||
async def delete_draft(draft_id: uuid.UUID, user: CurrentUser, db: DbSession) -> dict[str, str]:
|
||||
draft = await db.scalar(select(Draft).where(Draft.id == draft_id, Draft.user_id == user.id))
|
||||
if draft is None:
|
||||
raise_api_error(404, "not_found", "draft not found")
|
||||
await db.delete(draft)
|
||||
await db.commit()
|
||||
return {"status": "deleted"}
|
||||
|
||||
|
||||
@router.post("/send", response_model=SendResponse)
|
||||
async def send(body: SendRequest, user: CurrentUser, db: DbSession) -> SendResponse:
|
||||
draft = await db.scalar(
|
||||
select(Draft).where(Draft.id == body.draft_id, Draft.user_id == user.id)
|
||||
)
|
||||
if draft is None:
|
||||
raise_api_error(404, "not_found", "draft not found")
|
||||
outbox = await create_outbox(db, user_id=user.id, draft=draft)
|
||||
await db.commit()
|
||||
|
||||
pool = await get_arq_pool()
|
||||
await pool.enqueue_job(
|
||||
"send_job",
|
||||
str(outbox.id),
|
||||
_defer_by=timedelta(seconds=get_settings().undo_seconds),
|
||||
)
|
||||
return SendResponse(outbox_id=outbox.id, undo_until=outbox.send_after.isoformat())
|
||||
|
||||
|
||||
@router.post("/send/{outbox_id}/undo")
|
||||
async def undo_send(outbox_id: uuid.UUID, user: CurrentUser, db: DbSession) -> dict[str, str]:
|
||||
ok = await cancel_outbox(db, user_id=user.id, outbox_id=outbox_id)
|
||||
await db.commit()
|
||||
if not ok:
|
||||
raise_api_error(409, "undo_too_late", "the message has already left the undo window")
|
||||
return {"status": "cancelled"}
|
||||
@@ -0,0 +1,23 @@
|
||||
"""Keyset cursor helpers for thread listings (SPEC §9.1: cursor = last_message_at,id)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import datetime as dt
|
||||
import uuid
|
||||
|
||||
|
||||
def encode_cursor(last_message_at: dt.datetime, thread_id: uuid.UUID) -> str:
|
||||
raw = f"{last_message_at.isoformat()}|{thread_id}"
|
||||
return base64.urlsafe_b64encode(raw.encode()).decode()
|
||||
|
||||
|
||||
def decode_cursor(cursor: str | None) -> tuple[dt.datetime, uuid.UUID] | None:
|
||||
if not cursor:
|
||||
return None
|
||||
try:
|
||||
raw = base64.urlsafe_b64decode(cursor.encode()).decode()
|
||||
date_str, id_str = raw.split("|", 1)
|
||||
return dt.datetime.fromisoformat(date_str), uuid.UUID(id_str)
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
@@ -0,0 +1,35 @@
|
||||
"""Consistent API error shape ``{error:{code,message}}`` (SPEC §9.1)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import NoReturn
|
||||
|
||||
from fastapi import FastAPI, HTTPException, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
from starlette.exceptions import HTTPException as StarletteHTTPException
|
||||
|
||||
|
||||
def api_error(status_code: int, code: str, message: str) -> HTTPException:
|
||||
"""Build an :class:`HTTPException` carrying the ``{code,message}`` detail."""
|
||||
return HTTPException(status_code=status_code, detail={"code": code, "message": message})
|
||||
|
||||
|
||||
def raise_api_error(status_code: int, code: str, message: str) -> NoReturn:
|
||||
"""Raise :func:`api_error` (typed as ``NoReturn`` for control-flow clarity)."""
|
||||
raise api_error(status_code, code, message)
|
||||
|
||||
|
||||
async def _handler(request: Request, exc: StarletteHTTPException) -> JSONResponse:
|
||||
detail = exc.detail
|
||||
if isinstance(detail, dict) and "code" in detail:
|
||||
body = {"error": detail}
|
||||
else:
|
||||
body = {"error": {"code": "http_error", "message": str(detail)}}
|
||||
headers = getattr(exc, "headers", None)
|
||||
return JSONResponse(status_code=exc.status_code, content=body, headers=headers)
|
||||
|
||||
|
||||
def register_error_handlers(app: FastAPI) -> None:
|
||||
"""Install the error-shape handler for all HTTP exceptions."""
|
||||
app.add_exception_handler(StarletteHTTPException, _handler) # type: ignore[arg-type]
|
||||
app.add_exception_handler(HTTPException, _handler) # type: ignore[arg-type]
|
||||
@@ -0,0 +1,37 @@
|
||||
"""Mailbox (folder) listing (SPEC §9.3 folder views, archive action)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.db import get_session
|
||||
from app.identity.deps import CurrentUser
|
||||
from app.models import Mailbox
|
||||
|
||||
router = APIRouter(prefix="/mailboxes", tags=["mail"])
|
||||
|
||||
DbSession = Annotated[AsyncSession, Depends(get_session)]
|
||||
|
||||
|
||||
class MailboxOut(BaseModel):
|
||||
id: uuid.UUID
|
||||
account_id: uuid.UUID
|
||||
path: str
|
||||
special_use: str | None
|
||||
|
||||
|
||||
@router.get("", response_model=list[MailboxOut])
|
||||
async def list_mailboxes(user: CurrentUser, db: DbSession) -> list[MailboxOut]:
|
||||
rows = await db.scalars(
|
||||
select(Mailbox).where(Mailbox.user_id == user.id).order_by(Mailbox.account_id, Mailbox.path)
|
||||
)
|
||||
return [
|
||||
MailboxOut(id=m.id, account_id=m.account_id, path=m.path, special_use=m.special_use)
|
||||
for m in rows
|
||||
]
|
||||
@@ -0,0 +1,231 @@
|
||||
"""Message body/attachment endpoints (SPEC §9.1 Mail)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import html as html_lib
|
||||
import io
|
||||
import uuid
|
||||
import zipfile
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from fastapi.responses import HTMLResponse, Response
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.errors import raise_api_error
|
||||
from app.api.schemas import AttachmentOut
|
||||
from app.api.settings import add_allowed_image_sender, allowed_image_senders
|
||||
from app.db import get_session
|
||||
from app.identity.deps import CurrentUser, require_csrf
|
||||
from app.indexer import blobstore
|
||||
from app.indexer.mime import parse_message
|
||||
from app.indexer.sanitize import SANITIZER_VERSION, sanitize_html
|
||||
from app.models import Attachment, BodyPart, Message
|
||||
|
||||
router = APIRouter(prefix="/messages", tags=["mail"])
|
||||
|
||||
DbSession = Annotated[AsyncSession, Depends(get_session)]
|
||||
|
||||
# Content types safe to serve inline (SPEC §10.8); everything else is forced to
|
||||
# an attachment download with a neutral type.
|
||||
_INLINE_SAFE = {"image/png", "image/jpeg", "image/gif", "image/webp", "application/pdf"}
|
||||
|
||||
# Base styles injected around the sanitized fragment so email content renders on a
|
||||
# readable light canvas with images that fit the pane (the iframe is sandboxed).
|
||||
_DOC_TEMPLATE = """<!doctype html><html><head><meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<style>
|
||||
html,body{{margin:0;padding:0}}
|
||||
body{{font-family:system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;font-size:14px;
|
||||
line-height:1.5;color:#1a1a1a;background:#fff;padding:14px;word-break:break-word}}
|
||||
img{{max-width:100%;height:auto}}
|
||||
table{{max-width:100%;border-collapse:collapse}}
|
||||
blockquote{{margin:0 0 0 12px;padding-left:12px;border-left:3px solid #e0e0e0;color:#555}}
|
||||
a{{color:#2563eb}}
|
||||
pre{{white-space:pre-wrap;word-wrap:break-word}}
|
||||
</style></head><body>{body}</body></html>"""
|
||||
|
||||
|
||||
def _wrap(fragment: str) -> str:
|
||||
return _DOC_TEMPLATE.format(body=fragment)
|
||||
|
||||
|
||||
async def _owned_message(db: AsyncSession, user_id: uuid.UUID, message_id: uuid.UUID) -> Message:
|
||||
msg = await db.scalar(
|
||||
select(Message).where(Message.id == message_id, Message.user_id == user_id)
|
||||
)
|
||||
if msg is None:
|
||||
raise_api_error(404, "not_found", "message not found")
|
||||
return msg
|
||||
|
||||
|
||||
def _sender_email(message: Message) -> str:
|
||||
return message.from_addr[0].get("email", "").lower() if message.from_addr else ""
|
||||
|
||||
|
||||
@router.get("/{message_id}/html", response_class=HTMLResponse)
|
||||
async def message_html(message_id: uuid.UUID, user: CurrentUser, db: DbSession) -> HTMLResponse:
|
||||
"""Return sanitized HTML wrapped in a document for the sandboxed iframe (SPEC §9.4, I-8).
|
||||
|
||||
Re-sanitizes from the raw blob when the sender is image-approved, or when the
|
||||
stored copy predates the current sanitizer version (SPEC §10.4).
|
||||
"""
|
||||
message = await _owned_message(db, user.id, message_id)
|
||||
allow_remote = _sender_email(message) in allowed_image_senders(user)
|
||||
|
||||
parts = list(await db.scalars(select(BodyPart).where(BodyPart.message_id == message_id)))
|
||||
html_part = next((p for p in parts if p.kind == "html_sanitized"), None)
|
||||
|
||||
stale = html_part is not None and html_part.sanitizer_v < SANITIZER_VERSION
|
||||
if html_part is not None and not allow_remote and not stale:
|
||||
return HTMLResponse(content=_wrap(html_part.content))
|
||||
|
||||
# Re-sanitize the original HTML (image-approved sender, or a stale stored copy).
|
||||
# If the raw blob is unavailable, fall back to whatever is already stored.
|
||||
if allow_remote or stale:
|
||||
try:
|
||||
raw = blobstore.load(user.id, message.blob_sha256)
|
||||
except FileNotFoundError:
|
||||
raw = None
|
||||
if raw is not None:
|
||||
parsed = parse_message(raw)
|
||||
if parsed.html_body is not None:
|
||||
result = sanitize_html(
|
||||
parsed.html_body, message.id, allow_remote_images=allow_remote
|
||||
)
|
||||
if html_part is not None and not allow_remote:
|
||||
html_part.content = result.html # persist the upgraded sanitization
|
||||
html_part.sanitizer_v = result.sanitizer_v
|
||||
await db.commit()
|
||||
return HTMLResponse(content=_wrap(result.html))
|
||||
|
||||
if html_part is not None:
|
||||
return HTMLResponse(content=_wrap(html_part.content))
|
||||
text_part = next((p for p in parts if p.kind == "text"), None)
|
||||
body = html_lib.escape(text_part.content) if text_part else ""
|
||||
return HTMLResponse(content=_wrap(f"<pre>{body}</pre>"))
|
||||
|
||||
|
||||
@router.post("/{message_id}/allow-remote-images", dependencies=[Depends(require_csrf)])
|
||||
async def allow_remote_images(
|
||||
message_id: uuid.UUID, user: CurrentUser, db: DbSession
|
||||
) -> dict[str, str]:
|
||||
"""Add the message's sender to the per-sender image allowlist (SPEC §9.4)."""
|
||||
message = await _owned_message(db, user.id, message_id)
|
||||
sender = _sender_email(message)
|
||||
if not sender:
|
||||
raise_api_error(400, "no_sender", "message has no sender address")
|
||||
add_allowed_image_sender(user, sender)
|
||||
await db.commit()
|
||||
return {"status": "ok", "sender": sender}
|
||||
|
||||
|
||||
@router.get("/{message_id}/cid/{content_id}")
|
||||
async def message_cid(
|
||||
message_id: uuid.UUID, content_id: str, user: CurrentUser, db: DbSession
|
||||
) -> Response:
|
||||
"""Serve an inline (cid:) attachment image referenced by the sanitized HTML."""
|
||||
msg = await _owned_message(db, user.id, message_id)
|
||||
att = await db.scalar(
|
||||
select(Attachment).where(
|
||||
Attachment.message_id == message_id, Attachment.content_id == content_id
|
||||
)
|
||||
)
|
||||
if att is None:
|
||||
raise_api_error(404, "not_found", "inline part not found")
|
||||
data = blobstore.load(msg.user_id, att.blob_sha256)
|
||||
media_type = att.mime_type if att.mime_type in _INLINE_SAFE else "application/octet-stream"
|
||||
return Response(content=data, media_type=media_type)
|
||||
|
||||
|
||||
async def _real_attachments(db: AsyncSession, message_id: uuid.UUID) -> list[Attachment]:
|
||||
"""Downloadable (non-inline) attachments for a message, in a stable order."""
|
||||
rows = await db.scalars(
|
||||
select(Attachment)
|
||||
.where(Attachment.message_id == message_id, Attachment.content_id.is_(None))
|
||||
.order_by(Attachment.filename)
|
||||
)
|
||||
return list(rows)
|
||||
|
||||
|
||||
@router.get("/{message_id}/attachments", response_model=list[AttachmentOut])
|
||||
async def list_attachments(
|
||||
message_id: uuid.UUID, user: CurrentUser, db: DbSession
|
||||
) -> list[AttachmentOut]:
|
||||
"""List a message's downloadable attachments (inline cid parts excluded)."""
|
||||
await _owned_message(db, user.id, message_id)
|
||||
return [
|
||||
AttachmentOut(id=a.id, filename=a.filename, mime_type=a.mime_type, size_bytes=a.size_bytes)
|
||||
for a in await _real_attachments(db, message_id)
|
||||
]
|
||||
|
||||
|
||||
@router.get("/{message_id}/attachments.zip")
|
||||
async def download_attachments_zip(
|
||||
message_id: uuid.UUID,
|
||||
user: CurrentUser,
|
||||
db: DbSession,
|
||||
ids: str | None = Query(None, description="comma-separated attachment ids; all if omitted"),
|
||||
) -> Response:
|
||||
"""Bundle a message's attachments into a single ``Attachments.zip`` download."""
|
||||
msg = await _owned_message(db, user.id, message_id)
|
||||
wanted: set[uuid.UUID] | None = None
|
||||
if ids:
|
||||
try:
|
||||
wanted = {uuid.UUID(v) for v in ids.split(",") if v.strip()}
|
||||
except ValueError:
|
||||
raise_api_error(400, "bad_ids", "invalid attachment id list")
|
||||
attachments = [
|
||||
a for a in await _real_attachments(db, message_id) if wanted is None or a.id in wanted
|
||||
]
|
||||
if not attachments:
|
||||
raise_api_error(404, "not_found", "no attachments to download")
|
||||
|
||||
buffer = io.BytesIO()
|
||||
with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as zf:
|
||||
seen: dict[str, int] = {}
|
||||
for att in attachments:
|
||||
name = att.filename or "attachment"
|
||||
# De-dupe identical filenames within the archive (name, name-2, …).
|
||||
if name in seen:
|
||||
seen[name] += 1
|
||||
stem, dot, ext = name.rpartition(".")
|
||||
name = f"{stem}-{seen[name]}{dot}{ext}" if dot else f"{name}-{seen[name]}"
|
||||
else:
|
||||
seen[name] = 1
|
||||
zf.writestr(name, blobstore.load(msg.user_id, att.blob_sha256))
|
||||
|
||||
return Response(
|
||||
content=buffer.getvalue(),
|
||||
media_type="application/zip",
|
||||
headers={"Content-Disposition": 'attachment; filename="Attachments.zip"'},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{message_id}/attachments/{attachment_id}")
|
||||
async def message_attachment(
|
||||
message_id: uuid.UUID,
|
||||
attachment_id: uuid.UUID,
|
||||
user: CurrentUser,
|
||||
db: DbSession,
|
||||
download: bool = Query(False),
|
||||
) -> Response:
|
||||
"""Download an attachment (inline for safe types unless ``download`` forces it)."""
|
||||
msg = await _owned_message(db, user.id, message_id)
|
||||
att = await db.scalar(
|
||||
select(Attachment).where(
|
||||
Attachment.id == attachment_id, Attachment.message_id == message_id
|
||||
)
|
||||
)
|
||||
if att is None:
|
||||
raise_api_error(404, "not_found", "attachment not found")
|
||||
data = blobstore.load(msg.user_id, att.blob_sha256)
|
||||
inline = att.mime_type in _INLINE_SAFE and not download
|
||||
media_type = att.mime_type if inline else "application/octet-stream"
|
||||
disposition = "inline" if inline else "attachment"
|
||||
return Response(
|
||||
content=data,
|
||||
media_type=media_type,
|
||||
headers={"Content-Disposition": f'{disposition}; filename="{att.filename}"'},
|
||||
)
|
||||
@@ -0,0 +1,52 @@
|
||||
"""Recipient autocomplete from known_recipients (SPEC §9.1, §4).
|
||||
|
||||
Prefix match on email or name, ranked by ``times_used`` decayed by recency. The
|
||||
composer merges these with plugin capability providers client-side (M3).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.db import get_session
|
||||
from app.identity.deps import CurrentUser
|
||||
from app.models import KnownRecipient
|
||||
|
||||
router = APIRouter(prefix="/recipients", tags=["compose"])
|
||||
|
||||
DbSession = Annotated[AsyncSession, Depends(get_session)]
|
||||
|
||||
|
||||
class Suggestion(BaseModel):
|
||||
name: str
|
||||
email: str
|
||||
|
||||
|
||||
@router.get("/suggest", response_model=list[Suggestion])
|
||||
async def suggest(
|
||||
user: CurrentUser, db: DbSession, q: str = Query("", max_length=200)
|
||||
) -> list[Suggestion]:
|
||||
term = q.strip()
|
||||
if not term:
|
||||
return []
|
||||
like = f"{term}%"
|
||||
# Recency-decayed score: times_used / (1 + age_in_days). Ranks frequent and
|
||||
# recent correspondents ahead of stale ones (SPEC §9.1).
|
||||
age_days = func.extract("epoch", func.now() - KnownRecipient.last_used_at) / 86400.0
|
||||
score = KnownRecipient.times_used / (1.0 + age_days)
|
||||
stmt = (
|
||||
select(KnownRecipient.name, KnownRecipient.email)
|
||||
.where(
|
||||
KnownRecipient.user_id == user.id,
|
||||
KnownRecipient.email.ilike(like) | KnownRecipient.name.ilike(like),
|
||||
)
|
||||
.order_by(score.desc())
|
||||
.limit(10)
|
||||
)
|
||||
rows = (await db.execute(stmt)).all()
|
||||
return [Suggestion(name=row.name, email=row.email) for row in rows]
|
||||
@@ -0,0 +1,91 @@
|
||||
"""Shared API response models (SPEC §9.1)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as dt
|
||||
import uuid
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class Address(BaseModel):
|
||||
name: str = ""
|
||||
email: str = ""
|
||||
|
||||
|
||||
class ThreadSummary(BaseModel):
|
||||
id: uuid.UUID
|
||||
subject: str
|
||||
snippet: str
|
||||
participants: list[Address]
|
||||
account_ids: list[uuid.UUID]
|
||||
message_count: int
|
||||
unread_count: int
|
||||
has_attachments: bool
|
||||
pinned: bool
|
||||
last_message_at: dt.datetime
|
||||
|
||||
|
||||
class ThreadListOut(BaseModel):
|
||||
threads: list[ThreadSummary]
|
||||
cursor: str | None = None
|
||||
|
||||
|
||||
class AttachmentOut(BaseModel):
|
||||
id: uuid.UUID
|
||||
filename: str
|
||||
mime_type: str
|
||||
size_bytes: int
|
||||
|
||||
|
||||
class MessageEnvelope(BaseModel):
|
||||
id: uuid.UUID
|
||||
account_id: uuid.UUID
|
||||
mailbox_id: uuid.UUID
|
||||
thread_id: uuid.UUID | None
|
||||
subject: str
|
||||
from_addr: list[Address]
|
||||
to_addr: list[Address]
|
||||
cc_addr: list[Address]
|
||||
date: dt.datetime
|
||||
flags: list[str]
|
||||
is_unread: bool
|
||||
is_starred: bool
|
||||
has_attachments: bool
|
||||
has_remote_images: bool
|
||||
snippet: str
|
||||
|
||||
|
||||
class ThreadDetail(BaseModel):
|
||||
id: uuid.UUID
|
||||
subject: str
|
||||
order: str # "asc" | "desc"
|
||||
pinned: bool
|
||||
messages: list[MessageEnvelope] # always ASC (SPEC §9.4)
|
||||
|
||||
|
||||
class FlagChangeRequest(BaseModel):
|
||||
add: list[str] = []
|
||||
remove: list[str] = []
|
||||
|
||||
|
||||
class MoveRequest(BaseModel):
|
||||
mailbox_id: uuid.UUID
|
||||
|
||||
|
||||
class AccountOut(BaseModel):
|
||||
id: uuid.UUID
|
||||
email: str
|
||||
display_name: str
|
||||
color: str
|
||||
state: str
|
||||
|
||||
|
||||
class SearchThreadHit(BaseModel):
|
||||
thread: ThreadSummary
|
||||
highlights: dict[str, str]
|
||||
|
||||
|
||||
class SearchOut(BaseModel):
|
||||
threads: list[SearchThreadHit]
|
||||
cursor: str | None = None
|
||||
@@ -0,0 +1,64 @@
|
||||
"""Search endpoint (SPEC §9.1 Search, §8). Results grouped by thread (FR-28)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.schemas import SearchOut, SearchThreadHit
|
||||
from app.api.threads import _summary
|
||||
from app.db import get_session
|
||||
from app.identity.deps import CurrentUser
|
||||
from app.models import Thread
|
||||
from app.search.factory import get_search_backend
|
||||
from app.search.queryparser import to_search_query
|
||||
|
||||
router = APIRouter(prefix="/search", tags=["search"])
|
||||
|
||||
DbSession = Annotated[AsyncSession, Depends(get_session)]
|
||||
|
||||
|
||||
@router.get("", response_model=SearchOut)
|
||||
async def search(
|
||||
user: CurrentUser,
|
||||
db: DbSession,
|
||||
q: str = Query(""),
|
||||
cursor: str | None = None,
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
) -> SearchOut:
|
||||
if not q.strip():
|
||||
return SearchOut(threads=[], cursor=None)
|
||||
|
||||
backend = get_search_backend()
|
||||
query = to_search_query(q, user.id)
|
||||
hits, next_cursor = await backend.query(query, limit=limit, cursor=cursor)
|
||||
|
||||
# Group hits by thread, preserving order and keeping the first highlight.
|
||||
order: list[uuid.UUID] = []
|
||||
highlights: dict[uuid.UUID, dict[str, str]] = {}
|
||||
for hit in hits:
|
||||
if hit.thread_id not in highlights:
|
||||
order.append(hit.thread_id)
|
||||
highlights[hit.thread_id] = hit.highlights
|
||||
elif hit.highlights and not highlights[hit.thread_id]:
|
||||
highlights[hit.thread_id] = hit.highlights
|
||||
|
||||
if not order:
|
||||
return SearchOut(threads=[], cursor=next_cursor)
|
||||
|
||||
threads = {
|
||||
t.id: t
|
||||
for t in await db.scalars(
|
||||
select(Thread).where(Thread.id.in_(order), Thread.user_id == user.id)
|
||||
)
|
||||
}
|
||||
results = [
|
||||
SearchThreadHit(thread=_summary(threads[tid]), highlights=highlights.get(tid, {}))
|
||||
for tid in order
|
||||
if tid in threads
|
||||
]
|
||||
return SearchOut(threads=results, cursor=next_cursor)
|
||||
@@ -0,0 +1,71 @@
|
||||
"""User settings + per-sender image allowlist (SPEC §9.1 Settings, §9.4)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.db import get_session
|
||||
from app.identity.deps import CurrentUser, require_csrf
|
||||
from app.models import User
|
||||
|
||||
router = APIRouter(prefix="/settings", tags=["settings"])
|
||||
|
||||
DbSession = Annotated[AsyncSession, Depends(get_session)]
|
||||
|
||||
_ALLOWED_ORDER = {"asc", "desc"}
|
||||
|
||||
|
||||
class SettingsOut(BaseModel):
|
||||
thread_order: str = "asc"
|
||||
tz: str = "UTC"
|
||||
allowed_image_senders: list[str] = []
|
||||
|
||||
|
||||
class SettingsPatch(BaseModel):
|
||||
thread_order: str | None = None
|
||||
tz: str | None = None
|
||||
|
||||
|
||||
def allowed_image_senders(user: User) -> set[str]:
|
||||
value = user.settings.get("allowed_image_senders", [])
|
||||
if isinstance(value, list):
|
||||
return {str(e).lower() for e in value}
|
||||
return set()
|
||||
|
||||
|
||||
def add_allowed_image_sender(user: User, email: str) -> None:
|
||||
current = sorted(allowed_image_senders(user))
|
||||
if email.lower() not in current:
|
||||
current.append(email.lower())
|
||||
# Reassign the whole dict so SQLAlchemy detects the JSONB change.
|
||||
user.settings = {**user.settings, "allowed_image_senders": current}
|
||||
|
||||
|
||||
def _settings_out(user: User) -> SettingsOut:
|
||||
s = user.settings
|
||||
return SettingsOut(
|
||||
thread_order=str(s.get("thread_order", "asc")),
|
||||
tz=str(s.get("tz", "UTC")),
|
||||
allowed_image_senders=sorted(allowed_image_senders(user)),
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=SettingsOut)
|
||||
async def get_settings_endpoint(user: CurrentUser) -> SettingsOut:
|
||||
return _settings_out(user)
|
||||
|
||||
|
||||
@router.patch("", response_model=SettingsOut, dependencies=[Depends(require_csrf)])
|
||||
async def patch_settings(body: SettingsPatch, user: CurrentUser, db: DbSession) -> SettingsOut:
|
||||
updates = dict(user.settings)
|
||||
if body.thread_order is not None and body.thread_order in _ALLOWED_ORDER:
|
||||
updates["thread_order"] = body.thread_order
|
||||
if body.tz is not None:
|
||||
updates["tz"] = body.tz
|
||||
user.settings = updates
|
||||
await db.commit()
|
||||
return _settings_out(user)
|
||||
@@ -0,0 +1,160 @@
|
||||
"""Thread listing and detail endpoints (SPEC §9.1 Mail)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy import literal, select, tuple_
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.cursors import decode_cursor, encode_cursor
|
||||
from app.api.errors import raise_api_error
|
||||
from app.api.schemas import (
|
||||
Address,
|
||||
MessageEnvelope,
|
||||
ThreadDetail,
|
||||
ThreadListOut,
|
||||
ThreadSummary,
|
||||
)
|
||||
from app.db import get_session
|
||||
from app.identity.deps import CurrentUser
|
||||
from app.models import Mailbox, Message, Thread, User
|
||||
|
||||
router = APIRouter(prefix="/threads", tags=["mail"])
|
||||
|
||||
DbSession = Annotated[AsyncSession, Depends(get_session)]
|
||||
|
||||
|
||||
def _addresses(raw: list[dict[str, str]]) -> list[Address]:
|
||||
return [Address(name=a.get("name", ""), email=a.get("email", "")) for a in raw]
|
||||
|
||||
|
||||
def _summary(thread: Thread) -> ThreadSummary:
|
||||
return ThreadSummary(
|
||||
id=thread.id,
|
||||
subject=thread.subject_norm or "(no subject)",
|
||||
snippet=thread.snippet,
|
||||
participants=_addresses(thread.participants),
|
||||
account_ids=list(thread.account_ids),
|
||||
message_count=thread.message_count,
|
||||
unread_count=thread.unread_count,
|
||||
has_attachments=thread.has_attachments,
|
||||
pinned=thread.pinned,
|
||||
last_message_at=thread.last_message_at,
|
||||
)
|
||||
|
||||
|
||||
def _envelope(msg: Message) -> MessageEnvelope:
|
||||
return MessageEnvelope(
|
||||
id=msg.id,
|
||||
account_id=msg.account_id,
|
||||
mailbox_id=msg.mailbox_id,
|
||||
thread_id=msg.thread_id,
|
||||
subject=msg.subject,
|
||||
from_addr=_addresses(msg.from_addr),
|
||||
to_addr=_addresses(msg.to_addr),
|
||||
cc_addr=_addresses(msg.cc_addr),
|
||||
date=msg.date_hdr or msg.internal_date,
|
||||
flags=list(msg.flags),
|
||||
is_unread="\\Seen" not in msg.flags,
|
||||
is_starred="\\Flagged" in msg.flags,
|
||||
has_attachments=msg.has_attachments,
|
||||
has_remote_images=msg.has_remote_images,
|
||||
snippet=msg.snippet,
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=ThreadListOut)
|
||||
async def list_threads(
|
||||
user: CurrentUser,
|
||||
db: DbSession,
|
||||
view: str = Query("unified"),
|
||||
cursor: str | None = None,
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
) -> ThreadListOut:
|
||||
stmt = select(Thread).where(Thread.user_id == user.id)
|
||||
|
||||
if view.startswith("account:"):
|
||||
# A single account's inbox: threads with a message in THIS account's
|
||||
# inbox mailbox. Keeps the per-account view consistent with the unified
|
||||
# Inbox (archived/trashed/sent threads don't leak in).
|
||||
try:
|
||||
account_id = uuid.UUID(view.split(":", 1)[1])
|
||||
except ValueError:
|
||||
raise_api_error(400, "bad_view", "invalid account id in view")
|
||||
in_account_inbox = (
|
||||
select(Message.thread_id)
|
||||
.join(Mailbox, Mailbox.id == Message.mailbox_id)
|
||||
.where(
|
||||
Message.user_id == user.id,
|
||||
Message.account_id == account_id,
|
||||
Mailbox.special_use == "inbox",
|
||||
)
|
||||
)
|
||||
stmt = stmt.where(Thread.id.in_(in_account_inbox))
|
||||
elif view.startswith("folder:"):
|
||||
# Threads with at least one message in a mailbox of this special_use,
|
||||
# across all of the user's accounts (unified per-folder view).
|
||||
special = view.split(":", 1)[1]
|
||||
in_folder = (
|
||||
select(Message.thread_id)
|
||||
.join(Mailbox, Mailbox.id == Message.mailbox_id)
|
||||
.where(Message.user_id == user.id, Mailbox.special_use == special)
|
||||
)
|
||||
stmt = stmt.where(Thread.id.in_(in_folder))
|
||||
elif view != "unified":
|
||||
raise_api_error(400, "bad_view", "unsupported view")
|
||||
|
||||
decoded = decode_cursor(cursor)
|
||||
if decoded is not None:
|
||||
cur_date, cur_id = decoded
|
||||
stmt = stmt.where(
|
||||
tuple_(Thread.last_message_at, Thread.id) < tuple_(literal(cur_date), literal(cur_id))
|
||||
)
|
||||
stmt = stmt.order_by(Thread.last_message_at.desc(), Thread.id.desc()).limit(limit + 1)
|
||||
|
||||
threads = list(await db.scalars(stmt))
|
||||
has_more = len(threads) > limit
|
||||
threads = threads[:limit]
|
||||
next_cursor = (
|
||||
encode_cursor(threads[-1].last_message_at, threads[-1].id) if has_more and threads else None
|
||||
)
|
||||
return ThreadListOut(threads=[_summary(t) for t in threads], cursor=next_cursor)
|
||||
|
||||
|
||||
async def _resolve_thread(db: AsyncSession, user: User, thread_id: uuid.UUID) -> Thread:
|
||||
thread = await db.scalar(
|
||||
select(Thread).where(Thread.id == thread_id, Thread.user_id == user.id)
|
||||
)
|
||||
if thread is None:
|
||||
raise_api_error(404, "not_found", "thread not found")
|
||||
return thread
|
||||
|
||||
|
||||
@router.get("/{thread_id}", response_model=ThreadDetail)
|
||||
async def get_thread(
|
||||
thread_id: uuid.UUID,
|
||||
user: CurrentUser,
|
||||
db: DbSession,
|
||||
sort: str = Query("asc"),
|
||||
) -> ThreadDetail:
|
||||
thread = await _resolve_thread(db, user, thread_id)
|
||||
# Messages are always returned ASC (oldest first); the client reverses for
|
||||
# display per the requested order (invariant I-5 / §9.4).
|
||||
messages = list(
|
||||
await db.scalars(
|
||||
select(Message)
|
||||
.where(Message.thread_id == thread_id, Message.user_id == user.id)
|
||||
.order_by(Message.internal_date.asc(), Message.id.asc())
|
||||
)
|
||||
)
|
||||
order = "desc" if sort == "desc" else "asc"
|
||||
return ThreadDetail(
|
||||
id=thread.id,
|
||||
subject=thread.subject_norm or "(no subject)",
|
||||
order=order,
|
||||
pinned=thread.pinned,
|
||||
messages=[_envelope(m) for m in messages],
|
||||
)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
"""Identity: app accounts, auth, sessions, handles (SPEC §6.1)."""
|
||||
@@ -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="/")
|
||||
@@ -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)]
|
||||
@@ -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}"
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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"
|
||||
@@ -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)
|
||||
@@ -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))
|
||||
@@ -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")
|
||||
@@ -0,0 +1 @@
|
||||
"""Indexer: MIME parsing, sanitization, blob storage, DB rows (SPEC §6.4)."""
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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),
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
@@ -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",
|
||||
]
|
||||
@@ -0,0 +1 @@
|
||||
"""Outbox: drafts, MIME assembly, send queue, undo-send (SPEC §6.5)."""
|
||||
@@ -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"},
|
||||
)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,2 @@
|
||||
"""Mail provider abstraction (SPEC §5.1). Nothing above this package may
|
||||
import IMAP/SMTP libraries — only concrete providers do."""
|
||||
@@ -0,0 +1,111 @@
|
||||
# INTERFACE VERSION: 1
|
||||
"""MailProvider — the frozen provider contract (SPEC §5.1).
|
||||
|
||||
This signature is a frozen contract (SPEC instruction 5): implementations may
|
||||
evolve, signatures may not without bumping the version comment above.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncIterator
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Protocol
|
||||
|
||||
|
||||
class ProviderError(Exception):
|
||||
"""Base class for provider errors; carries a human-readable ``.detail``."""
|
||||
|
||||
def __init__(self, detail: str) -> None:
|
||||
super().__init__(detail)
|
||||
self.detail = detail
|
||||
|
||||
|
||||
class ProviderAuthError(ProviderError):
|
||||
"""Authentication failed (bad credentials / token)."""
|
||||
|
||||
|
||||
class ProviderConnectError(ProviderError):
|
||||
"""Could not connect / TLS / greeting failure."""
|
||||
|
||||
|
||||
class ProviderNotSupported(ProviderError):
|
||||
"""A capability (e.g. IDLE) is not supported by the server."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RemoteMailbox:
|
||||
path: str
|
||||
special_use: str | None # "inbox"|"sent"|"drafts"|"trash"|"junk"|"archive"|None
|
||||
uidvalidity: int
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RemoteMessage:
|
||||
uid: int
|
||||
raw: bytes # full RFC822
|
||||
flags: frozenset[str]
|
||||
internal_date: datetime
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FlagChange:
|
||||
uid: int
|
||||
flags: frozenset[str]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MailboxDelta:
|
||||
new: list[RemoteMessage]
|
||||
flag_changes: list[FlagChange]
|
||||
expunged_uids: list[int]
|
||||
new_uidvalidity: int | None # non-None => UIDVALIDITY changed, caller must full-resync
|
||||
new_highestmodseq: int | None
|
||||
|
||||
|
||||
class MailProvider(Protocol):
|
||||
async def verify(self) -> None:
|
||||
"""Raises ProviderAuthError / ProviderConnectError with a human-readable .detail."""
|
||||
...
|
||||
|
||||
async def list_mailboxes(self) -> list[RemoteMailbox]: ...
|
||||
|
||||
def backfill(
|
||||
self, mailbox: str, *, newest_first: bool = True, batch_size: int = 200
|
||||
) -> AsyncIterator[list[RemoteMessage]]:
|
||||
"""Yields batches until the mailbox is exhausted. Headers+body in one pass."""
|
||||
...
|
||||
|
||||
async def poll(
|
||||
self, mailbox: str, *, last_seen_uid: int, highestmodseq: int | None
|
||||
) -> MailboxDelta:
|
||||
"""One reconciliation pass. Uses QRESYNC/CONDSTORE when highestmodseq is not None,
|
||||
otherwise UID SEARCH + FETCH FLAGS diff."""
|
||||
...
|
||||
|
||||
async def idle(self, mailbox: str) -> None:
|
||||
"""Blocks until the server signals change or 25 min elapse (re-issue IDLE). May raise
|
||||
ProviderNotSupported => sync loop falls back to poll() every POLL_INTERVAL (default 120 s).
|
||||
"""
|
||||
...
|
||||
|
||||
async def push_flags(
|
||||
self, mailbox: str, uid: int, add: frozenset[str], remove: frozenset[str]
|
||||
) -> None: ...
|
||||
|
||||
async def move(self, uid: int, src: str, dst: str) -> int: # returns new UID
|
||||
...
|
||||
|
||||
async def delete_messages(self, mailbox: str, uids: list[int]) -> None:
|
||||
"""Permanently delete messages: set \\Deleted then EXPUNGE (SPEC §6.3)."""
|
||||
...
|
||||
|
||||
async def append(
|
||||
self, mailbox: str, raw: bytes, flags: frozenset[str]
|
||||
) -> int: # Sent/Drafts upload
|
||||
...
|
||||
|
||||
async def send(
|
||||
self, raw: bytes, envelope_from: str, envelope_to: list[str]
|
||||
) -> None: # SMTP submission
|
||||
...
|
||||
@@ -0,0 +1,7 @@
|
||||
"""IMAP+SMTP provider — the only M0-M2 MailProvider implementation (SPEC §5.1)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from app.providers.imap_smtp.provider import ImapSmtpProvider, ProviderConfig
|
||||
|
||||
__all__ = ["ImapSmtpProvider", "ProviderConfig"]
|
||||
@@ -0,0 +1,165 @@
|
||||
"""Pure parsing helpers for IMAP responses (unit-testable, no I/O).
|
||||
|
||||
aioimaplib returns responses as lists of ``bytes``/``bytearray`` lines. A FETCH
|
||||
with ``BODY[]`` yields, per message, a header line ending ``... {<size>}`` followed
|
||||
by a ``bytearray`` literal, then a lone ``)``. LIST lines carry SPECIAL-USE flags.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
|
||||
from imapclient.imap_utf7 import decode as imap_utf7_decode
|
||||
|
||||
# --- SPECIAL-USE mapping (SPEC §4 special_use values) -----------------------
|
||||
|
||||
_SPECIAL_USE_FLAGS = {
|
||||
"\\sent": "sent",
|
||||
"\\drafts": "drafts",
|
||||
"\\trash": "trash",
|
||||
"\\junk": "junk",
|
||||
"\\archive": "archive",
|
||||
"\\all": "archive", # Gmail "All Mail" acts as archive
|
||||
}
|
||||
|
||||
_LIST_RE = re.compile(rb'^\(([^)]*)\)\s+(?:"([^"]*)"|NIL)\s+(.+)$')
|
||||
_UID_RE = re.compile(rb"UID\s+(\d+)")
|
||||
_FLAGS_RE = re.compile(rb"FLAGS\s+\(([^)]*)\)")
|
||||
_INTERNALDATE_RE = re.compile(rb'INTERNALDATE\s+"([^"]+)"')
|
||||
_LITERAL_RE = re.compile(rb"\{(\d+)\}\s*$")
|
||||
_FETCH_HEADER_RE = re.compile(rb"^\d+\s+FETCH\s+\(", re.IGNORECASE)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ParsedMailbox:
|
||||
path: str
|
||||
special_use: str | None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ParsedFetch:
|
||||
uid: int
|
||||
flags: frozenset[str]
|
||||
internal_date: datetime | None
|
||||
raw: bytes | None # None when the fetch did not request the body
|
||||
|
||||
|
||||
def _decode_mailbox_name(raw: bytes) -> str:
|
||||
name = raw.strip()
|
||||
if name.startswith(b'"') and name.endswith(b'"'):
|
||||
name = name[1:-1]
|
||||
# IMAP mailbox names are modified UTF-7 (SPEC §4: store UTF-7 decoded).
|
||||
return str(imap_utf7_decode(bytes(name)))
|
||||
|
||||
|
||||
def parse_list_line(line: bytes) -> ParsedMailbox | None:
|
||||
"""Parse one LIST response line into a :class:`ParsedMailbox` (or None)."""
|
||||
m = _LIST_RE.match(line.strip())
|
||||
if not m:
|
||||
return None
|
||||
flags = m.group(1).lower().split()
|
||||
name = _decode_mailbox_name(m.group(3))
|
||||
special: str | None = None
|
||||
for flag in flags:
|
||||
mapped = _SPECIAL_USE_FLAGS.get(flag.decode("ascii", "ignore"))
|
||||
if mapped:
|
||||
special = mapped
|
||||
break
|
||||
if special is None and name.upper() == "INBOX":
|
||||
special = "inbox"
|
||||
return ParsedMailbox(path=name, special_use=special)
|
||||
|
||||
|
||||
def parse_internaldate(raw: bytes | str) -> datetime | None:
|
||||
"""Parse an IMAP INTERNALDATE (``d-Mon-Y H:M:S +ZZZZ``) to an aware datetime."""
|
||||
text = raw.decode("ascii", "ignore") if isinstance(raw, bytes) else raw
|
||||
text = text.strip()
|
||||
try:
|
||||
return datetime.strptime(text, "%d-%b-%Y %H:%M:%S %z")
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _parse_flag_token(tok: bytes) -> str:
|
||||
return tok.decode("ascii", "ignore")
|
||||
|
||||
|
||||
def parse_fetch_header(line: bytes) -> tuple[int | None, frozenset[str], datetime | None]:
|
||||
"""Extract (uid, flags, internaldate) from a FETCH header line."""
|
||||
uid_m = _UID_RE.search(line)
|
||||
uid = int(uid_m.group(1)) if uid_m else None
|
||||
flags_m = _FLAGS_RE.search(line)
|
||||
if flags_m:
|
||||
flags = frozenset(_parse_flag_token(t) for t in flags_m.group(1).split())
|
||||
else:
|
||||
flags = frozenset()
|
||||
date_m = _INTERNALDATE_RE.search(line)
|
||||
internal = parse_internaldate(date_m.group(1)) if date_m else None
|
||||
return uid, flags, internal
|
||||
|
||||
|
||||
def parse_fetch_lines(lines: list[bytes | bytearray]) -> list[ParsedFetch]:
|
||||
"""Walk a full FETCH response, returning one :class:`ParsedFetch` per message.
|
||||
|
||||
Handles both metadata-only fetches (FLAGS/INTERNALDATE) and body fetches
|
||||
(a ``{size}`` literal announced on the header line, body on the next line).
|
||||
"""
|
||||
results: list[ParsedFetch] = []
|
||||
i = 0
|
||||
n = len(lines)
|
||||
while i < n:
|
||||
line = lines[i]
|
||||
if not isinstance(line, (bytes, bytearray)) or not _FETCH_HEADER_RE.match(bytes(line)):
|
||||
i += 1
|
||||
continue
|
||||
header = bytes(line)
|
||||
uid, flags, internal = parse_fetch_header(header)
|
||||
raw: bytes | None = None
|
||||
if _LITERAL_RE.search(header) and i + 1 < n:
|
||||
body = lines[i + 1]
|
||||
raw = bytes(body)
|
||||
i += 2
|
||||
# Skip the trailing ")" continuation line if present.
|
||||
if (
|
||||
i < n
|
||||
and isinstance(lines[i], (bytes, bytearray))
|
||||
and bytes(lines[i]).strip() == b")"
|
||||
):
|
||||
i += 1
|
||||
else:
|
||||
i += 1
|
||||
if uid is not None:
|
||||
results.append(ParsedFetch(uid=uid, flags=flags, internal_date=internal, raw=raw))
|
||||
return results
|
||||
|
||||
|
||||
def parse_search_uids(lines: list[bytes | bytearray]) -> list[int]:
|
||||
"""Parse a UID SEARCH response's first line into a list of ints."""
|
||||
if not lines:
|
||||
return []
|
||||
first = lines[0]
|
||||
if not isinstance(first, (bytes, bytearray)):
|
||||
return []
|
||||
return [int(tok) for tok in bytes(first).split() if tok.isdigit()]
|
||||
|
||||
|
||||
def parse_select_untagged(lines: list[bytes | bytearray]) -> dict[str, int]:
|
||||
"""Extract UIDVALIDITY / UIDNEXT / HIGHESTMODSEQ / EXISTS from SELECT lines."""
|
||||
out: dict[str, int] = {}
|
||||
patterns = {
|
||||
"uidvalidity": re.compile(rb"UIDVALIDITY\s+(\d+)"),
|
||||
"uidnext": re.compile(rb"UIDNEXT\s+(\d+)"),
|
||||
"highestmodseq": re.compile(rb"HIGHESTMODSEQ\s+(\d+)"),
|
||||
"exists": re.compile(rb"^(\d+)\s+EXISTS"),
|
||||
}
|
||||
for line in lines:
|
||||
if not isinstance(line, (bytes, bytearray)):
|
||||
continue
|
||||
b = bytes(line)
|
||||
for key, pat in patterns.items():
|
||||
m = pat.search(b)
|
||||
if m:
|
||||
out[key] = int(m.group(1))
|
||||
return out
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
"""Search: engine-independent query grammar + pluggable backends (SPEC §5.2, §8)."""
|
||||
@@ -0,0 +1,58 @@
|
||||
# INTERFACE VERSION: 1
|
||||
"""SearchBackend — the frozen search contract (SPEC §5.2).
|
||||
|
||||
Both backends (``pg.py`` for M0-M1, ``meili.py`` for M2) index the same
|
||||
:class:`SearchDoc` and honour invariant I-7 (every query carries ``user_id``).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Protocol
|
||||
from uuid import UUID
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SearchQuery:
|
||||
text: str | None # free-text remainder after operator extraction
|
||||
filters: dict[str, object] # parsed operators, keys per §8.2
|
||||
user_id: UUID # ALWAYS set (invariant I-7)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SearchHit:
|
||||
message_id: UUID
|
||||
thread_id: UUID
|
||||
score: float
|
||||
highlights: dict[str, str] # field -> HTML-escaped snippet with <mark>
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SearchDoc:
|
||||
"""The document indexed by every backend (SPEC §5.2)."""
|
||||
|
||||
message_id: UUID
|
||||
thread_id: UUID
|
||||
user_id: UUID
|
||||
account_id: UUID
|
||||
mailbox_special_use: str | None
|
||||
from_: list[dict[str, str]] # name+email
|
||||
to: list[dict[str, str]]
|
||||
cc: list[dict[str, str]]
|
||||
subject: str
|
||||
body_text: str
|
||||
attachment_names: list[str] = field(default_factory=list)
|
||||
has_attachments: bool = False
|
||||
is_unread: bool = False
|
||||
is_starred: bool = False
|
||||
date: int = 0 # unix seconds
|
||||
size_bytes: int = 0
|
||||
|
||||
|
||||
class SearchBackend(Protocol):
|
||||
async def index(self, doc: SearchDoc) -> None: ...
|
||||
async def delete(self, message_id: UUID) -> None: ...
|
||||
async def delete_user(self, user_id: UUID) -> None: ...
|
||||
async def query(
|
||||
self, q: SearchQuery, *, limit: int, cursor: str | None
|
||||
) -> tuple[list[SearchHit], str | None]: ...
|
||||
@@ -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
|
||||
@@ -0,0 +1,271 @@
|
||||
"""Postgres full-text SearchBackend (SPEC §5.2, §8; M0-M1).
|
||||
|
||||
Indexing populates ``messages.body_indexed`` (a ``tsvector``) from the doc's
|
||||
searchable fields. Querying translates the parsed §8.2 groups (AND of ORs) into
|
||||
SQL over messages joined to mailboxes/accounts, always filtered by ``user_id``
|
||||
(invariant I-7). Relevance ranking / typo tolerance is Meilisearch's job (M2);
|
||||
here results are date-ordered with keyset pagination.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import datetime as dt
|
||||
import html
|
||||
import re
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy import Text, any_, cast, exists, func, literal, not_, or_, select, tuple_, update
|
||||
from sqlalchemy.sql.elements import ColumnElement
|
||||
|
||||
from app.db import get_sessionmaker
|
||||
from app.models import Attachment, Mailbox, Message
|
||||
from app.search.base import SearchBackend, SearchDoc, SearchHit, SearchQuery
|
||||
|
||||
_TS_CONFIG = "simple" # language-neutral; avoids wrong-language stemming (M0)
|
||||
_RELATIVE_MULT = {"d": 1, "w": 7, "m": 30, "y": 365}
|
||||
|
||||
|
||||
def _doc_text_blob(doc: SearchDoc) -> str:
|
||||
parts = [doc.subject, doc.body_text]
|
||||
for field in (doc.from_, doc.to, doc.cc):
|
||||
for addr in field:
|
||||
parts.append(addr.get("name", ""))
|
||||
parts.append(addr.get("email", ""))
|
||||
parts.extend(doc.attachment_names)
|
||||
return " ".join(p for p in parts if p)
|
||||
|
||||
|
||||
def _parse_date(value: str) -> dt.datetime | None:
|
||||
try:
|
||||
return dt.datetime.strptime(value, "%Y-%m-%d").replace(tzinfo=dt.UTC)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _parse_relative(value: str) -> dt.timedelta | None:
|
||||
m = re.fullmatch(r"(\d+)([dwmy])", value.strip().lower())
|
||||
if not m:
|
||||
return None
|
||||
n = int(m.group(1)) * _RELATIVE_MULT[m.group(2)]
|
||||
return dt.timedelta(days=n)
|
||||
|
||||
|
||||
def _parse_size(value: str) -> int | None:
|
||||
m = re.fullmatch(r"(\d+)([km])", value.strip().lower())
|
||||
if not m:
|
||||
return None
|
||||
return int(m.group(1)) * (1024 if m.group(2) == "k" else 1024 * 1024)
|
||||
|
||||
|
||||
def _flag_condition(flag: str) -> ColumnElement[bool]:
|
||||
# flag = ANY(messages.flags) — the array membership operator, not func any().
|
||||
return literal(flag) == any_(Message.flags)
|
||||
|
||||
|
||||
class PgSearchBackend(SearchBackend):
|
||||
async def index(self, doc: SearchDoc) -> None:
|
||||
blob = _doc_text_blob(doc)
|
||||
async with get_sessionmaker()() as session:
|
||||
await session.execute(
|
||||
update(Message)
|
||||
.where(Message.id == doc.message_id, Message.user_id == doc.user_id)
|
||||
.values(body_indexed=func.to_tsvector(_TS_CONFIG, blob))
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
async def delete(self, message_id: UUID) -> None:
|
||||
async with get_sessionmaker()() as session:
|
||||
await session.execute(
|
||||
update(Message).where(Message.id == message_id).values(body_indexed=None)
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
async def delete_user(self, user_id: UUID) -> None:
|
||||
# User deletion cascades message rows (FK ON DELETE CASCADE); clearing the
|
||||
# tsvector here keeps the backend contract satisfied for partial re-index.
|
||||
async with get_sessionmaker()() as session:
|
||||
await session.execute(
|
||||
update(Message).where(Message.user_id == user_id).values(body_indexed=None)
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
# --- querying ------------------------------------------------------------
|
||||
|
||||
def _clause_condition(self, clause: dict[str, object]) -> ColumnElement[bool] | None:
|
||||
value = str(clause.get("value", ""))
|
||||
if clause.get("type") == "text":
|
||||
return Message.body_indexed.op("@@")(func.websearch_to_tsquery(_TS_CONFIG, value))
|
||||
key = clause.get("key")
|
||||
cond = self._filter_condition(str(key), value)
|
||||
return cond
|
||||
|
||||
def _filter_condition(self, key: str, value: str) -> ColumnElement[bool] | None:
|
||||
like = f"%{value}%"
|
||||
if key == "from":
|
||||
return cast(Message.from_addr, Text).ilike(like)
|
||||
if key == "to":
|
||||
return cast(Message.to_addr, Text).ilike(like)
|
||||
if key == "cc":
|
||||
return cast(Message.cc_addr, Text).ilike(like)
|
||||
if key == "subject":
|
||||
return Message.subject.ilike(like)
|
||||
if key == "in":
|
||||
return or_(Mailbox.special_use == value.lower(), Mailbox.path.ilike(like))
|
||||
if key == "is":
|
||||
v = value.lower()
|
||||
if v == "unread":
|
||||
return not_(_flag_condition("\\Seen"))
|
||||
if v == "read":
|
||||
return _flag_condition("\\Seen")
|
||||
if v == "starred":
|
||||
return _flag_condition("\\Flagged")
|
||||
if v == "answered":
|
||||
return _flag_condition("\\Answered")
|
||||
return None
|
||||
if key == "has":
|
||||
return Message.has_attachments.is_(True) if value.lower() == "attachment" else None
|
||||
if key == "filename":
|
||||
return exists().where(
|
||||
Attachment.message_id == Message.id, Attachment.filename.ilike(like)
|
||||
)
|
||||
if key == "before":
|
||||
d = _parse_date(value)
|
||||
return Message.internal_date < d if d else None
|
||||
if key == "after":
|
||||
d = _parse_date(value)
|
||||
return Message.internal_date >= d if d else None
|
||||
if key == "newer_than":
|
||||
delta = _parse_relative(value)
|
||||
return Message.internal_date >= func.now() - delta if delta else None
|
||||
if key == "older_than":
|
||||
delta = _parse_relative(value)
|
||||
return Message.internal_date < func.now() - delta if delta else None
|
||||
if key == "larger":
|
||||
n = _parse_size(value)
|
||||
return Message.size_bytes > n if n is not None else None
|
||||
if key == "smaller":
|
||||
n = _parse_size(value)
|
||||
return Message.size_bytes < n if n is not None else None
|
||||
return None
|
||||
|
||||
def _build_conditions(self, groups: list[list[dict[str, object]]]) -> list[ColumnElement[bool]]:
|
||||
conditions: list[ColumnElement[bool]] = []
|
||||
for group in groups:
|
||||
ors: list[ColumnElement[bool]] = []
|
||||
for clause in group:
|
||||
cond = self._clause_condition(clause)
|
||||
if cond is None:
|
||||
continue
|
||||
if clause.get("negate"):
|
||||
cond = not_(cond)
|
||||
ors.append(cond)
|
||||
if ors:
|
||||
conditions.append(or_(*ors))
|
||||
return conditions
|
||||
|
||||
async def query(
|
||||
self, q: SearchQuery, *, limit: int, cursor: str | None
|
||||
) -> tuple[list[SearchHit], str | None]:
|
||||
groups = q.filters.get("groups", []) if isinstance(q.filters, dict) else []
|
||||
assert isinstance(groups, list)
|
||||
conditions = self._build_conditions(groups)
|
||||
|
||||
rank = (
|
||||
func.ts_rank_cd(Message.body_indexed, func.websearch_to_tsquery(_TS_CONFIG, q.text))
|
||||
if q.text
|
||||
else literal(0.0)
|
||||
)
|
||||
stmt = (
|
||||
select(
|
||||
Message.id,
|
||||
Message.thread_id,
|
||||
Message.subject,
|
||||
Message.snippet,
|
||||
Message.internal_date,
|
||||
rank.label("score"),
|
||||
)
|
||||
.select_from(Message)
|
||||
.join(Mailbox, Mailbox.id == Message.mailbox_id)
|
||||
.where(Message.user_id == q.user_id, Message.thread_id.is_not(None))
|
||||
)
|
||||
for cond in conditions:
|
||||
stmt = stmt.where(cond)
|
||||
|
||||
decoded = _decode_cursor(cursor)
|
||||
if decoded is not None:
|
||||
cur_date, cur_id = decoded
|
||||
stmt = stmt.where(
|
||||
tuple_(Message.internal_date, Message.id)
|
||||
< tuple_(literal(cur_date), literal(cur_id))
|
||||
)
|
||||
stmt = stmt.order_by(Message.internal_date.desc(), Message.id.desc()).limit(limit + 1)
|
||||
|
||||
terms = _positive_text_terms(groups)
|
||||
async with get_sessionmaker()() as session:
|
||||
rows = (await session.execute(stmt)).all()
|
||||
|
||||
has_more = len(rows) > limit
|
||||
rows = rows[:limit]
|
||||
hits = [
|
||||
SearchHit(
|
||||
message_id=row.id,
|
||||
thread_id=row.thread_id,
|
||||
score=float(row.score or 0.0),
|
||||
highlights=_highlights(row.subject, row.snippet, terms),
|
||||
)
|
||||
for row in rows
|
||||
]
|
||||
next_cursor = (
|
||||
_encode_cursor(rows[-1].internal_date, rows[-1].id) if has_more and rows else None
|
||||
)
|
||||
return hits, next_cursor
|
||||
|
||||
|
||||
def _positive_text_terms(groups: list[list[dict[str, object]]]) -> list[str]:
|
||||
terms: list[str] = []
|
||||
for group in groups:
|
||||
for clause in group:
|
||||
if clause.get("type") == "text" and not clause.get("negate"):
|
||||
terms.append(str(clause.get("value", "")))
|
||||
return terms
|
||||
|
||||
|
||||
def _highlights(subject: str, snippet: str, terms: list[str]) -> dict[str, str]:
|
||||
out: dict[str, str] = {}
|
||||
if not terms:
|
||||
return out
|
||||
marked_subject = _mark(subject, terms)
|
||||
if "<mark>" in marked_subject:
|
||||
out["subject"] = marked_subject
|
||||
marked_body = _mark(snippet, terms)
|
||||
if "<mark>" in marked_body:
|
||||
out["body"] = marked_body
|
||||
return out
|
||||
|
||||
|
||||
def _mark(value: str, terms: list[str]) -> str:
|
||||
"""HTML-escape ``value`` and wrap term matches in <mark> (SPEC §5.2 highlights)."""
|
||||
escaped = html.escape(value)
|
||||
for term in terms:
|
||||
if not term:
|
||||
continue
|
||||
pattern = re.compile(re.escape(html.escape(term)), re.IGNORECASE)
|
||||
escaped = pattern.sub(lambda m: f"<mark>{m.group(0)}</mark>", escaped)
|
||||
return escaped
|
||||
|
||||
|
||||
def _encode_cursor(date: dt.datetime, message_id: UUID) -> str:
|
||||
raw = f"{date.isoformat()}|{message_id}"
|
||||
return base64.urlsafe_b64encode(raw.encode()).decode()
|
||||
|
||||
|
||||
def _decode_cursor(cursor: str | None) -> tuple[dt.datetime, UUID] | None:
|
||||
if not cursor:
|
||||
return None
|
||||
try:
|
||||
raw = base64.urlsafe_b64decode(cursor.encode()).decode()
|
||||
date_str, id_str = raw.split("|", 1)
|
||||
return dt.datetime.fromisoformat(date_str), UUID(id_str)
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
"""Security primitives: credential vault, CSRF, secret types (SPEC §10)."""
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
"""Sync engine: bootstrap, backfill, live IDLE/poll loops (SPEC §6.3)."""
|
||||
@@ -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)}
|
||||
)
|
||||
@@ -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()
|
||||
@@ -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())
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
"""JWZ threading (SPEC §7). Trailing underscore avoids the stdlib ``threading`` clash."""
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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]
|
||||
@@ -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())
|
||||
@@ -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"}
|
||||
@@ -0,0 +1,200 @@
|
||||
"""initial schema (SPEC §4)
|
||||
|
||||
Revision ID: 0001
|
||||
Revises:
|
||||
Create Date: 2026-07-16
|
||||
|
||||
The authoritative DDL lives in SPEC §4; this migration reproduces it verbatim.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0001"
|
||||
down_revision: str | None = None
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
UPGRADE_STMTS: list[str] = [
|
||||
"CREATE EXTENSION IF NOT EXISTS citext",
|
||||
"""
|
||||
CREATE TABLE users (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
handle citext UNIQUE NOT NULL,
|
||||
password_hash text NOT NULL,
|
||||
totp_secret bytea,
|
||||
recovery_email citext,
|
||||
settings jsonb NOT NULL DEFAULT '{}',
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
)
|
||||
""",
|
||||
"""
|
||||
CREATE TABLE accounts (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
email citext NOT NULL,
|
||||
display_name text NOT NULL DEFAULT '',
|
||||
color text NOT NULL DEFAULT '#888888',
|
||||
imap_config jsonb NOT NULL,
|
||||
smtp_config jsonb NOT NULL,
|
||||
secret_ct bytea NOT NULL,
|
||||
discovery_src text NOT NULL,
|
||||
state text NOT NULL DEFAULT 'backfilling',
|
||||
state_detail text NOT NULL DEFAULT '',
|
||||
last_sync_at timestamptz,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
UNIQUE (user_id, email)
|
||||
)
|
||||
""",
|
||||
"CREATE INDEX ix_accounts_user_id ON accounts (user_id)",
|
||||
"""
|
||||
CREATE TABLE mailboxes (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
account_id uuid NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
|
||||
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
path text NOT NULL,
|
||||
special_use text,
|
||||
uidvalidity bigint NOT NULL DEFAULT 0,
|
||||
highestmodseq bigint,
|
||||
last_seen_uid bigint NOT NULL DEFAULT 0,
|
||||
UNIQUE (account_id, path)
|
||||
)
|
||||
""",
|
||||
"""
|
||||
CREATE TABLE threads (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
subject_norm text NOT NULL,
|
||||
snippet text NOT NULL DEFAULT '',
|
||||
participants jsonb NOT NULL DEFAULT '[]',
|
||||
account_ids uuid[] NOT NULL DEFAULT '{}',
|
||||
message_count int NOT NULL DEFAULT 0,
|
||||
unread_count int NOT NULL DEFAULT 0,
|
||||
has_attachments boolean NOT NULL DEFAULT false,
|
||||
last_message_at timestamptz NOT NULL
|
||||
)
|
||||
""",
|
||||
"CREATE INDEX ix_threads_user_last ON threads (user_id, last_message_at DESC)",
|
||||
"""
|
||||
CREATE TABLE messages (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
account_id uuid NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
|
||||
mailbox_id uuid NOT NULL REFERENCES mailboxes(id) ON DELETE CASCADE,
|
||||
uid bigint NOT NULL,
|
||||
thread_id uuid REFERENCES threads(id),
|
||||
message_id_hdr text,
|
||||
in_reply_to text,
|
||||
references_hdr text[] NOT NULL DEFAULT '{}',
|
||||
subject text NOT NULL DEFAULT '',
|
||||
from_addr jsonb NOT NULL DEFAULT '[]',
|
||||
to_addr jsonb NOT NULL DEFAULT '[]',
|
||||
cc_addr jsonb NOT NULL DEFAULT '[]',
|
||||
date_hdr timestamptz,
|
||||
internal_date timestamptz NOT NULL,
|
||||
flags text[] NOT NULL DEFAULT '{}',
|
||||
size_bytes int NOT NULL DEFAULT 0,
|
||||
has_attachments boolean NOT NULL DEFAULT false,
|
||||
snippet text NOT NULL DEFAULT '',
|
||||
blob_sha256 text NOT NULL,
|
||||
body_indexed tsvector,
|
||||
UNIQUE (mailbox_id, uid)
|
||||
)
|
||||
""",
|
||||
"CREATE INDEX ix_messages_user_thread ON messages (user_id, thread_id)",
|
||||
"CREATE INDEX ix_messages_account_msgid ON messages (account_id, message_id_hdr)",
|
||||
"CREATE INDEX ix_messages_body_indexed ON messages USING gin (body_indexed)",
|
||||
"""
|
||||
CREATE TABLE body_parts (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
message_id uuid NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
|
||||
kind text NOT NULL,
|
||||
content text NOT NULL,
|
||||
sanitizer_v int NOT NULL DEFAULT 0
|
||||
)
|
||||
""",
|
||||
"""
|
||||
CREATE TABLE attachments (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
message_id uuid NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
|
||||
user_id uuid NOT NULL,
|
||||
filename text NOT NULL,
|
||||
mime_type text NOT NULL,
|
||||
size_bytes int NOT NULL,
|
||||
content_id text,
|
||||
blob_sha256 text NOT NULL
|
||||
)
|
||||
""",
|
||||
"""
|
||||
CREATE TABLE drafts (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
account_id uuid NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
|
||||
thread_id uuid REFERENCES threads(id),
|
||||
reply_to_message_id uuid REFERENCES messages(id),
|
||||
kind text NOT NULL DEFAULT 'reply',
|
||||
payload jsonb NOT NULL,
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
)
|
||||
""",
|
||||
"""
|
||||
CREATE TABLE outbox (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id uuid NOT NULL,
|
||||
draft_id uuid NOT NULL,
|
||||
state text NOT NULL DEFAULT 'undo_window',
|
||||
send_after timestamptz NOT NULL,
|
||||
error text
|
||||
)
|
||||
""",
|
||||
"""
|
||||
CREATE TABLE saved_searches (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
name text NOT NULL,
|
||||
query text NOT NULL,
|
||||
pinned boolean NOT NULL DEFAULT true,
|
||||
UNIQUE (user_id, name)
|
||||
)
|
||||
""",
|
||||
"""
|
||||
CREATE TABLE known_recipients (
|
||||
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
email citext NOT NULL,
|
||||
name text NOT NULL DEFAULT '',
|
||||
times_used int NOT NULL DEFAULT 0,
|
||||
last_used_at timestamptz NOT NULL,
|
||||
PRIMARY KEY (user_id, email)
|
||||
)
|
||||
""",
|
||||
"CREATE INDEX ix_known_recipients_user_email "
|
||||
"ON known_recipients (user_id, email text_pattern_ops)",
|
||||
]
|
||||
|
||||
DOWNGRADE_STMTS: list[str] = [
|
||||
"DROP TABLE IF EXISTS known_recipients",
|
||||
"DROP TABLE IF EXISTS saved_searches",
|
||||
"DROP TABLE IF EXISTS outbox",
|
||||
"DROP TABLE IF EXISTS drafts",
|
||||
"DROP TABLE IF EXISTS attachments",
|
||||
"DROP TABLE IF EXISTS body_parts",
|
||||
"DROP TABLE IF EXISTS messages",
|
||||
"DROP TABLE IF EXISTS threads",
|
||||
"DROP TABLE IF EXISTS mailboxes",
|
||||
"DROP TABLE IF EXISTS accounts",
|
||||
"DROP TABLE IF EXISTS users",
|
||||
]
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
for stmt in UPGRADE_STMTS:
|
||||
op.execute(stmt)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
for stmt in DOWNGRADE_STMTS:
|
||||
op.execute(stmt)
|
||||
@@ -0,0 +1,28 @@
|
||||
"""index messages(user_id, message_id_hdr)
|
||||
|
||||
Speeds the per-user Message-ID lookups used by JWZ threading (§7.2 candidate
|
||||
set) and by the duplicate-Message-ID resolution in the indexer (§7.1).
|
||||
|
||||
Revision ID: 0002
|
||||
Revises: 0001
|
||||
Create Date: 2026-07-17
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0002"
|
||||
down_revision: str | None = "0001"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute("CREATE INDEX ix_messages_user_msgid ON messages (user_id, message_id_hdr)")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("DROP INDEX IF EXISTS ix_messages_user_msgid")
|
||||
@@ -0,0 +1,28 @@
|
||||
"""add messages.has_remote_images
|
||||
|
||||
Surfaces whether the sanitizer blocked remote images so the UI can show the
|
||||
"load images" bar (SPEC §9.4).
|
||||
|
||||
Revision ID: 0003
|
||||
Revises: 0002
|
||||
Create Date: 2026-07-17
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0003"
|
||||
down_revision: str | None = "0002"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute("ALTER TABLE messages ADD COLUMN has_remote_images boolean NOT NULL DEFAULT false")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("ALTER TABLE messages DROP COLUMN IF EXISTS has_remote_images")
|
||||
@@ -0,0 +1,28 @@
|
||||
"""add threads.pinned
|
||||
|
||||
Denormalized "pinned" state for a thread (SPEC extension for the UI pin action):
|
||||
true when any message in the thread carries the \\Flagged flag.
|
||||
|
||||
Revision ID: 0004
|
||||
Revises: 0003
|
||||
Create Date: 2026-07-17
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0004"
|
||||
down_revision: str | None = "0003"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute("ALTER TABLE threads ADD COLUMN pinned boolean NOT NULL DEFAULT false")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("ALTER TABLE threads DROP COLUMN IF EXISTS pinned")
|
||||
@@ -0,0 +1,53 @@
|
||||
"""add compose_attachments
|
||||
|
||||
Files uploaded for outgoing mail (the compose "Attach" action). Referenced by a
|
||||
draft's payload.attachment_ids and assembled into the MIME at send time.
|
||||
|
||||
Revision ID: 0005
|
||||
Revises: 0004
|
||||
Create Date: 2026-07-17
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0005"
|
||||
down_revision: str | None = "0004"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"compose_attachments",
|
||||
sa.Column(
|
||||
"id",
|
||||
sa.dialects.postgresql.UUID(as_uuid=True),
|
||||
primary_key=True,
|
||||
server_default=sa.text("gen_random_uuid()"),
|
||||
),
|
||||
sa.Column(
|
||||
"user_id",
|
||||
sa.dialects.postgresql.UUID(as_uuid=True),
|
||||
sa.ForeignKey("users.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("filename", sa.Text(), nullable=False),
|
||||
sa.Column("mime_type", sa.Text(), nullable=False),
|
||||
sa.Column("size_bytes", sa.Integer(), nullable=False),
|
||||
sa.Column("blob_sha256", sa.Text(), nullable=False),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.TIMESTAMP(timezone=True),
|
||||
nullable=False,
|
||||
server_default=sa.text("now()"),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("compose_attachments")
|
||||
@@ -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 = ["."]
|
||||
@@ -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())
|
||||
@@ -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
|
||||
@@ -0,0 +1,185 @@
|
||||
"""Autodiscovery resolver (SPEC §6.2), with respx-mocked HTTP and a DNS stub."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
import respx
|
||||
|
||||
from app.accounts import discovery, ssrf
|
||||
from app.accounts.discovery import discover, domain_of, parse_autoconfig_xml
|
||||
from app.accounts.types import ServerConfig
|
||||
|
||||
AUTOCONFIG_XML = """<?xml version="1.0"?>
|
||||
<clientConfig version="1.1">
|
||||
<emailProvider id="example.com">
|
||||
<domain>example.com</domain>
|
||||
<incomingServer type="imap">
|
||||
<hostname>imap.example.com</hostname>
|
||||
<port>993</port>
|
||||
<socketType>SSL</socketType>
|
||||
<authentication>password-cleartext</authentication>
|
||||
<username>%EMAILADDRESS%</username>
|
||||
</incomingServer>
|
||||
<outgoingServer type="smtp">
|
||||
<hostname>smtp.example.com</hostname>
|
||||
<port>587</port>
|
||||
<socketType>STARTTLS</socketType>
|
||||
<authentication>password-cleartext</authentication>
|
||||
<username>%EMAILADDRESS%</username>
|
||||
</outgoingServer>
|
||||
</emailProvider>
|
||||
</clientConfig>
|
||||
"""
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _skip_ssrf(monkeypatch) -> None: # type: ignore[no-untyped-def]
|
||||
"""Bypass the real DNS/public-IP check so respx can intercept the fetch."""
|
||||
|
||||
async def ok(_host: str) -> None:
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(ssrf, "assert_host_is_public", ok)
|
||||
|
||||
|
||||
# --- XML parsing -------------------------------------------------------------
|
||||
|
||||
|
||||
def test_parse_autoconfig_xml() -> None:
|
||||
cand = parse_autoconfig_xml(AUTOCONFIG_XML)
|
||||
assert cand is not None
|
||||
assert cand.imap == ServerConfig("imap.example.com", 993, "ssl", "password")
|
||||
assert cand.smtp == ServerConfig("smtp.example.com", 587, "starttls", "password")
|
||||
|
||||
|
||||
def test_parse_autoconfig_xml_rejects_garbage() -> None:
|
||||
assert parse_autoconfig_xml("not xml <<<") is None
|
||||
assert parse_autoconfig_xml("<clientConfig></clientConfig>") is None
|
||||
|
||||
|
||||
def test_domain_of() -> None:
|
||||
assert domain_of("Alice@Example.COM") == "example.com"
|
||||
|
||||
|
||||
# --- preset step -------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_preset_hit_short_circuits() -> None:
|
||||
result = await discover("someone@gmail.com")
|
||||
assert len(result.candidates) == 1
|
||||
c = result.candidates[0]
|
||||
assert c.source == "preset"
|
||||
assert c.imap.host == "imap.gmail.com"
|
||||
assert c.oauth_provider == "google"
|
||||
assert result.steps[0].method == "preset" and result.steps[0].status == "found"
|
||||
|
||||
|
||||
async def test_preset_polish_provider() -> None:
|
||||
result = await discover("jan@wp.pl")
|
||||
assert result.candidates[0].imap.host == "imap.wp.pl"
|
||||
assert result.candidates[0].source == "preset"
|
||||
|
||||
|
||||
# --- autoconfig / ispdb via HTTP --------------------------------------------
|
||||
|
||||
|
||||
@respx.mock
|
||||
async def test_autoconfig_found() -> None:
|
||||
respx.get(url__regex=r"https://autoconfig\.example\.com/mail/.*").mock(
|
||||
return_value=httpx.Response(200, text=AUTOCONFIG_XML)
|
||||
)
|
||||
result = await discover("user@example.com")
|
||||
assert result.candidates[0].source == "autoconfig"
|
||||
assert result.candidates[0].imap.host == "imap.example.com"
|
||||
# preset step was recorded as not_found before autoconfig succeeded
|
||||
assert result.steps[0].status == "not_found"
|
||||
|
||||
|
||||
@respx.mock
|
||||
async def test_ispdb_found_after_autoconfig_misses() -> None:
|
||||
respx.get(url__regex=r"https://autoconfig\.example\.com/.*").mock(
|
||||
return_value=httpx.Response(404)
|
||||
)
|
||||
respx.get(url__regex=r"https://example\.com/\.well-known/.*").mock(
|
||||
return_value=httpx.Response(404)
|
||||
)
|
||||
respx.get(url__regex=r"https://autoconfig\.thunderbird\.net/v1\.1/example\.com").mock(
|
||||
return_value=httpx.Response(200, text=AUTOCONFIG_XML)
|
||||
)
|
||||
result = await discover("user@example.com")
|
||||
assert result.candidates[0].source == "ispdb"
|
||||
methods = {s.method for s in result.steps}
|
||||
assert {"preset", "autoconfig", "ispdb"} <= methods
|
||||
|
||||
|
||||
# --- SRV via DNS stub --------------------------------------------------------
|
||||
|
||||
|
||||
class _FakeSrv:
|
||||
def __init__(self, priority: int, weight: int, port: int, target: str) -> None:
|
||||
self.priority = priority
|
||||
self.weight = weight
|
||||
self.port = port
|
||||
self.target = target
|
||||
|
||||
|
||||
@respx.mock
|
||||
async def test_srv_found(monkeypatch) -> None: # type: ignore[no-untyped-def]
|
||||
# autoconfig + ispdb all miss
|
||||
respx.get(url__regex=r"https://.*").mock(return_value=httpx.Response(404))
|
||||
|
||||
async def fake_srv_lookup(name: str, tls: str) -> ServerConfig | None:
|
||||
if name.startswith("_imaps._tcp"):
|
||||
return ServerConfig("imap.example.com", 993, "ssl")
|
||||
if name.startswith("_submissions._tcp"):
|
||||
return ServerConfig("smtp.example.com", 465, "ssl")
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(discovery, "_srv_lookup", fake_srv_lookup)
|
||||
result = await discover("user@example.com")
|
||||
assert result.candidates[0].source == "srv"
|
||||
assert result.candidates[0].imap.port == 993
|
||||
|
||||
|
||||
# --- probe fallback ----------------------------------------------------------
|
||||
|
||||
|
||||
@respx.mock
|
||||
async def test_probe_found_when_all_else_misses(monkeypatch) -> None: # type: ignore[no-untyped-def]
|
||||
respx.get(url__regex=r"https://.*").mock(return_value=httpx.Response(404))
|
||||
|
||||
async def no_srv(_name: str, _tls: str) -> ServerConfig | None:
|
||||
return None
|
||||
|
||||
async def fake_probe(host: str, port: int, tls: str, kind: str) -> ServerConfig | None:
|
||||
# only imap.example.com:993 and smtp.example.com:465 "answer"
|
||||
if (host, port) in {("imap.example.com", 993), ("smtp.example.com", 465)}:
|
||||
return ServerConfig(host, port, tls)
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(discovery, "_srv_lookup", no_srv)
|
||||
monkeypatch.setattr(discovery, "_probe_one", fake_probe)
|
||||
result = await discover("user@example.com")
|
||||
assert result.candidates[0].source == "probe"
|
||||
assert result.candidates[0].imap.host == "imap.example.com"
|
||||
assert result.candidates[0].smtp.host == "smtp.example.com"
|
||||
|
||||
|
||||
@respx.mock
|
||||
async def test_all_steps_fail_records_every_step(monkeypatch) -> None: # type: ignore[no-untyped-def]
|
||||
respx.get(url__regex=r"https://.*").mock(return_value=httpx.Response(404))
|
||||
|
||||
async def no_srv(_name: str, _tls: str) -> ServerConfig | None:
|
||||
return None
|
||||
|
||||
async def no_probe(_h: str, _p: int, _t: str, _k: str) -> ServerConfig | None:
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(discovery, "_srv_lookup", no_srv)
|
||||
monkeypatch.setattr(discovery, "_probe_one", no_probe)
|
||||
result = await discover("user@nowhere.invalid")
|
||||
assert result.candidates == []
|
||||
methods = [s.method for s in result.steps]
|
||||
assert methods == ["preset", "autoconfig", "ispdb", "srv", "probe"]
|
||||
assert all(s.status in ("not_found", "error") for s in result.steps)
|
||||
@@ -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")
|
||||
@@ -0,0 +1,283 @@
|
||||
"""REST API integration tests (SPEC §9.1) driving the ASGI app with seeded data."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from collections.abc import AsyncIterator
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import UTC, datetime
|
||||
from email.message import EmailMessage
|
||||
|
||||
import httpx
|
||||
import pytest_asyncio
|
||||
from httpx import ASGITransport
|
||||
from sqlalchemy import delete, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.pool import NullPool
|
||||
|
||||
from app.config import get_settings
|
||||
from app.identity.passwords import hash_password
|
||||
from app.identity.sessions import create_session
|
||||
from app.indexer.indexer import persist_message
|
||||
from app.main import create_app
|
||||
from app.models import Account, Mailbox, Message, User
|
||||
from app.providers.base import RemoteMessage
|
||||
from app.search.pg import PgSearchBackend
|
||||
|
||||
|
||||
@dataclass
|
||||
class ApiCtx:
|
||||
client: httpx.AsyncClient
|
||||
user_id: uuid.UUID
|
||||
account_id: uuid.UUID | None = None
|
||||
archive_id: uuid.UUID | None = None
|
||||
session: AsyncSession | None = None
|
||||
thread_ids: list[uuid.UUID] = field(default_factory=list)
|
||||
message_ids: list[uuid.UUID] = field(default_factory=list)
|
||||
|
||||
|
||||
def _raw(
|
||||
subject: str, body: str, *, mid: str, refs: str | None = None, html: str | None = None
|
||||
) -> bytes:
|
||||
m = EmailMessage()
|
||||
m["Subject"] = subject
|
||||
m["From"] = "Alice <alice@example.com>"
|
||||
m["To"] = "me@example.com"
|
||||
m["Message-ID"] = mid
|
||||
m["Date"] = "Mon, 09 Mar 2026 08:36:37 +0100"
|
||||
if refs:
|
||||
m["References"] = refs
|
||||
m["In-Reply-To"] = refs
|
||||
m.set_content(body)
|
||||
if html:
|
||||
m.add_alternative(html, subtype="html")
|
||||
return m.as_bytes()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def api() -> AsyncIterator[ApiCtx]:
|
||||
engine = create_async_engine(get_settings().database_url, poolclass=NullPool)
|
||||
sm = async_sessionmaker(engine, expire_on_commit=False)
|
||||
session = sm()
|
||||
user = User(handle=f"api{uuid.uuid4().hex[:8]}@test.local", password_hash=hash_password("pw"))
|
||||
session.add(user)
|
||||
await session.flush()
|
||||
account = Account(
|
||||
user_id=user.id,
|
||||
email="me@example.com",
|
||||
imap_config={},
|
||||
smtp_config={},
|
||||
secret_ct=b"",
|
||||
discovery_src="manual",
|
||||
)
|
||||
session.add(account)
|
||||
await session.flush()
|
||||
mailbox = Mailbox(account_id=account.id, user_id=user.id, path="INBOX", special_use="inbox")
|
||||
archive = Mailbox(account_id=account.id, user_id=user.id, path="Archive", special_use="archive")
|
||||
session.add_all([mailbox, archive])
|
||||
await session.commit()
|
||||
|
||||
backend = PgSearchBackend()
|
||||
ctx = ApiCtx(
|
||||
client=None, # type: ignore[arg-type]
|
||||
user_id=user.id,
|
||||
account_id=account.id,
|
||||
archive_id=archive.id,
|
||||
session=session,
|
||||
)
|
||||
specs = [
|
||||
(_raw("Project kickoff", "kickoff meeting notes and revenue plan", mid="<a@x.com>"), 1),
|
||||
(
|
||||
_raw(
|
||||
"Re: Project kickoff",
|
||||
"sounds good, revenue looks strong",
|
||||
mid="<b@x.com>",
|
||||
refs="<a@x.com>",
|
||||
),
|
||||
2,
|
||||
),
|
||||
(
|
||||
_raw(
|
||||
"Lunch",
|
||||
"grab lunch tomorrow?",
|
||||
mid="<c@x.com>",
|
||||
html="<p>grab <b>lunch</b> <script>x()</script>tomorrow?</p>",
|
||||
),
|
||||
3,
|
||||
),
|
||||
]
|
||||
for raw, u in specs:
|
||||
remote = RemoteMessage(uid=u, raw=raw, flags=frozenset(), internal_date=datetime.now(UTC))
|
||||
result = await persist_message(
|
||||
session,
|
||||
user_id=user.id,
|
||||
account_id=account.id,
|
||||
mailbox_id=mailbox.id,
|
||||
mailbox_special_use="inbox",
|
||||
remote=remote,
|
||||
)
|
||||
await session.commit()
|
||||
assert result is not None
|
||||
ctx.message_ids.append(result.message.id)
|
||||
if result.message.thread_id not in ctx.thread_ids:
|
||||
ctx.thread_ids.append(result.message.thread_id)
|
||||
await backend.index(result.search_doc)
|
||||
|
||||
token = await create_session(user.id, "pytest")
|
||||
transport = ASGITransport(app=create_app())
|
||||
ctx.client = httpx.AsyncClient(
|
||||
transport=transport, base_url="http://testserver", cookies={"session": token}
|
||||
)
|
||||
try:
|
||||
yield ctx
|
||||
finally:
|
||||
await ctx.client.aclose()
|
||||
await session.execute(delete(User).where(User.id == user.id))
|
||||
await session.commit()
|
||||
await session.close()
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
async def test_requires_auth() -> None:
|
||||
transport = ASGITransport(app=create_app())
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as client:
|
||||
resp = await client.get("/api/threads")
|
||||
assert resp.status_code == 401
|
||||
assert resp.json()["error"]["code"] == "not_authenticated"
|
||||
|
||||
|
||||
async def test_list_threads(api: ApiCtx) -> None:
|
||||
resp = await api.client.get("/api/threads")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
# 2 threads: the kickoff reply-chain + lunch
|
||||
assert len(data["threads"]) == 2
|
||||
# newest first
|
||||
subjects = [t["subject"] for t in data["threads"]]
|
||||
assert "Lunch" in subjects
|
||||
kickoff = next(t for t in data["threads"] if "kickoff" in t["subject"].lower())
|
||||
assert kickoff["message_count"] == 2
|
||||
|
||||
|
||||
async def test_folder_view(api: ApiCtx) -> None:
|
||||
# all seeded mail is in an inbox mailbox
|
||||
inbox = await api.client.get("/api/threads", params={"view": "folder:inbox"})
|
||||
assert inbox.status_code == 200
|
||||
assert len(inbox.json()["threads"]) == 2 # kickoff chain + lunch
|
||||
# no messages are in a sent folder
|
||||
sent = await api.client.get("/api/threads", params={"view": "folder:sent"})
|
||||
assert sent.status_code == 200
|
||||
assert sent.json()["threads"] == []
|
||||
|
||||
|
||||
async def test_account_view_shows_only_that_accounts_inbox(api: ApiCtx) -> None:
|
||||
assert api.session is not None
|
||||
# The account view starts equal to the inbox (all seeded mail is in inbox).
|
||||
resp = await api.client.get("/api/threads", params={"view": f"account:{api.account_id}"})
|
||||
assert resp.status_code == 200
|
||||
assert len(resp.json()["threads"]) == 2
|
||||
|
||||
# Move the whole "Lunch" thread out of the inbox into Archive.
|
||||
lunch_id = next(t["id"] for t in resp.json()["threads"] if t["subject"] == "Lunch")
|
||||
msgs = list(
|
||||
await api.session.scalars(select(Message).where(Message.thread_id == uuid.UUID(lunch_id)))
|
||||
)
|
||||
for m in msgs:
|
||||
m.mailbox_id = api.archive_id
|
||||
await api.session.commit()
|
||||
|
||||
# It must drop out of both the unified inbox and the account view...
|
||||
inbox = await api.client.get("/api/threads", params={"view": "folder:inbox"})
|
||||
assert [t["subject"] for t in inbox.json()["threads"]] == ["Project kickoff"]
|
||||
account = await api.client.get("/api/threads", params={"view": f"account:{api.account_id}"})
|
||||
assert [t["subject"] for t in account.json()["threads"]] == ["Project kickoff"]
|
||||
# ...but still be reachable under the Archive folder.
|
||||
archive = await api.client.get("/api/threads", params={"view": "folder:archive"})
|
||||
assert [t["subject"] for t in archive.json()["threads"]] == ["Lunch"]
|
||||
|
||||
|
||||
async def test_attachments_list_and_zip(api: ApiCtx) -> None:
|
||||
import io
|
||||
import zipfile
|
||||
|
||||
from app.indexer import blobstore
|
||||
from app.models import Attachment
|
||||
|
||||
assert api.session is not None
|
||||
mid = api.message_ids[-1] # the "Lunch" message
|
||||
sha = blobstore.store(api.user_id, b"hello attachment")
|
||||
api.session.add(
|
||||
Attachment(
|
||||
message_id=mid,
|
||||
user_id=api.user_id,
|
||||
filename="notes.txt",
|
||||
mime_type="text/plain",
|
||||
size_bytes=16,
|
||||
content_id=None,
|
||||
blob_sha256=sha,
|
||||
)
|
||||
)
|
||||
await api.session.commit()
|
||||
|
||||
listing = await api.client.get(f"/api/messages/{mid}/attachments")
|
||||
assert listing.status_code == 200
|
||||
body = listing.json()
|
||||
assert [a["filename"] for a in body] == ["notes.txt"]
|
||||
assert body[0]["size_bytes"] == 16
|
||||
|
||||
zipped = await api.client.get(f"/api/messages/{mid}/attachments.zip")
|
||||
assert zipped.status_code == 200
|
||||
assert zipped.headers["content-type"] == "application/zip"
|
||||
assert "Attachments.zip" in zipped.headers["content-disposition"]
|
||||
archive = zipfile.ZipFile(io.BytesIO(zipped.content))
|
||||
assert archive.namelist() == ["notes.txt"]
|
||||
assert archive.read("notes.txt") == b"hello attachment"
|
||||
|
||||
|
||||
async def test_thread_detail_messages_ascending(api: ApiCtx) -> None:
|
||||
# find the kickoff thread (2 messages)
|
||||
resp = await api.client.get("/api/threads")
|
||||
kickoff = next(t for t in resp.json()["threads"] if t["message_count"] == 2)
|
||||
detail = await api.client.get(f"/api/threads/{kickoff['id']}")
|
||||
assert detail.status_code == 200
|
||||
body = detail.json()
|
||||
assert body["order"] == "asc"
|
||||
dates = [m["date"] for m in body["messages"]]
|
||||
assert dates == sorted(dates) # ascending
|
||||
|
||||
|
||||
async def test_message_html_is_sanitized(api: ApiCtx) -> None:
|
||||
# the lunch message has HTML with a script
|
||||
resp = await api.client.get("/api/threads")
|
||||
lunch = next(t for t in resp.json()["threads"] if t["subject"] == "Lunch")
|
||||
detail = (await api.client.get(f"/api/threads/{lunch['id']}")).json()
|
||||
mid = detail["messages"][0]["id"]
|
||||
html = await api.client.get(f"/api/messages/{mid}/html")
|
||||
assert html.status_code == 200
|
||||
assert "<script" not in html.text.lower()
|
||||
assert "lunch" in html.text.lower()
|
||||
|
||||
|
||||
async def test_search_grouped_by_thread(api: ApiCtx) -> None:
|
||||
resp = await api.client.get("/api/search", params={"q": "revenue"})
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert len(data["threads"]) == 1 # both kickoff messages -> one thread
|
||||
assert data["threads"][0]["thread"]["message_count"] == 2
|
||||
|
||||
|
||||
async def test_accounts_list(api: ApiCtx) -> None:
|
||||
resp = await api.client.get("/api/accounts")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()[0]["email"] == "me@example.com"
|
||||
|
||||
|
||||
async def test_thread_pagination_cursor(api: ApiCtx) -> None:
|
||||
first = await api.client.get("/api/threads", params={"limit": 1})
|
||||
body = first.json()
|
||||
assert len(body["threads"]) == 1
|
||||
assert body["cursor"] is not None
|
||||
second = await api.client.get("/api/threads", params={"limit": 1, "cursor": body["cursor"]})
|
||||
body2 = second.json()
|
||||
assert len(body2["threads"]) == 1
|
||||
assert body2["threads"][0]["id"] != body["threads"][0]["id"]
|
||||
@@ -0,0 +1,65 @@
|
||||
"""Shared test fixtures and environment defaults.
|
||||
|
||||
Ensures the SPEC §14 env vars exist even when tests run outside the conda
|
||||
activate hook (e.g. CI), so ``app.config`` imports cleanly.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import os
|
||||
|
||||
os.environ.setdefault("APP_DOMAIN", "test.webmail.local")
|
||||
os.environ.setdefault(
|
||||
"DATABASE_URL", "postgresql+asyncpg://webmail:webmail_dev@127.0.0.1:5433/webmail_dev"
|
||||
)
|
||||
# Force a dedicated Redis DB for tests (index 15) so the suite is isolated from the
|
||||
# dev app's Redis (DB 0) and can be flushed between tests without touching real data.
|
||||
os.environ["REDIS_URL"] = "redis://127.0.0.1:6380/15"
|
||||
os.environ.setdefault("MASTER_KEY", base64.b64encode(b"\x00" * 32).decode())
|
||||
os.environ.setdefault("BLOB_ROOT", "./var/blobs-test")
|
||||
|
||||
from collections.abc import AsyncIterator # noqa: E402
|
||||
|
||||
import pytest_asyncio # noqa: E402
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine # noqa: E402
|
||||
from sqlalchemy.pool import NullPool # noqa: E402
|
||||
|
||||
from app.config import get_settings # noqa: E402
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(autouse=True)
|
||||
async def _reset_global_engine() -> AsyncIterator[None]:
|
||||
"""Dispose process-wide async resources after each test.
|
||||
|
||||
Each test runs on its own event loop; disposing here ensures the next test
|
||||
(and any code using the global engine/redis, e.g. the search backend) rebinds
|
||||
to its own loop rather than reusing a closed one.
|
||||
"""
|
||||
from app.redis_client import get_redis
|
||||
|
||||
# Flush the dedicated test Redis DB so rate-limit / session / token state does
|
||||
# not leak between tests (each test starts from a clean slate).
|
||||
await get_redis().flushdb()
|
||||
yield
|
||||
from app.db import dispose_engine
|
||||
from app.redis_client import close_redis
|
||||
|
||||
await dispose_engine()
|
||||
await close_redis()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def db_session() -> AsyncIterator[AsyncSession]:
|
||||
"""Rollback-per-test session (nothing persists to the dev database)."""
|
||||
engine = create_async_engine(get_settings().database_url, poolclass=NullPool)
|
||||
conn = await engine.connect()
|
||||
trans = await conn.begin()
|
||||
session = AsyncSession(bind=conn, expire_on_commit=False)
|
||||
try:
|
||||
yield session
|
||||
finally:
|
||||
await session.close()
|
||||
await trans.rollback()
|
||||
await conn.close()
|
||||
await engine.dispose()
|
||||
@@ -0,0 +1,67 @@
|
||||
"""Handle validation rules (SPEC §4)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from app.config import get_settings
|
||||
from app.identity.handles import InvalidHandle, full_handle, validate_localpart
|
||||
from app.identity.reserved import is_reserved
|
||||
from app.identity.service import resolve_handle
|
||||
|
||||
_DOMAIN = get_settings().app_domain
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"raw,expected",
|
||||
[
|
||||
("alice", "alice"),
|
||||
("Alice", "alice"), # case-insensitive
|
||||
(" bob ", "bob"), # trimmed
|
||||
("a.b_c-d", "a.b_c-d"),
|
||||
("user123", "user123"),
|
||||
("abc", "abc"), # min length
|
||||
("a" * 30, "a" * 30), # max length
|
||||
],
|
||||
)
|
||||
def test_valid_localparts(raw: str, expected: str) -> None:
|
||||
assert validate_localpart(raw) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"raw,code",
|
||||
[
|
||||
("ab", "length"), # too short
|
||||
("a" * 31, "length"), # too long
|
||||
(".alice", "dot_edge"),
|
||||
("alice.", "dot_edge"),
|
||||
("al..ice", "double_dot"),
|
||||
("al ice", "charset"), # space
|
||||
("aliçe", "charset"), # non-ascii
|
||||
("al@ice", "charset"), # '@'
|
||||
("UPPER!", "charset"),
|
||||
("postmaster", "reserved"),
|
||||
("admin", "reserved"),
|
||||
("root", "reserved"),
|
||||
("NoReply", "reserved"), # reserved, case-insensitive
|
||||
],
|
||||
)
|
||||
def test_invalid_localparts(raw: str, code: str) -> None:
|
||||
with pytest.raises(InvalidHandle) as exc:
|
||||
validate_localpart(raw)
|
||||
assert exc.value.code == code
|
||||
|
||||
|
||||
def test_full_handle_appends_domain() -> None:
|
||||
assert full_handle("alice") == f"alice@{_DOMAIN}"
|
||||
|
||||
|
||||
def test_reserved_is_case_insensitive() -> None:
|
||||
assert is_reserved("ADMIN")
|
||||
assert is_reserved("admin")
|
||||
assert not is_reserved("alice")
|
||||
|
||||
|
||||
def test_resolve_handle_accepts_localpart_or_full() -> None:
|
||||
assert resolve_handle("Alice") == f"alice@{_DOMAIN}"
|
||||
assert resolve_handle("alice@other.example") == "alice@other.example"
|
||||
@@ -0,0 +1,188 @@
|
||||
"""M1 identity: 2FA (TOTP), session list/revoke, password reset (SPEC §6.1/§9.1)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from collections.abc import AsyncIterator
|
||||
from dataclasses import dataclass
|
||||
|
||||
import httpx
|
||||
import pyotp
|
||||
import pytest_asyncio
|
||||
from httpx import ASGITransport
|
||||
from sqlalchemy import delete
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.pool import NullPool
|
||||
|
||||
from app.config import get_settings
|
||||
from app.identity.passwords import hash_password
|
||||
from app.identity.sessions import create_session
|
||||
from app.main import create_app
|
||||
from app.models import User
|
||||
from app.redis_client import get_redis
|
||||
|
||||
CSRF = "csrf-token"
|
||||
PASSWORD = "hunter2pass"
|
||||
|
||||
|
||||
@dataclass
|
||||
class Ctx:
|
||||
app: object
|
||||
client: httpx.AsyncClient
|
||||
user_id: uuid.UUID
|
||||
handle: str
|
||||
session: AsyncSession
|
||||
|
||||
|
||||
def _hdr() -> dict[str, str]:
|
||||
return {"X-CSRF": CSRF}
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def ctx() -> AsyncIterator[Ctx]:
|
||||
engine = create_async_engine(get_settings().database_url, poolclass=NullPool)
|
||||
sm = async_sessionmaker(engine, expire_on_commit=False)
|
||||
session = sm()
|
||||
handle = f"id{uuid.uuid4().hex[:8]}@test.local"
|
||||
user = User(handle=handle, password_hash=hash_password(PASSWORD), recovery_email="r@x.com")
|
||||
session.add(user)
|
||||
await session.commit()
|
||||
token = await create_session(user.id, "pytest")
|
||||
app = create_app()
|
||||
client = httpx.AsyncClient(
|
||||
transport=ASGITransport(app=app),
|
||||
base_url="http://testserver",
|
||||
cookies={"session": token, "csrf": CSRF},
|
||||
)
|
||||
try:
|
||||
yield Ctx(app=app, client=client, user_id=user.id, handle=handle, session=session)
|
||||
finally:
|
||||
await client.aclose()
|
||||
await session.execute(delete(User).where(User.id == user.id))
|
||||
await session.commit()
|
||||
await session.close()
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
def _fresh_client(app: object) -> httpx.AsyncClient:
|
||||
return httpx.AsyncClient(transport=ASGITransport(app=app), base_url="http://testserver")
|
||||
|
||||
|
||||
async def test_totp_setup_enable_and_login(ctx: Ctx) -> None:
|
||||
setup = await ctx.client.post("/api/auth/totp/setup", headers=_hdr())
|
||||
assert setup.status_code == 200
|
||||
secret = setup.json()["secret"]
|
||||
assert setup.json()["provisioning_uri"].startswith("otpauth://")
|
||||
|
||||
# wrong code rejected
|
||||
bad = await ctx.client.post("/api/auth/totp/enable", json={"code": "000000"}, headers=_hdr())
|
||||
assert bad.status_code == 400
|
||||
|
||||
code = pyotp.TOTP(secret).now()
|
||||
ok = await ctx.client.post("/api/auth/totp/enable", json={"code": code}, headers=_hdr())
|
||||
assert ok.status_code == 200
|
||||
|
||||
# login now requires the 2FA code
|
||||
async with _fresh_client(ctx.app) as anon:
|
||||
no_totp = await anon.post(
|
||||
"/api/auth/login", json={"handle": ctx.handle, "password": PASSWORD}
|
||||
)
|
||||
assert no_totp.status_code == 401
|
||||
assert no_totp.json()["error"]["code"] == "totp_required"
|
||||
|
||||
with_totp = await anon.post(
|
||||
"/api/auth/login",
|
||||
json={"handle": ctx.handle, "password": PASSWORD, "totp": pyotp.TOTP(secret).now()},
|
||||
)
|
||||
assert with_totp.status_code == 200
|
||||
|
||||
|
||||
async def test_totp_disable(ctx: Ctx) -> None:
|
||||
# enable first
|
||||
secret = (await ctx.client.post("/api/auth/totp/setup", headers=_hdr())).json()["secret"]
|
||||
await ctx.client.post(
|
||||
"/api/auth/totp/enable", json={"code": pyotp.TOTP(secret).now()}, headers=_hdr()
|
||||
)
|
||||
assert (await ctx.client.get("/api/auth/me")).json()["totp_enabled"] is True
|
||||
|
||||
# wrong password is rejected
|
||||
bad = await ctx.client.post("/api/auth/totp/disable", json={"password": "nope"}, headers=_hdr())
|
||||
assert bad.status_code == 401
|
||||
assert (await ctx.client.get("/api/auth/me")).json()["totp_enabled"] is True
|
||||
|
||||
# correct password disables it
|
||||
ok = await ctx.client.post(
|
||||
"/api/auth/totp/disable", json={"password": PASSWORD}, headers=_hdr()
|
||||
)
|
||||
assert ok.status_code == 200
|
||||
assert (await ctx.client.get("/api/auth/me")).json()["totp_enabled"] is False
|
||||
|
||||
# login now works without a code
|
||||
async with _fresh_client(ctx.app) as anon:
|
||||
resp = await anon.post("/api/auth/login", json={"handle": ctx.handle, "password": PASSWORD})
|
||||
assert resp.status_code == 200
|
||||
|
||||
|
||||
async def test_sessions_list_and_revoke(ctx: Ctx) -> None:
|
||||
# create a second session for the same user (a different device)
|
||||
other_token = await create_session(ctx.user_id, "other-device")
|
||||
|
||||
listing = await ctx.client.get("/api/auth/sessions")
|
||||
assert listing.status_code == 200
|
||||
rows = listing.json()
|
||||
assert len(rows) == 2
|
||||
current = [r for r in rows if r["current"]]
|
||||
assert len(current) == 1
|
||||
|
||||
other_sid = next(r["sid"] for r in rows if not r["current"])
|
||||
revoked = await ctx.client.request("DELETE", f"/api/auth/sessions/{other_sid}", headers=_hdr())
|
||||
assert revoked.status_code == 200
|
||||
|
||||
# the revoked token no longer resolves
|
||||
async with _fresh_client(ctx.app) as anon:
|
||||
anon.cookies.set("session", other_token)
|
||||
me = await anon.get("/api/auth/me")
|
||||
assert me.status_code == 401
|
||||
|
||||
|
||||
async def test_password_reset_flow(ctx: Ctx) -> None:
|
||||
# request a reset; token is stored in redis (not returned)
|
||||
req = await ctx.client.post("/api/auth/password/reset-request", json={"handle": ctx.handle})
|
||||
assert req.status_code == 200
|
||||
|
||||
redis = get_redis()
|
||||
keys = await redis.keys("pwreset:*")
|
||||
# Pick the token bound to THIS user (redis DB may hold others from prior runs).
|
||||
token = None
|
||||
for key in keys:
|
||||
if await redis.get(key) == str(ctx.user_id):
|
||||
token = key.split(":", 1)[1]
|
||||
break
|
||||
assert token is not None, "a reset token should have been issued for this user"
|
||||
|
||||
new_password = "brandNewPass9"
|
||||
reset = await ctx.client.post(
|
||||
"/api/auth/password/reset", json={"token": token, "password": new_password}
|
||||
)
|
||||
assert reset.status_code == 200
|
||||
|
||||
# token is single-use
|
||||
again = await ctx.client.post(
|
||||
"/api/auth/password/reset", json={"token": token, "password": new_password}
|
||||
)
|
||||
assert again.status_code == 400
|
||||
|
||||
# new password works, old one does not
|
||||
async with _fresh_client(ctx.app) as anon:
|
||||
good = await anon.post(
|
||||
"/api/auth/login", json={"handle": ctx.handle, "password": new_password}
|
||||
)
|
||||
assert good.status_code == 200
|
||||
bad = await anon.post("/api/auth/login", json={"handle": ctx.handle, "password": PASSWORD})
|
||||
assert bad.status_code == 401
|
||||
|
||||
|
||||
async def test_reset_request_unknown_handle_still_ok(ctx: Ctx) -> None:
|
||||
# must not leak whether a handle exists
|
||||
resp = await ctx.client.post("/api/auth/password/reset-request", json={"handle": "nobody-here"})
|
||||
assert resp.status_code == 200
|
||||
@@ -0,0 +1,43 @@
|
||||
"""Content-addressed blob store (SPEC §4, §6.4)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
|
||||
from app.indexer import blobstore
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _blob_root(tmp_path, monkeypatch) -> None: # type: ignore[no-untyped-def]
|
||||
from app.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
monkeypatch.setattr(settings, "blob_root", str(tmp_path))
|
||||
|
||||
|
||||
def test_store_and_load_roundtrip() -> None:
|
||||
uid = uuid.uuid4()
|
||||
data = b"hello world"
|
||||
sha = blobstore.store(uid, data)
|
||||
assert blobstore.load(uid, sha) == data
|
||||
assert blobstore.exists(uid, sha)
|
||||
|
||||
|
||||
def test_store_is_content_addressed_and_dedupes() -> None:
|
||||
uid = uuid.uuid4()
|
||||
sha1 = blobstore.store(uid, b"same payload")
|
||||
sha2 = blobstore.store(uid, b"same payload")
|
||||
assert sha1 == sha2
|
||||
# path layout: {user}/{sha[:2]}/{sha}
|
||||
path = blobstore.blob_path(uid, sha1)
|
||||
assert path.parent.name == sha1[:2]
|
||||
assert path.parent.parent.name == str(uid)
|
||||
|
||||
|
||||
def test_different_users_isolated() -> None:
|
||||
u1, u2 = uuid.uuid4(), uuid.uuid4()
|
||||
sha = blobstore.store(u1, b"payload")
|
||||
assert blobstore.exists(u1, sha)
|
||||
assert not blobstore.exists(u2, sha)
|
||||
@@ -0,0 +1,124 @@
|
||||
"""Duplicate Message-ID handling in the indexer (SPEC §7.1).
|
||||
|
||||
Real-world trigger: home.pl's welcome mailer reuses one static Message-ID for
|
||||
every recipient, so the same id lands in different accounts as different messages.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from collections.abc import AsyncIterator
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from email.message import EmailMessage
|
||||
|
||||
import pytest_asyncio
|
||||
from sqlalchemy import delete
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.pool import NullPool
|
||||
|
||||
from app.config import get_settings
|
||||
from app.indexer.indexer import persist_message
|
||||
from app.models import Account, Mailbox, Message, User
|
||||
from app.providers.base import RemoteMessage
|
||||
|
||||
|
||||
class Dupenv:
|
||||
def __init__(self, session: AsyncSession, user_id: uuid.UUID) -> None:
|
||||
self.session = session
|
||||
self.user_id = user_id
|
||||
self.accounts: dict[str, uuid.UUID] = {}
|
||||
self.mailboxes: dict[str, uuid.UUID] = {}
|
||||
self._uid = 0
|
||||
|
||||
async def add_account(self, label: str) -> None:
|
||||
acc = Account(
|
||||
user_id=self.user_id,
|
||||
email=f"{label}@example.com",
|
||||
imap_config={},
|
||||
smtp_config={},
|
||||
secret_ct=b"",
|
||||
discovery_src="manual",
|
||||
)
|
||||
self.session.add(acc)
|
||||
await self.session.flush()
|
||||
self.accounts[label] = acc.id
|
||||
|
||||
async def add_mailbox(self, account_label: str, path: str) -> None:
|
||||
mb = Mailbox(
|
||||
account_id=self.accounts[account_label],
|
||||
user_id=self.user_id,
|
||||
path=path,
|
||||
special_use="inbox",
|
||||
)
|
||||
self.session.add(mb)
|
||||
await self.session.flush()
|
||||
self.mailboxes[f"{account_label}/{path}"] = mb.id
|
||||
|
||||
async def ingest(
|
||||
self, account_label: str, path: str, *, mid: str, minutes: int = 0
|
||||
) -> Message | None:
|
||||
self._uid += 1
|
||||
m = EmailMessage()
|
||||
m["Subject"] = "Pierwsze kroki z pocztą w home.pl"
|
||||
m["From"] = "info@home.pl"
|
||||
m["Message-ID"] = mid
|
||||
m["Date"] = "Sun, 23 Nov 2025 12:47:30 +0000"
|
||||
m.set_content("welcome template body")
|
||||
remote = RemoteMessage(
|
||||
uid=self._uid,
|
||||
raw=m.as_bytes(),
|
||||
flags=frozenset(),
|
||||
internal_date=datetime(2026, 1, 1, tzinfo=UTC) + timedelta(minutes=minutes),
|
||||
)
|
||||
result = await persist_message(
|
||||
self.session,
|
||||
user_id=self.user_id,
|
||||
account_id=self.accounts[account_label],
|
||||
mailbox_id=self.mailboxes[f"{account_label}/{path}"],
|
||||
mailbox_special_use="inbox",
|
||||
remote=remote,
|
||||
)
|
||||
return result.message if result else None
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def dup() -> AsyncIterator[Dupenv]:
|
||||
engine = create_async_engine(get_settings().database_url, poolclass=NullPool)
|
||||
sm = async_sessionmaker(engine, expire_on_commit=False)
|
||||
session = sm()
|
||||
user = User(handle=f"dup{uuid.uuid4().hex[:8]}@test.local", password_hash="x")
|
||||
session.add(user)
|
||||
await session.flush()
|
||||
env = Dupenv(session, user.id)
|
||||
await env.add_account("a")
|
||||
await env.add_account("b")
|
||||
await env.add_mailbox("a", "INBOX")
|
||||
await env.add_mailbox("a", "Archive")
|
||||
await env.add_mailbox("b", "INBOX")
|
||||
try:
|
||||
yield env
|
||||
finally:
|
||||
await session.execute(delete(User).where(User.id == user.id))
|
||||
await session.commit()
|
||||
await session.close()
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
async def test_cross_account_duplicate_isolates(dup: Dupenv) -> None:
|
||||
# Same static Message-ID delivered to two different accounts -> two threads.
|
||||
a = await dup.ingest("a", "INBOX", mid="<static@home.pl>", minutes=0)
|
||||
b = await dup.ingest("b", "INBOX", mid="<static@home.pl>", minutes=60)
|
||||
assert a is not None and b is not None
|
||||
assert a.thread_id != b.thread_id
|
||||
# The second one was stored with a synthesized, non-authoritative id.
|
||||
assert a.message_id_hdr == "static@home.pl"
|
||||
assert b.message_id_hdr.endswith("@synthetic.local")
|
||||
|
||||
|
||||
async def test_same_account_duplicate_merges(dup: Dupenv) -> None:
|
||||
# Same message in INBOX + Archive of ONE account (Gmail-style) -> one thread.
|
||||
a = await dup.ingest("a", "INBOX", mid="<msg@x.com>", minutes=0)
|
||||
b = await dup.ingest("a", "Archive", mid="<msg@x.com>", minutes=1)
|
||||
assert a is not None and b is not None
|
||||
assert a.thread_id == b.thread_id
|
||||
assert b.message_id_hdr == "msg@x.com"
|
||||
@@ -0,0 +1,103 @@
|
||||
"""MIME parsing (SPEC §6.4)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from email.message import EmailMessage
|
||||
|
||||
from app.indexer.mime import parse_message
|
||||
|
||||
|
||||
def _build(
|
||||
*,
|
||||
subject: str = "Hello",
|
||||
from_addr: str = "Alice <alice@example.com>",
|
||||
to_addr: str = "bob@example.com",
|
||||
message_id: str = "<m1@example.com>",
|
||||
references: str | None = None,
|
||||
in_reply_to: str | None = None,
|
||||
text: str | None = "plain body",
|
||||
html: str | None = None,
|
||||
) -> bytes:
|
||||
msg = EmailMessage()
|
||||
msg["Subject"] = subject
|
||||
msg["From"] = from_addr
|
||||
msg["To"] = to_addr
|
||||
msg["Message-ID"] = message_id
|
||||
msg["Date"] = "Mon, 09 Mar 2026 08:36:37 +0100"
|
||||
if references:
|
||||
msg["References"] = references
|
||||
if in_reply_to:
|
||||
msg["In-Reply-To"] = in_reply_to
|
||||
if text is not None:
|
||||
msg.set_content(text)
|
||||
if html is not None:
|
||||
if text is not None:
|
||||
msg.add_alternative(html, subtype="html")
|
||||
else:
|
||||
msg.set_content(html, subtype="html")
|
||||
return msg.as_bytes()
|
||||
|
||||
|
||||
def test_parse_basic_headers_and_body() -> None:
|
||||
parsed = parse_message(_build())
|
||||
assert parsed.subject == "Hello"
|
||||
assert parsed.message_id == "m1@example.com"
|
||||
assert parsed.from_addr == [{"name": "Alice", "email": "alice@example.com"}]
|
||||
assert parsed.to_addr == [{"name": "", "email": "bob@example.com"}]
|
||||
assert "plain body" in parsed.text_body
|
||||
assert parsed.date is not None
|
||||
assert parsed.snippet == "plain body"
|
||||
|
||||
|
||||
def test_references_include_in_reply_to_in_order() -> None:
|
||||
parsed = parse_message(_build(references="<a@x.com> <b@x.com>", in_reply_to="<b@x.com>"))
|
||||
# References first, then In-Reply-To, deduped
|
||||
assert parsed.references == ["a@x.com", "b@x.com"]
|
||||
assert parsed.in_reply_to == "b@x.com"
|
||||
|
||||
|
||||
def test_html_only_derives_text() -> None:
|
||||
parsed = parse_message(_build(text=None, html="<p>Hello <b>world</b></p>"))
|
||||
assert parsed.html_body is not None
|
||||
assert "Hello" in parsed.text_body
|
||||
assert "world" in parsed.text_body
|
||||
assert "<b>" not in parsed.text_body # tags stripped for indexing text
|
||||
|
||||
|
||||
def test_multipart_alternative_prefers_html_for_display() -> None:
|
||||
parsed = parse_message(_build(text="text version", html="<p>html version</p>"))
|
||||
assert parsed.html_body is not None
|
||||
assert "html version" in parsed.html_body
|
||||
assert "text version" in parsed.text_body
|
||||
|
||||
|
||||
def test_rfc2047_encoded_subject_is_decoded() -> None:
|
||||
# "Próba" base64-encoded UTF-8
|
||||
parsed = parse_message(_build(subject="=?UTF-8?B?UHLDs2Jh?="))
|
||||
assert parsed.subject == "Próba"
|
||||
|
||||
|
||||
def test_attachment_extracted() -> None:
|
||||
msg = EmailMessage()
|
||||
msg["Subject"] = "with attachment"
|
||||
msg["From"] = "a@b.com"
|
||||
msg["To"] = "c@d.com"
|
||||
msg["Message-ID"] = "<att@example.com>"
|
||||
msg.set_content("see attached")
|
||||
msg.add_attachment(
|
||||
b"\x00\x01\x02data", maintype="application", subtype="octet-stream", filename="file.bin"
|
||||
)
|
||||
parsed = parse_message(msg.as_bytes())
|
||||
assert parsed.has_attachments
|
||||
assert parsed.attachments[0].filename == "file.bin"
|
||||
assert parsed.attachments[0].payload == b"\x00\x01\x02data"
|
||||
|
||||
|
||||
def test_missing_message_id_returns_none() -> None:
|
||||
msg = EmailMessage()
|
||||
msg["Subject"] = "no id"
|
||||
msg["From"] = "a@b.com"
|
||||
msg["To"] = "c@d.com"
|
||||
msg.set_content("body")
|
||||
parsed = parse_message(msg.as_bytes())
|
||||
assert parsed.message_id is None
|
||||
@@ -0,0 +1,186 @@
|
||||
"""End-to-end indexer pipeline + Postgres search (SPEC §6.4, §8).
|
||||
|
||||
Uses a committed session (search reads through its own connection) and deletes
|
||||
the user on teardown; the user FK cascade removes all created rows.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from collections.abc import AsyncIterator
|
||||
from datetime import UTC, datetime
|
||||
from email.message import EmailMessage
|
||||
|
||||
import pytest_asyncio
|
||||
from sqlalchemy import delete, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.pool import NullPool
|
||||
|
||||
from app.config import get_settings
|
||||
from app.indexer.indexer import persist_message
|
||||
from app.models import Account, BodyPart, KnownRecipient, Mailbox, Message, User
|
||||
from app.providers.base import RemoteMessage
|
||||
from app.search.pg import PgSearchBackend
|
||||
from app.search.queryparser import to_search_query
|
||||
|
||||
|
||||
class Fixture:
|
||||
def __init__(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
user_id: uuid.UUID,
|
||||
account_id: uuid.UUID,
|
||||
mailbox_id: uuid.UUID,
|
||||
) -> None:
|
||||
self.session = session
|
||||
self.user_id = user_id
|
||||
self.account_id = account_id
|
||||
self.mailbox_id = mailbox_id
|
||||
self.backend = PgSearchBackend()
|
||||
self._uid = 0
|
||||
|
||||
async def ingest(self, raw: bytes, *, flags: frozenset[str] = frozenset()) -> Message | None:
|
||||
self._uid += 1
|
||||
remote = RemoteMessage(uid=self._uid, raw=raw, flags=flags, internal_date=datetime.now(UTC))
|
||||
result = await persist_message(
|
||||
self.session,
|
||||
user_id=self.user_id,
|
||||
account_id=self.account_id,
|
||||
mailbox_id=self.mailbox_id,
|
||||
mailbox_special_use="inbox",
|
||||
remote=remote,
|
||||
)
|
||||
await self.session.commit()
|
||||
if result is not None:
|
||||
await self.backend.index(result.search_doc)
|
||||
return result.message
|
||||
return None
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def fx() -> AsyncIterator[Fixture]:
|
||||
engine = create_async_engine(get_settings().database_url, poolclass=NullPool)
|
||||
sm = async_sessionmaker(engine, expire_on_commit=False)
|
||||
session = sm()
|
||||
user = User(handle=f"pipe{uuid.uuid4().hex[:8]}@test.local", password_hash="x")
|
||||
session.add(user)
|
||||
await session.flush()
|
||||
account = Account(
|
||||
user_id=user.id,
|
||||
email="me@example.com",
|
||||
imap_config={},
|
||||
smtp_config={},
|
||||
secret_ct=b"",
|
||||
discovery_src="manual",
|
||||
)
|
||||
session.add(account)
|
||||
await session.flush()
|
||||
mailbox = Mailbox(account_id=account.id, user_id=user.id, path="INBOX", special_use="inbox")
|
||||
session.add(mailbox)
|
||||
await session.commit()
|
||||
try:
|
||||
yield Fixture(session, user.id, account.id, mailbox.id)
|
||||
finally:
|
||||
await session.execute(delete(User).where(User.id == user.id))
|
||||
await session.commit()
|
||||
await session.close()
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
def _msg(
|
||||
subject: str,
|
||||
body: str,
|
||||
*,
|
||||
message_id: str,
|
||||
frm: str = "Alice <alice@example.com>",
|
||||
html: str | None = None,
|
||||
) -> bytes:
|
||||
m = EmailMessage()
|
||||
m["Subject"] = subject
|
||||
m["From"] = frm
|
||||
m["To"] = "me@example.com"
|
||||
m["Message-ID"] = message_id
|
||||
m["Date"] = "Mon, 09 Mar 2026 08:36:37 +0100"
|
||||
m.set_content(body)
|
||||
if html:
|
||||
m.add_alternative(html, subtype="html")
|
||||
return m.as_bytes()
|
||||
|
||||
|
||||
async def test_ingest_creates_rows_and_thread(fx: Fixture) -> None:
|
||||
msg = await fx.ingest(
|
||||
_msg("Invoice 2026", "Please find the invoice attached.", message_id="<a@example.com>")
|
||||
)
|
||||
assert msg is not None
|
||||
assert msg.thread_id is not None
|
||||
body = (await fx.session.scalars(select(BodyPart).where(BodyPart.message_id == msg.id))).all()
|
||||
kinds = {b.kind for b in body}
|
||||
assert "text" in kinds
|
||||
# known_recipients harvested the sender
|
||||
kr = await fx.session.scalar(
|
||||
select(KnownRecipient).where(KnownRecipient.email == "alice@example.com")
|
||||
)
|
||||
assert kr is not None and kr.times_used == 1
|
||||
|
||||
|
||||
async def test_idempotent_reingest(fx: Fixture) -> None:
|
||||
raw = _msg("Dup", "body", message_id="<dup@example.com>")
|
||||
first = await fx.ingest(raw)
|
||||
assert first is not None
|
||||
# Same (mailbox_id, uid) — but ingest increments uid; force same uid path:
|
||||
remote = RemoteMessage(uid=1, raw=raw, flags=frozenset(), internal_date=datetime.now(UTC))
|
||||
dup = await persist_message(
|
||||
fx.session,
|
||||
user_id=fx.user_id,
|
||||
account_id=fx.account_id,
|
||||
mailbox_id=fx.mailbox_id,
|
||||
mailbox_special_use="inbox",
|
||||
remote=remote,
|
||||
)
|
||||
assert dup is None # (mailbox_id, uid=1) already exists
|
||||
|
||||
|
||||
async def test_search_finds_indexed_message(fx: Fixture) -> None:
|
||||
await fx.ingest(
|
||||
_msg("Quarterly report", "revenue numbers and projections", message_id="<r@example.com>")
|
||||
)
|
||||
await fx.ingest(_msg("Lunch plans", "let us grab lunch tomorrow", message_id="<l@example.com>"))
|
||||
|
||||
q = to_search_query("revenue", fx.user_id)
|
||||
hits, cursor = await fx.backend.query(q, limit=50, cursor=None)
|
||||
assert len(hits) == 1
|
||||
assert hits[0].highlights # revenue highlighted
|
||||
|
||||
q2 = to_search_query("lunch", fx.user_id)
|
||||
hits2, _ = await fx.backend.query(q2, limit=50, cursor=None)
|
||||
assert len(hits2) == 1
|
||||
|
||||
|
||||
async def test_search_operator_from_filter(fx: Fixture) -> None:
|
||||
await fx.ingest(
|
||||
_msg("Hi", "hello there", message_id="<h1@example.com>", frm="Bob <bob@example.com>")
|
||||
)
|
||||
await fx.ingest(
|
||||
_msg("Yo", "hello again", message_id="<h2@example.com>", frm="Carol <carol@example.com>")
|
||||
)
|
||||
q = to_search_query("hello from:bob", fx.user_id)
|
||||
hits, _ = await fx.backend.query(q, limit=50, cursor=None)
|
||||
assert len(hits) == 1
|
||||
|
||||
|
||||
async def test_html_sanitized_stored(fx: Fixture) -> None:
|
||||
msg = await fx.ingest(
|
||||
_msg(
|
||||
"HTML",
|
||||
"text fallback",
|
||||
message_id="<html@example.com>",
|
||||
html="<p>Hello <script>evil()</script><b>world</b></p>",
|
||||
)
|
||||
)
|
||||
assert msg is not None
|
||||
html_part = await fx.session.scalar(
|
||||
select(BodyPart).where(BodyPart.message_id == msg.id, BodyPart.kind == "html_sanitized")
|
||||
)
|
||||
assert html_part is not None
|
||||
assert "<script" not in html_part.content.lower()
|
||||
assert "world" in html_part.content
|
||||
@@ -0,0 +1,56 @@
|
||||
"""HTML sanitization (SPEC §6.4, §10.4, I-8)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
from app.indexer.sanitize import sanitize_html
|
||||
|
||||
MID = uuid.uuid4()
|
||||
|
||||
|
||||
def test_scripts_and_handlers_are_removed() -> None:
|
||||
html = '<div onclick="evil()">hi<script>alert(1)</script></div>'
|
||||
result = sanitize_html(html, MID)
|
||||
assert "<script" not in result.html.lower()
|
||||
assert "onclick" not in result.html.lower()
|
||||
assert "hi" in result.html
|
||||
|
||||
|
||||
def test_remote_image_is_blocked_and_flagged() -> None:
|
||||
html = '<p>see <img src="http://tracker.example/pixel.gif"> this</p>'
|
||||
result = sanitize_html(html, MID)
|
||||
assert result.has_remote_images
|
||||
assert "tracker.example" not in result.html
|
||||
|
||||
|
||||
def test_cid_image_is_rewritten_to_endpoint() -> None:
|
||||
html = '<img src="cid:logo123">'
|
||||
result = sanitize_html(html, MID)
|
||||
assert f"/api/messages/{MID}/cid/logo123" in result.html
|
||||
assert not result.has_remote_images
|
||||
|
||||
|
||||
def test_data_image_is_preserved() -> None:
|
||||
html = '<img src="data:image/png;base64,iVBORw0KGgo=">'
|
||||
result = sanitize_html(html, MID)
|
||||
assert "data:image/png" in result.html
|
||||
assert not result.has_remote_images
|
||||
|
||||
|
||||
def test_safe_links_survive_with_rel() -> None:
|
||||
html = '<a href="https://example.com">link</a>'
|
||||
result = sanitize_html(html, MID)
|
||||
assert "https://example.com" in result.html
|
||||
assert "javascript:" not in result.html.lower()
|
||||
|
||||
|
||||
def test_javascript_uri_is_stripped() -> None:
|
||||
html = '<a href="javascript:alert(1)">x</a>'
|
||||
result = sanitize_html(html, MID)
|
||||
assert "javascript:" not in result.html.lower()
|
||||
|
||||
|
||||
def test_sanitizer_version_reported() -> None:
|
||||
result = sanitize_html("<p>hi</p>", MID)
|
||||
assert result.sanitizer_v >= 1
|
||||
@@ -0,0 +1,303 @@
|
||||
"""Flag / move actions (SPEC §6.3, §9.1): DB-first + enqueue + revert."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from collections.abc import AsyncIterator
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import UTC, datetime
|
||||
from email.message import EmailMessage
|
||||
|
||||
import httpx
|
||||
import pytest_asyncio
|
||||
from httpx import ASGITransport
|
||||
from sqlalchemy import delete, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.pool import NullPool
|
||||
|
||||
from app.api import actions as actions_api
|
||||
from app.config import get_settings
|
||||
from app.db import session_scope
|
||||
from app.identity.passwords import hash_password
|
||||
from app.identity.sessions import create_session
|
||||
from app.indexer.indexer import persist_message
|
||||
from app.main import create_app
|
||||
from app.models import Account, Mailbox, Message, Thread, User
|
||||
from app.providers.base import RemoteMessage
|
||||
from app.sync import actions as sync_actions
|
||||
|
||||
CSRF = "csrf"
|
||||
|
||||
|
||||
@dataclass
|
||||
class Ctx:
|
||||
client: httpx.AsyncClient
|
||||
session: AsyncSession
|
||||
user_id: uuid.UUID
|
||||
inbox_id: uuid.UUID
|
||||
archive_id: uuid.UUID
|
||||
message_ids: list[uuid.UUID] = field(default_factory=list)
|
||||
thread_id: uuid.UUID | None = None
|
||||
enqueued: list[tuple] = field(default_factory=list)
|
||||
|
||||
|
||||
def _hdr() -> dict[str, str]:
|
||||
return {"X-CSRF": CSRF}
|
||||
|
||||
|
||||
def _raw(subject: str, mid: str, refs: str | None = None) -> bytes:
|
||||
m = EmailMessage()
|
||||
m["Subject"] = subject
|
||||
m["From"] = "a@x.com"
|
||||
m["To"] = "me@example.com"
|
||||
m["Message-ID"] = mid
|
||||
m["Date"] = "Mon, 09 Mar 2026 08:36:37 +0100"
|
||||
if refs:
|
||||
m["References"] = refs
|
||||
m.set_content("body")
|
||||
return m.as_bytes()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def ctx(monkeypatch) -> AsyncIterator[Ctx]: # type: ignore[no-untyped-def]
|
||||
engine = create_async_engine(get_settings().database_url, poolclass=NullPool)
|
||||
sm = async_sessionmaker(engine, expire_on_commit=False)
|
||||
session = sm()
|
||||
user = User(handle=f"act{uuid.uuid4().hex[:8]}@test.local", password_hash=hash_password("pw"))
|
||||
session.add(user)
|
||||
await session.flush()
|
||||
account = Account(
|
||||
user_id=user.id,
|
||||
email="me@example.com",
|
||||
imap_config={},
|
||||
smtp_config={},
|
||||
secret_ct=b"",
|
||||
discovery_src="manual",
|
||||
)
|
||||
session.add(account)
|
||||
await session.flush()
|
||||
inbox = Mailbox(account_id=account.id, user_id=user.id, path="INBOX", special_use="inbox")
|
||||
archive = Mailbox(account_id=account.id, user_id=user.id, path="Archive", special_use="archive")
|
||||
session.add_all([inbox, archive])
|
||||
await session.commit()
|
||||
|
||||
c = Ctx(
|
||||
client=None,
|
||||
session=session,
|
||||
user_id=user.id,
|
||||
inbox_id=inbox.id, # type: ignore[arg-type]
|
||||
archive_id=archive.id,
|
||||
)
|
||||
for i, (subj, mid, refs) in enumerate(
|
||||
[
|
||||
("Topic", "<a@x.com>", None),
|
||||
("Re: Topic", "<b@x.com>", "<a@x.com>"),
|
||||
]
|
||||
):
|
||||
remote = RemoteMessage(
|
||||
uid=i + 1, raw=_raw(subj, mid, refs), flags=frozenset(), internal_date=datetime.now(UTC)
|
||||
)
|
||||
result = await persist_message(
|
||||
session,
|
||||
user_id=user.id,
|
||||
account_id=account.id,
|
||||
mailbox_id=inbox.id,
|
||||
mailbox_special_use="inbox",
|
||||
remote=remote,
|
||||
)
|
||||
await session.commit()
|
||||
assert result is not None
|
||||
c.message_ids.append(result.message.id)
|
||||
c.thread_id = result.message.thread_id
|
||||
|
||||
# capture enqueues instead of hitting a real arq pool
|
||||
class FakePool:
|
||||
async def enqueue_job(self, *args: object) -> None:
|
||||
c.enqueued.append(args)
|
||||
|
||||
async def fake_pool() -> FakePool:
|
||||
return FakePool()
|
||||
|
||||
monkeypatch.setattr(actions_api, "get_arq_pool", fake_pool)
|
||||
|
||||
token = await create_session(user.id, "pytest")
|
||||
c.client = httpx.AsyncClient(
|
||||
transport=ASGITransport(app=create_app()),
|
||||
base_url="http://testserver",
|
||||
cookies={"session": token, "csrf": CSRF},
|
||||
)
|
||||
try:
|
||||
yield c
|
||||
finally:
|
||||
await c.client.aclose()
|
||||
await session.execute(delete(User).where(User.id == user.id))
|
||||
await session.commit()
|
||||
await session.close()
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
async def test_mark_message_read_enqueues_push(ctx: Ctx) -> None:
|
||||
mid = ctx.message_ids[0]
|
||||
resp = await ctx.client.post(
|
||||
f"/api/messages/{mid}/flags", json={"add": ["\\Seen"]}, headers=_hdr()
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert "\\Seen" in resp.json()["flags"]
|
||||
|
||||
await ctx.session.refresh(await ctx.session.get(Message, mid))
|
||||
msg = await ctx.session.get(Message, mid)
|
||||
assert msg is not None and "\\Seen" in msg.flags
|
||||
assert ctx.enqueued == [("push_flags_job", str(mid), ["\\Seen"], [])]
|
||||
|
||||
|
||||
async def test_noop_flag_change_does_not_enqueue(ctx: Ctx) -> None:
|
||||
mid = ctx.message_ids[0]
|
||||
await ctx.client.post(f"/api/messages/{mid}/flags", json={"add": ["\\Seen"]}, headers=_hdr())
|
||||
ctx.enqueued.clear()
|
||||
# already \Seen -> no-op
|
||||
resp = await ctx.client.post(
|
||||
f"/api/messages/{mid}/flags", json={"add": ["\\Seen"]}, headers=_hdr()
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert ctx.enqueued == []
|
||||
|
||||
|
||||
async def test_thread_flags_mark_all_read(ctx: Ctx) -> None:
|
||||
resp = await ctx.client.post(
|
||||
f"/api/threads/{ctx.thread_id}/flags", json={"add": ["\\Seen"]}, headers=_hdr()
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
thread = await ctx.session.get(Thread, ctx.thread_id)
|
||||
await ctx.session.refresh(thread)
|
||||
assert thread is not None and thread.unread_count == 0
|
||||
assert len(ctx.enqueued) == 2 # both messages pushed
|
||||
|
||||
|
||||
async def test_thread_flag_sets_pinned(ctx: Ctx) -> None:
|
||||
resp = await ctx.client.post(
|
||||
f"/api/threads/{ctx.thread_id}/flags", json={"add": ["\\Flagged"]}, headers=_hdr()
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
thread = await ctx.session.get(Thread, ctx.thread_id)
|
||||
await ctx.session.refresh(thread)
|
||||
assert thread is not None and thread.pinned is True
|
||||
|
||||
# Unpinning clears it again.
|
||||
await ctx.client.post(
|
||||
f"/api/threads/{ctx.thread_id}/flags", json={"remove": ["\\Flagged"]}, headers=_hdr()
|
||||
)
|
||||
await ctx.session.refresh(thread)
|
||||
assert thread.pinned is False
|
||||
|
||||
|
||||
async def test_move_message(ctx: Ctx) -> None:
|
||||
mid = ctx.message_ids[0]
|
||||
resp = await ctx.client.post(
|
||||
f"/api/messages/{mid}/move", json={"mailbox_id": str(ctx.archive_id)}, headers=_hdr()
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
msg = await ctx.session.get(Message, mid)
|
||||
await ctx.session.refresh(msg)
|
||||
assert msg is not None and msg.mailbox_id == ctx.archive_id
|
||||
assert ctx.enqueued[0][0] == "move_job"
|
||||
|
||||
|
||||
async def test_archive_thread_enqueues_move(ctx: Ctx) -> None:
|
||||
resp = await ctx.client.post(f"/api/threads/{ctx.thread_id}/archive", headers=_hdr())
|
||||
assert resp.status_code == 200
|
||||
assert resp.json() == {"status": "queued"}
|
||||
assert ctx.enqueued == [
|
||||
("move_thread_to_folder_job", str(ctx.thread_id), "archive"),
|
||||
]
|
||||
|
||||
|
||||
async def test_trash_thread_enqueues_move(ctx: Ctx) -> None:
|
||||
resp = await ctx.client.post(f"/api/threads/{ctx.thread_id}/trash", headers=_hdr())
|
||||
assert resp.status_code == 200
|
||||
assert ctx.enqueued == [
|
||||
("move_thread_to_folder_job", str(ctx.thread_id), "trash"),
|
||||
]
|
||||
|
||||
|
||||
async def test_archive_unknown_thread_404(ctx: Ctx) -> None:
|
||||
resp = await ctx.client.post(f"/api/threads/{uuid.uuid4()}/archive", headers=_hdr())
|
||||
assert resp.status_code == 404
|
||||
assert ctx.enqueued == []
|
||||
|
||||
|
||||
async def test_restore_thread_enqueues_move_to_inbox(ctx: Ctx) -> None:
|
||||
resp = await ctx.client.post(f"/api/threads/{ctx.thread_id}/restore", headers=_hdr())
|
||||
assert resp.status_code == 200
|
||||
assert ctx.enqueued == [
|
||||
("move_thread_to_folder_job", str(ctx.thread_id), "inbox"),
|
||||
]
|
||||
|
||||
|
||||
async def test_delete_thread_enqueues_permanent_delete(ctx: Ctx) -> None:
|
||||
resp = await ctx.client.post(f"/api/threads/{ctx.thread_id}/delete", headers=_hdr())
|
||||
assert resp.status_code == 200
|
||||
assert ctx.enqueued == [("delete_thread_job", str(ctx.thread_id))]
|
||||
|
||||
|
||||
async def test_ignores_unknown_flags(ctx: Ctx) -> None:
|
||||
mid = ctx.message_ids[0]
|
||||
resp = await ctx.client.post(
|
||||
f"/api/messages/{mid}/flags", json={"add": ["\\Deleted", "bogus"]}, headers=_hdr()
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
msg = await ctx.session.get(Message, mid)
|
||||
await ctx.session.refresh(msg)
|
||||
assert msg is not None and "\\Deleted" not in msg.flags # not user-settable here
|
||||
|
||||
|
||||
async def test_delete_thread_job_expunges_and_removes_rows(ctx: Ctx, monkeypatch) -> None: # type: ignore[no-untyped-def]
|
||||
tid = ctx.thread_id
|
||||
assert tid is not None
|
||||
deleted: list[tuple[str, list[int]]] = []
|
||||
|
||||
class FakeProvider:
|
||||
async def delete_messages(self, mailbox: str, uids: list[int]) -> None:
|
||||
deleted.append((mailbox, uids))
|
||||
|
||||
async def close(self) -> None:
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(sync_actions, "build_provider", lambda _account: FakeProvider())
|
||||
|
||||
await sync_actions.delete_thread_job({}, str(tid))
|
||||
|
||||
# server expunge happened for the thread's INBOX uids
|
||||
assert deleted and deleted[0][0] == "INBOX"
|
||||
# DB rows for the thread's messages are gone (verify in a fresh session; the
|
||||
# job deleted them in its own session, so drop ctx's stale identity map).
|
||||
ctx.session.expunge_all()
|
||||
async with session_scope() as verify:
|
||||
remaining = await verify.scalar(
|
||||
select(func.count()).select_from(Message).where(Message.thread_id == tid)
|
||||
)
|
||||
assert remaining == 0
|
||||
assert await verify.scalar(select(Thread).where(Thread.id == tid)) is None
|
||||
|
||||
|
||||
async def test_push_flags_job_reverts_on_failure(ctx: Ctx, monkeypatch) -> None: # type: ignore[no-untyped-def]
|
||||
mid = ctx.message_ids[0]
|
||||
# mark read in DB first (as the endpoint would)
|
||||
msg = await ctx.session.get(Message, mid)
|
||||
assert msg is not None
|
||||
msg.flags = ["\\Seen"]
|
||||
await ctx.session.commit()
|
||||
|
||||
class FailingProvider:
|
||||
async def push_flags(self, *a: object, **k: object) -> None:
|
||||
raise RuntimeError("imap down")
|
||||
|
||||
async def close(self) -> None:
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(sync_actions, "build_provider", lambda _account: FailingProvider())
|
||||
|
||||
# final try -> should revert (remove the \Seen we added)
|
||||
await sync_actions.push_flags_job({"job_try": 5}, str(mid), ["\\Seen"], [])
|
||||
reverted = await ctx.session.get(Message, mid)
|
||||
await ctx.session.refresh(reverted)
|
||||
assert reverted is not None and "\\Seen" not in reverted.flags
|
||||
@@ -0,0 +1,188 @@
|
||||
"""Recipient suggest, settings, and per-sender remote-image control (SPEC §9.1/§9.4)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from collections.abc import AsyncIterator
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from email.message import EmailMessage
|
||||
|
||||
import httpx
|
||||
import pytest_asyncio
|
||||
from httpx import ASGITransport
|
||||
from sqlalchemy import delete
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.pool import NullPool
|
||||
|
||||
from app.config import get_settings
|
||||
from app.identity.passwords import hash_password
|
||||
from app.identity.sessions import create_session
|
||||
from app.indexer.indexer import persist_message
|
||||
from app.main import create_app
|
||||
from app.models import Account, KnownRecipient, Mailbox, User
|
||||
from app.providers.base import RemoteMessage
|
||||
from app.search.pg import PgSearchBackend
|
||||
|
||||
CSRF = "csrf"
|
||||
|
||||
|
||||
@dataclass
|
||||
class Ctx:
|
||||
client: httpx.AsyncClient
|
||||
session: AsyncSession
|
||||
user_id: uuid.UUID
|
||||
account_id: uuid.UUID
|
||||
mailbox_id: uuid.UUID
|
||||
|
||||
|
||||
def _hdr() -> dict[str, str]:
|
||||
return {"X-CSRF": CSRF}
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def ctx() -> AsyncIterator[Ctx]:
|
||||
engine = create_async_engine(get_settings().database_url, poolclass=NullPool)
|
||||
sm = async_sessionmaker(engine, expire_on_commit=False)
|
||||
session = sm()
|
||||
user = User(handle=f"rs{uuid.uuid4().hex[:8]}@test.local", password_hash=hash_password("pw"))
|
||||
session.add(user)
|
||||
await session.flush()
|
||||
account = Account(
|
||||
user_id=user.id,
|
||||
email="me@example.com",
|
||||
imap_config={},
|
||||
smtp_config={},
|
||||
secret_ct=b"",
|
||||
discovery_src="manual",
|
||||
)
|
||||
session.add(account)
|
||||
await session.flush()
|
||||
mailbox = Mailbox(account_id=account.id, user_id=user.id, path="INBOX", special_use="inbox")
|
||||
session.add(mailbox)
|
||||
await session.commit()
|
||||
token = await create_session(user.id, "pytest")
|
||||
client = httpx.AsyncClient(
|
||||
transport=ASGITransport(app=create_app()),
|
||||
base_url="http://testserver",
|
||||
cookies={"session": token, "csrf": CSRF},
|
||||
)
|
||||
try:
|
||||
yield Ctx(
|
||||
client=client,
|
||||
session=session,
|
||||
user_id=user.id,
|
||||
account_id=account.id,
|
||||
mailbox_id=mailbox.id,
|
||||
)
|
||||
finally:
|
||||
await client.aclose()
|
||||
await session.execute(delete(User).where(User.id == user.id))
|
||||
await session.commit()
|
||||
await session.close()
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
async def test_recipient_suggest_prefix_and_ranking(ctx: Ctx) -> None:
|
||||
now = datetime.now(UTC)
|
||||
ctx.session.add_all(
|
||||
[
|
||||
KnownRecipient(
|
||||
user_id=ctx.user_id,
|
||||
email="alice@example.com",
|
||||
name="Alice",
|
||||
times_used=10,
|
||||
last_used_at=now,
|
||||
),
|
||||
KnownRecipient(
|
||||
user_id=ctx.user_id,
|
||||
email="alan@example.com",
|
||||
name="Alan",
|
||||
times_used=1,
|
||||
last_used_at=now - timedelta(days=200),
|
||||
),
|
||||
KnownRecipient(
|
||||
user_id=ctx.user_id,
|
||||
email="bob@example.com",
|
||||
name="Bob",
|
||||
times_used=99,
|
||||
last_used_at=now,
|
||||
),
|
||||
]
|
||||
)
|
||||
await ctx.session.commit()
|
||||
|
||||
resp = await ctx.client.get("/api/recipients/suggest", params={"q": "al"})
|
||||
assert resp.status_code == 200
|
||||
emails = [r["email"] for r in resp.json()]
|
||||
assert set(emails) == {"alice@example.com", "alan@example.com"} # prefix match, bob excluded
|
||||
assert emails[0] == "alice@example.com" # higher score (recent + frequent) ranks first
|
||||
|
||||
|
||||
async def test_recipient_suggest_empty_query(ctx: Ctx) -> None:
|
||||
resp = await ctx.client.get("/api/recipients/suggest", params={"q": ""})
|
||||
assert resp.status_code == 200 and resp.json() == []
|
||||
|
||||
|
||||
async def test_settings_get_and_patch(ctx: Ctx) -> None:
|
||||
got = await ctx.client.get("/api/settings")
|
||||
assert got.status_code == 200
|
||||
assert got.json()["thread_order"] == "asc"
|
||||
|
||||
patched = await ctx.client.patch(
|
||||
"/api/settings", json={"thread_order": "desc", "tz": "Europe/Warsaw"}, headers=_hdr()
|
||||
)
|
||||
assert patched.status_code == 200
|
||||
assert patched.json()["thread_order"] == "desc"
|
||||
assert patched.json()["tz"] == "Europe/Warsaw"
|
||||
|
||||
# invalid order is ignored
|
||||
patched2 = await ctx.client.patch(
|
||||
"/api/settings", json={"thread_order": "sideways"}, headers=_hdr()
|
||||
)
|
||||
assert patched2.json()["thread_order"] == "desc"
|
||||
|
||||
|
||||
async def _ingest_html(ctx: Ctx, *, sender: str, html: str) -> uuid.UUID:
|
||||
m = EmailMessage()
|
||||
m["Subject"] = "Newsletter"
|
||||
m["From"] = sender
|
||||
m["To"] = "me@example.com"
|
||||
m["Message-ID"] = f"<{uuid.uuid4().hex}@x.com>"
|
||||
m["Date"] = "Mon, 09 Mar 2026 08:36:37 +0100"
|
||||
m.set_content("text fallback")
|
||||
m.add_alternative(html, subtype="html")
|
||||
result = await persist_message(
|
||||
ctx.session,
|
||||
user_id=ctx.user_id,
|
||||
account_id=ctx.account_id,
|
||||
mailbox_id=ctx.mailbox_id,
|
||||
mailbox_special_use="inbox",
|
||||
remote=RemoteMessage(
|
||||
uid=1, raw=m.as_bytes(), flags=frozenset(), internal_date=datetime.now(UTC)
|
||||
),
|
||||
)
|
||||
await ctx.session.commit()
|
||||
assert result is not None
|
||||
await PgSearchBackend().index(result.search_doc)
|
||||
return result.message.id
|
||||
|
||||
|
||||
async def test_remote_images_blocked_then_allowed(ctx: Ctx) -> None:
|
||||
mid = await _ingest_html(
|
||||
ctx,
|
||||
sender="News <news@sender.com>",
|
||||
html='<p>Hi<img src="http://tracker.example/pixel.gif"></p>',
|
||||
)
|
||||
# blocked by default
|
||||
before = await ctx.client.get(f"/api/messages/{mid}/html")
|
||||
assert "tracker.example" not in before.text
|
||||
|
||||
# approve the sender
|
||||
allow = await ctx.client.post(f"/api/messages/{mid}/allow-remote-images", headers=_hdr())
|
||||
assert allow.status_code == 200
|
||||
assert allow.json()["sender"] == "news@sender.com"
|
||||
|
||||
# now the remote image is rendered
|
||||
after = await ctx.client.get(f"/api/messages/{mid}/html")
|
||||
assert "http://tracker.example/pixel.gif" in after.text
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user