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>
This commit is contained in:
@@ -475,3 +475,133 @@ async def test_thread_pagination_offset(api: ApiCtx) -> None:
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user