Files
webmail-client/backend/tests/accounts/test_account_api.py
T
lukasandClaude Opus 4.8 4c200ab94a Gmail label sync + no-dup backfill, live label/UI updates, account-delete cleanup, Sync-SPAM toggle, sidebar logo
Gmail sync (SPEC: Gmail sync):
- Provider fetches X-GM-LABELS when X-GM-EXT-1 is advertised; parses custom labels
  (system labels dropped, mUTF-7 decoded) onto RemoteMessage.
- Engine syncs only system folders + All Mail for Gmail, skips every label folder,
  and dedupes the All Mail pass by Message-ID — kills the INBOX/All-Mail duplication.
- Custom labels ride the message.new event; the labels plugin subscribes and maps
  them to chips as "<name> [Gmail]" (per-message + thread aggregate + known set),
  so labels fill in gradually with the sync window. Non-ASCII labels decode right.

Labels self-healing (message-driven reconcile):
- Per-message keys are the source of truth; t: aggregates and _labels are derived.
  Reconcile drops labels of deleted messages, regroups survivors by their current
  thread (fixing JWZ thread merges), rewrites changed t: keys, and rebuilds _labels
  from live messages — keeping user labels, dropping orphaned Gmail ones.
- New SDK reads: sdk.threads.existing / sdk.messages.threads_of. Sidebar refreshes
  live via a plugin.labels.changed WS event on new labels.

Account deletion: prune threads left empty after the cascade (they were user-scoped,
so only messages cascaded) + de-list the account from surviving cross-account threads.

Sync SPAM: opt-in per-account sync_spam (migration 0015, default off) — the engine
skips the Junk folder in backfill and poll unless enabled; wizard checkbox added.

Robustness / UX:
- WebSocket auto-reconnects with backoff and resyncs on (re)connect, and account
  add/delete invalidates threads/labels — no more hard refresh for live updates.
- Backfill banner + sidebar ring also require done < total, so a completed account
  can't leave a stuck "X of X".
- Scrub lone UTF-16 surrogates from parsed headers/bodies so bad messages no longer
  crash the backfill on Postgres text/JSONB.
- Ultimo signet logo in the Mail/Calendar/Contacts sidebar header (and at the top of
  the collapsed rail), via the shared plugin-ui.css + MailLayout.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U72FZiF1W1pb8XxyWBTREA
2026-08-05 18:04:49 +02:00

327 lines
11 KiB
Python

"""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 import queue as app_queue
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, Thread, 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, **kwargs: object) -> None:
enqueued.append((name, arg))
async def fake_pool() -> FakePool:
return FakePool()
monkeypatch.setattr(accounts_api, "test_account", fake_test)
monkeypatch.setattr(app_queue, "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 _make_account(ctx: Ctx, monkeypatch) -> str: # 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, **kwargs: object) -> None:
return None
async def fake_pool() -> FakePool:
return FakePool()
monkeypatch.setattr(accounts_api, "test_account", fake_test)
monkeypatch.setattr(app_queue, "get_arq_pool", fake_pool)
resp = await ctx.client.post(
"/api/accounts",
json={**GOOD_CFG, "email": "me@example.com", "display_name": "Me", "password": "secret"},
headers=_hdr(),
)
assert resp.status_code == 200, resp.text
return str(resp.json()["id"])
async def test_sync_all_enqueues_refresh_per_account(ctx: Ctx, monkeypatch) -> None: # type: ignore[no-untyped-def]
aid = await _make_account(ctx, monkeypatch)
enqueued: list[tuple[str, str]] = []
class FakePool:
async def enqueue_job(self, name: str, arg: str, **kwargs: object) -> None:
enqueued.append((name, arg))
async def fake_pool() -> FakePool:
return FakePool()
monkeypatch.setattr(app_queue, "get_arq_pool", fake_pool)
resp = await ctx.client.post("/api/accounts/sync", headers=_hdr())
assert resp.status_code == 200
assert enqueued == [("refresh_account_job", aid)]
async def test_update_display_and_color_no_resync(ctx: Ctx, monkeypatch) -> None: # type: ignore[no-untyped-def]
aid = await _make_account(ctx, monkeypatch)
resp = await ctx.client.patch(
f"/api/accounts/{aid}",
json={"display_name": "New Name", "color": "#00ff00"},
headers=_hdr(),
)
assert resp.status_code == 200, resp.text
body = resp.json()
assert body["display_name"] == "New Name" and body["color"] == "#00ff00"
async def test_update_signature(ctx: Ctx, monkeypatch) -> None: # type: ignore[no-untyped-def]
aid = await _make_account(ctx, monkeypatch)
resp = await ctx.client.patch(
f"/api/accounts/{aid}", json={"signature": "<p>Best,<br>Me</p>"}, headers=_hdr()
)
assert resp.status_code == 200
assert resp.json()["signature"] == "<p>Best,<br>Me</p>"
# The accounts list also carries the signature (used by the composer).
listing = await ctx.client.get("/api/accounts")
acct = next(a for a in listing.json() if a["id"] == aid)
assert acct["signature"] == "<p>Best,<br>Me</p>"
async def test_update_password_reencrypts_and_resyncs(ctx: Ctx, monkeypatch) -> None: # type: ignore[no-untyped-def]
aid = await _make_account(ctx, monkeypatch)
acct = await ctx.session.scalar(select(Account).where(Account.id == uuid.UUID(aid)))
assert acct is not None
await ctx.session.refresh(acct)
old_secret = bytes(acct.secret_ct)
enqueued: list[tuple[str, str]] = []
class FakePool:
async def enqueue_job(self, name: str, arg: str, **kwargs: object) -> None:
enqueued.append((name, arg))
async def fake_pool() -> FakePool:
return FakePool()
async def fake_test(**_kw: object) -> None:
return None
monkeypatch.setattr(accounts_api, "test_account", fake_test)
monkeypatch.setattr(app_queue, "get_arq_pool", fake_pool)
resp = await ctx.client.patch(
f"/api/accounts/{aid}", json={"password": "newsecret"}, headers=_hdr()
)
assert resp.status_code == 200, resp.text
assert resp.json()["state"] == "backfilling"
await ctx.session.refresh(acct)
assert bytes(acct.secret_ct) != old_secret # re-encrypted
assert enqueued == [("sync_bootstrap_account", aid)]
async def test_update_server_change_requires_password(ctx: Ctx, monkeypatch) -> None: # type: ignore[no-untyped-def]
aid = await _make_account(ctx, monkeypatch)
resp = await ctx.client.patch(
f"/api/accounts/{aid}",
json={"imap": {"host": "imap2.example.com", "port": 993, "tls": "ssl"}},
headers=_hdr(),
)
assert resp.status_code == 400
assert resp.json()["error"]["code"] == "password_required"
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, **kwargs: object) -> None:
return None
async def fake_pool() -> FakePool:
return FakePool()
monkeypatch.setattr(accounts_api, "test_account", fake_test)
monkeypatch.setattr(app_queue, "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
async def test_delete_account_prunes_orphaned_threads(ctx: Ctx, monkeypatch) -> None: # type: ignore[no-untyped-def]
"""Deleting an account removes threads left with no messages (else the unified
list / label views show empty rows that can't be opened)."""
import datetime as dt
async def fake_test(**_kw: object) -> None:
return None
class FakePool:
async def enqueue_job(self, name: str, arg: str, **kwargs: object) -> None:
return None
async def fake_pool() -> FakePool:
return FakePool()
monkeypatch.setattr(accounts_api, "test_account", fake_test)
monkeypatch.setattr(app_queue, "get_arq_pool", fake_pool)
created = await ctx.client.post(
"/api/accounts",
json={**GOOD_CFG, "email": "orphan@example.com", "password": "secret"},
headers=_hdr(),
)
account_id = uuid.UUID(created.json()["id"])
# An empty thread tagged to this account (message-less, as after a cascade delete).
thread_id = uuid.uuid4()
ctx.session.add(
Thread(
id=thread_id,
user_id=ctx.user_id,
subject_norm="orphan",
last_message_at=dt.datetime.now(dt.UTC),
account_ids=[account_id],
)
)
await ctx.session.commit()
ctx.session.expunge_all() # force the assertion query to hit the DB
resp = await ctx.client.request("DELETE", f"/api/accounts/{account_id}", headers=_hdr())
assert resp.status_code == 200
assert await ctx.session.scalar(select(Thread).where(Thread.id == thread_id)) is None