Files
lukasandClaude Opus 5.5 3957dd540a 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>
2026-10-08 20:20:21 +02:00

140 lines
5.3 KiB
Python

"""Account lifecycle: create (encrypt + enqueue sync), delete (cascade + blobs) (SPEC §6.2/§9.1)."""
from __future__ import annotations
import uuid
from sqlalchemy import delete, func, select, update
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from app.accounts.schemas import CreateAccountRequest
from app.accounts.types import ServerConfig
from app.indexer import blobstore
from app.models import Account, Attachment, ComposeAttachment, Message, Thread
from app.security.crypto import encrypt_secret
class AccountExistsError(Exception):
"""The user already has an account for this email."""
def _server_dict(cfg: ServerConfig) -> dict[str, object]:
return {"host": cfg.host, "port": cfg.port, "tls": cfg.tls, "auth": cfg.auth}
async def create_account(
session: AsyncSession, *, user_id: uuid.UUID, req: CreateAccountRequest
) -> Account:
"""Create an account row with encrypted credentials (state=backfilling)."""
imap = ServerConfig(req.imap.host, req.imap.port, req.imap.tls, req.imap.auth)
smtp = ServerConfig(req.smtp.host, req.smtp.port, req.smtp.tls, req.smtp.auth)
account = Account(
user_id=user_id,
email=str(req.email),
display_name=req.display_name,
color=req.color,
imap_config=_server_dict(imap),
smtp_config=_server_dict(smtp),
secret_ct=encrypt_secret(req.password.encode("utf-8")),
discovery_src="manual",
state="backfilling",
sync_window=req.sync_window,
sync_spam=req.sync_spam,
)
session.add(account)
try:
await session.flush()
except IntegrityError as exc:
await session.rollback()
raise AccountExistsError(str(req.email)) from exc
return account
async def delete_account(
session: AsyncSession, *, user_id: uuid.UUID, account_id: uuid.UUID
) -> bool:
"""Delete an account and its data. Removes blobs no longer referenced (dedup-aware).
Returns False if the account does not belong to the user. DB rows (mailboxes,
messages, body_parts, attachments) and the Postgres search vector cascade via
FK ``ON DELETE CASCADE``.
"""
account = await session.scalar(
select(Account).where(Account.id == account_id, Account.user_id == user_id)
)
if account is None:
return False
# Gather blob digests this account references, before the cascade delete.
msg_shas = set(
await session.scalars(select(Message.blob_sha256).where(Message.account_id == account_id))
)
att_shas = set(
await session.scalars(
select(Attachment.blob_sha256)
.join(Message, Message.id == Attachment.message_id)
.where(Message.account_id == account_id)
)
)
candidate_shas = msg_shas | att_shas
await session.execute(delete(Account).where(Account.id == account_id))
await session.flush()
# Threads are user-scoped and can span accounts (Thread.account_ids), so the
# account cascade only removes its messages — not the threads. Clean up: drop
# threads left with no messages (else they linger as empty rows the unified list
# and label views still show but can't open), and de-list the account from
# threads that survive on another account's messages.
await session.execute(
delete(Thread).where(
Thread.user_id == user_id,
~select(Message.id).where(Message.thread_id == Thread.id).exists(),
)
)
await session.execute(
update(Thread)
.where(Thread.user_id == user_id, Thread.account_ids.contains([account_id]))
.values(account_ids=func.array_remove(Thread.account_ids, account_id))
)
# Deregister from sync:live and drop any lease so the owning sync worker
# stops the account's runner on its next renewal (refactor §2 fencing).
from app.events import get_event_bus
from app.redis_client import get_redis
from app.sync import leases
await leases.mark_not_live(get_redis(), account_id)
# Sync v2 §3: signal the owning worker to tear the runner down now (≤30s),
# rather than waiting for the lease to lapse.
await get_event_bus().publish(
"account.state_changed",
{"user_id": str(user_id), "account_id": str(account_id), "state": "deleted"},
)
# Content-addressed blobs are shared across the user's accounts (dedup): only
# remove a blob when no remaining message/attachment references it.
for sha in candidate_shas:
still_used = await session.scalar(
select(Message.id)
.where(Message.user_id == user_id, Message.blob_sha256 == sha)
.limit(1)
)
if still_used is None:
still_used = await session.scalar(
select(Attachment.id)
.where(Attachment.user_id == user_id, Attachment.blob_sha256 == sha)
.limit(1)
)
if still_used is None:
# A forwarded file waiting in a draft shares the hash: keep it for that.
still_used = await session.scalar(
select(ComposeAttachment.id)
.where(ComposeAttachment.user_id == user_id, ComposeAttachment.blob_sha256 == sha)
.limit(1)
)
if still_used is None:
blobstore.remove(user_id, sha)
return True