Search: - Meilisearch backend (app/search/meili.py) + factory wiring; pinned SDK 2.7 - Search-as-you-type: debounced query + Postgres prefix matching, built from the DB tokenizer so full email addresses match (email stays one lexeme) - Saved searches API + sidebar virtual mailboxes; save affordance in the box Account sync windows (SPEC §12 M2): - Choose at setup: All (recent 30d first, then remainder), Last 3 months, New only - Per-account sync_window + "Sync remaining mail" from settings (idempotent re-run) - Provider windowed backfill (SINCE/BEFORE), message_count, newest_uid - migration 0012: accounts.sync_window + backfill_total/backfill_done Backfill progress UX: - Floating progress card (auto-dissolves after 10s) + spinning sidebar ring that fills with the account colour; driven by sync.progress over WS Thread list pagination: - Server-side offset + accurate grand total, with filter/order in SQL; working prev/next across all synced mail Reading panel: - "Load images" one-off now actually renders images (?images=1) without trusting the sender; envelope carries images_allowed so a trusted sender shows no prompt Threading: - Subject fallback no longer merges messages that declare an explicit (different) parent; true siblings still group via a shared-parent reference match. Fixes unrelated auto-replies with the same subject landing in one thread Backend 257 tests green, ruff + mypy --strict clean; frontend tsc/eslint/vitest clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01U72FZiF1W1pb8XxyWBTREA
115 lines
4.1 KiB
Python
115 lines
4.1 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, 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
|