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 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user