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,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
|
||||
Reference in New Issue
Block a user