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
+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