2026-07-17 23:20:34 +02:00
|
|
|
"""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
|
2026-08-04 19:59:52 +02:00
|
|
|
from app.accounts.types import (
|
|
|
|
|
CandidateConfig,
|
|
|
|
|
DiscoveryResult,
|
|
|
|
|
DiscoveryStep,
|
|
|
|
|
HelpBlock,
|
|
|
|
|
ServerConfig,
|
|
|
|
|
)
|
2026-07-17 23:20:34 +02:00
|
|
|
|
|
|
|
|
_PROBE_TIMEOUT = 5.0
|
|
|
|
|
|
|
|
|
|
_presets_cache: dict[str, CandidateConfig] | None = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# --- presets -----------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
2026-08-04 19:59:52 +02:00
|
|
|
def _strategy_from_auth(auth: str) -> tuple[str, ...]:
|
|
|
|
|
"""Default credential flow when a preset doesn't declare one (SPEC §6.2.1)."""
|
|
|
|
|
return ("oauth2",) if auth == "oauth2" else ("password",)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _help_block(raw: object) -> HelpBlock | None:
|
|
|
|
|
if not isinstance(raw, dict):
|
|
|
|
|
return None
|
|
|
|
|
steps = raw.get("steps", [])
|
|
|
|
|
return HelpBlock(
|
|
|
|
|
help_id=str(raw["help_id"]),
|
|
|
|
|
deep_link=str(raw["deep_link"]),
|
|
|
|
|
app_name_hint=str(raw.get("app_name_hint", "")),
|
|
|
|
|
steps=tuple(str(s) for s in steps) if isinstance(steps, list) else (),
|
|
|
|
|
docs_url=str(raw.get("docs_url", "")),
|
2026-08-05 18:04:49 +02:00
|
|
|
setup_link=str(raw.get("setup_link", "")),
|
|
|
|
|
button_after_step=int(raw.get("button_after_step", 0)),
|
2026-08-04 19:59:52 +02:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2026-07-17 23:20:34 +02:00
|
|
|
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
|
|
|
|
|
)
|
2026-08-04 19:59:52 +02:00
|
|
|
strategy = tuple(prov.get("auth_strategy", [])) or _strategy_from_auth(auth)
|
|
|
|
|
candidate = CandidateConfig(
|
|
|
|
|
imap,
|
|
|
|
|
smtp,
|
|
|
|
|
"preset",
|
|
|
|
|
prov.get("oauth_provider"),
|
|
|
|
|
strategy,
|
|
|
|
|
_help_block(prov.get("help")),
|
|
|
|
|
)
|
2026-07-17 23:20:34 +02:00
|
|
|
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
|
2026-08-04 19:59:52 +02:00
|
|
|
return CandidateConfig(
|
|
|
|
|
imap, smtp, "autoconfig", auth_strategy=_strategy_from_auth(imap.auth)
|
|
|
|
|
)
|
2026-07-17 23:20:34 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
# --- 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
|
2026-08-04 19:59:52 +02:00
|
|
|
return CandidateConfig(
|
|
|
|
|
candidate.imap,
|
|
|
|
|
candidate.smtp,
|
|
|
|
|
source,
|
|
|
|
|
candidate.oauth_provider,
|
|
|
|
|
candidate.auth_strategy,
|
|
|
|
|
candidate.help,
|
|
|
|
|
)
|
2026-07-17 23:20:34 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
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
|