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>
126 lines
5.0 KiB
Python
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)
|