"""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", sync_window=req.sync_window, ) 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() # 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: blobstore.remove(user_id, sha) return True