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>
140 lines
5.3 KiB
Python
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
|