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:
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import uuid
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Annotated
|
||||
@@ -15,6 +16,7 @@ from app.api.schemas import AttachmentOut
|
||||
from app.config import get_settings
|
||||
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
|
||||
from app.models import Attachment, BodyPart, ComposeAttachment, Draft, Mailbox, Message, Outbox
|
||||
from app.outbox.jobs import RETRY_WINDOW
|
||||
@@ -207,6 +209,11 @@ async def adopt_remote_draft(message_id: uuid.UUID, user: CurrentUser, db: DbSes
|
||||
)
|
||||
attachment_ids: list[str] = []
|
||||
for a in att_rows:
|
||||
# Kept only inside the message: give the compose attachment a blob of its own.
|
||||
if not await asyncio.to_thread(
|
||||
att_store.materialize, user.id, msg.blob_sha256, a.blob_sha256, a.part_index
|
||||
):
|
||||
continue
|
||||
ca = ComposeAttachment(
|
||||
user_id=user.id,
|
||||
filename=a.filename,
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user