Files
webmail-client/backend/app/accounts/service.py
T
lukasandClaude Opus 4.8 49738740c0 M2: Meili search + saved searches, sync windows + backfill progress, threading & image fixes
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
2026-07-23 23:00:08 +02:00

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