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:
2026-10-06 20:15:17 +02:00
co-authored by Claude Opus 5.5
parent bfe011cd2c
commit fba3c947bf
17 changed files with 663 additions and 47 deletions
+62 -2
View File
@@ -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,
+115
View File
@@ -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