Files
webmail-client/backend/tests/api/test_api.py
T
lukasandClaude Opus 5.5 fba3c947bf Attachment previews, forwarded attachments, row dividers
Attachments in the reading pane are tiles with a preview: images and a PDF's
first page, rendered on the server (Pillow, pypdfium2) as WebP of at most
480 px, cached beside the blob store and in the browser. Tiles are all one
size — at most 230 px wide, narrower so three still fit in a row — and frame
each preview by its shape: close to the tile's it fills it, very tall / wide /
square ones show whole over a blurred copy, small ones are not enlarged, PDFs
show the top of the page. The name bar (checkbox, type, name, size, download)
slides in on hover and adapts to the tile's width; files without a preview
get a large type icon. Previews of opened conversations are kept offline.

Forward now carries the original's attachments: new compose attachments
sharing the stored blobs (POST /messages/{id}/forward-attachments).

Mailbox display / Settings → Appearance: "Show a dividing line between
rows" (off by default, per device), with a new switch. Settings drop the
"On a phone…" note, and "Language" reads "Spellchecking language".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 20:15:17 +02:00

608 lines
23 KiB
Python

"""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,
sender: str = "Alice <alice@example.com>",
) -> bytes:
m = EmailMessage()
m["Subject"] = subject
m["From"] = sender
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_list_senders_are_authors_oldest_first(api: ApiCtx) -> None:
# Our reply joins the kickoff thread: the sender column lists both authors in
# the order they wrote — Alice twice collapses to one entry, and the recipients
# (me, on Alice's messages) are not senders.
assert api.session is not None and api.account_id is not None
inbox = await api.session.scalar(
select(Mailbox).where(Mailbox.account_id == api.account_id, Mailbox.path == "INBOX")
)
assert inbox is not None
raw = _raw(
"Re: Project kickoff",
"thanks",
mid="<d@x.com>",
refs="<b@x.com>",
sender="Me <me@example.com>",
)
remote = RemoteMessage(uid=9, raw=raw, flags=frozenset(), internal_date=datetime.now(UTC))
await persist_message(
api.session,
user_id=api.user_id,
account_id=api.account_id,
mailbox_id=inbox.id,
mailbox_special_use="inbox",
remote=remote,
)
await api.session.commit()
resp = await api.client.get("/api/threads")
kickoff = next(t for t in resp.json()["threads"] if "kickoff" in t["subject"].lower())
assert [a["email"] for a in kickoff["senders"]] == ["alice@example.com", "me@example.com"]
lunch = next(t for t in resp.json()["threads"] if t["subject"] == "Lunch")
assert [a["email"] for a in lunch["senders"]] == ["alice@example.com"]
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_all_but_trash_spam(api: ApiCtx) -> None:
assert api.session is not None
# The account view shows the account's mail (both seeded threads are 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
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)))
)
# Moving "Lunch" to Archive drops it from the inbox but KEEPS it in the account
# view — only Trash/Spam are excluded there.
for m in msgs:
m.mailbox_id = api.archive_id
await api.session.commit()
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 sorted(t["subject"] for t in account.json()["threads"]) == ["Lunch", "Project kickoff"]
# Moving it to Trash removes it from the account view (still reachable via Trash).
trash = Mailbox(
account_id=api.account_id, user_id=api.user_id, path="Trash", special_use="trash"
)
api.session.add(trash)
await api.session.flush()
for m in msgs:
m.mailbox_id = trash.id
await api.session.commit()
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"]
trash_view = await api.client.get("/api/threads", params={"view": "folder:trash"})
assert [t["subject"] for t in trash_view.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_attachment_download_non_ascii_filename(api: ApiCtx) -> None:
# A Polish filename (ł, ń) is not Latin-1, which would crash the response with
# a 500 if put raw in Content-Disposition. It must serve 200 with RFC 6266.
from app.indexer import blobstore
from app.models import Attachment
assert api.session is not None
mid = api.message_ids[-1]
payload = b"PK\x03\x04 zip-ish bytes"
sha = blobstore.store(api.user_id, payload)
name = "oferta - składowe ceny.ods"
api.session.add(
Attachment(
message_id=mid,
user_id=api.user_id,
filename=name,
mime_type="application/vnd.oasis.opendocument.spreadsheet",
size_bytes=len(payload),
content_id=None,
blob_sha256=sha,
)
)
await api.session.commit()
aid = next(
a["id"] for a in (await api.client.get(f"/api/messages/{mid}/attachments")).json()
if a["filename"] == name
)
resp = await api.client.get(f"/api/messages/{mid}/attachments/{aid}")
assert resp.status_code == 200 # not 500
assert resp.content == payload
cd = resp.headers["content-disposition"]
assert "filename*=UTF-8''" in cd # UTF-8 encoded name for modern clients
assert "sk%C5%82adowe" in cd # ł percent-encoded
async def test_quota_reports_usage_and_default(api: ApiCtx) -> None:
resp = await api.client.get("/api/settings/quota")
assert resp.status_code == 200
body = resp.json()
assert body["quota_bytes"] == 5 * 1024**3 # default 5 GiB
assert body["used_bytes"] > 0 # seeded messages have a size
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_thread_detail_hides_trashed_message_unless_viewing_trash(api: ApiCtx) -> None:
assert api.session is not None
# Give the account a Trash folder and delete a single message of the 2-message
# kickoff thread (move it to Trash) — as the per-message Delete action does.
trash = Mailbox(
account_id=api.account_id, user_id=api.user_id, path="Trash", special_use="trash"
)
api.session.add(trash)
await api.session.flush()
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']}")).json()
victim = uuid.UUID(detail["messages"][-1]["id"])
msg = await api.session.get(Message, victim)
assert msg is not None
msg.mailbox_id = trash.id
await api.session.commit()
# Viewing the thread from the Inbox: the trashed message is gone.
inbox_view = await api.client.get(
f"/api/threads/{kickoff['id']}", params={"view": "folder:inbox"}
)
ids = [m["id"] for m in inbox_view.json()["messages"]]
assert str(victim) not in ids
assert len(ids) == 1
# Viewing from the Trash folder: only the trashed message is shown (Trash is
# message-scoped), not the rest of the conversation.
trash_view = await api.client.get(
f"/api/threads/{kickoff['id']}", params={"view": "folder:trash"}
)
ids_trash = [m["id"] for m in trash_view.json()["messages"]]
assert ids_trash == [str(victim)]
# The Inbox LIST count drops the trashed message (was 2, now 1); the Trash list
# counts only the one trashed message.
inbox_list = await api.client.get("/api/threads", params={"view": "folder:inbox"})
row = next(t for t in inbox_list.json()["threads"] if t["id"] == kickoff["id"])
assert row["message_count"] == 1
trash_list = await api.client.get("/api/threads", params={"view": "folder:trash"})
trow = next(t for t in trash_list.json()["threads"] if t["id"] == kickoff["id"])
assert trow["message_count"] == 1
async def test_folder_counts(api: ApiCtx) -> None:
assert api.session is not None
# All three seeded inbox messages are unread; no drafts yet.
resp = await api.client.get("/api/mailboxes/counts")
assert resp.status_code == 200
assert resp.json() == {"inbox_unread": 3, "drafts": 0}
# Marking one message \Seen lowers the unread count.
msg = await api.session.get(Message, api.message_ids[0])
assert msg is not None
msg.flags = ["\\Seen"]
await api.session.commit()
body = (await api.client.get("/api/mailboxes/counts")).json()
assert body["inbox_unread"] == 2
async def test_thread_collapses_cross_account_duplicate(api: ApiCtx) -> None:
assert api.session is not None
# The kickoff thread's first message; give it a real delivery_id.
kickoff = next(t for t in (await api.client.get("/api/threads")).json()["threads"]
if t["message_count"] == 2)
detail = (await api.client.get(f"/api/threads/{kickoff['id']}")).json()
first_id = uuid.UUID(detail["messages"][0]["id"])
first = await api.session.get(Message, first_id)
assert first is not None
first.delivery_id = "<shared@x.com>"
# A second account receives the SAME email (same delivery_id) into the same thread.
acct2 = Account(
user_id=api.user_id, email="me2@example.com", imap_config={}, smtp_config={},
secret_ct=b"", discovery_src="manual",
)
api.session.add(acct2)
await api.session.flush()
inbox2 = Mailbox(account_id=acct2.id, user_id=api.user_id, path="INBOX", special_use="inbox")
api.session.add(inbox2)
await api.session.flush()
api.session.add(Message(
user_id=api.user_id, account_id=acct2.id, mailbox_id=inbox2.id, uid=1,
thread_id=first.thread_id, message_id_hdr="<synthetic@synthetic.local>",
delivery_id="<shared@x.com>", subject=first.subject,
internal_date=datetime.now(UTC), blob_sha256="z" * 64, # later -> first stays the rep
))
await api.session.commit()
body = (await api.client.get(f"/api/threads/{kickoff['id']}")).json()
ids = [m["id"] for m in body["messages"]]
# Still two rows (kickoff + reply) — the duplicate collapsed into the first, not
# a third row — and the first now notes the other account.
assert len(ids) == 2
rep = next(m for m in body["messages"] if m["id"] == str(first_id))
assert rep["also_in_account_ids"] == [str(acct2.id)]
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_offset(api: ApiCtx) -> None:
first = await api.client.get("/api/threads", params={"limit": 1, "offset": 0})
body = first.json()
assert len(body["threads"]) == 1
assert body["total"] >= 2 # more than one page of threads exists
second = await api.client.get("/api/threads", params={"limit": 1, "offset": 1})
body2 = second.json()
assert len(body2["threads"]) == 1
assert body2["total"] == body["total"] # grand total is stable across pages
assert body2["threads"][0]["id"] != body["threads"][0]["id"]
async def test_forward_carries_the_attachments(api: ApiCtx) -> None:
# Forwarding attaches the original's files (not its inline images), sharing
# the stored blob — and sending the forward then really includes them.
from app.indexer import blobstore
from app.models import Attachment, ComposeAttachment, Draft
from app.outbox.attachments import load_attachments
assert api.session is not None
mid = api.message_ids[-1]
sha = blobstore.store(api.user_id, b"%PDF-1.4 quarterly numbers")
logo = blobstore.store(api.user_id, b"\x89PNG logo")
api.session.add_all(
[
Attachment(
message_id=mid,
user_id=api.user_id,
filename="numbers.pdf",
mime_type="application/pdf",
size_bytes=26,
content_id=None,
blob_sha256=sha,
),
Attachment(
message_id=mid,
user_id=api.user_id,
filename="logo.png",
mime_type="image/png",
size_bytes=9,
content_id="logo@x", # inline: part of the body, not a file
blob_sha256=logo,
),
]
)
await api.session.commit()
api.client.cookies.set("csrf", "t") # a POST: double-submit CSRF
resp = await api.client.post(
f"/api/messages/{mid}/forward-attachments", headers={"X-CSRF": "t"}
)
assert resp.status_code == 200
body = resp.json()
assert [(a["filename"], a["size_bytes"]) for a in body] == [("numbers.pdf", 26)]
row = await api.session.get(ComposeAttachment, uuid.UUID(body[0]["id"]))
assert row is not None and row.blob_sha256 == sha and row.user_id == api.user_id
draft = Draft(
user_id=api.user_id,
account_id=api.account_id,
kind="forward",
payload={
"to": ["bob@example.com"],
"subject": "Fwd: Lunch",
"attachment_ids": [body[0]["id"]],
},
)
assert await load_attachments(api.session, api.user_id, draft) == [
("numbers.pdf", "application/pdf", b"%PDF-1.4 quarterly numbers")
]
# Someone else's message: not found.
other = await api.client.post(
f"/api/messages/{uuid.uuid4()}/forward-attachments", headers={"X-CSRF": "t"}
)
assert other.status_code == 404
async def test_attachment_thumbnails(api: ApiCtx) -> None:
# Images and a PDF's first page get a WebP preview (aspect kept, at most
# 480 px, browser-cacheable); other files, and broken ones, get a 404.
import io
import pypdfium2 as pdfium # type: ignore[import-untyped]
from PIL import Image
from app.indexer import blobstore
from app.models import Attachment
assert api.session is not None
mid = api.message_ids[-1]
def png(w: int, h: int) -> bytes:
out = io.BytesIO()
Image.new("RGB", (w, h), (200, 30, 30)).save(out, "PNG")
return out.getvalue()
pdf = pdfium.PdfDocument.new()
pdf.new_page(595, 842)
pdf_bytes = io.BytesIO()
pdf.save(pdf_bytes)
files = {
"wide.png": ("image/png", png(1600, 400)),
"scan.pdf": ("application/octet-stream", pdf_bytes.getvalue()), # mislabelled
"notes.txt": ("text/plain", b"hello"),
"broken.jpg": ("image/jpeg", b"not really a jpeg"),
}
ids: dict[str, uuid.UUID] = {}
for name, (mime, data) in files.items():
att = Attachment(
message_id=mid,
user_id=api.user_id,
filename=name,
mime_type=mime,
size_bytes=len(data),
content_id=None,
blob_sha256=blobstore.store(api.user_id, data),
)
api.session.add(att)
await api.session.flush()
ids[name] = att.id
await api.session.commit()
def url(name: str) -> str:
return f"/api/messages/{mid}/attachments/{ids[name]}/thumb"
wide = await api.client.get(url("wide.png"))
assert wide.status_code == 200 and wide.headers["content-type"] == "image/webp"
assert "immutable" in wide.headers["cache-control"]
assert Image.open(io.BytesIO(wide.content)).size == (480, 120) # aspect kept
assert (await api.client.get(url("wide.png"))).content == wide.content # cached
page = await api.client.get(url("scan.pdf"))
assert page.status_code == 200
w, h = Image.open(io.BytesIO(page.content)).size
assert h == 480 and 330 < w < 350 # an A4 page, upright
assert (await api.client.get(url("notes.txt"))).status_code == 404
assert (await api.client.get(url("broken.jpg"))).status_code == 404