"""Account lifecycle: create (encrypt + enqueue sync), delete (cascade + blobs) (SPEC §6.2/§9.1).""" from __future__ import annotations import uuid from sqlalchemy import delete, select 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, Message 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", ) 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() # 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: blobstore.remove(user_id, sha) return True