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
+28 -3
View File
@@ -14,15 +14,17 @@ from datetime import UTC, datetime, timedelta
from email.utils import getaddresses
from typing import Any
from arq import Retry
from sqlalchemy import select, update
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.ext.asyncio import AsyncSession
from app.db import session_scope
from app.events import get_event_bus
from app.indexer import blobstore
from app.indexer import blobstore, gc
from app.indexer.indexer import persist_message
from app.models import Account, Draft, KnownRecipient, Mailbox, Message, Outbox
from app.outbox import inline_images
from app.outbox.attachments import load_attachments
from app.outbox.mime_build import build_mime
from app.outbox.service import delete_mirrored_draft_row
@@ -171,7 +173,21 @@ async def _send_failed(
async def file_sent_copy_job(ctx: dict[str, Any], account_id: str, user_id: str, sha: str) -> None:
"""File a sent message's copy in Sent, after the send itself succeeded but
the IMAP server could not be reached. Retried (arq Retry) for ~24 hours."""
the IMAP server could not be reached. Retried (arq Retry) for ~24 hours.
Until then the copy's file is referenced only by this job: it is held from
the nightly clean-up (app/indexer/gc.py) and let go once filed or given up."""
try:
await _file_sent_copy(ctx, account_id, user_id, sha)
except Retry:
raise # tried again later: the file is still needed
except BaseException:
await gc.release(user_id, sha)
raise
await gc.release(user_id, sha)
async def _file_sent_copy(ctx: dict[str, Any], account_id: str, user_id: str, sha: str) -> None:
uid = uuid.UUID(user_id)
async with session_scope() as session:
account = await session.get(Account, uuid.UUID(account_id))
@@ -236,8 +252,16 @@ async def send_job(ctx: dict[str, Any], outbox_id: str) -> None:
return
user_id = outbox.user_id
attachments = await load_attachments(session, user_id, draft)
# Images quoted from the original travel inside the message, not as
# links to this server (app/outbox/inline_images.py).
html, inline = await inline_images.embed(
session,
user_id,
str(draft.payload.get("html", "") or ""),
account.email.rsplit("@", 1)[-1],
)
transmit_raw, sent_raw, envelope_from, envelope_to = build_mime(
account, draft, reply_to, attachments
account, draft, reply_to, attachments, html=html, inline=inline
)
sent_path = await session.scalar(
select(Mailbox.path).where(
@@ -271,6 +295,7 @@ async def send_job(ctx: dict[str, Any], outbox_id: str) -> None:
except Exception as exc: # noqa: BLE001
logger.warning("outbox %s sent; filing it in Sent failed, will retry: %s", oid, exc)
sha = blobstore.store(user_id, sent_raw)
await gc.hold(user_id, sha) # until filed: only the job refers to it
await enqueue_job("file_sent_copy_job", str(account_obj.id), str(user_id), sha)
# Remove the mirrored Drafts-folder copy now that it has been sent.
if drafts_path and draft_imap_uid: