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:
2026-07-17 23:20:34 +02:00
co-authored by Claude Opus 4.8
commit a5ec0d21cb
174 changed files with 22239 additions and 0 deletions
+174
View File
@@ -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
+185
View File
@@ -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)
+55
View File
@@ -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")
+283
View File
@@ -0,0 +1,283 @@
"""REST API integration tests (SPEC §9.1) driving the ASGI app with seeded data."""
from __future__ import annotations
import uuid
from collections.abc import AsyncIterator
from dataclasses import dataclass, field
from datetime import UTC, datetime
from email.message import EmailMessage
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.config import get_settings
from app.identity.passwords import hash_password
from app.identity.sessions import create_session
from app.indexer.indexer import persist_message
from app.main import create_app
from app.models import Account, Mailbox, Message, User
from app.providers.base import RemoteMessage
from app.search.pg import PgSearchBackend
@dataclass
class ApiCtx:
client: httpx.AsyncClient
user_id: uuid.UUID
account_id: uuid.UUID | None = None
archive_id: uuid.UUID | None = None
session: AsyncSession | None = None
thread_ids: list[uuid.UUID] = field(default_factory=list)
message_ids: list[uuid.UUID] = field(default_factory=list)
def _raw(
subject: str, body: str, *, mid: str, refs: str | None = None, html: str | None = None
) -> bytes:
m = EmailMessage()
m["Subject"] = subject
m["From"] = "Alice <alice@example.com>"
m["To"] = "me@example.com"
m["Message-ID"] = mid
m["Date"] = "Mon, 09 Mar 2026 08:36:37 +0100"
if refs:
m["References"] = refs
m["In-Reply-To"] = refs
m.set_content(body)
if html:
m.add_alternative(html, subtype="html")
return m.as_bytes()
@pytest_asyncio.fixture
async def api() -> AsyncIterator[ApiCtx]:
engine = create_async_engine(get_settings().database_url, poolclass=NullPool)
sm = async_sessionmaker(engine, expire_on_commit=False)
session = sm()
user = User(handle=f"api{uuid.uuid4().hex[:8]}@test.local", password_hash=hash_password("pw"))
session.add(user)
await session.flush()
account = Account(
user_id=user.id,
email="me@example.com",
imap_config={},
smtp_config={},
secret_ct=b"",
discovery_src="manual",
)
session.add(account)
await session.flush()
mailbox = Mailbox(account_id=account.id, user_id=user.id, path="INBOX", special_use="inbox")
archive = Mailbox(account_id=account.id, user_id=user.id, path="Archive", special_use="archive")
session.add_all([mailbox, archive])
await session.commit()
backend = PgSearchBackend()
ctx = ApiCtx(
client=None, # type: ignore[arg-type]
user_id=user.id,
account_id=account.id,
archive_id=archive.id,
session=session,
)
specs = [
(_raw("Project kickoff", "kickoff meeting notes and revenue plan", mid="<a@x.com>"), 1),
(
_raw(
"Re: Project kickoff",
"sounds good, revenue looks strong",
mid="<b@x.com>",
refs="<a@x.com>",
),
2,
),
(
_raw(
"Lunch",
"grab lunch tomorrow?",
mid="<c@x.com>",
html="<p>grab <b>lunch</b> <script>x()</script>tomorrow?</p>",
),
3,
),
]
for raw, u in specs:
remote = RemoteMessage(uid=u, raw=raw, flags=frozenset(), internal_date=datetime.now(UTC))
result = await persist_message(
session,
user_id=user.id,
account_id=account.id,
mailbox_id=mailbox.id,
mailbox_special_use="inbox",
remote=remote,
)
await session.commit()
assert result is not None
ctx.message_ids.append(result.message.id)
if result.message.thread_id not in ctx.thread_ids:
ctx.thread_ids.append(result.message.thread_id)
await backend.index(result.search_doc)
token = await create_session(user.id, "pytest")
transport = ASGITransport(app=create_app())
ctx.client = httpx.AsyncClient(
transport=transport, base_url="http://testserver", cookies={"session": token}
)
try:
yield ctx
finally:
await ctx.client.aclose()
await session.execute(delete(User).where(User.id == user.id))
await session.commit()
await session.close()
await engine.dispose()
async def test_requires_auth() -> None:
transport = ASGITransport(app=create_app())
async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as client:
resp = await client.get("/api/threads")
assert resp.status_code == 401
assert resp.json()["error"]["code"] == "not_authenticated"
async def test_list_threads(api: ApiCtx) -> None:
resp = await api.client.get("/api/threads")
assert resp.status_code == 200
data = resp.json()
# 2 threads: the kickoff reply-chain + lunch
assert len(data["threads"]) == 2
# newest first
subjects = [t["subject"] for t in data["threads"]]
assert "Lunch" in subjects
kickoff = next(t for t in data["threads"] if "kickoff" in t["subject"].lower())
assert kickoff["message_count"] == 2
async def test_folder_view(api: ApiCtx) -> None:
# all seeded mail is in an inbox mailbox
inbox = await api.client.get("/api/threads", params={"view": "folder:inbox"})
assert inbox.status_code == 200
assert len(inbox.json()["threads"]) == 2 # kickoff chain + lunch
# no messages are in a sent folder
sent = await api.client.get("/api/threads", params={"view": "folder:sent"})
assert sent.status_code == 200
assert sent.json()["threads"] == []
async def test_account_view_shows_only_that_accounts_inbox(api: ApiCtx) -> None:
assert api.session is not None
# The account view starts equal to the inbox (all seeded mail is in inbox).
resp = await api.client.get("/api/threads", params={"view": f"account:{api.account_id}"})
assert resp.status_code == 200
assert len(resp.json()["threads"]) == 2
# Move the whole "Lunch" thread out of the inbox into Archive.
lunch_id = next(t["id"] for t in resp.json()["threads"] if t["subject"] == "Lunch")
msgs = list(
await api.session.scalars(select(Message).where(Message.thread_id == uuid.UUID(lunch_id)))
)
for m in msgs:
m.mailbox_id = api.archive_id
await api.session.commit()
# It must drop out of both the unified inbox and the account view...
inbox = await api.client.get("/api/threads", params={"view": "folder:inbox"})
assert [t["subject"] for t in inbox.json()["threads"]] == ["Project kickoff"]
account = await api.client.get("/api/threads", params={"view": f"account:{api.account_id}"})
assert [t["subject"] for t in account.json()["threads"]] == ["Project kickoff"]
# ...but still be reachable under the Archive folder.
archive = await api.client.get("/api/threads", params={"view": "folder:archive"})
assert [t["subject"] for t in archive.json()["threads"]] == ["Lunch"]
async def test_attachments_list_and_zip(api: ApiCtx) -> None:
import io
import zipfile
from app.indexer import blobstore
from app.models import Attachment
assert api.session is not None
mid = api.message_ids[-1] # the "Lunch" message
sha = blobstore.store(api.user_id, b"hello attachment")
api.session.add(
Attachment(
message_id=mid,
user_id=api.user_id,
filename="notes.txt",
mime_type="text/plain",
size_bytes=16,
content_id=None,
blob_sha256=sha,
)
)
await api.session.commit()
listing = await api.client.get(f"/api/messages/{mid}/attachments")
assert listing.status_code == 200
body = listing.json()
assert [a["filename"] for a in body] == ["notes.txt"]
assert body[0]["size_bytes"] == 16
zipped = await api.client.get(f"/api/messages/{mid}/attachments.zip")
assert zipped.status_code == 200
assert zipped.headers["content-type"] == "application/zip"
assert "Attachments.zip" in zipped.headers["content-disposition"]
archive = zipfile.ZipFile(io.BytesIO(zipped.content))
assert archive.namelist() == ["notes.txt"]
assert archive.read("notes.txt") == b"hello attachment"
async def test_thread_detail_messages_ascending(api: ApiCtx) -> None:
# find the kickoff thread (2 messages)
resp = await api.client.get("/api/threads")
kickoff = next(t for t in resp.json()["threads"] if t["message_count"] == 2)
detail = await api.client.get(f"/api/threads/{kickoff['id']}")
assert detail.status_code == 200
body = detail.json()
assert body["order"] == "asc"
dates = [m["date"] for m in body["messages"]]
assert dates == sorted(dates) # ascending
async def test_message_html_is_sanitized(api: ApiCtx) -> None:
# the lunch message has HTML with a script
resp = await api.client.get("/api/threads")
lunch = next(t for t in resp.json()["threads"] if t["subject"] == "Lunch")
detail = (await api.client.get(f"/api/threads/{lunch['id']}")).json()
mid = detail["messages"][0]["id"]
html = await api.client.get(f"/api/messages/{mid}/html")
assert html.status_code == 200
assert "<script" not in html.text.lower()
assert "lunch" in html.text.lower()
async def test_search_grouped_by_thread(api: ApiCtx) -> None:
resp = await api.client.get("/api/search", params={"q": "revenue"})
assert resp.status_code == 200
data = resp.json()
assert len(data["threads"]) == 1 # both kickoff messages -> one thread
assert data["threads"][0]["thread"]["message_count"] == 2
async def test_accounts_list(api: ApiCtx) -> None:
resp = await api.client.get("/api/accounts")
assert resp.status_code == 200
assert resp.json()[0]["email"] == "me@example.com"
async def test_thread_pagination_cursor(api: ApiCtx) -> None:
first = await api.client.get("/api/threads", params={"limit": 1})
body = first.json()
assert len(body["threads"]) == 1
assert body["cursor"] is not None
second = await api.client.get("/api/threads", params={"limit": 1, "cursor": body["cursor"]})
body2 = second.json()
assert len(body2["threads"]) == 1
assert body2["threads"][0]["id"] != body["threads"][0]["id"]
+65
View File
@@ -0,0 +1,65 @@
"""Shared test fixtures and environment defaults.
Ensures the SPEC §14 env vars exist even when tests run outside the conda
activate hook (e.g. CI), so ``app.config`` imports cleanly.
"""
from __future__ import annotations
import base64
import os
os.environ.setdefault("APP_DOMAIN", "test.webmail.local")
os.environ.setdefault(
"DATABASE_URL", "postgresql+asyncpg://webmail:webmail_dev@127.0.0.1:5433/webmail_dev"
)
# Force a dedicated Redis DB for tests (index 15) so the suite is isolated from the
# dev app's Redis (DB 0) and can be flushed between tests without touching real data.
os.environ["REDIS_URL"] = "redis://127.0.0.1:6380/15"
os.environ.setdefault("MASTER_KEY", base64.b64encode(b"\x00" * 32).decode())
os.environ.setdefault("BLOB_ROOT", "./var/blobs-test")
from collections.abc import AsyncIterator # noqa: E402
import pytest_asyncio # noqa: E402
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine # noqa: E402
from sqlalchemy.pool import NullPool # noqa: E402
from app.config import get_settings # noqa: E402
@pytest_asyncio.fixture(autouse=True)
async def _reset_global_engine() -> AsyncIterator[None]:
"""Dispose process-wide async resources after each test.
Each test runs on its own event loop; disposing here ensures the next test
(and any code using the global engine/redis, e.g. the search backend) rebinds
to its own loop rather than reusing a closed one.
"""
from app.redis_client import get_redis
# Flush the dedicated test Redis DB so rate-limit / session / token state does
# not leak between tests (each test starts from a clean slate).
await get_redis().flushdb()
yield
from app.db import dispose_engine
from app.redis_client import close_redis
await dispose_engine()
await close_redis()
@pytest_asyncio.fixture
async def db_session() -> AsyncIterator[AsyncSession]:
"""Rollback-per-test session (nothing persists to the dev database)."""
engine = create_async_engine(get_settings().database_url, poolclass=NullPool)
conn = await engine.connect()
trans = await conn.begin()
session = AsyncSession(bind=conn, expire_on_commit=False)
try:
yield session
finally:
await session.close()
await trans.rollback()
await conn.close()
await engine.dispose()
+67
View File
@@ -0,0 +1,67 @@
"""Handle validation rules (SPEC §4)."""
from __future__ import annotations
import pytest
from app.config import get_settings
from app.identity.handles import InvalidHandle, full_handle, validate_localpart
from app.identity.reserved import is_reserved
from app.identity.service import resolve_handle
_DOMAIN = get_settings().app_domain
@pytest.mark.parametrize(
"raw,expected",
[
("alice", "alice"),
("Alice", "alice"), # case-insensitive
(" bob ", "bob"), # trimmed
("a.b_c-d", "a.b_c-d"),
("user123", "user123"),
("abc", "abc"), # min length
("a" * 30, "a" * 30), # max length
],
)
def test_valid_localparts(raw: str, expected: str) -> None:
assert validate_localpart(raw) == expected
@pytest.mark.parametrize(
"raw,code",
[
("ab", "length"), # too short
("a" * 31, "length"), # too long
(".alice", "dot_edge"),
("alice.", "dot_edge"),
("al..ice", "double_dot"),
("al ice", "charset"), # space
("aliçe", "charset"), # non-ascii
("al@ice", "charset"), # '@'
("UPPER!", "charset"),
("postmaster", "reserved"),
("admin", "reserved"),
("root", "reserved"),
("NoReply", "reserved"), # reserved, case-insensitive
],
)
def test_invalid_localparts(raw: str, code: str) -> None:
with pytest.raises(InvalidHandle) as exc:
validate_localpart(raw)
assert exc.value.code == code
def test_full_handle_appends_domain() -> None:
assert full_handle("alice") == f"alice@{_DOMAIN}"
def test_reserved_is_case_insensitive() -> None:
assert is_reserved("ADMIN")
assert is_reserved("admin")
assert not is_reserved("alice")
def test_resolve_handle_accepts_localpart_or_full() -> None:
assert resolve_handle("Alice") == f"alice@{_DOMAIN}"
assert resolve_handle("alice@other.example") == "alice@other.example"
+188
View File
@@ -0,0 +1,188 @@
"""M1 identity: 2FA (TOTP), session list/revoke, password reset (SPEC §6.1/§9.1)."""
from __future__ import annotations
import uuid
from collections.abc import AsyncIterator
from dataclasses import dataclass
import httpx
import pyotp
import pytest_asyncio
from httpx import ASGITransport
from sqlalchemy import delete
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.pool import NullPool
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 User
from app.redis_client import get_redis
CSRF = "csrf-token"
PASSWORD = "hunter2pass"
@dataclass
class Ctx:
app: object
client: httpx.AsyncClient
user_id: uuid.UUID
handle: str
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()
handle = f"id{uuid.uuid4().hex[:8]}@test.local"
user = User(handle=handle, password_hash=hash_password(PASSWORD), recovery_email="r@x.com")
session.add(user)
await session.commit()
token = await create_session(user.id, "pytest")
app = create_app()
client = httpx.AsyncClient(
transport=ASGITransport(app=app),
base_url="http://testserver",
cookies={"session": token, "csrf": CSRF},
)
try:
yield Ctx(app=app, client=client, user_id=user.id, handle=handle, 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()
def _fresh_client(app: object) -> httpx.AsyncClient:
return httpx.AsyncClient(transport=ASGITransport(app=app), base_url="http://testserver")
async def test_totp_setup_enable_and_login(ctx: Ctx) -> None:
setup = await ctx.client.post("/api/auth/totp/setup", headers=_hdr())
assert setup.status_code == 200
secret = setup.json()["secret"]
assert setup.json()["provisioning_uri"].startswith("otpauth://")
# wrong code rejected
bad = await ctx.client.post("/api/auth/totp/enable", json={"code": "000000"}, headers=_hdr())
assert bad.status_code == 400
code = pyotp.TOTP(secret).now()
ok = await ctx.client.post("/api/auth/totp/enable", json={"code": code}, headers=_hdr())
assert ok.status_code == 200
# login now requires the 2FA code
async with _fresh_client(ctx.app) as anon:
no_totp = await anon.post(
"/api/auth/login", json={"handle": ctx.handle, "password": PASSWORD}
)
assert no_totp.status_code == 401
assert no_totp.json()["error"]["code"] == "totp_required"
with_totp = await anon.post(
"/api/auth/login",
json={"handle": ctx.handle, "password": PASSWORD, "totp": pyotp.TOTP(secret).now()},
)
assert with_totp.status_code == 200
async def test_totp_disable(ctx: Ctx) -> None:
# enable first
secret = (await ctx.client.post("/api/auth/totp/setup", headers=_hdr())).json()["secret"]
await ctx.client.post(
"/api/auth/totp/enable", json={"code": pyotp.TOTP(secret).now()}, headers=_hdr()
)
assert (await ctx.client.get("/api/auth/me")).json()["totp_enabled"] is True
# wrong password is rejected
bad = await ctx.client.post("/api/auth/totp/disable", json={"password": "nope"}, headers=_hdr())
assert bad.status_code == 401
assert (await ctx.client.get("/api/auth/me")).json()["totp_enabled"] is True
# correct password disables it
ok = await ctx.client.post(
"/api/auth/totp/disable", json={"password": PASSWORD}, headers=_hdr()
)
assert ok.status_code == 200
assert (await ctx.client.get("/api/auth/me")).json()["totp_enabled"] is False
# login now works without a code
async with _fresh_client(ctx.app) as anon:
resp = await anon.post("/api/auth/login", json={"handle": ctx.handle, "password": PASSWORD})
assert resp.status_code == 200
async def test_sessions_list_and_revoke(ctx: Ctx) -> None:
# create a second session for the same user (a different device)
other_token = await create_session(ctx.user_id, "other-device")
listing = await ctx.client.get("/api/auth/sessions")
assert listing.status_code == 200
rows = listing.json()
assert len(rows) == 2
current = [r for r in rows if r["current"]]
assert len(current) == 1
other_sid = next(r["sid"] for r in rows if not r["current"])
revoked = await ctx.client.request("DELETE", f"/api/auth/sessions/{other_sid}", headers=_hdr())
assert revoked.status_code == 200
# the revoked token no longer resolves
async with _fresh_client(ctx.app) as anon:
anon.cookies.set("session", other_token)
me = await anon.get("/api/auth/me")
assert me.status_code == 401
async def test_password_reset_flow(ctx: Ctx) -> None:
# request a reset; token is stored in redis (not returned)
req = await ctx.client.post("/api/auth/password/reset-request", json={"handle": ctx.handle})
assert req.status_code == 200
redis = get_redis()
keys = await redis.keys("pwreset:*")
# Pick the token bound to THIS user (redis DB may hold others from prior runs).
token = None
for key in keys:
if await redis.get(key) == str(ctx.user_id):
token = key.split(":", 1)[1]
break
assert token is not None, "a reset token should have been issued for this user"
new_password = "brandNewPass9"
reset = await ctx.client.post(
"/api/auth/password/reset", json={"token": token, "password": new_password}
)
assert reset.status_code == 200
# token is single-use
again = await ctx.client.post(
"/api/auth/password/reset", json={"token": token, "password": new_password}
)
assert again.status_code == 400
# new password works, old one does not
async with _fresh_client(ctx.app) as anon:
good = await anon.post(
"/api/auth/login", json={"handle": ctx.handle, "password": new_password}
)
assert good.status_code == 200
bad = await anon.post("/api/auth/login", json={"handle": ctx.handle, "password": PASSWORD})
assert bad.status_code == 401
async def test_reset_request_unknown_handle_still_ok(ctx: Ctx) -> None:
# must not leak whether a handle exists
resp = await ctx.client.post("/api/auth/password/reset-request", json={"handle": "nobody-here"})
assert resp.status_code == 200
+43
View File
@@ -0,0 +1,43 @@
"""Content-addressed blob store (SPEC §4, §6.4)."""
from __future__ import annotations
import uuid
import pytest
from app.indexer import blobstore
@pytest.fixture(autouse=True)
def _blob_root(tmp_path, monkeypatch) -> None: # type: ignore[no-untyped-def]
from app.config import get_settings
settings = get_settings()
monkeypatch.setattr(settings, "blob_root", str(tmp_path))
def test_store_and_load_roundtrip() -> None:
uid = uuid.uuid4()
data = b"hello world"
sha = blobstore.store(uid, data)
assert blobstore.load(uid, sha) == data
assert blobstore.exists(uid, sha)
def test_store_is_content_addressed_and_dedupes() -> None:
uid = uuid.uuid4()
sha1 = blobstore.store(uid, b"same payload")
sha2 = blobstore.store(uid, b"same payload")
assert sha1 == sha2
# path layout: {user}/{sha[:2]}/{sha}
path = blobstore.blob_path(uid, sha1)
assert path.parent.name == sha1[:2]
assert path.parent.parent.name == str(uid)
def test_different_users_isolated() -> None:
u1, u2 = uuid.uuid4(), uuid.uuid4()
sha = blobstore.store(u1, b"payload")
assert blobstore.exists(u1, sha)
assert not blobstore.exists(u2, sha)
@@ -0,0 +1,124 @@
"""Duplicate Message-ID handling in the indexer (SPEC §7.1).
Real-world trigger: home.pl's welcome mailer reuses one static Message-ID for
every recipient, so the same id lands in different accounts as different messages.
"""
from __future__ import annotations
import uuid
from collections.abc import AsyncIterator
from datetime import UTC, datetime, timedelta
from email.message import EmailMessage
import pytest_asyncio
from sqlalchemy import delete
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.pool import NullPool
from app.config import get_settings
from app.indexer.indexer import persist_message
from app.models import Account, Mailbox, Message, User
from app.providers.base import RemoteMessage
class Dupenv:
def __init__(self, session: AsyncSession, user_id: uuid.UUID) -> None:
self.session = session
self.user_id = user_id
self.accounts: dict[str, uuid.UUID] = {}
self.mailboxes: dict[str, uuid.UUID] = {}
self._uid = 0
async def add_account(self, label: str) -> None:
acc = Account(
user_id=self.user_id,
email=f"{label}@example.com",
imap_config={},
smtp_config={},
secret_ct=b"",
discovery_src="manual",
)
self.session.add(acc)
await self.session.flush()
self.accounts[label] = acc.id
async def add_mailbox(self, account_label: str, path: str) -> None:
mb = Mailbox(
account_id=self.accounts[account_label],
user_id=self.user_id,
path=path,
special_use="inbox",
)
self.session.add(mb)
await self.session.flush()
self.mailboxes[f"{account_label}/{path}"] = mb.id
async def ingest(
self, account_label: str, path: str, *, mid: str, minutes: int = 0
) -> Message | None:
self._uid += 1
m = EmailMessage()
m["Subject"] = "Pierwsze kroki z pocztą w home.pl"
m["From"] = "info@home.pl"
m["Message-ID"] = mid
m["Date"] = "Sun, 23 Nov 2025 12:47:30 +0000"
m.set_content("welcome template body")
remote = RemoteMessage(
uid=self._uid,
raw=m.as_bytes(),
flags=frozenset(),
internal_date=datetime(2026, 1, 1, tzinfo=UTC) + timedelta(minutes=minutes),
)
result = await persist_message(
self.session,
user_id=self.user_id,
account_id=self.accounts[account_label],
mailbox_id=self.mailboxes[f"{account_label}/{path}"],
mailbox_special_use="inbox",
remote=remote,
)
return result.message if result else None
@pytest_asyncio.fixture
async def dup() -> AsyncIterator[Dupenv]:
engine = create_async_engine(get_settings().database_url, poolclass=NullPool)
sm = async_sessionmaker(engine, expire_on_commit=False)
session = sm()
user = User(handle=f"dup{uuid.uuid4().hex[:8]}@test.local", password_hash="x")
session.add(user)
await session.flush()
env = Dupenv(session, user.id)
await env.add_account("a")
await env.add_account("b")
await env.add_mailbox("a", "INBOX")
await env.add_mailbox("a", "Archive")
await env.add_mailbox("b", "INBOX")
try:
yield env
finally:
await session.execute(delete(User).where(User.id == user.id))
await session.commit()
await session.close()
await engine.dispose()
async def test_cross_account_duplicate_isolates(dup: Dupenv) -> None:
# Same static Message-ID delivered to two different accounts -> two threads.
a = await dup.ingest("a", "INBOX", mid="<static@home.pl>", minutes=0)
b = await dup.ingest("b", "INBOX", mid="<static@home.pl>", minutes=60)
assert a is not None and b is not None
assert a.thread_id != b.thread_id
# The second one was stored with a synthesized, non-authoritative id.
assert a.message_id_hdr == "static@home.pl"
assert b.message_id_hdr.endswith("@synthetic.local")
async def test_same_account_duplicate_merges(dup: Dupenv) -> None:
# Same message in INBOX + Archive of ONE account (Gmail-style) -> one thread.
a = await dup.ingest("a", "INBOX", mid="<msg@x.com>", minutes=0)
b = await dup.ingest("a", "Archive", mid="<msg@x.com>", minutes=1)
assert a is not None and b is not None
assert a.thread_id == b.thread_id
assert b.message_id_hdr == "msg@x.com"
+103
View File
@@ -0,0 +1,103 @@
"""MIME parsing (SPEC §6.4)."""
from __future__ import annotations
from email.message import EmailMessage
from app.indexer.mime import parse_message
def _build(
*,
subject: str = "Hello",
from_addr: str = "Alice <alice@example.com>",
to_addr: str = "bob@example.com",
message_id: str = "<m1@example.com>",
references: str | None = None,
in_reply_to: str | None = None,
text: str | None = "plain body",
html: str | None = None,
) -> bytes:
msg = EmailMessage()
msg["Subject"] = subject
msg["From"] = from_addr
msg["To"] = to_addr
msg["Message-ID"] = message_id
msg["Date"] = "Mon, 09 Mar 2026 08:36:37 +0100"
if references:
msg["References"] = references
if in_reply_to:
msg["In-Reply-To"] = in_reply_to
if text is not None:
msg.set_content(text)
if html is not None:
if text is not None:
msg.add_alternative(html, subtype="html")
else:
msg.set_content(html, subtype="html")
return msg.as_bytes()
def test_parse_basic_headers_and_body() -> None:
parsed = parse_message(_build())
assert parsed.subject == "Hello"
assert parsed.message_id == "m1@example.com"
assert parsed.from_addr == [{"name": "Alice", "email": "alice@example.com"}]
assert parsed.to_addr == [{"name": "", "email": "bob@example.com"}]
assert "plain body" in parsed.text_body
assert parsed.date is not None
assert parsed.snippet == "plain body"
def test_references_include_in_reply_to_in_order() -> None:
parsed = parse_message(_build(references="<a@x.com> <b@x.com>", in_reply_to="<b@x.com>"))
# References first, then In-Reply-To, deduped
assert parsed.references == ["a@x.com", "b@x.com"]
assert parsed.in_reply_to == "b@x.com"
def test_html_only_derives_text() -> None:
parsed = parse_message(_build(text=None, html="<p>Hello <b>world</b></p>"))
assert parsed.html_body is not None
assert "Hello" in parsed.text_body
assert "world" in parsed.text_body
assert "<b>" not in parsed.text_body # tags stripped for indexing text
def test_multipart_alternative_prefers_html_for_display() -> None:
parsed = parse_message(_build(text="text version", html="<p>html version</p>"))
assert parsed.html_body is not None
assert "html version" in parsed.html_body
assert "text version" in parsed.text_body
def test_rfc2047_encoded_subject_is_decoded() -> None:
# "Próba" base64-encoded UTF-8
parsed = parse_message(_build(subject="=?UTF-8?B?UHLDs2Jh?="))
assert parsed.subject == "Próba"
def test_attachment_extracted() -> None:
msg = EmailMessage()
msg["Subject"] = "with attachment"
msg["From"] = "a@b.com"
msg["To"] = "c@d.com"
msg["Message-ID"] = "<att@example.com>"
msg.set_content("see attached")
msg.add_attachment(
b"\x00\x01\x02data", maintype="application", subtype="octet-stream", filename="file.bin"
)
parsed = parse_message(msg.as_bytes())
assert parsed.has_attachments
assert parsed.attachments[0].filename == "file.bin"
assert parsed.attachments[0].payload == b"\x00\x01\x02data"
def test_missing_message_id_returns_none() -> None:
msg = EmailMessage()
msg["Subject"] = "no id"
msg["From"] = "a@b.com"
msg["To"] = "c@d.com"
msg.set_content("body")
parsed = parse_message(msg.as_bytes())
assert parsed.message_id is None
+186
View File
@@ -0,0 +1,186 @@
"""End-to-end indexer pipeline + Postgres search (SPEC §6.4, §8).
Uses a committed session (search reads through its own connection) and deletes
the user on teardown; the user FK cascade removes all created rows.
"""
from __future__ import annotations
import uuid
from collections.abc import AsyncIterator
from datetime import UTC, datetime
from email.message import EmailMessage
import pytest_asyncio
from sqlalchemy import delete, select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.pool import NullPool
from app.config import get_settings
from app.indexer.indexer import persist_message
from app.models import Account, BodyPart, KnownRecipient, Mailbox, Message, User
from app.providers.base import RemoteMessage
from app.search.pg import PgSearchBackend
from app.search.queryparser import to_search_query
class Fixture:
def __init__(
self,
session: AsyncSession,
user_id: uuid.UUID,
account_id: uuid.UUID,
mailbox_id: uuid.UUID,
) -> None:
self.session = session
self.user_id = user_id
self.account_id = account_id
self.mailbox_id = mailbox_id
self.backend = PgSearchBackend()
self._uid = 0
async def ingest(self, raw: bytes, *, flags: frozenset[str] = frozenset()) -> Message | None:
self._uid += 1
remote = RemoteMessage(uid=self._uid, raw=raw, flags=flags, internal_date=datetime.now(UTC))
result = await persist_message(
self.session,
user_id=self.user_id,
account_id=self.account_id,
mailbox_id=self.mailbox_id,
mailbox_special_use="inbox",
remote=remote,
)
await self.session.commit()
if result is not None:
await self.backend.index(result.search_doc)
return result.message
return None
@pytest_asyncio.fixture
async def fx() -> AsyncIterator[Fixture]:
engine = create_async_engine(get_settings().database_url, poolclass=NullPool)
sm = async_sessionmaker(engine, expire_on_commit=False)
session = sm()
user = User(handle=f"pipe{uuid.uuid4().hex[:8]}@test.local", password_hash="x")
session.add(user)
await session.flush()
account = Account(
user_id=user.id,
email="me@example.com",
imap_config={},
smtp_config={},
secret_ct=b"",
discovery_src="manual",
)
session.add(account)
await session.flush()
mailbox = Mailbox(account_id=account.id, user_id=user.id, path="INBOX", special_use="inbox")
session.add(mailbox)
await session.commit()
try:
yield Fixture(session, user.id, account.id, mailbox.id)
finally:
await session.execute(delete(User).where(User.id == user.id))
await session.commit()
await session.close()
await engine.dispose()
def _msg(
subject: str,
body: str,
*,
message_id: str,
frm: str = "Alice <alice@example.com>",
html: str | None = None,
) -> bytes:
m = EmailMessage()
m["Subject"] = subject
m["From"] = frm
m["To"] = "me@example.com"
m["Message-ID"] = message_id
m["Date"] = "Mon, 09 Mar 2026 08:36:37 +0100"
m.set_content(body)
if html:
m.add_alternative(html, subtype="html")
return m.as_bytes()
async def test_ingest_creates_rows_and_thread(fx: Fixture) -> None:
msg = await fx.ingest(
_msg("Invoice 2026", "Please find the invoice attached.", message_id="<a@example.com>")
)
assert msg is not None
assert msg.thread_id is not None
body = (await fx.session.scalars(select(BodyPart).where(BodyPart.message_id == msg.id))).all()
kinds = {b.kind for b in body}
assert "text" in kinds
# known_recipients harvested the sender
kr = await fx.session.scalar(
select(KnownRecipient).where(KnownRecipient.email == "alice@example.com")
)
assert kr is not None and kr.times_used == 1
async def test_idempotent_reingest(fx: Fixture) -> None:
raw = _msg("Dup", "body", message_id="<dup@example.com>")
first = await fx.ingest(raw)
assert first is not None
# Same (mailbox_id, uid) — but ingest increments uid; force same uid path:
remote = RemoteMessage(uid=1, raw=raw, flags=frozenset(), internal_date=datetime.now(UTC))
dup = await persist_message(
fx.session,
user_id=fx.user_id,
account_id=fx.account_id,
mailbox_id=fx.mailbox_id,
mailbox_special_use="inbox",
remote=remote,
)
assert dup is None # (mailbox_id, uid=1) already exists
async def test_search_finds_indexed_message(fx: Fixture) -> None:
await fx.ingest(
_msg("Quarterly report", "revenue numbers and projections", message_id="<r@example.com>")
)
await fx.ingest(_msg("Lunch plans", "let us grab lunch tomorrow", message_id="<l@example.com>"))
q = to_search_query("revenue", fx.user_id)
hits, cursor = await fx.backend.query(q, limit=50, cursor=None)
assert len(hits) == 1
assert hits[0].highlights # revenue highlighted
q2 = to_search_query("lunch", fx.user_id)
hits2, _ = await fx.backend.query(q2, limit=50, cursor=None)
assert len(hits2) == 1
async def test_search_operator_from_filter(fx: Fixture) -> None:
await fx.ingest(
_msg("Hi", "hello there", message_id="<h1@example.com>", frm="Bob <bob@example.com>")
)
await fx.ingest(
_msg("Yo", "hello again", message_id="<h2@example.com>", frm="Carol <carol@example.com>")
)
q = to_search_query("hello from:bob", fx.user_id)
hits, _ = await fx.backend.query(q, limit=50, cursor=None)
assert len(hits) == 1
async def test_html_sanitized_stored(fx: Fixture) -> None:
msg = await fx.ingest(
_msg(
"HTML",
"text fallback",
message_id="<html@example.com>",
html="<p>Hello <script>evil()</script><b>world</b></p>",
)
)
assert msg is not None
html_part = await fx.session.scalar(
select(BodyPart).where(BodyPart.message_id == msg.id, BodyPart.kind == "html_sanitized")
)
assert html_part is not None
assert "<script" not in html_part.content.lower()
assert "world" in html_part.content
+56
View File
@@ -0,0 +1,56 @@
"""HTML sanitization (SPEC §6.4, §10.4, I-8)."""
from __future__ import annotations
import uuid
from app.indexer.sanitize import sanitize_html
MID = uuid.uuid4()
def test_scripts_and_handlers_are_removed() -> None:
html = '<div onclick="evil()">hi<script>alert(1)</script></div>'
result = sanitize_html(html, MID)
assert "<script" not in result.html.lower()
assert "onclick" not in result.html.lower()
assert "hi" in result.html
def test_remote_image_is_blocked_and_flagged() -> None:
html = '<p>see <img src="http://tracker.example/pixel.gif"> this</p>'
result = sanitize_html(html, MID)
assert result.has_remote_images
assert "tracker.example" not in result.html
def test_cid_image_is_rewritten_to_endpoint() -> None:
html = '<img src="cid:logo123">'
result = sanitize_html(html, MID)
assert f"/api/messages/{MID}/cid/logo123" in result.html
assert not result.has_remote_images
def test_data_image_is_preserved() -> None:
html = '<img src="data:image/png;base64,iVBORw0KGgo=">'
result = sanitize_html(html, MID)
assert "data:image/png" in result.html
assert not result.has_remote_images
def test_safe_links_survive_with_rel() -> None:
html = '<a href="https://example.com">link</a>'
result = sanitize_html(html, MID)
assert "https://example.com" in result.html
assert "javascript:" not in result.html.lower()
def test_javascript_uri_is_stripped() -> None:
html = '<a href="javascript:alert(1)">x</a>'
result = sanitize_html(html, MID)
assert "javascript:" not in result.html.lower()
def test_sanitizer_version_reported() -> None:
result = sanitize_html("<p>hi</p>", MID)
assert result.sanitizer_v >= 1
+303
View File
@@ -0,0 +1,303 @@
"""Flag / move actions (SPEC §6.3, §9.1): DB-first + enqueue + revert."""
from __future__ import annotations
import uuid
from collections.abc import AsyncIterator
from dataclasses import dataclass, field
from datetime import UTC, datetime
from email.message import EmailMessage
import httpx
import pytest_asyncio
from httpx import ASGITransport
from sqlalchemy import delete, func, select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.pool import NullPool
from app.api import actions as actions_api
from app.config import get_settings
from app.db import session_scope
from app.identity.passwords import hash_password
from app.identity.sessions import create_session
from app.indexer.indexer import persist_message
from app.main import create_app
from app.models import Account, Mailbox, Message, Thread, User
from app.providers.base import RemoteMessage
from app.sync import actions as sync_actions
CSRF = "csrf"
@dataclass
class Ctx:
client: httpx.AsyncClient
session: AsyncSession
user_id: uuid.UUID
inbox_id: uuid.UUID
archive_id: uuid.UUID
message_ids: list[uuid.UUID] = field(default_factory=list)
thread_id: uuid.UUID | None = None
enqueued: list[tuple] = field(default_factory=list)
def _hdr() -> dict[str, str]:
return {"X-CSRF": CSRF}
def _raw(subject: str, mid: str, refs: str | None = None) -> bytes:
m = EmailMessage()
m["Subject"] = subject
m["From"] = "a@x.com"
m["To"] = "me@example.com"
m["Message-ID"] = mid
m["Date"] = "Mon, 09 Mar 2026 08:36:37 +0100"
if refs:
m["References"] = refs
m.set_content("body")
return m.as_bytes()
@pytest_asyncio.fixture
async def ctx(monkeypatch) -> AsyncIterator[Ctx]: # type: ignore[no-untyped-def]
engine = create_async_engine(get_settings().database_url, poolclass=NullPool)
sm = async_sessionmaker(engine, expire_on_commit=False)
session = sm()
user = User(handle=f"act{uuid.uuid4().hex[:8]}@test.local", password_hash=hash_password("pw"))
session.add(user)
await session.flush()
account = Account(
user_id=user.id,
email="me@example.com",
imap_config={},
smtp_config={},
secret_ct=b"",
discovery_src="manual",
)
session.add(account)
await session.flush()
inbox = Mailbox(account_id=account.id, user_id=user.id, path="INBOX", special_use="inbox")
archive = Mailbox(account_id=account.id, user_id=user.id, path="Archive", special_use="archive")
session.add_all([inbox, archive])
await session.commit()
c = Ctx(
client=None,
session=session,
user_id=user.id,
inbox_id=inbox.id, # type: ignore[arg-type]
archive_id=archive.id,
)
for i, (subj, mid, refs) in enumerate(
[
("Topic", "<a@x.com>", None),
("Re: Topic", "<b@x.com>", "<a@x.com>"),
]
):
remote = RemoteMessage(
uid=i + 1, raw=_raw(subj, mid, refs), flags=frozenset(), internal_date=datetime.now(UTC)
)
result = await persist_message(
session,
user_id=user.id,
account_id=account.id,
mailbox_id=inbox.id,
mailbox_special_use="inbox",
remote=remote,
)
await session.commit()
assert result is not None
c.message_ids.append(result.message.id)
c.thread_id = result.message.thread_id
# capture enqueues instead of hitting a real arq pool
class FakePool:
async def enqueue_job(self, *args: object) -> None:
c.enqueued.append(args)
async def fake_pool() -> FakePool:
return FakePool()
monkeypatch.setattr(actions_api, "get_arq_pool", fake_pool)
token = await create_session(user.id, "pytest")
c.client = httpx.AsyncClient(
transport=ASGITransport(app=create_app()),
base_url="http://testserver",
cookies={"session": token, "csrf": CSRF},
)
try:
yield c
finally:
await c.client.aclose()
await session.execute(delete(User).where(User.id == user.id))
await session.commit()
await session.close()
await engine.dispose()
async def test_mark_message_read_enqueues_push(ctx: Ctx) -> None:
mid = ctx.message_ids[0]
resp = await ctx.client.post(
f"/api/messages/{mid}/flags", json={"add": ["\\Seen"]}, headers=_hdr()
)
assert resp.status_code == 200
assert "\\Seen" in resp.json()["flags"]
await ctx.session.refresh(await ctx.session.get(Message, mid))
msg = await ctx.session.get(Message, mid)
assert msg is not None and "\\Seen" in msg.flags
assert ctx.enqueued == [("push_flags_job", str(mid), ["\\Seen"], [])]
async def test_noop_flag_change_does_not_enqueue(ctx: Ctx) -> None:
mid = ctx.message_ids[0]
await ctx.client.post(f"/api/messages/{mid}/flags", json={"add": ["\\Seen"]}, headers=_hdr())
ctx.enqueued.clear()
# already \Seen -> no-op
resp = await ctx.client.post(
f"/api/messages/{mid}/flags", json={"add": ["\\Seen"]}, headers=_hdr()
)
assert resp.status_code == 200
assert ctx.enqueued == []
async def test_thread_flags_mark_all_read(ctx: Ctx) -> None:
resp = await ctx.client.post(
f"/api/threads/{ctx.thread_id}/flags", json={"add": ["\\Seen"]}, headers=_hdr()
)
assert resp.status_code == 200
thread = await ctx.session.get(Thread, ctx.thread_id)
await ctx.session.refresh(thread)
assert thread is not None and thread.unread_count == 0
assert len(ctx.enqueued) == 2 # both messages pushed
async def test_thread_flag_sets_pinned(ctx: Ctx) -> None:
resp = await ctx.client.post(
f"/api/threads/{ctx.thread_id}/flags", json={"add": ["\\Flagged"]}, headers=_hdr()
)
assert resp.status_code == 200
thread = await ctx.session.get(Thread, ctx.thread_id)
await ctx.session.refresh(thread)
assert thread is not None and thread.pinned is True
# Unpinning clears it again.
await ctx.client.post(
f"/api/threads/{ctx.thread_id}/flags", json={"remove": ["\\Flagged"]}, headers=_hdr()
)
await ctx.session.refresh(thread)
assert thread.pinned is False
async def test_move_message(ctx: Ctx) -> None:
mid = ctx.message_ids[0]
resp = await ctx.client.post(
f"/api/messages/{mid}/move", json={"mailbox_id": str(ctx.archive_id)}, headers=_hdr()
)
assert resp.status_code == 200
msg = await ctx.session.get(Message, mid)
await ctx.session.refresh(msg)
assert msg is not None and msg.mailbox_id == ctx.archive_id
assert ctx.enqueued[0][0] == "move_job"
async def test_archive_thread_enqueues_move(ctx: Ctx) -> None:
resp = await ctx.client.post(f"/api/threads/{ctx.thread_id}/archive", headers=_hdr())
assert resp.status_code == 200
assert resp.json() == {"status": "queued"}
assert ctx.enqueued == [
("move_thread_to_folder_job", str(ctx.thread_id), "archive"),
]
async def test_trash_thread_enqueues_move(ctx: Ctx) -> None:
resp = await ctx.client.post(f"/api/threads/{ctx.thread_id}/trash", headers=_hdr())
assert resp.status_code == 200
assert ctx.enqueued == [
("move_thread_to_folder_job", str(ctx.thread_id), "trash"),
]
async def test_archive_unknown_thread_404(ctx: Ctx) -> None:
resp = await ctx.client.post(f"/api/threads/{uuid.uuid4()}/archive", headers=_hdr())
assert resp.status_code == 404
assert ctx.enqueued == []
async def test_restore_thread_enqueues_move_to_inbox(ctx: Ctx) -> None:
resp = await ctx.client.post(f"/api/threads/{ctx.thread_id}/restore", headers=_hdr())
assert resp.status_code == 200
assert ctx.enqueued == [
("move_thread_to_folder_job", str(ctx.thread_id), "inbox"),
]
async def test_delete_thread_enqueues_permanent_delete(ctx: Ctx) -> None:
resp = await ctx.client.post(f"/api/threads/{ctx.thread_id}/delete", headers=_hdr())
assert resp.status_code == 200
assert ctx.enqueued == [("delete_thread_job", str(ctx.thread_id))]
async def test_ignores_unknown_flags(ctx: Ctx) -> None:
mid = ctx.message_ids[0]
resp = await ctx.client.post(
f"/api/messages/{mid}/flags", json={"add": ["\\Deleted", "bogus"]}, headers=_hdr()
)
assert resp.status_code == 200
msg = await ctx.session.get(Message, mid)
await ctx.session.refresh(msg)
assert msg is not None and "\\Deleted" not in msg.flags # not user-settable here
async def test_delete_thread_job_expunges_and_removes_rows(ctx: Ctx, monkeypatch) -> None: # type: ignore[no-untyped-def]
tid = ctx.thread_id
assert tid is not None
deleted: list[tuple[str, list[int]]] = []
class FakeProvider:
async def delete_messages(self, mailbox: str, uids: list[int]) -> None:
deleted.append((mailbox, uids))
async def close(self) -> None:
return None
monkeypatch.setattr(sync_actions, "build_provider", lambda _account: FakeProvider())
await sync_actions.delete_thread_job({}, str(tid))
# server expunge happened for the thread's INBOX uids
assert deleted and deleted[0][0] == "INBOX"
# DB rows for the thread's messages are gone (verify in a fresh session; the
# job deleted them in its own session, so drop ctx's stale identity map).
ctx.session.expunge_all()
async with session_scope() as verify:
remaining = await verify.scalar(
select(func.count()).select_from(Message).where(Message.thread_id == tid)
)
assert remaining == 0
assert await verify.scalar(select(Thread).where(Thread.id == tid)) is None
async def test_push_flags_job_reverts_on_failure(ctx: Ctx, monkeypatch) -> None: # type: ignore[no-untyped-def]
mid = ctx.message_ids[0]
# mark read in DB first (as the endpoint would)
msg = await ctx.session.get(Message, mid)
assert msg is not None
msg.flags = ["\\Seen"]
await ctx.session.commit()
class FailingProvider:
async def push_flags(self, *a: object, **k: object) -> None:
raise RuntimeError("imap down")
async def close(self) -> None:
return None
monkeypatch.setattr(sync_actions, "build_provider", lambda _account: FailingProvider())
# final try -> should revert (remove the \Seen we added)
await sync_actions.push_flags_job({"job_try": 5}, str(mid), ["\\Seen"], [])
reverted = await ctx.session.get(Message, mid)
await ctx.session.refresh(reverted)
assert reverted is not None and "\\Seen" not in reverted.flags
@@ -0,0 +1,188 @@
"""Recipient suggest, settings, and per-sender remote-image control (SPEC §9.1/§9.4)."""
from __future__ import annotations
import uuid
from collections.abc import AsyncIterator
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from email.message import EmailMessage
import httpx
import pytest_asyncio
from httpx import ASGITransport
from sqlalchemy import delete
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.pool import NullPool
from app.config import get_settings
from app.identity.passwords import hash_password
from app.identity.sessions import create_session
from app.indexer.indexer import persist_message
from app.main import create_app
from app.models import Account, KnownRecipient, Mailbox, User
from app.providers.base import RemoteMessage
from app.search.pg import PgSearchBackend
CSRF = "csrf"
@dataclass
class Ctx:
client: httpx.AsyncClient
session: AsyncSession
user_id: uuid.UUID
account_id: uuid.UUID
mailbox_id: uuid.UUID
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"rs{uuid.uuid4().hex[:8]}@test.local", password_hash=hash_password("pw"))
session.add(user)
await session.flush()
account = Account(
user_id=user.id,
email="me@example.com",
imap_config={},
smtp_config={},
secret_ct=b"",
discovery_src="manual",
)
session.add(account)
await session.flush()
mailbox = Mailbox(account_id=account.id, user_id=user.id, path="INBOX", special_use="inbox")
session.add(mailbox)
await session.commit()
token = await create_session(user.id, "pytest")
client = httpx.AsyncClient(
transport=ASGITransport(app=create_app()),
base_url="http://testserver",
cookies={"session": token, "csrf": CSRF},
)
try:
yield Ctx(
client=client,
session=session,
user_id=user.id,
account_id=account.id,
mailbox_id=mailbox.id,
)
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_recipient_suggest_prefix_and_ranking(ctx: Ctx) -> None:
now = datetime.now(UTC)
ctx.session.add_all(
[
KnownRecipient(
user_id=ctx.user_id,
email="alice@example.com",
name="Alice",
times_used=10,
last_used_at=now,
),
KnownRecipient(
user_id=ctx.user_id,
email="alan@example.com",
name="Alan",
times_used=1,
last_used_at=now - timedelta(days=200),
),
KnownRecipient(
user_id=ctx.user_id,
email="bob@example.com",
name="Bob",
times_used=99,
last_used_at=now,
),
]
)
await ctx.session.commit()
resp = await ctx.client.get("/api/recipients/suggest", params={"q": "al"})
assert resp.status_code == 200
emails = [r["email"] for r in resp.json()]
assert set(emails) == {"alice@example.com", "alan@example.com"} # prefix match, bob excluded
assert emails[0] == "alice@example.com" # higher score (recent + frequent) ranks first
async def test_recipient_suggest_empty_query(ctx: Ctx) -> None:
resp = await ctx.client.get("/api/recipients/suggest", params={"q": ""})
assert resp.status_code == 200 and resp.json() == []
async def test_settings_get_and_patch(ctx: Ctx) -> None:
got = await ctx.client.get("/api/settings")
assert got.status_code == 200
assert got.json()["thread_order"] == "asc"
patched = await ctx.client.patch(
"/api/settings", json={"thread_order": "desc", "tz": "Europe/Warsaw"}, headers=_hdr()
)
assert patched.status_code == 200
assert patched.json()["thread_order"] == "desc"
assert patched.json()["tz"] == "Europe/Warsaw"
# invalid order is ignored
patched2 = await ctx.client.patch(
"/api/settings", json={"thread_order": "sideways"}, headers=_hdr()
)
assert patched2.json()["thread_order"] == "desc"
async def _ingest_html(ctx: Ctx, *, sender: str, html: str) -> uuid.UUID:
m = EmailMessage()
m["Subject"] = "Newsletter"
m["From"] = sender
m["To"] = "me@example.com"
m["Message-ID"] = f"<{uuid.uuid4().hex}@x.com>"
m["Date"] = "Mon, 09 Mar 2026 08:36:37 +0100"
m.set_content("text fallback")
m.add_alternative(html, subtype="html")
result = await persist_message(
ctx.session,
user_id=ctx.user_id,
account_id=ctx.account_id,
mailbox_id=ctx.mailbox_id,
mailbox_special_use="inbox",
remote=RemoteMessage(
uid=1, raw=m.as_bytes(), flags=frozenset(), internal_date=datetime.now(UTC)
),
)
await ctx.session.commit()
assert result is not None
await PgSearchBackend().index(result.search_doc)
return result.message.id
async def test_remote_images_blocked_then_allowed(ctx: Ctx) -> None:
mid = await _ingest_html(
ctx,
sender="News <news@sender.com>",
html='<p>Hi<img src="http://tracker.example/pixel.gif"></p>',
)
# blocked by default
before = await ctx.client.get(f"/api/messages/{mid}/html")
assert "tracker.example" not in before.text
# approve the sender
allow = await ctx.client.post(f"/api/messages/{mid}/allow-remote-images", headers=_hdr())
assert allow.status_code == 200
assert allow.json()["sender"] == "news@sender.com"
# now the remote image is rendered
after = await ctx.client.get(f"/api/messages/{mid}/html")
assert "http://tracker.example/pixel.gif" in after.text
+361
View File
@@ -0,0 +1,361 @@
"""Outbox: MIME assembly, drafts, send, undo, send job (SPEC §6.5)."""
from __future__ import annotations
import email
import uuid
from collections.abc import AsyncIterator
from dataclasses import dataclass, field
from datetime import UTC, datetime
from email.policy import default as default_policy
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 compose as compose_api
from app.config import get_settings
from app.db import session_scope
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, Draft, KnownRecipient, Mailbox, Message, Outbox, User
from app.outbox import jobs as outbox_jobs
from app.outbox.mime_build import build_mime
CSRF = "csrf"
# --- pure MIME assembly ------------------------------------------------------
def test_build_mime_sets_threading_and_alternative() -> None:
account = Account(
email="me@example.com",
display_name="Me",
imap_config={},
smtp_config={},
secret_ct=b"",
discovery_src="manual",
user_id=uuid.uuid4(),
)
parent = Message(
message_id_hdr="orig@x.com",
references_hdr=["root@x.com"],
user_id=uuid.uuid4(),
account_id=uuid.uuid4(),
mailbox_id=uuid.uuid4(),
uid=1,
internal_date=datetime.now(UTC),
blob_sha256="0",
)
draft = Draft(
user_id=uuid.uuid4(),
account_id=uuid.uuid4(),
kind="reply",
payload={
"to": ["bob@example.com"],
"cc": ["c@example.com"],
"bcc": ["secret@example.com"],
"subject": "Re: Hi",
"html": "<p>Hello <b>there</b></p>",
"text": "",
},
)
raw, env_from, env_to = build_mime(account, draft, parent)
assert env_from == "me@example.com"
assert set(env_to) == {"bob@example.com", "c@example.com", "secret@example.com"}
msg = email.message_from_bytes(raw, policy=default_policy)
assert msg["From"] == "Me <me@example.com>"
assert msg["To"] == "bob@example.com"
assert "secret@example.com" not in (raw.decode()) # bcc not in headers
assert msg["In-Reply-To"] == "<orig@x.com>"
assert "<root@x.com>" in msg["References"] and "<orig@x.com>" in msg["References"]
# multipart/alternative with an auto-generated text part
body_types = {p.get_content_type() for p in msg.walk()}
assert "text/plain" in body_types and "text/html" in body_types
def test_build_mime_includes_attachments() -> None:
account = Account(
email="me@example.com",
display_name="Me",
imap_config={},
smtp_config={},
secret_ct=b"",
discovery_src="manual",
user_id=uuid.uuid4(),
)
draft = Draft(
user_id=uuid.uuid4(),
account_id=uuid.uuid4(),
kind="new",
payload={"to": ["bob@example.com"], "subject": "Files", "html": "<p>hi</p>", "text": ""},
)
raw, _from, _to = build_mime(
account, draft, None, [("report.pdf", "application/pdf", b"%PDF-1.4 data")]
)
msg = email.message_from_bytes(raw, policy=default_policy)
attached = [p for p in msg.walk() if p.get_filename() == "report.pdf"]
assert len(attached) == 1
assert attached[0].get_content_type() == "application/pdf"
assert attached[0].get_payload(decode=True) == b"%PDF-1.4 data"
# --- API + send job ----------------------------------------------------------
@dataclass
class Ctx:
client: httpx.AsyncClient
session: AsyncSession
user_id: uuid.UUID
account_id: uuid.UUID
sent_path: str
reply_to_id: uuid.UUID
enqueued: list[tuple] = field(default_factory=list)
def _hdr() -> dict[str, str]:
return {"X-CSRF": CSRF}
@pytest_asyncio.fixture
async def ctx(monkeypatch) -> AsyncIterator[Ctx]: # type: ignore[no-untyped-def]
engine = create_async_engine(get_settings().database_url, poolclass=NullPool)
sm = async_sessionmaker(engine, expire_on_commit=False)
session = sm()
user = User(handle=f"ob{uuid.uuid4().hex[:8]}@test.local", password_hash=hash_password("pw"))
session.add(user)
await session.flush()
account = Account(
user_id=user.id,
email="me@example.com",
display_name="Me",
imap_config={"host": "imap.example.com", "port": 993, "tls": "ssl"},
smtp_config={"host": "smtp.example.com", "port": 465, "tls": "ssl"},
secret_ct=b"",
discovery_src="manual",
)
session.add(account)
await session.flush()
inbox = Mailbox(account_id=account.id, user_id=user.id, path="INBOX", special_use="inbox")
sent = Mailbox(account_id=account.id, user_id=user.id, path="Sent", special_use="sent")
session.add_all([inbox, sent])
await session.flush()
parent = Message(
user_id=user.id,
account_id=account.id,
mailbox_id=inbox.id,
uid=1,
message_id_hdr="orig@x.com",
references_hdr=[],
subject="Hi",
internal_date=datetime.now(UTC),
blob_sha256="0" * 64,
)
session.add(parent)
await session.commit()
c = Ctx(
client=None,
session=session,
user_id=user.id,
account_id=account.id, # type: ignore[arg-type]
sent_path="Sent",
reply_to_id=parent.id,
)
class FakePool:
async def enqueue_job(self, *args: object, **kwargs: object) -> None:
c.enqueued.append((args, kwargs))
async def fake_pool() -> FakePool:
return FakePool()
monkeypatch.setattr(compose_api, "get_arq_pool", fake_pool)
token = await create_session(user.id, "pytest")
c.client = httpx.AsyncClient(
transport=ASGITransport(app=create_app()),
base_url="http://testserver",
cookies={"session": token, "csrf": CSRF},
)
try:
yield c
finally:
await c.client.aclose()
await session.execute(delete(User).where(User.id == user.id))
await session.commit()
await session.close()
await engine.dispose()
def _draft_body(ctx: Ctx) -> dict:
return {
"account_id": str(ctx.account_id),
"reply_to_message_id": str(ctx.reply_to_id),
"kind": "reply",
"payload": {"to": ["bob@example.com"], "subject": "Re: Hi", "text": "hello"},
}
async def test_draft_crud(ctx: Ctx) -> None:
created = await ctx.client.post("/api/drafts", json=_draft_body(ctx), headers=_hdr())
assert created.status_code == 200
did = created.json()["id"]
body = _draft_body(ctx)
body["payload"]["subject"] = "Re: Hi (edited)"
updated = await ctx.client.put(f"/api/drafts/{did}", json=body, headers=_hdr())
assert updated.status_code == 200
assert updated.json()["payload"]["subject"] == "Re: Hi (edited)"
deleted = await ctx.client.request("DELETE", f"/api/drafts/{did}", headers=_hdr())
assert deleted.status_code == 200
async def test_upload_and_list_compose_attachment(ctx: Ctx) -> None:
resp = await ctx.client.post(
"/api/attachments",
files={"file": ("hello.txt", b"file contents", "text/plain")},
headers=_hdr(),
)
assert resp.status_code == 200, resp.text
data = resp.json()
assert data["filename"] == "hello.txt"
assert data["mime_type"] == "text/plain"
assert data["size_bytes"] == len(b"file contents")
listing = await ctx.client.get(f"/api/attachments?ids={data['id']}", headers=_hdr())
assert listing.status_code == 200
assert [a["id"] for a in listing.json()] == [data["id"]]
async def test_upload_rejects_oversized_attachment(ctx: Ctx, monkeypatch) -> None: # type: ignore[no-untyped-def]
from app.api import compose as compose_module
monkeypatch.setattr(compose_module, "MAX_ATTACHMENT_BYTES", 4)
resp = await ctx.client.post(
"/api/attachments",
files={"file": ("big.bin", b"12345", "application/octet-stream")},
headers=_hdr(),
)
assert resp.status_code == 413
assert resp.json()["error"]["code"] == "too_large"
async def test_send_creates_outbox_and_defers(ctx: Ctx) -> None:
did = (await ctx.client.post("/api/drafts", json=_draft_body(ctx), headers=_hdr())).json()["id"]
resp = await ctx.client.post("/api/send", json={"draft_id": did}, headers=_hdr())
assert resp.status_code == 200
outbox_id = resp.json()["outbox_id"]
ob = await ctx.session.get(Outbox, uuid.UUID(outbox_id))
assert ob is not None and ob.state == "undo_window"
# scheduled with a defer (the undo window)
assert ctx.enqueued and ctx.enqueued[-1][0][0] == "send_job"
assert "_defer_by" in ctx.enqueued[-1][1]
async def test_undo_cancels(ctx: Ctx) -> None:
did = (await ctx.client.post("/api/drafts", json=_draft_body(ctx), headers=_hdr())).json()["id"]
outbox_id = (await ctx.client.post("/api/send", json={"draft_id": did}, headers=_hdr())).json()[
"outbox_id"
]
undo = await ctx.client.post(f"/api/send/{outbox_id}/undo", headers=_hdr())
assert undo.status_code == 200
ob = await ctx.session.get(Outbox, uuid.UUID(outbox_id))
await ctx.session.refresh(ob)
assert ob is not None and ob.state == "cancelled"
class FakeProvider:
def __init__(self) -> None:
self.sent: list = []
self.appended: list = []
self.raise_on_send = False
async def send(self, raw: bytes, env_from: str, env_to: list) -> None:
if self.raise_on_send:
raise RuntimeError("smtp down")
self.sent.append((env_from, env_to))
async def append(self, mailbox: str, raw: bytes, flags: frozenset) -> int:
self.appended.append(mailbox)
return 99
async def close(self) -> None:
return None
async def _make_outbox(ctx: Ctx, state: str = "undo_window") -> uuid.UUID:
draft = Draft(
user_id=ctx.user_id,
account_id=ctx.account_id,
reply_to_message_id=ctx.reply_to_id,
kind="reply",
payload={"to": ["bob@example.com"], "subject": "Re: Hi", "text": "hello"},
)
ctx.session.add(draft)
await ctx.session.flush()
outbox = Outbox(
user_id=ctx.user_id, draft_id=draft.id, state=state, send_after=datetime.now(UTC)
)
ctx.session.add(outbox)
await ctx.session.commit()
return outbox.id
async def test_send_job_success(ctx: Ctx, monkeypatch) -> None: # type: ignore[no-untyped-def]
oid = await _make_outbox(ctx)
fake = FakeProvider()
monkeypatch.setattr(outbox_jobs, "build_provider", lambda _a: fake)
ob0 = await ctx.session.get(Outbox, oid)
assert ob0 is not None
draft_id = ob0.draft_id
await outbox_jobs.send_job({"job_try": 1}, str(oid))
ob = await ctx.session.get(Outbox, oid)
await ctx.session.refresh(ob)
assert ob is not None and ob.state == "sent"
assert len(fake.sent) == 1
assert fake.appended == ["Sent"] # non-Gmail -> we append
# A successfully sent message's draft is deleted (verify in a fresh session).
ctx.session.expunge_all()
async with session_scope() as verify:
assert await verify.get(Draft, draft_id) is None
kr = await ctx.session.scalar(
select(KnownRecipient).where(
KnownRecipient.user_id == ctx.user_id, KnownRecipient.email == "bob@example.com"
)
)
assert kr is not None and kr.times_used == 3 # sent-to weight
async def test_send_job_skips_when_cancelled(ctx: Ctx, monkeypatch) -> None: # type: ignore[no-untyped-def]
oid = await _make_outbox(ctx, state="cancelled")
fake = FakeProvider()
monkeypatch.setattr(outbox_jobs, "build_provider", lambda _a: fake)
await outbox_jobs.send_job({"job_try": 1}, str(oid))
assert fake.sent == [] # no SMTP traffic for a cancelled send
async def test_send_job_failure_marks_failed(ctx: Ctx, monkeypatch) -> None: # type: ignore[no-untyped-def]
oid = await _make_outbox(ctx)
fake = FakeProvider()
fake.raise_on_send = True
monkeypatch.setattr(outbox_jobs, "build_provider", lambda _a: fake)
await outbox_jobs.send_job({"job_try": 1}, str(oid))
ob = await ctx.session.get(Outbox, oid)
await ctx.session.refresh(ob)
assert ob is not None and ob.state == "failed" and ob.error
+122
View File
@@ -0,0 +1,122 @@
"""IMAP response parsing (SPEC §5.1 provider), using real home.pl wire samples."""
from __future__ import annotations
from datetime import datetime
from app.providers.imap_smtp.parsing import (
parse_fetch_lines,
parse_internaldate,
parse_list_line,
parse_search_uids,
parse_select_untagged,
)
def test_parse_list_special_use() -> None:
assert parse_list_line(rb'(\HasNoChildren \UnMarked \Sent) "/" SENT') is not None
cases = {
rb'(\HasNoChildren \UnMarked \Sent) "/" SENT': ("SENT", "sent"),
rb'(\HasNoChildren \UnMarked \Drafts) "/" DRAFTS': ("DRAFTS", "drafts"),
rb'(\HasNoChildren \UnMarked \Trash) "/" TRASH': ("TRASH", "trash"),
rb'(\HasNoChildren \UnMarked \Junk) "/" SPAM': ("SPAM", "junk"),
rb'(\HasNoChildren) "/" INBOX': ("INBOX", "inbox"),
}
for line, (path, special) in cases.items():
parsed = parse_list_line(line)
assert parsed is not None
assert parsed.path == path
assert parsed.special_use == special
def test_parse_list_plain_folder_has_no_special_use() -> None:
parsed = parse_list_line(rb'(\HasNoChildren) "/" "Work Stuff"')
assert parsed is not None
assert parsed.path == "Work Stuff"
assert parsed.special_use is None
def test_parse_list_utf7_name() -> None:
# Modified UTF-7: "&AOk-" decodes to "é" -> folder "Réponses"
parsed = parse_list_line(rb'(\HasNoChildren) "/" "R&AOk-ponses"')
assert parsed is not None
assert parsed.path == "Réponses"
def test_parse_internaldate() -> None:
dt = parse_internaldate("27-May-2026 19:08:09 +0200")
assert dt == datetime.fromisoformat("2026-05-27T19:08:09+02:00")
# single-digit day (space padded)
assert parse_internaldate(" 1-Jan-2026 05:06:07 +0000") is not None
assert parse_internaldate("garbage") is None
def test_parse_search_uids() -> None:
assert parse_search_uids([b"1 2 3 4 5 8", b"Search completed."]) == [1, 2, 3, 4, 5, 8]
assert parse_search_uids([b""]) == []
assert parse_search_uids([]) == []
def test_parse_select_untagged() -> None:
lines = [
b"6 EXISTS",
b"0 RECENT",
b"OK [UIDVALIDITY 1772887273] UIDs valid",
b"OK [UIDNEXT 9] Predicted next UID",
b"OK [HIGHESTMODSEQ 31] Highest",
]
info = parse_select_untagged(lines)
assert info["uidvalidity"] == 1772887273
assert info["uidnext"] == 9
assert info["highestmodseq"] == 31
assert info["exists"] == 6
def test_parse_fetch_body() -> None:
raw = b"From: a@b.com\r\nSubject: Hi\r\n\r\nBody"
header = (
b"6 FETCH (UID 8 FLAGS (\\Answered \\Seen $label1) "
b'INTERNALDATE "27-May-2026 19:08:09 +0200" RFC822.SIZE 34 BODY[] {'
+ str(len(raw)).encode()
+ b"}"
)
lines: list[bytes | bytearray] = [header, bytearray(raw), b")", b"Fetch completed."]
parsed = parse_fetch_lines(lines)
assert len(parsed) == 1
pf = parsed[0]
assert pf.uid == 8
assert pf.raw == raw
assert "\\Seen" in pf.flags
assert "\\Answered" in pf.flags
assert pf.internal_date is not None
def test_parse_fetch_flags_only() -> None:
lines: list[bytes | bytearray] = [
b"1 FETCH (UID 5 FLAGS (\\Seen))",
b"2 FETCH (UID 6 FLAGS (\\Flagged \\Seen))",
b"Fetch completed.",
]
parsed = parse_fetch_lines(lines)
assert [p.uid for p in parsed] == [5, 6]
assert parsed[0].raw is None
assert parsed[1].flags == frozenset({"\\Flagged", "\\Seen"})
def test_parse_fetch_multiple_bodies() -> None:
raw1 = b"msg-one"
raw2 = b"message-two-longer"
lines: list[bytes | bytearray] = [
b'1 FETCH (UID 1 FLAGS () INTERNALDATE "1-Jan-2026 00:00:00 +0000" BODY[] {%d}' % len(raw1),
bytearray(raw1),
b")",
b'2 FETCH (UID 2 FLAGS (\\Seen) INTERNALDATE "2-Jan-2026 00:00:00 +0000" BODY[] {%d}'
% len(raw2),
bytearray(raw2),
b")",
b"Fetch completed.",
]
parsed = parse_fetch_lines(lines)
assert [p.uid for p in parsed] == [1, 2]
assert parsed[0].raw == raw1
assert parsed[1].raw == raw2
+109
View File
@@ -0,0 +1,109 @@
"""Query grammar parser (SPEC §8.2). Covers every operator, negation, quoting,
OR, and garbage input (the parser must never raise)."""
from __future__ import annotations
import uuid
import pytest
from app.search.queryparser import KNOWN_KEYS, free_text, parse, to_search_query
def _flat(query: str) -> list[dict[str, object]]:
return [clause.as_dict() for group in parse(query) for clause in group]
def test_plain_free_text() -> None:
groups = parse("hello world")
assert len(groups) == 2
assert all(len(g) == 1 for g in groups)
assert free_text(groups) == "hello world"
def test_quoted_phrase_is_one_term() -> None:
groups = parse('"hello world" foo')
assert groups[0][0].value == "hello world"
assert groups[0][0].phrase is True
assert groups[1][0].value == "foo"
@pytest.mark.parametrize("key", sorted(KNOWN_KEYS))
def test_every_operator_parses_as_filter(key: str) -> None:
groups = parse(f"{key}:value")
clause = groups[0][0]
assert clause.type == "filter"
assert clause.key == key
assert clause.value == "value"
def test_filter_with_quoted_value() -> None:
clause = parse('from:"John Doe"')[0][0]
assert clause.type == "filter"
assert clause.key == "from"
assert clause.value == "John Doe"
def test_negation_of_term_and_filter() -> None:
clauses = _flat("-spam -from:boss@x.com")
assert clauses[0] == {
"type": "text",
"key": None,
"value": "spam",
"negate": True,
"phrase": False,
}
assert clauses[1]["type"] == "filter"
assert clauses[1]["negate"] is True
assert clauses[1]["key"] == "from"
def test_or_groups_adjacent_clauses() -> None:
groups = parse("cat OR dog fish")
# (cat OR dog) AND fish
assert len(groups) == 2
assert [c.value for c in groups[0]] == ["cat", "dog"]
assert [c.value for c in groups[1]] == ["fish"]
def test_or_mixing_text_and_filter() -> None:
groups = parse("is:unread OR is:starred")
assert len(groups) == 1
assert [c.value for c in groups[0]] == ["unread", "starred"]
def test_unknown_key_is_free_text() -> None:
clause = parse("bogus:value")[0][0]
assert clause.type == "text"
assert clause.value == "bogus:value"
def test_case_insensitive_keys_lowercased() -> None:
clause = parse("FROM:alice")[0][0]
assert clause.type == "filter"
assert clause.key == "from"
@pytest.mark.parametrize(
"garbage",
['unbalanced "quote', "'", '"', "-", ":", "from:", " ", "", "OR OR OR", "a OR"],
)
def test_garbage_never_raises(garbage: str) -> None:
# Must not raise; returns some (possibly empty) parse.
result = parse(garbage)
assert isinstance(result, list)
def test_to_search_query_sets_user_id_and_text() -> None:
uid = uuid.uuid4()
sq = to_search_query("invoice from:alice", uid)
assert sq.user_id == uid
assert sq.text == "invoice"
assert "groups" in sq.filters
def test_dash_only_token_is_text() -> None:
# A lone "-" must not be treated as negation of an empty term.
clause = parse("-")[0][0]
assert clause.value == "-"
assert clause.negate is False
+178
View File
@@ -0,0 +1,178 @@
"""Operator search through the API (SPEC §8.2, §9.1), on the Postgres backend."""
from __future__ import annotations
import uuid
from collections.abc import AsyncIterator
from dataclasses import dataclass
from datetime import UTC, datetime
from email.message import EmailMessage
import httpx
import pytest_asyncio
from httpx import ASGITransport
from sqlalchemy import delete
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.pool import NullPool
from app.config import get_settings
from app.identity.passwords import hash_password
from app.identity.sessions import create_session
from app.indexer.indexer import persist_message
from app.main import create_app
from app.models import Account, Mailbox, User
from app.providers.base import RemoteMessage
from app.search.pg import PgSearchBackend
@dataclass
class Ctx:
client: httpx.AsyncClient
session: AsyncSession
user_id: uuid.UUID
account_id: uuid.UUID
mailbox_id: uuid.UUID
uid: int = 0
@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"sq{uuid.uuid4().hex[:8]}@test.local", password_hash=hash_password("pw"))
session.add(user)
await session.flush()
account = Account(
user_id=user.id,
email="me@example.com",
imap_config={},
smtp_config={},
secret_ct=b"",
discovery_src="manual",
)
session.add(account)
await session.flush()
mailbox = Mailbox(account_id=account.id, user_id=user.id, path="INBOX", special_use="inbox")
session.add(mailbox)
await session.commit()
token = await create_session(user.id, "pytest")
client = httpx.AsyncClient(
transport=ASGITransport(app=create_app()),
base_url="http://testserver",
cookies={"session": token},
)
try:
yield Ctx(
client=client,
session=session,
user_id=user.id,
account_id=account.id,
mailbox_id=mailbox.id,
)
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 _ingest(
ctx: Ctx,
*,
frm: str,
subject: str,
body: str,
flags: frozenset[str] = frozenset(),
attach: bool = False,
) -> None:
ctx.uid += 1
m = EmailMessage()
m["Subject"] = subject
m["From"] = frm
m["To"] = "me@example.com"
m["Message-ID"] = f"<{uuid.uuid4().hex}@x.com>"
m["Date"] = "Mon, 09 Mar 2026 08:36:37 +0100"
m.set_content(body)
if attach:
m.add_attachment(b"data", maintype="application", subtype="pdf", filename="report.pdf")
result = await persist_message(
ctx.session,
user_id=ctx.user_id,
account_id=ctx.account_id,
mailbox_id=ctx.mailbox_id,
mailbox_special_use="inbox",
remote=RemoteMessage(
uid=ctx.uid, raw=m.as_bytes(), flags=flags, internal_date=datetime.now(UTC)
),
)
await ctx.session.commit()
assert result is not None
await PgSearchBackend().index(result.search_doc)
async def _search(ctx: Ctx, q: str) -> int:
resp = await ctx.client.get("/api/search", params={"q": q})
assert resp.status_code == 200, resp.text
return len(resp.json()["threads"])
@pytest_asyncio.fixture
async def seeded(ctx: Ctx) -> Ctx:
await _ingest(
ctx,
frm="Bob <bob@corp.com>",
subject="Quarterly revenue",
body="revenue numbers up",
flags=frozenset(),
) # unread
await _ingest(
ctx,
frm="Carol <carol@corp.com>",
subject="Lunch",
body="grab lunch",
flags=frozenset({"\\Seen"}),
) # read
await _ingest(
ctx,
frm="Dave <dave@corp.com>",
subject="Report attached",
body="see the pdf",
flags=frozenset({"\\Seen"}),
attach=True,
)
return ctx
async def test_free_text(seeded: Ctx) -> None:
assert await _search(seeded, "revenue") == 1
assert await _search(seeded, "lunch") == 1
async def test_from_operator(seeded: Ctx) -> None:
assert await _search(seeded, "from:bob") == 1
assert await _search(seeded, "from:corp.com") == 3 # all three share the domain
async def test_is_unread_operator(seeded: Ctx) -> None:
assert await _search(seeded, "is:unread") == 1
assert await _search(seeded, "is:read") == 2
async def test_has_attachment_operator(seeded: Ctx) -> None:
assert await _search(seeded, "has:attachment") == 1
async def test_subject_operator(seeded: Ctx) -> None:
assert await _search(seeded, "subject:revenue") == 1
async def test_negation(seeded: Ctx) -> None:
# everything unread that is NOT from bob -> none
assert await _search(seeded, "is:unread -from:bob") == 0
async def test_combined_operators(seeded: Ctx) -> None:
assert await _search(seeded, "from:dave has:attachment") == 1
assert await _search(seeded, "from:bob has:attachment") == 0
+34
View File
@@ -0,0 +1,34 @@
"""Folder-name special-use heuristics (SPEC §6.3)."""
from __future__ import annotations
import pytest
from app.sync.folder_names import infer_special_use
@pytest.mark.parametrize(
"path,expected",
[
("INBOX", "inbox"),
("inbox", "inbox"),
("Sent", "sent"),
("Wysłane", "sent"), # Polish
("Elementy wysłane", "sent"),
("Gesendet", "sent"), # German
("Drafts", "drafts"),
("Robocze", "drafts"),
("Trash", "trash"),
("Kosz", "trash"), # Polish
("Papierkorb", "trash"), # German
("Spam", "junk"),
("Junk", "junk"),
("Archive", "archive"),
("Archiwum", "archive"),
("INBOX/Sent", "sent"), # nested leaf
("Work", None),
("Random Folder", None),
],
)
def test_infer_special_use(path: str, expected: str | None) -> None:
assert infer_special_use(path) == expected
+15
View File
@@ -0,0 +1,15 @@
"""Fixtures for threading tests: the shared rollback session + a threading env."""
from __future__ import annotations
import pytest_asyncio
from sqlalchemy.ext.asyncio import AsyncSession
from tests.threading.env import ThreadingEnv
@pytest_asyncio.fixture
async def env(db_session: AsyncSession) -> ThreadingEnv:
e = ThreadingEnv(db_session)
await e.setup()
return e
+87
View File
@@ -0,0 +1,87 @@
"""Test helper: a user with two accounts/mailboxes + a message factory.
Kept out of ``conftest.py`` so tests can import :class:`ThreadingEnv` without
re-importing the conftest module.
"""
from __future__ import annotations
import uuid
from datetime import UTC, datetime, timedelta
from sqlalchemy.ext.asyncio import AsyncSession
from app.models import Account, Mailbox, Message, User
from app.threading_.threader import thread_message
BASE = datetime(2026, 1, 1, 12, 0, 0, tzinfo=UTC)
class ThreadingEnv:
"""A user with two accounts + one mailbox each, plus a message factory."""
def __init__(self, session: AsyncSession) -> None:
self.session = session
self.user_id: uuid.UUID
self.account_a: uuid.UUID
self.account_b: uuid.UUID
self.mailbox_a: uuid.UUID
self.mailbox_b: uuid.UUID
self._uid = 0
async def setup(self) -> None:
user = User(handle=f"t{uuid.uuid4().hex[:8]}@test.local", password_hash="x")
self.session.add(user)
await self.session.flush()
self.user_id = user.id
for label in ("a", "b"):
account = Account(
user_id=self.user_id,
email=f"{label}@example.com",
imap_config={},
smtp_config={},
secret_ct=b"",
discovery_src="manual",
)
self.session.add(account)
await self.session.flush()
mailbox = Mailbox(account_id=account.id, user_id=self.user_id, path="INBOX")
self.session.add(mailbox)
await self.session.flush()
setattr(self, f"account_{label}", account.id)
setattr(self, f"mailbox_{label}", mailbox.id)
async def add(
self,
*,
message_id: str | None,
subject: str = "Subject",
references: list[str] | None = None,
in_reply_to: str | None = None,
from_email: str = "alice@example.com",
to_email: str = "bob@example.com",
minutes: int = 0,
account: str = "a",
flags: list[str] | None = None,
) -> Message:
self._uid += 1
msg = Message(
user_id=self.user_id,
account_id=getattr(self, f"account_{account}"),
mailbox_id=getattr(self, f"mailbox_{account}"),
uid=self._uid,
message_id_hdr=message_id,
in_reply_to=in_reply_to,
references_hdr=references or [],
subject=subject,
from_addr=[{"name": "", "email": from_email}],
to_addr=[{"name": "", "email": to_email}],
internal_date=BASE + timedelta(minutes=minutes),
flags=flags or [],
blob_sha256="0" * 64,
snippet=subject,
)
self.session.add(msg)
await self.session.flush()
await thread_message(self.session, msg)
return msg
+55
View File
@@ -0,0 +1,55 @@
"""Message-ID and subject normalization (SPEC §7.1, §7.3)."""
from __future__ import annotations
from app.threading_.normalize import (
is_synthetic,
normalize_message_id,
normalize_reference_list,
normalize_subject,
synthesize_message_id,
)
def test_normalize_message_id_strips_brackets_and_lowercases_domain() -> None:
assert normalize_message_id("<Abc.123@Example.COM>") == "Abc.123@example.com"
assert normalize_message_id(" <x@Y.Z> ") == "x@y.z"
# local part case is preserved, domain lowercased
assert normalize_message_id("<CaseSensitive@Domain.Com>") == "CaseSensitive@domain.com"
def test_normalize_message_id_rejects_invalid() -> None:
assert normalize_message_id(None) is None
assert normalize_message_id("no-at-sign") is None
assert normalize_message_id("<>") is None
assert normalize_message_id("@nolocal.com") is None
def test_normalize_reference_list_order_and_dedupe() -> None:
raw = "<a@x.com> <b@y.com> <a@X.com>"
assert normalize_reference_list(raw) == ["a@x.com", "b@y.com"]
assert normalize_reference_list("") == []
assert normalize_reference_list(None) == []
def test_normalize_reference_list_whitespace_separated_without_brackets() -> None:
assert normalize_reference_list("a@x.com b@y.com") == ["a@x.com", "b@y.com"]
def test_synthetic_id() -> None:
sid = synthesize_message_id(b"raw bytes here")
assert sid.endswith("@synthetic.local")
assert is_synthetic(sid)
assert not is_synthetic("real@example.com")
assert not is_synthetic(None)
# deterministic
assert synthesize_message_id(b"raw bytes here") == sid
def test_normalize_subject_strips_prefixes() -> None:
assert normalize_subject("Re: Hello") == "Hello"
assert normalize_subject("RE: FW: Fwd: Hello world") == "Hello world"
assert normalize_subject("Odp: Cześć") == "Cześć" # Polish reply prefix
assert normalize_subject("PD: X") == "X"
assert normalize_subject("No prefix") == "No prefix"
assert normalize_subject("") == ""
+137
View File
@@ -0,0 +1,137 @@
"""JWZ threading test vectors (SPEC §7.4). All must pass."""
from __future__ import annotations
from tests.threading.env import ThreadingEnv
async def test_linear_chain(env: ThreadingEnv) -> None:
a = await env.add(message_id="a@x.com", subject="Hello")
b = await env.add(
message_id="b@x.com",
subject="Re: Hello",
in_reply_to="a@x.com",
references=["a@x.com"],
minutes=1,
)
c = await env.add(
message_id="c@x.com",
subject="Re: Hello",
in_reply_to="b@x.com",
references=["a@x.com", "b@x.com"],
minutes=2,
)
assert a.thread_id == b.thread_id == c.thread_id
async def test_chain_out_of_order(env: ThreadingEnv) -> None:
# C arrives first, then A, then B — all must end in one thread.
c = await env.add(
message_id="c@x.com", subject="Re: Hi", references=["a@x.com", "b@x.com"], minutes=2
)
a = await env.add(message_id="a@x.com", subject="Hi", minutes=0)
b = await env.add(message_id="b@x.com", subject="Re: Hi", references=["a@x.com"], minutes=1)
assert a.thread_id == b.thread_id == c.thread_id
async def test_reply_missing_in_reply_to_but_has_references(env: ThreadingEnv) -> None:
a = await env.add(message_id="a@x.com", subject="Topic")
b = await env.add(
message_id="b@x.com", subject="Re: Topic", references=["a@x.com"], minutes=1
) # no In-Reply-To
assert a.thread_id == b.thread_id
async def test_thread_merge_on_late_parent(env: ThreadingEnv) -> None:
# Two children of A arrive first (each referencing only A) forming separate
# threads; the late parent A must merge them into one.
b = await env.add(message_id="b@x.com", subject="Re: T", references=["a@x.com"], minutes=1)
c = await env.add(message_id="c@x.com", subject="Re: T", references=["a@x.com"], minutes=2)
# b and c both reference a but not each other -> initially could be separate threads
a = await env.add(message_id="a@x.com", subject="T", minutes=0)
await env.session.refresh(b)
await env.session.refresh(c)
assert a.thread_id == b.thread_id == c.thread_id
async def test_broken_client_subject_only_joins(env: ThreadingEnv) -> None:
# A client that drops References; joins by subject + shared participant within 7 days.
a = await env.add(
message_id="a@x.com",
subject="Project update",
from_email="alice@example.com",
to_email="bob@example.com",
)
b = await env.add(
message_id="b@x.com",
subject="Re: Project update",
from_email="bob@example.com",
to_email="alice@example.com",
minutes=60,
) # no references, shares participants
assert a.thread_id == b.thread_id
async def test_distinct_topic_same_subject_does_not_join(env: ThreadingEnv) -> None:
a = await env.add(
message_id="a@x.com",
subject="hi",
from_email="alice@example.com",
to_email="bob@example.com",
)
b = await env.add(
message_id="b@x.com",
subject="hi",
from_email="carol@example.com",
to_email="dave@example.com",
minutes=5,
)
assert a.thread_id != b.thread_id
async def test_cross_account_merge(env: ThreadingEnv) -> None:
# A message in account A references a message in account B -> one thread (I-3).
a = await env.add(message_id="a@x.com", subject="Shared", account="a")
b = await env.add(
message_id="b@x.com", subject="Re: Shared", references=["a@x.com"], account="b", minutes=1
)
assert a.thread_id == b.thread_id
assert a.account_id != b.account_id
async def test_duplicate_message_id_same_account_merges(env: ThreadingEnv) -> None:
# Same message in two mailboxes of ONE account (e.g. Gmail INBOX + All Mail):
# the shared Message-ID is authoritative -> one thread. (Threading-level view;
# the indexer decides authoritative vs synthetic per SPEC §7.1 — see indexer tests.)
a1 = await env.add(message_id="dup@x.com", subject="Dup", account="a")
a2 = await env.add(message_id="dup@x.com", subject="Dup", account="a", minutes=1)
assert a1.thread_id == a2.thread_id
async def test_synthetic_ids_do_not_join(env: ThreadingEnv) -> None:
# Two messages that were given synthetic ids (§7.1 duplicate handling) with no
# references must not merge, even with the same subject far apart in time.
a = await env.add(message_id="deadbeef@synthetic.local", subject="Welcome", minutes=0)
b = await env.add(
message_id="cafebabe@synthetic.local", subject="Welcome", account="b", minutes=60 * 24 * 30
)
assert a.thread_id != b.thread_id
async def test_synthetic_id_isolation(env: ThreadingEnv) -> None:
# Two messages lacking Message-ID and References with empty subjects never join.
a = await env.add(message_id=None, subject="")
b = await env.add(message_id=None, subject="", minutes=1)
assert a.thread_id != b.thread_id
async def test_denormalization_counts(env: ThreadingEnv) -> None:
from app.models import Thread
a = await env.add(message_id="a@x.com", subject="Count", flags=["\\Seen"])
await env.add(message_id="b@x.com", subject="Re: Count", references=["a@x.com"], minutes=1)
thread = await env.session.get(Thread, a.thread_id)
assert thread is not None
assert thread.message_count == 2
assert thread.unread_count == 1 # only b is unread
assert thread.snippet == "Re: Count" # newest message's snippet