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,174 @@
|
||||
"""Account CRUD API (SPEC §9.1) driven through the ASGI app."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from collections.abc import AsyncIterator
|
||||
from dataclasses import dataclass
|
||||
|
||||
import httpx
|
||||
import pytest_asyncio
|
||||
from httpx import ASGITransport
|
||||
from sqlalchemy import delete, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.pool import NullPool
|
||||
|
||||
from app.api import accounts as accounts_api
|
||||
from app.config import get_settings
|
||||
from app.identity.passwords import hash_password
|
||||
from app.identity.sessions import create_session
|
||||
from app.main import create_app
|
||||
from app.models import Account, User
|
||||
|
||||
CSRF = "test-csrf-token"
|
||||
GOOD_CFG = {
|
||||
"imap": {"host": "imap.example.com", "port": 993, "tls": "ssl"},
|
||||
"smtp": {"host": "smtp.example.com", "port": 465, "tls": "ssl"},
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class Ctx:
|
||||
client: httpx.AsyncClient
|
||||
user_id: uuid.UUID
|
||||
session: AsyncSession
|
||||
|
||||
|
||||
def _hdr() -> dict[str, str]:
|
||||
return {"X-CSRF": CSRF}
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def ctx() -> AsyncIterator[Ctx]:
|
||||
engine = create_async_engine(get_settings().database_url, poolclass=NullPool)
|
||||
sm = async_sessionmaker(engine, expire_on_commit=False)
|
||||
session = sm()
|
||||
user = User(handle=f"acc{uuid.uuid4().hex[:8]}@test.local", password_hash=hash_password("pw"))
|
||||
session.add(user)
|
||||
await session.commit()
|
||||
token = await create_session(user.id, "pytest")
|
||||
transport = ASGITransport(app=create_app())
|
||||
client = httpx.AsyncClient(
|
||||
transport=transport,
|
||||
base_url="http://testserver",
|
||||
cookies={"session": token, "csrf": CSRF},
|
||||
)
|
||||
try:
|
||||
yield Ctx(client=client, user_id=user.id, session=session)
|
||||
finally:
|
||||
await client.aclose()
|
||||
await session.execute(delete(User).where(User.id == user.id))
|
||||
await session.commit()
|
||||
await session.close()
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
async def test_discover_gmail_preset(ctx: Ctx) -> None:
|
||||
resp = await ctx.client.post(
|
||||
"/api/accounts/discover", json={"email": "someone@gmail.com"}, headers=_hdr()
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["candidates"][0]["imap"]["host"] == "imap.gmail.com"
|
||||
assert data["candidates"][0]["source"] == "preset"
|
||||
assert data["steps"][0]["method"] == "preset"
|
||||
|
||||
|
||||
async def test_discover_requires_csrf(ctx: Ctx) -> None:
|
||||
resp = await ctx.client.post(
|
||||
"/api/accounts/discover", json={"email": "someone@gmail.com"}
|
||||
) # no X-CSRF header
|
||||
assert resp.status_code == 403
|
||||
assert resp.json()["error"]["code"] == "csrf_failed"
|
||||
|
||||
|
||||
async def test_discover_rate_limited(ctx: Ctx) -> None:
|
||||
last = None
|
||||
for _ in range(11):
|
||||
last = await ctx.client.post(
|
||||
"/api/accounts/discover", json={"email": "x@gmail.com"}, headers=_hdr()
|
||||
)
|
||||
assert last is not None and last.status_code == 429
|
||||
|
||||
|
||||
async def test_test_endpoint_maps_failure(ctx: Ctx, monkeypatch) -> None: # type: ignore[no-untyped-def]
|
||||
from app.accounts.testing import AccountTestError
|
||||
|
||||
async def fake_test(**_kw: object) -> None:
|
||||
raise AccountTestError("auth_failed", "bad password")
|
||||
|
||||
monkeypatch.setattr(accounts_api, "test_account", fake_test)
|
||||
resp = await ctx.client.post(
|
||||
"/api/accounts/test",
|
||||
json={**GOOD_CFG, "email": "me@example.com", "password": "x"},
|
||||
headers=_hdr(),
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
assert resp.json()["error"]["code"] == "auth_failed"
|
||||
|
||||
|
||||
async def test_create_account_enqueues_sync(ctx: Ctx, monkeypatch) -> None: # type: ignore[no-untyped-def]
|
||||
async def fake_test(**_kw: object) -> None:
|
||||
return None
|
||||
|
||||
enqueued: list[tuple[str, str]] = []
|
||||
|
||||
class FakePool:
|
||||
async def enqueue_job(self, name: str, arg: str) -> None:
|
||||
enqueued.append((name, arg))
|
||||
|
||||
async def fake_pool() -> FakePool:
|
||||
return FakePool()
|
||||
|
||||
monkeypatch.setattr(accounts_api, "test_account", fake_test)
|
||||
monkeypatch.setattr(accounts_api, "get_arq_pool", fake_pool)
|
||||
|
||||
resp = await ctx.client.post(
|
||||
"/api/accounts",
|
||||
json={
|
||||
**GOOD_CFG,
|
||||
"email": "me@example.com",
|
||||
"display_name": "Me",
|
||||
"color": "#ff0000",
|
||||
"password": "secret",
|
||||
},
|
||||
headers=_hdr(),
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
body = resp.json()
|
||||
assert body["email"] == "me@example.com"
|
||||
assert body["state"] == "backfilling"
|
||||
|
||||
account = await ctx.session.scalar(select(Account).where(Account.id == uuid.UUID(body["id"])))
|
||||
assert account is not None
|
||||
assert account.secret_ct # credentials stored encrypted, non-empty
|
||||
assert account.imap_config["host"] == "imap.example.com"
|
||||
assert enqueued == [("sync_bootstrap_account", body["id"])]
|
||||
|
||||
|
||||
async def test_delete_account(ctx: Ctx, monkeypatch) -> None: # type: ignore[no-untyped-def]
|
||||
async def fake_test(**_kw: object) -> None:
|
||||
return None
|
||||
|
||||
class FakePool:
|
||||
async def enqueue_job(self, name: str, arg: str) -> None:
|
||||
return None
|
||||
|
||||
async def fake_pool() -> FakePool:
|
||||
return FakePool()
|
||||
|
||||
monkeypatch.setattr(accounts_api, "test_account", fake_test)
|
||||
monkeypatch.setattr(accounts_api, "get_arq_pool", fake_pool)
|
||||
|
||||
created = await ctx.client.post(
|
||||
"/api/accounts",
|
||||
json={**GOOD_CFG, "email": "del@example.com", "password": "secret"},
|
||||
headers=_hdr(),
|
||||
)
|
||||
account_id = created.json()["id"]
|
||||
|
||||
resp = await ctx.client.request("DELETE", f"/api/accounts/{account_id}", headers=_hdr())
|
||||
assert resp.status_code == 200
|
||||
|
||||
gone = await ctx.session.scalar(select(Account).where(Account.id == uuid.UUID(account_id)))
|
||||
assert gone is None
|
||||
@@ -0,0 +1,185 @@
|
||||
"""Autodiscovery resolver (SPEC §6.2), with respx-mocked HTTP and a DNS stub."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
import respx
|
||||
|
||||
from app.accounts import discovery, ssrf
|
||||
from app.accounts.discovery import discover, domain_of, parse_autoconfig_xml
|
||||
from app.accounts.types import ServerConfig
|
||||
|
||||
AUTOCONFIG_XML = """<?xml version="1.0"?>
|
||||
<clientConfig version="1.1">
|
||||
<emailProvider id="example.com">
|
||||
<domain>example.com</domain>
|
||||
<incomingServer type="imap">
|
||||
<hostname>imap.example.com</hostname>
|
||||
<port>993</port>
|
||||
<socketType>SSL</socketType>
|
||||
<authentication>password-cleartext</authentication>
|
||||
<username>%EMAILADDRESS%</username>
|
||||
</incomingServer>
|
||||
<outgoingServer type="smtp">
|
||||
<hostname>smtp.example.com</hostname>
|
||||
<port>587</port>
|
||||
<socketType>STARTTLS</socketType>
|
||||
<authentication>password-cleartext</authentication>
|
||||
<username>%EMAILADDRESS%</username>
|
||||
</outgoingServer>
|
||||
</emailProvider>
|
||||
</clientConfig>
|
||||
"""
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _skip_ssrf(monkeypatch) -> None: # type: ignore[no-untyped-def]
|
||||
"""Bypass the real DNS/public-IP check so respx can intercept the fetch."""
|
||||
|
||||
async def ok(_host: str) -> None:
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(ssrf, "assert_host_is_public", ok)
|
||||
|
||||
|
||||
# --- XML parsing -------------------------------------------------------------
|
||||
|
||||
|
||||
def test_parse_autoconfig_xml() -> None:
|
||||
cand = parse_autoconfig_xml(AUTOCONFIG_XML)
|
||||
assert cand is not None
|
||||
assert cand.imap == ServerConfig("imap.example.com", 993, "ssl", "password")
|
||||
assert cand.smtp == ServerConfig("smtp.example.com", 587, "starttls", "password")
|
||||
|
||||
|
||||
def test_parse_autoconfig_xml_rejects_garbage() -> None:
|
||||
assert parse_autoconfig_xml("not xml <<<") is None
|
||||
assert parse_autoconfig_xml("<clientConfig></clientConfig>") is None
|
||||
|
||||
|
||||
def test_domain_of() -> None:
|
||||
assert domain_of("Alice@Example.COM") == "example.com"
|
||||
|
||||
|
||||
# --- preset step -------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_preset_hit_short_circuits() -> None:
|
||||
result = await discover("someone@gmail.com")
|
||||
assert len(result.candidates) == 1
|
||||
c = result.candidates[0]
|
||||
assert c.source == "preset"
|
||||
assert c.imap.host == "imap.gmail.com"
|
||||
assert c.oauth_provider == "google"
|
||||
assert result.steps[0].method == "preset" and result.steps[0].status == "found"
|
||||
|
||||
|
||||
async def test_preset_polish_provider() -> None:
|
||||
result = await discover("jan@wp.pl")
|
||||
assert result.candidates[0].imap.host == "imap.wp.pl"
|
||||
assert result.candidates[0].source == "preset"
|
||||
|
||||
|
||||
# --- autoconfig / ispdb via HTTP --------------------------------------------
|
||||
|
||||
|
||||
@respx.mock
|
||||
async def test_autoconfig_found() -> None:
|
||||
respx.get(url__regex=r"https://autoconfig\.example\.com/mail/.*").mock(
|
||||
return_value=httpx.Response(200, text=AUTOCONFIG_XML)
|
||||
)
|
||||
result = await discover("user@example.com")
|
||||
assert result.candidates[0].source == "autoconfig"
|
||||
assert result.candidates[0].imap.host == "imap.example.com"
|
||||
# preset step was recorded as not_found before autoconfig succeeded
|
||||
assert result.steps[0].status == "not_found"
|
||||
|
||||
|
||||
@respx.mock
|
||||
async def test_ispdb_found_after_autoconfig_misses() -> None:
|
||||
respx.get(url__regex=r"https://autoconfig\.example\.com/.*").mock(
|
||||
return_value=httpx.Response(404)
|
||||
)
|
||||
respx.get(url__regex=r"https://example\.com/\.well-known/.*").mock(
|
||||
return_value=httpx.Response(404)
|
||||
)
|
||||
respx.get(url__regex=r"https://autoconfig\.thunderbird\.net/v1\.1/example\.com").mock(
|
||||
return_value=httpx.Response(200, text=AUTOCONFIG_XML)
|
||||
)
|
||||
result = await discover("user@example.com")
|
||||
assert result.candidates[0].source == "ispdb"
|
||||
methods = {s.method for s in result.steps}
|
||||
assert {"preset", "autoconfig", "ispdb"} <= methods
|
||||
|
||||
|
||||
# --- SRV via DNS stub --------------------------------------------------------
|
||||
|
||||
|
||||
class _FakeSrv:
|
||||
def __init__(self, priority: int, weight: int, port: int, target: str) -> None:
|
||||
self.priority = priority
|
||||
self.weight = weight
|
||||
self.port = port
|
||||
self.target = target
|
||||
|
||||
|
||||
@respx.mock
|
||||
async def test_srv_found(monkeypatch) -> None: # type: ignore[no-untyped-def]
|
||||
# autoconfig + ispdb all miss
|
||||
respx.get(url__regex=r"https://.*").mock(return_value=httpx.Response(404))
|
||||
|
||||
async def fake_srv_lookup(name: str, tls: str) -> ServerConfig | None:
|
||||
if name.startswith("_imaps._tcp"):
|
||||
return ServerConfig("imap.example.com", 993, "ssl")
|
||||
if name.startswith("_submissions._tcp"):
|
||||
return ServerConfig("smtp.example.com", 465, "ssl")
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(discovery, "_srv_lookup", fake_srv_lookup)
|
||||
result = await discover("user@example.com")
|
||||
assert result.candidates[0].source == "srv"
|
||||
assert result.candidates[0].imap.port == 993
|
||||
|
||||
|
||||
# --- probe fallback ----------------------------------------------------------
|
||||
|
||||
|
||||
@respx.mock
|
||||
async def test_probe_found_when_all_else_misses(monkeypatch) -> None: # type: ignore[no-untyped-def]
|
||||
respx.get(url__regex=r"https://.*").mock(return_value=httpx.Response(404))
|
||||
|
||||
async def no_srv(_name: str, _tls: str) -> ServerConfig | None:
|
||||
return None
|
||||
|
||||
async def fake_probe(host: str, port: int, tls: str, kind: str) -> ServerConfig | None:
|
||||
# only imap.example.com:993 and smtp.example.com:465 "answer"
|
||||
if (host, port) in {("imap.example.com", 993), ("smtp.example.com", 465)}:
|
||||
return ServerConfig(host, port, tls)
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(discovery, "_srv_lookup", no_srv)
|
||||
monkeypatch.setattr(discovery, "_probe_one", fake_probe)
|
||||
result = await discover("user@example.com")
|
||||
assert result.candidates[0].source == "probe"
|
||||
assert result.candidates[0].imap.host == "imap.example.com"
|
||||
assert result.candidates[0].smtp.host == "smtp.example.com"
|
||||
|
||||
|
||||
@respx.mock
|
||||
async def test_all_steps_fail_records_every_step(monkeypatch) -> None: # type: ignore[no-untyped-def]
|
||||
respx.get(url__regex=r"https://.*").mock(return_value=httpx.Response(404))
|
||||
|
||||
async def no_srv(_name: str, _tls: str) -> ServerConfig | None:
|
||||
return None
|
||||
|
||||
async def no_probe(_h: str, _p: int, _t: str, _k: str) -> ServerConfig | None:
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(discovery, "_srv_lookup", no_srv)
|
||||
monkeypatch.setattr(discovery, "_probe_one", no_probe)
|
||||
result = await discover("user@nowhere.invalid")
|
||||
assert result.candidates == []
|
||||
methods = [s.method for s in result.steps]
|
||||
assert methods == ["preset", "autoconfig", "ispdb", "srv", "probe"]
|
||||
assert all(s.status in ("not_found", "error") for s in result.steps)
|
||||
@@ -0,0 +1,55 @@
|
||||
"""SSRF guard for autodiscovery (SPEC §10.6)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from app.accounts import ssrf
|
||||
from app.accounts.ssrf import SsrfError, _is_public_ip, assert_host_is_public, guarded_get
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"ip,public",
|
||||
[
|
||||
("8.8.8.8", True),
|
||||
("1.1.1.1", True),
|
||||
("2606:4700:4700::1111", True),
|
||||
("127.0.0.1", False),
|
||||
("10.0.0.1", False),
|
||||
("192.168.1.1", False),
|
||||
("172.16.5.4", False),
|
||||
("169.254.1.1", False), # link-local
|
||||
("::1", False),
|
||||
("fe80::1", False), # link-local v6
|
||||
("0.0.0.0", False),
|
||||
],
|
||||
)
|
||||
def test_is_public_ip(ip: str, public: bool) -> None:
|
||||
assert _is_public_ip(ip) is public
|
||||
|
||||
|
||||
async def test_assert_host_public_accepts_public(monkeypatch) -> None: # type: ignore[no-untyped-def]
|
||||
async def fake_resolve(_host: str) -> list[str]:
|
||||
return ["8.8.8.8"]
|
||||
|
||||
monkeypatch.setattr(ssrf, "_resolve_all", fake_resolve)
|
||||
await assert_host_is_public("mail.example.com") # no raise
|
||||
|
||||
|
||||
async def test_assert_host_rejects_private(monkeypatch) -> None: # type: ignore[no-untyped-def]
|
||||
async def fake_resolve(_host: str) -> list[str]:
|
||||
return ["8.8.8.8", "127.0.0.1"] # one private address is enough to reject
|
||||
|
||||
monkeypatch.setattr(ssrf, "_resolve_all", fake_resolve)
|
||||
with pytest.raises(SsrfError):
|
||||
await assert_host_is_public("evil.example.com")
|
||||
|
||||
|
||||
async def test_guarded_get_rejects_non_https() -> None:
|
||||
with pytest.raises(SsrfError):
|
||||
await guarded_get("http://example.com/config.xml")
|
||||
|
||||
|
||||
async def test_guarded_get_rejects_missing_host() -> None:
|
||||
with pytest.raises(SsrfError):
|
||||
await guarded_get("https:///config.xml")
|
||||
Reference in New Issue
Block a user