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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user