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>
608 lines
23 KiB
Python
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
|