Files
webmail-client/backend/app/identity/user_admin.py
T
lukasandClaude Opus 5.5 0ddc0cb001 Admin user management, password reset by email, and the Ultimo Email name
Branding: the page titles and app name are now "Ultimo Email".

Admin panel (now two columns with a divider):
- Users: search by login or by a mail account's address or name (results
  only, nothing listed until something is typed). A user opens a window to
  change their quota, block/unblock them, or delete them; block and delete ask
  for confirmation, and an admin cannot do either to their own account.
- Blocking (users.blocked_at, migration 0019) refuses sign-in with a clear
  message and revokes every session; a new user.signed_out event sends the
  user's open tabs to the sign-in screen at once. Any "not signed in" answer
  now does the same, not only /auth/me. Deleting removes mail accounts (sync
  stops), plugin rows (cleared inside the user's RLS context, since plugin
  tables have no FK to users), blobs, the search index, sessions and the user.
- Outgoing system mail (migration 0020): SMTP server, port, security, login,
  password (vault-encrypted, never sent back), From address and the public
  address reset links point to, plus a "Send test" that reports the server's
  error.

Password reset:
- "Forgot password?" on the sign-in page asks for the user name and always
  answers "If this account exists you will receive an email to your recovery
  email address"; the mail goes out after the response, so neither the reply
  nor its timing reveals an account, and it is capped at 3 per hour.
- The emailed link (token in the URL fragment, single use, 30 minutes) opens a
  page to set a new password. With 2-step verification on, the code is checked
  before anything changes; five wrong codes burn the link.
- Settings > Profile: the recovery email can be added, changed or removed,
  confirmed with the current password, which must be typed — autofill and
  paste are refused (TypedPasswordInput).

Also adds frontend/icons/ultimo-logo-bimi.svg, an SVG Tiny PS version of the
logo for a BIMI record.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 20:16:27 +02:00

126 lines
5.0 KiB
Python

"""Administrator actions on user accounts (SPEC §6.1 admin panel).
Search, per-user quota, blocking and deletion. Blocking keeps every byte of the
user's data (and keeps their mail syncing) so unblocking is instant; it only
refuses sign-in and revokes the sessions they hold. Deletion is total: mail
accounts (through the same path the user's own "remove account" takes, so the
sync runners stop), plugin rows, blobs on disk, the search index and the user
row, whose foreign keys cascade to everything else core owns.
"""
from __future__ import annotations
import datetime as dt
import shutil
import uuid
from pathlib import Path
from sqlalchemy import func, or_, select, text
from sqlalchemy.ext.asyncio import AsyncSession
from app.accounts.service import delete_account
from app.config import get_settings
from app.events import get_event_bus
from app.identity.sessions import destroy_all_sessions
from app.models import Account, Message, User
from app.plugins.db import plugin_session
from app.search.factory import get_search_backend
SEARCH_LIMIT = 25
async def search_users(db: AsyncSession, q: str) -> list[tuple[User, int, list[str]]]:
"""Users whose login, or one of whose mail accounts' address or display name,
contains ``q`` (case-insensitive). An empty ``q`` lists the first users by
login. Each row comes with its storage used and its mail account addresses."""
stmt = select(User).order_by(User.handle).limit(SEARCH_LIMIT)
q = q.strip()
if q:
# Typed % and _ are literal characters, not wildcards.
escaped = q.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
like = f"%{escaped}%"
by_account = select(Account.user_id).where(
or_(Account.email.ilike(like), Account.display_name.ilike(like))
)
stmt = stmt.where(or_(User.handle.ilike(like), User.id.in_(by_account)))
users = list(await db.scalars(stmt))
if not users:
return []
ids = [u.id for u in users]
used: dict[uuid.UUID, int] = {
user_id: int(total)
for user_id, total in await db.execute(
select(Message.user_id, func.coalesce(func.sum(Message.size_bytes), 0))
.where(Message.user_id.in_(ids))
.group_by(Message.user_id)
)
}
emails: dict[uuid.UUID, list[str]] = {}
for user_id, email in await db.execute(
select(Account.user_id, Account.email)
.where(Account.user_id.in_(ids))
.order_by(Account.email)
):
emails.setdefault(user_id, []).append(email)
return [(u, int(used.get(u.id, 0)), emails.get(u.id, [])) for u in users]
async def set_blocked(db: AsyncSession, user: User, blocked: bool) -> None:
"""Block or unblock. Blocking also signs the user out everywhere."""
if blocked and user.blocked_at is None:
user.blocked_at = dt.datetime.now(dt.UTC)
await db.flush()
await _sign_out_everywhere(user.id)
elif not blocked:
user.blocked_at = None
async def _sign_out_everywhere(user_id: uuid.UUID) -> None:
"""Revoke every session, and tell the user's open tabs so they leave at once
instead of showing already-loaded mail until their next request."""
await destroy_all_sessions(user_id)
await get_event_bus().publish("user.signed_out", {"user_id": str(user_id)})
async def _plugin_tables(db: AsyncSession) -> list[str]:
"""Every plugin table holding per-user rows (``plg_*`` with a ``user_id``)."""
rows = await db.execute(
text(
"SELECT table_name FROM information_schema.columns "
"WHERE table_schema = current_schema() AND column_name = 'user_id' "
"AND table_name LIKE 'plg\\_%' ORDER BY table_name"
)
)
return [r[0] for r in rows]
async def delete_user(db: AsyncSession, user: User) -> None:
"""Delete ``user`` and everything they own. Commits."""
user_id = user.id
# Mail accounts first, one by one: this is what stops their sync runners and
# tidies threads, exactly as when the user removes an account themselves.
for account_id in list(await db.scalars(select(Account.id).where(Account.user_id == user_id))):
await delete_account(db, user_id=user_id, account_id=account_id)
await db.commit()
# Plugin tables carry no foreign key to users and sit behind row-level
# security, so they are cleared from inside the user's own RLS context.
tables = await _plugin_tables(db)
if tables:
async with plugin_session(user_id) as ps:
for table in tables:
await ps.execute(
text(f'DELETE FROM "{table}" WHERE user_id = :uid'), {"uid": user_id}
)
await _sign_out_everywhere(user_id)
await get_search_backend().delete_user(user_id)
# The user row: its foreign keys cascade to the rest of core's tables.
await db.delete(user)
await db.commit()
# Whatever blobs are left (drafts' attachments and the like) go with the folder.
shutil.rmtree(Path(get_settings().blob_root) / str(user_id), ignore_errors=True)