diff --git a/backend/app/api/messages.py b/backend/app/api/messages.py index ab25826..357340b 100644 --- a/backend/app/api/messages.py +++ b/backend/app/api/messages.py @@ -2,6 +2,7 @@ from __future__ import annotations +import asyncio import html as html_lib import io import uuid @@ -19,10 +20,11 @@ from app.api.schemas import AttachmentOut from app.api.settings import add_allowed_image_sender, allowed_image_senders from app.db import get_session from app.identity.deps import CurrentUser, require_csrf -from app.indexer import blobstore +from app.indexer import blobstore, thumbs from app.indexer.mime import parse_message from app.indexer.sanitize import SANITIZER_VERSION, sanitize_html -from app.models import Attachment, BodyPart, Message +from app.indexer.thumbs import thumb_type +from app.models import Attachment, BodyPart, ComposeAttachment, Message router = APIRouter(prefix="/messages", tags=["mail"]) @@ -190,6 +192,37 @@ async def list_attachments( ] +@router.post( + "/{message_id}/forward-attachments", + response_model=list[AttachmentOut], + dependencies=[Depends(require_csrf)], +) +async def forward_attachments( + message_id: uuid.UUID, user: CurrentUser, db: DbSession +) -> list[AttachmentOut]: + """Attach a message's files to a forward of it: one compose attachment per + real (non-inline) attachment, sharing the stored blob — nothing is copied. + Returns them as the composer lists its attachments.""" + await _owned_message(db, user.id, message_id) + rows = [ + ComposeAttachment( + user_id=user.id, + filename=a.filename, + mime_type=a.mime_type, + size_bytes=a.size_bytes, + blob_sha256=a.blob_sha256, + ) + for a in await _real_attachments(db, message_id) + if blobstore.exists(user.id, a.blob_sha256) + ] + db.add_all(rows) + await db.commit() + return [ + AttachmentOut(id=r.id, filename=r.filename, mime_type=r.mime_type, size_bytes=r.size_bytes) + for r in rows + ] + + @router.get("/{message_id}/attachments.zip") async def download_attachments_zip( message_id: uuid.UUID, @@ -232,6 +265,33 @@ async def download_attachments_zip( ) +@router.get("/{message_id}/attachments/{attachment_id}/thumb") +async def attachment_thumbnail( + message_id: uuid.UUID, attachment_id: uuid.UUID, user: CurrentUser, db: DbSession +) -> Response: + """A small preview of an image or a PDF's first page (WebP, at most + 480 px, aspect kept). 404 when the file cannot have one: the reading pane + then shows its type icon instead.""" + msg = await _owned_message(db, user.id, message_id) + att = await db.scalar( + select(Attachment).where( + Attachment.id == attachment_id, Attachment.message_id == message_id + ) + ) + kind = thumb_type(att.mime_type, att.filename) if att is not None else None + if att is None or kind is None: + raise_api_error(404, "not_found", "no thumbnail") + data = await asyncio.to_thread(thumbs.thumbnail, msg.user_id, att.blob_sha256, kind) + if data is None: + raise_api_error(404, "not_found", "no thumbnail") + # An attachment never changes under its id: the browser may keep this. + return Response( + content=data, + media_type="image/webp", + headers={"Cache-Control": "private, max-age=31536000, immutable"}, + ) + + @router.get("/{message_id}/attachments/{attachment_id}") async def message_attachment( message_id: uuid.UUID, diff --git a/backend/app/indexer/thumbs.py b/backend/app/indexer/thumbs.py new file mode 100644 index 0000000..0758e31 --- /dev/null +++ b/backend/app/indexer/thumbs.py @@ -0,0 +1,115 @@ +"""Attachment thumbnails for the reading pane: images and a PDF's first page. + +A thumbnail fits within THUMB_MAX × THUMB_MAX with its aspect ratio kept (the +browser decides how to frame it), as WebP. Made once and kept beside the blob +store — ``{BLOB_ROOT}/{user}/thumbs/{sha}-{THUMB_MAX}.webp`` — with a ``.none`` +marker for files that cannot have one (a broken image, an encrypted PDF, HEIC), +so neither is attempted twice. + +Attachments are hostile input: decoding runs in a worker thread, oversized +files and decompression bombs are refused. +""" + +from __future__ import annotations + +import io +import logging +import uuid +from pathlib import Path + +from app.indexer import blobstore + +logger = logging.getLogger(__name__) + +THUMB_MAX = 480 # px: sharp on a 2× screen for a tile of up to 230 px +_MAX_INPUT_BYTES = 40 * 1024 * 1024 +_MAX_PIXELS = 64_000_000 # a larger image is refused, not decoded +_IMAGE_MIME = frozenset( + {"image/jpeg", "image/png", "image/gif", "image/webp", "image/bmp", "image/tiff"} +) + + +_EXT_MIME = { + "jpg": "image/jpeg", + "jpeg": "image/jpeg", + "png": "image/png", + "gif": "image/gif", + "webp": "image/webp", + "bmp": "image/bmp", + "tif": "image/tiff", + "tiff": "image/tiff", + "pdf": "application/pdf", +} + + +def thumb_type(mime_type: str, filename: str) -> str | None: + """The type to render a thumbnail as, or None. Senders often label files + application/octet-stream, so the extension stands in for a missing type.""" + mime = mime_type.lower() + if mime in _IMAGE_MIME or mime == "application/pdf": + return mime + return _EXT_MIME.get(filename.rsplit(".", 1)[-1].lower()) if "." in filename else None + + +def can_thumbnail(mime_type: str) -> bool: + return mime_type in _IMAGE_MIME or mime_type == "application/pdf" + + +def _path(user_id: uuid.UUID, sha: str, suffix: str) -> Path: + return blobstore.blob_path(user_id, sha).parent.parent / "thumbs" / f"{sha}-{THUMB_MAX}{suffix}" + + +def _render(data: bytes, mime_type: str) -> bytes | None: + from PIL import Image, ImageOps + + Image.MAX_IMAGE_PIXELS = _MAX_PIXELS # beyond: DecompressionBombError + if mime_type == "application/pdf": + import pypdfium2 as pdfium # type: ignore[import-untyped] + + doc = pdfium.PdfDocument(data) + try: + page = doc[0] + width, height = page.get_size() + scale = THUMB_MAX / max(width, height, 1) + img = page.render(scale=scale, draw_annots=True).to_pil() + finally: + doc.close() + else: + img = Image.open(io.BytesIO(data)) + img.seek(0) # an animation: its first frame + img = ImageOps.exif_transpose(img) # phone photos: upright + img.thumbnail((THUMB_MAX, THUMB_MAX)) + if img.mode not in ("RGB", "RGBA"): + img = img.convert("RGBA" if "A" in img.getbands() or img.mode == "P" else "RGB") + out = io.BytesIO() + img.save(out, "WEBP", quality=80, method=4) + return out.getvalue() + + +def thumbnail(user_id: uuid.UUID, sha: str, mime_type: str) -> bytes | None: + """The thumbnail of a stored attachment, or None if it cannot have one. + + Blocking (decoding is CPU work): call it in a thread. + """ + done = _path(user_id, sha, ".webp") + if done.exists(): + return done.read_bytes() + none = _path(user_id, sha, ".none") + if none.exists() or not can_thumbnail(mime_type): + return None + path = blobstore.blob_path(user_id, sha) + if not path.exists() or path.stat().st_size > _MAX_INPUT_BYTES: + return None + try: + thumb = _render(path.read_bytes(), mime_type) + except Exception as exc: # noqa: BLE001 - any undecodable file: no thumbnail + logger.info("no thumbnail for blob %s (%s): %s", sha, mime_type, exc) + thumb = None + done.parent.mkdir(parents=True, exist_ok=True) + if thumb is None: + none.touch() + return None + tmp = done.with_suffix(".tmp") + tmp.write_bytes(thumb) + tmp.replace(done) + return thumb diff --git a/backend/pyproject.toml b/backend/pyproject.toml index e820bec..eeb72ed 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -36,6 +36,11 @@ dependencies = [ # DEPENDENCY: autoconfig/ISPDB return XML from untrusted servers; defusedxml # blocks XXE/billion-laughs that stdlib ElementTree is vulnerable to (§10 hostile input). "defusedxml~=0.7", + # DEPENDENCY: thumbnails of attachments in the reading pane — Pillow scales + # images; pypdfium2 renders a PDF's first page (a self-contained wheel, no + # system libraries, so the slim runtime image needs nothing extra). + "pillow~=11.0", + "pypdfium2~=4.30", ] [project.optional-dependencies] diff --git a/backend/tests/api/test_api.py b/backend/tests/api/test_api.py index 609d829..f84588c 100644 --- a/backend/tests/api/test_api.py +++ b/backend/tests/api/test_api.py @@ -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 diff --git a/frontend/public/sw.js b/frontend/public/sw.js index a4839db..6bd968d 100644 --- a/frontend/public/sw.js +++ b/frontend/public/sw.js @@ -51,7 +51,9 @@ const META = new Set([ ]); const ID = "[0-9a-fA-F-]{36}"; const THREAD_RE = new RegExp(`^/api/threads/(${ID})$`); -const MESSAGE_RE = new RegExp(`^/api/messages/(${ID})/(?:html|attachments|cid/.+)$`); +const MESSAGE_RE = new RegExp( + `^/api/messages/(${ID})/(?:html|attachments|attachments/${ID}/thumb|cid/.+)$`, +); const ID_IN_PATH = new RegExp(ID); const PLUGIN_UI_RE = /^\/api\/plugins\/[^/]+\/ui\//; // plugin bundles (Labels' sidebar…) diff --git a/frontend/src/components/Switch.tsx b/frontend/src/components/Switch.tsx new file mode 100644 index 0000000..d43cbd6 --- /dev/null +++ b/frontend/src/components/Switch.tsx @@ -0,0 +1,39 @@ +// An on/off switch: off shows a dark knob with ✕ on a grey track, on a light +// knob with ✓ on the accent colour. +export function Switch({ + checked, + onChange, + label, + disabled = false, +}: { + checked: boolean; + onChange: (value: boolean) => void; + /** Accessible name, when no visible