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