Initial commit: webmail client (M0/M1)

Three-pane webmail client.
- Backend: FastAPI + async SQLAlchemy + arq worker (Postgres/Redis), IMAP/SMTP
  sync, threading, search, drafts/outbox with undo, attachments, 2FA.
- Frontend: React 18 + Vite + TypeScript + TanStack Query + Tailwind; three-pane
  UI with rich-text composer, light/dark themes, resizable panels.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-17 23:20:34 +02:00
co-authored by Claude Opus 4.8
commit a5ec0d21cb
174 changed files with 22239 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
"""Accounts: autodiscovery, credential testing, account lifecycle (SPEC §6.2)."""
+255
View File
@@ -0,0 +1,255 @@
"""Autodiscovery resolver (SPEC §6.2).
``discover(email)`` runs the chain — presets, Thunderbird autoconfig, ISPDB, DNS
SRV (RFC 6186), then a live probe — returning on the first success but recording
every step's outcome for wizard UX. All HTTP is SSRF-guarded (§10.6).
"""
from __future__ import annotations
import asyncio
import ssl
from pathlib import Path
import dns.asyncresolver
import dns.exception
import httpx
import yaml
from defusedxml import ElementTree as SafeET
from app.accounts.ssrf import SsrfError, assert_host_is_public, guarded_get
from app.accounts.types import CandidateConfig, DiscoveryResult, DiscoveryStep, ServerConfig
_PROBE_TIMEOUT = 5.0
_presets_cache: dict[str, CandidateConfig] | None = None
# --- presets -----------------------------------------------------------------
def _load_presets() -> dict[str, CandidateConfig]:
global _presets_cache
if _presets_cache is None:
raw = yaml.safe_load((Path(__file__).parent / "presets.yaml").read_text())
mapping: dict[str, CandidateConfig] = {}
for prov in raw["providers"].values():
auth = prov.get("auth", "password")
imap = ServerConfig(
prov["imap"]["host"], prov["imap"]["port"], prov["imap"]["tls"], auth
)
smtp = ServerConfig(
prov["smtp"]["host"], prov["smtp"]["port"], prov["smtp"]["tls"], auth
)
candidate = CandidateConfig(imap, smtp, "preset", prov.get("oauth_provider"))
for domain in prov["domains"]:
mapping[domain.lower()] = candidate
_presets_cache = mapping
return _presets_cache
def domain_of(email: str) -> str:
return email.rsplit("@", 1)[-1].strip().lower()
# --- Thunderbird autoconfig / ISPDB XML --------------------------------------
def _socket_type(text: str | None) -> str:
return {"ssl": "ssl", "starttls": "starttls", "plain": "plain"}.get(
(text or "").strip().lower(), "ssl"
)
def parse_autoconfig_xml(xml_text: str) -> CandidateConfig | None:
"""Parse Thunderbird ``clientConfig`` XML into a CandidateConfig (or None)."""
try:
root = SafeET.fromstring(xml_text)
except Exception:
return None
provider = root.find("emailProvider")
if provider is None:
return None
def _server(kind: str, server_type: str) -> ServerConfig | None:
for node in provider.findall(kind):
if node.get("type") == server_type:
host = node.findtext("hostname")
port = node.findtext("port")
if host and port and port.isdigit():
auth = node.findtext("authentication") or ""
is_oauth = "oauth" in auth.lower()
return ServerConfig(
host.strip(),
int(port),
_socket_type(node.findtext("socketType")),
"oauth2" if is_oauth else "password",
)
return None
imap = _server("incomingServer", "imap")
smtp = _server("outgoingServer", "smtp")
if imap is None or smtp is None:
return None
return CandidateConfig(imap, smtp, "autoconfig")
# --- DNS SRV (RFC 6186) ------------------------------------------------------
async def _srv_lookup(name: str, tls: str) -> ServerConfig | None:
resolver = dns.asyncresolver.Resolver()
resolver.lifetime = 5.0
try:
answer = await resolver.resolve(name, "SRV")
except (dns.exception.DNSException, OSError):
return None
for record in sorted(answer, key=lambda r: (r.priority, r.weight)):
target = str(record.target).rstrip(".")
if target and target != ".": # "." target => service explicitly unavailable
return ServerConfig(target, record.port, tls)
return None
async def _discover_srv(domain: str) -> CandidateConfig | None:
imap = await _srv_lookup(f"_imaps._tcp.{domain}", "ssl") or await _srv_lookup(
f"_imap._tcp.{domain}", "starttls"
)
smtp = await _srv_lookup(f"_submissions._tcp.{domain}", "ssl") or await _srv_lookup(
f"_submission._tcp.{domain}", "starttls"
)
if imap and smtp:
return CandidateConfig(imap, smtp, "srv")
return None
# --- live probe --------------------------------------------------------------
async def _probe_one(host: str, port: int, tls: str, kind: str) -> ServerConfig | None:
try:
await assert_host_is_public(host) # SSRF: never probe internal hosts
except SsrfError:
return None
try:
if tls == "ssl":
ctx = ssl.create_default_context()
conn = asyncio.open_connection(host, port, ssl=ctx)
else:
conn = asyncio.open_connection(host, port)
reader, writer = await asyncio.wait_for(conn, _PROBE_TIMEOUT)
greeting = await asyncio.wait_for(reader.readline(), _PROBE_TIMEOUT)
writer.close()
except (TimeoutError, OSError, ssl.SSLError):
return None
text = greeting.decode("ascii", "ignore")
if kind == "imap" and text.startswith("* OK"):
return ServerConfig(host, port, tls)
if kind == "smtp" and text.startswith("220"):
return ServerConfig(host, port, tls)
return None
async def _first_working(candidates: list[tuple[str, int, str]], kind: str) -> ServerConfig | None:
results = await asyncio.gather(
*(_probe_one(host, port, tls, kind) for host, port, tls in candidates)
)
for result in results: # candidates are in priority order
if result is not None:
return result
return None
async def _discover_probe(domain: str) -> CandidateConfig | None:
imap_hosts = [
(f"imap.{domain}", 993, "ssl"),
(f"mail.{domain}", 993, "ssl"),
(f"imap.{domain}", 143, "starttls"),
(f"mail.{domain}", 143, "starttls"),
]
smtp_hosts = [
(f"smtp.{domain}", 465, "ssl"),
(f"mail.{domain}", 465, "ssl"),
(f"smtp.{domain}", 587, "starttls"),
(f"mail.{domain}", 587, "starttls"),
]
imap, smtp = await asyncio.gather(
_first_working(imap_hosts, "imap"), _first_working(smtp_hosts, "smtp")
)
if imap and smtp:
return CandidateConfig(imap, smtp, "probe")
return None
# --- orchestration -----------------------------------------------------------
async def _try_http_config(url: str, source: str) -> CandidateConfig | None:
xml_text = await guarded_get(url)
if xml_text is None:
return None
candidate = parse_autoconfig_xml(xml_text)
if candidate is None:
return None
return CandidateConfig(candidate.imap, candidate.smtp, source, candidate.oauth_provider)
async def discover(email: str) -> DiscoveryResult:
"""Run the autodiscovery chain for ``email`` (SPEC §6.2)."""
result = DiscoveryResult()
domain = domain_of(email)
# 1. Presets.
presets = _load_presets()
if domain in presets:
result.candidates.append(presets[domain])
result.steps.append(DiscoveryStep("preset", "found", domain))
return result
result.steps.append(DiscoveryStep("preset", "not_found", domain))
# 2. Autoconfig (autoconfig.<domain>, then <domain>/.well-known).
autoconfig_urls = [
f"https://autoconfig.{domain}/mail/config-v1.1.xml?emailaddress={email}",
f"https://{domain}/.well-known/autoconfig/mail/config-v1.1.xml?emailaddress={email}",
]
for url in autoconfig_urls:
try:
candidate = await _try_http_config(url, "autoconfig")
except (SsrfError, httpx.HTTPError) as exc:
result.steps.append(DiscoveryStep("autoconfig", "error", str(exc)[:200]))
continue
if candidate is not None:
result.candidates.append(candidate)
result.steps.append(DiscoveryStep("autoconfig", "found", url))
return result
result.steps.append(DiscoveryStep("autoconfig", "not_found", domain))
# 3. ISPDB (Thunderbird's database).
ispdb_url = f"https://autoconfig.thunderbird.net/v1.1/{domain}"
try:
candidate = await _try_http_config(ispdb_url, "ispdb")
if candidate is not None:
result.candidates.append(candidate)
result.steps.append(DiscoveryStep("ispdb", "found", ispdb_url))
return result
result.steps.append(DiscoveryStep("ispdb", "not_found", domain))
except (SsrfError, httpx.HTTPError) as exc:
result.steps.append(DiscoveryStep("ispdb", "error", str(exc)[:200]))
# 4. DNS SRV (RFC 6186).
srv = await _discover_srv(domain)
if srv is not None:
result.candidates.append(srv)
result.steps.append(DiscoveryStep("srv", "found", domain))
return result
result.steps.append(DiscoveryStep("srv", "not_found", domain))
# 5. Live probe.
probe = await _discover_probe(domain)
if probe is not None:
result.candidates.append(probe)
result.steps.append(DiscoveryStep("probe", "found", domain))
return result
result.steps.append(DiscoveryStep("probe", "not_found", domain))
return result
+68
View File
@@ -0,0 +1,68 @@
# Provider presets (config, not code) — SPEC §6.2 step 1.
# Matched by the domain of the email address. Each entry gives IMAP + SMTP
# host/port/tls, the auth kind, and an oauth provider id when applicable.
# tls: "ssl" (implicit TLS) | "starttls"; auth: "password" | "oauth2".
providers:
google:
domains: [gmail.com, googlemail.com]
imap: { host: imap.gmail.com, port: 993, tls: ssl }
smtp: { host: smtp.gmail.com, port: 465, tls: ssl }
auth: oauth2
oauth_provider: google
microsoft:
domains: [outlook.com, hotmail.com, live.com, msn.com]
imap: { host: outlook.office365.com, port: 993, tls: ssl }
smtp: { host: smtp.office365.com, port: 587, tls: starttls }
auth: oauth2
oauth_provider: microsoft
office365:
domains: [office365.com]
imap: { host: outlook.office365.com, port: 993, tls: ssl }
smtp: { host: smtp.office365.com, port: 587, tls: starttls }
auth: oauth2
oauth_provider: microsoft
yahoo:
domains: [yahoo.com, yahoo.co.uk, ymail.com, rocketmail.com]
imap: { host: imap.mail.yahoo.com, port: 993, tls: ssl }
smtp: { host: smtp.mail.yahoo.com, port: 465, tls: ssl }
auth: password
icloud:
domains: [icloud.com, me.com, mac.com]
imap: { host: imap.mail.me.com, port: 993, tls: ssl }
smtp: { host: smtp.mail.me.com, port: 587, tls: starttls }
auth: password
gmx:
domains: [gmx.com, gmx.net, gmx.de]
imap: { host: imap.gmx.com, port: 993, tls: ssl }
smtp: { host: mail.gmx.com, port: 587, tls: starttls }
auth: password
wp:
domains: [wp.pl]
imap: { host: imap.wp.pl, port: 993, tls: ssl }
smtp: { host: smtp.wp.pl, port: 465, tls: ssl }
auth: password
onet:
domains: [onet.pl, poczta.onet.pl, op.pl]
imap: { host: imap.poczta.onet.pl, port: 993, tls: ssl }
smtp: { host: smtp.poczta.onet.pl, port: 465, tls: ssl }
auth: password
o2:
domains: [o2.pl, tlen.pl]
imap: { host: imap.poczta.o2.pl, port: 993, tls: ssl }
smtp: { host: smtp.poczta.o2.pl, port: 465, tls: ssl }
auth: password
interia:
domains: [interia.pl, interia.eu]
imap: { host: imap.poczta.interia.pl, port: 993, tls: ssl }
smtp: { host: smtp.poczta.interia.pl, port: 465, tls: ssl }
auth: password
+74
View File
@@ -0,0 +1,74 @@
"""Request/response models for the accounts API (SPEC §9.1)."""
from __future__ import annotations
import uuid
from pydantic import BaseModel, Field
class ServerConfigIn(BaseModel):
host: str
port: int = Field(ge=1, le=65535)
tls: str = "ssl" # ssl | starttls
auth: str = "password" # password | oauth2
class DiscoverRequest(BaseModel):
email: str
class CandidateOut(BaseModel):
imap: ServerConfigIn
smtp: ServerConfigIn
source: str
oauth_provider: str | None = None
class DiscoveryStepOut(BaseModel):
method: str
status: str
detail: str = ""
class DiscoverResponse(BaseModel):
candidates: list[CandidateOut]
steps: list[DiscoveryStepOut]
class TestRequest(BaseModel):
imap: ServerConfigIn
smtp: ServerConfigIn
email: str
username: str | None = None # defaults to the email address
password: str = Field(min_length=1)
class TestResponse(BaseModel):
ok: bool = True
class CreateAccountRequest(BaseModel):
email: str
display_name: str = ""
color: str = "#888888"
imap: ServerConfigIn
smtp: ServerConfigIn
username: str | None = None
password: str = Field(min_length=1)
class UpdateAccountRequest(BaseModel):
display_name: str | None = None
color: str | None = None
class AccountDetailOut(BaseModel):
id: uuid.UUID
email: str
display_name: str
color: str
state: str
state_detail: str
imap_config: dict[str, object]
smtp_config: dict[str, object]
+99
View File
@@ -0,0 +1,99 @@
"""Account lifecycle: create (encrypt + enqueue sync), delete (cascade + blobs) (SPEC §6.2/§9.1)."""
from __future__ import annotations
import uuid
from sqlalchemy import delete, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from app.accounts.schemas import CreateAccountRequest
from app.accounts.types import ServerConfig
from app.indexer import blobstore
from app.models import Account, Attachment, Message
from app.security.crypto import encrypt_secret
class AccountExistsError(Exception):
"""The user already has an account for this email."""
def _server_dict(cfg: ServerConfig) -> dict[str, object]:
return {"host": cfg.host, "port": cfg.port, "tls": cfg.tls, "auth": cfg.auth}
async def create_account(
session: AsyncSession, *, user_id: uuid.UUID, req: CreateAccountRequest
) -> Account:
"""Create an account row with encrypted credentials (state=backfilling)."""
imap = ServerConfig(req.imap.host, req.imap.port, req.imap.tls, req.imap.auth)
smtp = ServerConfig(req.smtp.host, req.smtp.port, req.smtp.tls, req.smtp.auth)
account = Account(
user_id=user_id,
email=str(req.email),
display_name=req.display_name,
color=req.color,
imap_config=_server_dict(imap),
smtp_config=_server_dict(smtp),
secret_ct=encrypt_secret(req.password.encode("utf-8")),
discovery_src="manual",
state="backfilling",
)
session.add(account)
try:
await session.flush()
except IntegrityError as exc:
await session.rollback()
raise AccountExistsError(str(req.email)) from exc
return account
async def delete_account(
session: AsyncSession, *, user_id: uuid.UUID, account_id: uuid.UUID
) -> bool:
"""Delete an account and its data. Removes blobs no longer referenced (dedup-aware).
Returns False if the account does not belong to the user. DB rows (mailboxes,
messages, body_parts, attachments) and the Postgres search vector cascade via
FK ``ON DELETE CASCADE``.
"""
account = await session.scalar(
select(Account).where(Account.id == account_id, Account.user_id == user_id)
)
if account is None:
return False
# Gather blob digests this account references, before the cascade delete.
msg_shas = set(
await session.scalars(select(Message.blob_sha256).where(Message.account_id == account_id))
)
att_shas = set(
await session.scalars(
select(Attachment.blob_sha256)
.join(Message, Message.id == Attachment.message_id)
.where(Message.account_id == account_id)
)
)
candidate_shas = msg_shas | att_shas
await session.execute(delete(Account).where(Account.id == account_id))
await session.flush()
# Content-addressed blobs are shared across the user's accounts (dedup): only
# remove a blob when no remaining message/attachment references it.
for sha in candidate_shas:
still_used = await session.scalar(
select(Message.id)
.where(Message.user_id == user_id, Message.blob_sha256 == sha)
.limit(1)
)
if still_used is None:
still_used = await session.scalar(
select(Attachment.id)
.where(Attachment.user_id == user_id, Attachment.blob_sha256 == sha)
.limit(1)
)
if still_used is None:
blobstore.remove(user_id, sha)
return True
+89
View File
@@ -0,0 +1,89 @@
"""SSRF-guarded outbound HTTP for autodiscovery (SPEC §10.6).
All autodiscovery HTTP is HTTPS-only, 5 s timeout, response capped at 256 KB, no
off-domain redirects, and the target host must resolve exclusively to public IPs
(reject private/loopback/link-local/reserved ranges). A residual TOCTOU gap
(DNS rebinding between our resolve and httpx's own resolve) is accepted for M1;
a pinned-IP transport can close it later.
"""
from __future__ import annotations
import asyncio
import ipaddress
import socket
from urllib.parse import urlsplit
import httpx
HTTP_TIMEOUT = 5.0
MAX_RESPONSE_BYTES = 256 * 1024
class SsrfError(Exception):
"""Raised when a URL fails the SSRF policy (bad scheme, private IP, oversize)."""
def _is_public_ip(ip: str) -> bool:
addr = ipaddress.ip_address(ip)
return not (
addr.is_private
or addr.is_loopback
or addr.is_link_local
or addr.is_multicast
or addr.is_reserved
or addr.is_unspecified
)
async def _resolve_all(host: str) -> list[str]:
loop = asyncio.get_running_loop()
infos = await loop.getaddrinfo(host, None, proto=socket.IPPROTO_TCP)
return [info[4][0] for info in infos]
async def assert_host_is_public(host: str) -> None:
"""Resolve ``host`` and reject if any address is non-public (SPEC §10.6)."""
try:
addresses = await _resolve_all(host)
except socket.gaierror as exc:
raise SsrfError(f"cannot resolve {host}") from exc
if not addresses:
raise SsrfError(f"no addresses for {host}")
for ip in addresses:
if not _is_public_ip(ip):
raise SsrfError(f"{host} resolves to non-public address {ip}")
async def guarded_get(url: str) -> str | None:
"""HTTPS GET with the full SSRF policy applied.
Returns the body text on 200, or ``None`` when the resource is absent
(any non-200 response). Raises :class:`SsrfError` on a policy violation
(bad scheme, private IP, oversize); lets httpx network errors propagate.
"""
parts = urlsplit(url)
if parts.scheme != "https":
raise SsrfError("only https is permitted")
if not parts.hostname:
raise SsrfError("missing host")
await assert_host_is_public(parts.hostname)
async with (
httpx.AsyncClient(
follow_redirects=False, # off-domain redirects are forbidden (§10.6)
timeout=HTTP_TIMEOUT,
headers={"User-Agent": "webmail-autodiscovery/1.0"},
) as client,
client.stream("GET", url) as resp,
):
if resp.status_code != 200:
return None
chunks: list[bytes] = []
total = 0
async for chunk in resp.aiter_bytes():
total += len(chunk)
if total > MAX_RESPONSE_BYTES:
raise SsrfError("response exceeds 256 KB cap")
chunks.append(chunk)
return b"".join(chunks).decode("utf-8", "replace")
+104
View File
@@ -0,0 +1,104 @@
"""Credential testing for the account wizard (SPEC §6.2 ``test(config, credentials)``).
Runs a full IMAP LOGIN + LIST and an SMTP EHLO + AUTH (no message sent), mapping
failures to precise user-facing codes. This is a §10.2 decryption-boundary sibling:
it handles plaintext credentials the user just typed, before they are encrypted.
"""
from __future__ import annotations
import socket
import ssl
import aiosmtplib
from app.accounts.ssrf import SsrfError, assert_host_is_public
from app.accounts.types import ServerConfig
from app.providers.base import ProviderAuthError, ProviderConnectError
from app.providers.imap_smtp import ImapSmtpProvider, ProviderConfig
_SMTP_TIMEOUT = 10.0
class AccountTestError(Exception):
"""A credential/connectivity test failed, with a precise ``code`` (SPEC §6.2)."""
def __init__(self, code: str, detail: str) -> None:
super().__init__(detail)
self.code = code
self.detail = detail
def _classify_cause(cause: BaseException | None) -> tuple[str, str]:
if isinstance(cause, ssl.SSLError):
return "tls_failed", str(cause)
if isinstance(cause, socket.gaierror):
return "unknown_host", str(cause)
if isinstance(cause, TimeoutError):
return "connect_timeout", "connection timed out"
return "connect_failed", str(cause or "connection failed")
async def _assert_public(config: ServerConfig, label: str) -> None:
try:
await assert_host_is_public(config.host)
except SsrfError as exc:
raise AccountTestError("host_not_allowed", f"{label} host is not permitted: {exc}") from exc
async def _test_smtp(config: ServerConfig, username: str, password: str) -> None:
smtp = aiosmtplib.SMTP(
hostname=config.host,
port=config.port,
use_tls=config.tls == "ssl",
start_tls=config.tls == "starttls",
timeout=_SMTP_TIMEOUT,
)
try:
await smtp.connect()
await smtp.login(username, password)
await smtp.quit()
except aiosmtplib.SMTPAuthenticationError as exc:
raise AccountTestError("auth_failed", f"SMTP authentication failed: {exc}") from exc
except aiosmtplib.SMTPConnectError as exc:
raise AccountTestError("connect_failed", f"SMTP connect failed: {exc}") from exc
except ssl.SSLError as exc:
raise AccountTestError("tls_failed", f"SMTP TLS failed: {exc}") from exc
except socket.gaierror as exc:
raise AccountTestError("unknown_host", f"SMTP host not found: {exc}") from exc
except (TimeoutError, aiosmtplib.SMTPTimeoutError) as exc:
raise AccountTestError("connect_timeout", "SMTP connection timed out") from exc
except aiosmtplib.SMTPException as exc:
raise AccountTestError("connect_failed", f"SMTP error: {exc}") from exc
async def test_account(
*, imap: ServerConfig, smtp: ServerConfig, username: str, password: str
) -> None:
"""Verify IMAP + SMTP credentials. Raises :class:`AccountTestError` on failure."""
await _assert_public(imap, "IMAP")
await _assert_public(smtp, "SMTP")
provider = ImapSmtpProvider(
ProviderConfig(
imap_host=imap.host,
imap_port=imap.port,
imap_tls=imap.tls,
smtp_host=smtp.host,
smtp_port=smtp.port,
smtp_tls=smtp.tls,
username=username,
password=password,
)
)
try:
await provider.verify() # IMAP LOGIN + LIST
except ProviderAuthError as exc:
raise AccountTestError("auth_failed", exc.detail) from exc
except ProviderConnectError as exc:
code, detail = _classify_cause(exc.__cause__)
raise AccountTestError(code, detail) from exc
finally:
await provider.close()
await _test_smtp(smtp, username, password)
+34
View File
@@ -0,0 +1,34 @@
"""Autodiscovery result types (SPEC §6.2)."""
from __future__ import annotations
from dataclasses import dataclass, field
@dataclass(frozen=True)
class ServerConfig:
host: str
port: int
tls: str # "ssl" | "starttls"
auth: str = "password" # "password" | "oauth2"
@dataclass(frozen=True)
class CandidateConfig:
imap: ServerConfig
smtp: ServerConfig
source: str # preset | autoconfig | ispdb | srv | probe | manual
oauth_provider: str | None = None
@dataclass(frozen=True)
class DiscoveryStep:
method: str # preset | autoconfig | ispdb | srv | probe
status: str # found | not_found | error
detail: str = ""
@dataclass
class DiscoveryResult:
candidates: list[CandidateConfig] = field(default_factory=list)
steps: list[DiscoveryStep] = field(default_factory=list)