Attachments stored once; nightly clean-up of unused files

Attachments are no longer stored apart from their message (they were kept
twice: base64 inside the raw message and decoded on their own). The indexer
records the MIME part (attachments.part_index, migration 0023); downloads,
zip, inline images, thumbnails and forwarding take it out of the message;
videos stream from a short-lived copy (kept a day, 1 GiB per user). A
background pass on worker start removes the copies stored before, each only
after checking it can be taken out again (spec App. D §D.6 item 1, criterion
corrected to <= 60%).

A nightly job (03:47, after the Trash/Spam purge) removes files nothing
refers to any more — deleted or purged messages, previews of attachments
gone, uploads no draft has used for a week, expired streaming copies. Files
younger than an hour are left alone; a sent message waiting for its Sent copy
is held until its job is done. Account removal keeps files a draft still uses.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 20:20:21 +02:00
co-authored by Claude Opus 5.5
parent 0514053252
commit 3957dd540a
17 changed files with 1063 additions and 27 deletions
+34 -8
View File
@@ -20,6 +20,7 @@ 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 attachments as att_store
from app.indexer import blobstore, thumbs
from app.indexer.mime import parse_message
from app.indexer.sanitize import SANITIZER_VERSION, sanitize_html
@@ -176,7 +177,7 @@ async def message_cid(
)
if att is None:
raise_api_error(404, "not_found", "inline part not found")
data = blobstore.load(msg.user_id, att.blob_sha256)
data = await _attachment_bytes(msg, att)
media_type = att.mime_type if att.mime_type in _INLINE_SAFE else "application/octet-stream"
return Response(content=data, media_type=media_type)
@@ -190,6 +191,16 @@ def _content_disposition(disposition: str, filename: str) -> str:
return f"{disposition}; filename=\"{fallback}\"; filename*=UTF-8''{quote(filename)}"
async def _attachment_bytes(msg: Message, att: Attachment) -> bytes:
"""An attachment's bytes, taken out of its message (404 if they cannot be)."""
try:
return await asyncio.to_thread(
att_store.load, msg.user_id, msg.blob_sha256, att.blob_sha256, att.part_index
)
except FileNotFoundError:
raise_api_error(404, "not_found", "attachment content missing")
async def _real_attachments(db: AsyncSession, message_id: uuid.UUID) -> list[Attachment]:
"""Downloadable (non-inline) attachments for a message, in a stable order."""
rows = await db.scalars(
@@ -221,9 +232,10 @@ 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.
real (non-inline) attachment. Each is taken out of the message into a blob
of its own (shared, by hash, with any other use of the same file).
Returns them as the composer lists its attachments."""
await _owned_message(db, user.id, message_id)
msg = await _owned_message(db, user.id, message_id)
rows = [
ComposeAttachment(
user_id=user.id,
@@ -233,7 +245,9 @@ async def forward_attachments(
blob_sha256=a.blob_sha256,
)
for a in await _real_attachments(db, message_id)
if blobstore.exists(user.id, a.blob_sha256)
if await asyncio.to_thread(
att_store.materialize, user.id, msg.blob_sha256, a.blob_sha256, a.part_index
)
]
db.add_all(rows)
await db.commit()
@@ -276,7 +290,7 @@ async def download_attachments_zip(
name = f"{stem}-{seen[name]}{dot}{ext}" if dot else f"{name}-{seen[name]}"
else:
seen[name] = 1
zf.writestr(name, blobstore.load(msg.user_id, att.blob_sha256))
zf.writestr(name, await _attachment_bytes(msg, att))
return Response(
content=buffer.getvalue(),
@@ -301,7 +315,14 @@ async def attachment_thumbnail(
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)
data = await asyncio.to_thread(
thumbs.thumbnail,
msg.user_id,
att.blob_sha256,
kind,
att.size_bytes,
lambda: att_store.load(msg.user_id, msg.blob_sha256, att.blob_sha256, att.part_index),
)
if data is None:
raise_api_error(404, "not_found", "no thumbnail")
# An attachment never changes under its id: the browser may keep this.
@@ -329,8 +350,13 @@ async def message_attachment(
)
if att is None:
raise_api_error(404, "not_found", "attachment not found")
path = blobstore.blob_path(msg.user_id, att.blob_sha256)
if not path.exists():
try:
# Taken out of the message into a short-lived file (kept a day after
# its last use), so it can be streamed with HTTP Range like before.
path = await asyncio.to_thread(
att_store.file, msg.user_id, msg.blob_sha256, att.blob_sha256, att.part_index
)
except FileNotFoundError:
raise_api_error(404, "not_found", "attachment content missing")
inline_type = None if download else _inline_type(att.mime_type, att.filename)
disposition = "inline" if inline_type else "attachment"