diff --git a/backend/app/api/admin.py b/backend/app/api/admin.py
index ae15ac9..0875763 100644
--- a/backend/app/api/admin.py
+++ b/backend/app/api/admin.py
@@ -1,24 +1,40 @@
"""Instance administration (SPEC §6.1).
-The backend seam for the admin panel. Today it carries only the registration
-gate — the first account on a fresh install becomes the administrator and closes
-signup behind itself, and this is how that gets reopened. Further instance-wide
-options land here as the panel grows.
+The backend seam for the admin panel: the registration gate — the first account
+on a fresh install becomes the administrator and closes signup behind itself,
+and this is how that gets reopened — the default quota, and managing users
+(search, per-user quota, block, delete). An administrator cannot block or delete
+their own account, so the instance can never lock out its last admin that way.
"""
from __future__ import annotations
import datetime as dt
+import uuid
from typing import Annotated
-from fastapi import APIRouter, Depends
+from fastapi import APIRouter, Depends, Query
+from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
+from app.api.errors import raise_api_error
from app.db import get_session
from app.identity.deps import CurrentAdmin, require_csrf
-from app.identity.schemas import AdminSettingsIn, AdminSettingsOut
+from app.identity.mailer import MailerError, mailer_config, send_mail
+from app.identity.schemas import (
+ AdminMailIn,
+ AdminMailOut,
+ AdminMailTestIn,
+ AdminSettingsIn,
+ AdminSettingsOut,
+ AdminUserOut,
+ AdminUserPatch,
+ MessageOut,
+)
from app.identity.service import app_settings
-from app.models import AppSettings
+from app.identity.user_admin import delete_user, search_users, set_blocked
+from app.models import Account, AppSettings, Message, User
+from app.security.crypto import encrypt_secret
router = APIRouter(prefix="/admin", tags=["admin"])
@@ -54,3 +70,134 @@ async def write_settings(
row.updated_at = dt.datetime.now(dt.UTC)
await db.commit()
return _out(row)
+
+
+# --- Outgoing system mail -------------------------------------------------------
+
+
+def _mail_out(row: AppSettings) -> AdminMailOut:
+ return AdminMailOut(
+ smtp_host=row.smtp_host,
+ smtp_port=row.smtp_port,
+ smtp_security=row.smtp_security,
+ smtp_username=row.smtp_username,
+ has_password=row.smtp_secret_ct is not None,
+ smtp_from=row.smtp_from,
+ public_url=row.public_url,
+ configured=bool(row.smtp_host and row.smtp_from and row.public_url),
+ )
+
+
+@router.get("/mail", response_model=AdminMailOut)
+async def read_mail(admin: CurrentAdmin, db: DbSession) -> AdminMailOut:
+ return _mail_out(await app_settings(db))
+
+
+@router.put("/mail", response_model=AdminMailOut, dependencies=[Depends(require_csrf)])
+async def write_mail(body: AdminMailIn, admin: CurrentAdmin, db: DbSession) -> AdminMailOut:
+ """Merge the supplied SMTP fields. The password is stored vault-encrypted."""
+ row = await app_settings(db, for_update=True)
+ for name in ("smtp_host", "smtp_username", "smtp_from"):
+ value = getattr(body, name)
+ if value is not None:
+ setattr(row, name, value.strip())
+ if body.smtp_port is not None:
+ row.smtp_port = body.smtp_port
+ if body.smtp_security is not None:
+ row.smtp_security = body.smtp_security
+ if body.smtp_password is not None:
+ row.smtp_secret_ct = (
+ encrypt_secret(body.smtp_password.encode()) if body.smtp_password else None
+ )
+ if body.public_url is not None:
+ url = body.public_url.strip().rstrip("/")
+ if url and not url.startswith(("http://", "https://")):
+ raise_api_error(400, "invalid_url", "the address must start with http:// or https://")
+ row.public_url = url
+ row.updated_at = dt.datetime.now(dt.UTC)
+ await db.commit()
+ return _mail_out(row)
+
+
+@router.post("/mail/test", response_model=MessageOut, dependencies=[Depends(require_csrf)])
+async def test_mail(body: AdminMailTestIn, admin: CurrentAdmin, db: DbSession) -> MessageOut:
+ """Send a test message with the saved settings, reporting the server's error."""
+ cfg = mailer_config(await app_settings(db))
+ if cfg is None:
+ raise_api_error(
+ 409, "mail_not_configured", "set the SMTP server, From address and public address first"
+ )
+ text = (
+ "This is a test message from Ultimo Email.\n\n"
+ "Outgoing system mail works: password-reset links will be sent from this account.\n"
+ )
+ html = f"
{text.replace(chr(10) * 2, '
').strip()}
"
+ try:
+ await send_mail(cfg, body.to.strip(), "Ultimo Email test message", text, html)
+ except MailerError as exc:
+ raise_api_error(502, "mail_failed", f"sending failed: {exc}")
+ return MessageOut()
+
+
+# --- Users --------------------------------------------------------------------
+
+
+def _user_out(user: User, used_bytes: int, accounts: list[str]) -> AdminUserOut:
+ return AdminUserOut(
+ id=user.id,
+ handle=user.handle,
+ is_admin=user.is_admin,
+ blocked_at=user.blocked_at.isoformat() if user.blocked_at else None,
+ quota_bytes=user.quota_bytes,
+ used_bytes=used_bytes,
+ created_at=user.created_at.isoformat(),
+ accounts=accounts,
+ )
+
+
+async def _target(db: AsyncSession, admin: User, user_id: uuid.UUID) -> User:
+ """The user an admin action is aimed at; never the admin themselves."""
+ user = await db.scalar(select(User).where(User.id == user_id))
+ if user is None:
+ raise_api_error(404, "not_found", "user not found")
+ if user.id == admin.id:
+ raise_api_error(409, "cannot_modify_self", "you cannot do this to your own account")
+ return user
+
+
+@router.get("/users", response_model=list[AdminUserOut])
+async def list_users(
+ admin: CurrentAdmin, db: DbSession, q: str = Query("", max_length=200)
+) -> list[AdminUserOut]:
+ """Search users by login, or by a mail account's address or display name."""
+ return [_user_out(u, used, emails) for u, used, emails in await search_users(db, q)]
+
+
+@router.patch("/users/{user_id}", response_model=AdminUserOut, dependencies=[Depends(require_csrf)])
+async def patch_user(
+ user_id: uuid.UUID, body: AdminUserPatch, admin: CurrentAdmin, db: DbSession
+) -> AdminUserOut:
+ """Change a user's quota and/or block or unblock them."""
+ user = await _target(db, admin, user_id)
+ if body.quota_bytes is not None:
+ user.quota_bytes = body.quota_bytes
+ if body.blocked is not None:
+ await set_blocked(db, user, body.blocked)
+ await db.commit()
+ used = await db.scalar(
+ select(func.coalesce(func.sum(Message.size_bytes), 0)).where(Message.user_id == user.id)
+ )
+ emails = list(
+ await db.scalars(
+ select(Account.email).where(Account.user_id == user.id).order_by(Account.email)
+ )
+ )
+ return _user_out(user, int(used or 0), emails)
+
+
+@router.delete("/users/{user_id}", dependencies=[Depends(require_csrf)])
+async def remove_user(user_id: uuid.UUID, admin: CurrentAdmin, db: DbSession) -> dict[str, str]:
+ """Delete a user and all of their data. Cannot be undone."""
+ user = await _target(db, admin, user_id)
+ await delete_user(db, user)
+ return {"status": "deleted"}
diff --git a/backend/app/events.py b/backend/app/events.py
index e1ea717..e74a2ae 100644
--- a/backend/app/events.py
+++ b/backend/app/events.py
@@ -41,6 +41,9 @@ ALLOWED_EVENTS: frozenset[str] = frozenset(
# Sync v2 §3: published on login and every WS connect; drives tier
# promotion in the sync workers (§5). Payload: {user_id}.
"user.active",
+ # The user's sessions were revoked by an administrator (blocked or
+ # deleted): open tabs drop to the sign-in screen. Payload: {user_id}.
+ "user.signed_out",
}
)
diff --git a/backend/app/identity/deps.py b/backend/app/identity/deps.py
index d6c97d6..1248ea0 100644
--- a/backend/app/identity/deps.py
+++ b/backend/app/identity/deps.py
@@ -30,8 +30,9 @@ async def current_user(
) -> User:
"""Load the authenticated :class:`User` (I-7: scoped by the session's user_id)."""
user = await db.scalar(select(User).where(User.id == session.user_id))
- if user is None:
- # Session references a deleted user; treat as unauthenticated.
+ if user is None or user.blocked_at is not None:
+ # Session references a deleted or blocked user; treat as unauthenticated.
+ # (Blocking also revokes the sessions; this covers any request in flight.)
raise_api_error(401, "not_authenticated", "authentication required")
return user
diff --git a/backend/app/identity/mailer.py b/backend/app/identity/mailer.py
new file mode 100644
index 0000000..a2a77b4
--- /dev/null
+++ b/backend/app/identity/mailer.py
@@ -0,0 +1,110 @@
+"""Outgoing system mail (SPEC §6.1): mail the server sends on its own behalf.
+
+Today that is only the password-reset link. It goes out through the SMTP
+account the administrator configured in the admin panel (``app_settings``),
+never through a user's own mail account — the whole point is that the user
+cannot sign in. The administrator's server is trusted, so unlike user mail
+accounts this connection is not held to the SSRF guard (it may well be a relay
+on the LAN).
+"""
+
+from __future__ import annotations
+
+import html as html_lib
+import logging
+from dataclasses import dataclass
+from email.message import EmailMessage
+from email.utils import formataddr, make_msgid
+
+import aiosmtplib
+
+from app.models import AppSettings
+from app.security.crypto import decrypt_secret
+
+logger = logging.getLogger(__name__)
+
+SENDER_NAME = "Ultimo Email"
+_TIMEOUT = 20.0
+
+
+class MailerError(Exception):
+ """System mail is not configured, or the SMTP server refused the message."""
+
+
+@dataclass(frozen=True)
+class MailerConfig:
+ host: str
+ port: int
+ security: str # starttls | ssl | none
+ username: str
+ password: str
+ sender: str
+ public_url: str
+
+
+def mailer_config(row: AppSettings) -> MailerConfig | None:
+ """The usable configuration, or None while the admin has not set one up."""
+ if not (row.smtp_host and row.smtp_from and row.public_url):
+ return None
+ password = decrypt_secret(row.smtp_secret_ct).decode() if row.smtp_secret_ct else ""
+ return MailerConfig(
+ host=row.smtp_host,
+ port=row.smtp_port,
+ security=row.smtp_security,
+ username=row.smtp_username,
+ password=password,
+ sender=row.smtp_from,
+ public_url=row.public_url.rstrip("/"),
+ )
+
+
+async def send_mail(cfg: MailerConfig, to: str, subject: str, text: str, html: str) -> None:
+ msg = EmailMessage()
+ msg["From"] = formataddr((SENDER_NAME, cfg.sender))
+ msg["To"] = to
+ msg["Subject"] = subject
+ msg["Message-ID"] = make_msgid(domain=cfg.sender.rsplit("@", 1)[-1])
+ msg["Auto-Submitted"] = "auto-generated" # RFC 3834: do not auto-reply to this
+ msg.set_content(text)
+ msg.add_alternative(html, subtype="html")
+ try:
+ await aiosmtplib.send(
+ msg,
+ hostname=cfg.host,
+ port=cfg.port,
+ username=cfg.username or None,
+ password=cfg.password or None,
+ use_tls=cfg.security == "ssl",
+ start_tls=cfg.security == "starttls",
+ timeout=_TIMEOUT,
+ )
+ except (aiosmtplib.SMTPException, OSError) as exc:
+ raise MailerError(str(exc) or type(exc).__name__) from exc
+
+
+def reset_mail(link: str, handle: str) -> tuple[str, str, str]:
+ """(subject, text, html) of the password-reset message."""
+ subject = "Reset your Ultimo Email password"
+ text = (
+ f"Someone asked to change the password of the Ultimo Email account {handle}.\n\n"
+ f"To choose a new password, open this link within 30 minutes:\n\n{link}\n\n"
+ "The link works once. If you did not ask for this, ignore this email — "
+ "your password stays as it is.\n"
+ )
+ h, u = html_lib.escape(handle), html_lib.escape(link, quote=True)
+ muted = "font-size:13px;color:#6b7280"
+ button = (
+ "display:inline-block;background:#2563eb;color:#ffffff;text-decoration:none;"
+ "padding:10px 18px;border-radius:8px;font-weight:bold"
+ )
+ body = "font-family:Arial,Helvetica,sans-serif;font-size:15px;color:#1f2937;line-height:1.5"
+ html = (
+ f'\n\n'
+ f"
Someone asked to change the password of the Ultimo Email account {h}.
\n"
+ "
To choose a new password, open this link within 30 minutes:
The link works once. If you did not ask for this, ignore this '
+ "email — your password stays as it is.
\n\n"
+ )
+ return subject, text, html
diff --git a/backend/app/identity/reset.py b/backend/app/identity/reset.py
index 07dac71..935f0c6 100644
--- a/backend/app/identity/reset.py
+++ b/backend/app/identity/reset.py
@@ -1,43 +1,76 @@
"""Password reset tokens (SPEC §6.1).
-Single-use, 30-minute, Redis-backed tokens. Only issued when the account has a
-recovery email on file. Actual delivery of the token to the recovery address
-requires an app-level mailer (not part of M0/M1); until then the token is logged
-server-side. See docs/open-questions.md.
+Single-use, 30-minute, Redis-backed tokens, emailed as a link to the account's
+recovery address through the system mailer (:mod:`app.identity.mailer`).
+
+A token survives a wrong 2FA code (the page asks again) but not five: after
+that it is burned, so the link cannot be used to guess codes. Requests are
+capped per account so the reset form cannot be used to flood someone's inbox.
"""
from __future__ import annotations
-import logging
import secrets
import uuid
from app.redis_client import aw, get_redis
-logger = logging.getLogger(__name__)
-
_PREFIX = "pwreset:"
+_FAILS = "pwreset-fail:"
+_RATE = "pwreset-rate:"
_TTL = 30 * 60 # 30 minutes
+MAX_TOTP_FAILURES = 5
+MAX_REQUESTS_PER_HOUR = 3
-async def issue_token(user_id: uuid.UUID, recovery_email: str) -> str:
+async def issue_token(user_id: uuid.UUID) -> str:
"""Create a single-use reset token bound to ``user_id`` and return it."""
token = secrets.token_urlsafe(32)
await get_redis().set(_PREFIX + token, str(user_id), ex=_TTL)
- # TODO(spec): deliver via the recovery address once an app mailer exists.
- logger.info("password reset token issued for user=%s recovery=%s", user_id, recovery_email)
return token
+async def request_allowed(user_id: uuid.UUID) -> bool:
+ """Count a reset request; False once the account has had its hourly share."""
+ redis = get_redis()
+ key = _RATE + str(user_id)
+ count = int(await redis.incr(key))
+ if count == 1:
+ await redis.expire(key, 3600)
+ return count <= MAX_REQUESTS_PER_HOUR
+
+
+def _user_id(raw: str | None) -> uuid.UUID | None:
+ if raw is None:
+ return None
+ try:
+ return uuid.UUID(raw)
+ except ValueError:
+ return None
+
+
+async def peek_token(token: str) -> uuid.UUID | None:
+ """The user a still-valid token belongs to, without using it up."""
+ if not token:
+ return None
+ return _user_id(await get_redis().get(_PREFIX + token))
+
+
async def consume_token(token: str) -> uuid.UUID | None:
"""Atomically consume a reset token, returning the user_id (or None)."""
if not token:
return None
redis = get_redis()
raw = await aw(redis.getdel(_PREFIX + token)) # single-use: fetch and delete atomically
- if raw is None:
- return None
- try:
- return uuid.UUID(raw)
- except ValueError:
- return None
+ await redis.delete(_FAILS + token)
+ return _user_id(raw)
+
+
+async def record_totp_failure(token: str) -> None:
+ """Count a wrong 2FA code against the token; burn it at the limit."""
+ redis = get_redis()
+ fails = await redis.incr(_FAILS + token)
+ if fails == 1:
+ await redis.expire(_FAILS + token, _TTL)
+ if fails >= MAX_TOTP_FAILURES:
+ await redis.delete(_PREFIX + token, _FAILS + token)
diff --git a/backend/app/identity/routes.py b/backend/app/identity/routes.py
index 0891190..2141558 100644
--- a/backend/app/identity/routes.py
+++ b/backend/app/identity/routes.py
@@ -3,9 +3,11 @@ TOTP setup/enable, session list/revoke, password reset."""
from __future__ import annotations
+import logging
+import re
from typing import Annotated
-from fastapi import APIRouter, Depends, Request, Response
+from fastapi import APIRouter, BackgroundTasks, Depends, Request, Response
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
@@ -20,13 +22,23 @@ from app.identity.cookies import (
set_auth_cookies,
)
from app.identity.deps import CurrentSession, CurrentUser, require_csrf
+from app.identity.mailer import MailerConfig, MailerError, mailer_config, reset_mail, send_mail
from app.identity.passwords import hash_password, verify_password
-from app.identity.reset import consume_token, issue_token
+from app.identity.reset import (
+ consume_token,
+ issue_token,
+ peek_token,
+ record_totp_failure,
+ request_allowed,
+)
from app.identity.schemas import (
LoginRequest,
MessageOut,
+ PasswordResetCheckIn,
+ PasswordResetCheckOut,
PasswordResetIn,
PasswordResetRequestIn,
+ RecoveryEmailIn,
RegisterRequest,
RegistrationStatusOut,
SessionOut,
@@ -36,10 +48,12 @@ from app.identity.schemas import (
UserOut,
)
from app.identity.service import (
+ AccountBlocked,
RateLimited,
RegistrationError,
TotpInvalid,
TotpRequired,
+ app_settings,
authenticate,
register_user,
registration_status,
@@ -56,8 +70,14 @@ from app.models import User
from app.redis_client import get_redis
from app.sync import leases
+logger = logging.getLogger(__name__)
+
router = APIRouter(prefix="/auth", tags=["auth"])
+# Deliberately loose: the recovery address only has to be something mail can be
+# sent to; the mail server is the real judge.
+_EMAIL_RE = re.compile(r"[^@\s]+@[^@\s]+\.[^@\s]+")
+
DbSession = Annotated[AsyncSession, Depends(get_session)]
@@ -128,6 +148,10 @@ async def login(body: LoginRequest, request: Request, response: Response, db: Db
raise_api_error(401, "totp_required", "a 2FA code is required")
except TotpInvalid:
raise_api_error(401, "totp_invalid", "incorrect 2FA code")
+ except AccountBlocked:
+ raise_api_error(
+ 403, "account_blocked", "this account has been blocked by the administrator"
+ )
if user is None:
raise_api_error(401, "invalid_credentials", "incorrect handle or password")
await _establish_session(request, response, user)
@@ -209,25 +233,86 @@ async def revoke(sid: str, user: CurrentUser) -> MessageOut:
# --- Password reset (SPEC §6.1) ---------------------------------------------
+async def _send_reset_mail(cfg: MailerConfig, to: str, handle: str, token: str) -> None:
+ """Runs after the response has gone out (see password_reset_request)."""
+ # The token rides in the fragment, which browsers never send to a server, so
+ # it stays out of access logs and Referer headers.
+ subject, text, html = reset_mail(f"{cfg.public_url}/reset-password#token={token}", handle)
+ try:
+ await send_mail(cfg, to, subject, text, html)
+ except MailerError as exc:
+ logger.warning("password reset mail for %s could not be sent: %s", handle, exc)
+
+
@router.post("/password/reset-request", response_model=MessageOut)
-async def password_reset_request(body: PasswordResetRequestIn, db: DbSession) -> MessageOut:
- # Always return ok (do not leak whether the handle exists / has recovery mail).
+async def password_reset_request(
+ body: PasswordResetRequestIn, background: BackgroundTasks, db: DbSession
+) -> MessageOut:
+ """Email a reset link to the account's recovery address.
+
+ The answer is the same whether or not the account exists, has a recovery
+ address, or hit the hourly cap, and the mail is sent after the response —
+ so neither the reply nor its timing tells a stranger anything.
+ """
handle = resolve_handle(body.handle)
user = await db.scalar(select(User).where(User.handle == handle))
- if user is not None and user.recovery_email:
- await issue_token(user.id, user.recovery_email)
+ if user is None or not user.recovery_email:
+ return MessageOut()
+ cfg = mailer_config(await app_settings(db))
+ if cfg is None:
+ logger.warning("password reset requested for %s, but system mail is not set up", handle)
+ return MessageOut()
+ if not await request_allowed(user.id):
+ return MessageOut()
+ token = await issue_token(user.id)
+ background.add_task(_send_reset_mail, cfg, user.recovery_email, user.handle, token)
return MessageOut()
+@router.post("/password/reset/check", response_model=PasswordResetCheckOut)
+async def password_reset_check(body: PasswordResetCheckIn, db: DbSession) -> PasswordResetCheckOut:
+ """Whether a reset link still works, so the page can say so before asking
+ for a new password. Does not use the link up."""
+ user_id = await peek_token(body.token)
+ user = await db.get(User, user_id) if user_id else None
+ if user is None:
+ return PasswordResetCheckOut(valid=False)
+ return PasswordResetCheckOut(valid=True, totp_required=user.totp_secret is not None)
+
+
@router.post("/password/reset", response_model=MessageOut)
async def password_reset(body: PasswordResetIn, db: DbSession) -> MessageOut:
- user_id = await consume_token(body.token)
- if user_id is None:
- raise_api_error(400, "invalid_token", "reset token is invalid or expired")
- user = await db.get(User, user_id)
+ """Set a new password with a reset link. With 2FA on, the code is checked
+ before anything changes; a missing or wrong code leaves the link usable
+ (up to MAX_TOTP_FAILURES wrong codes)."""
+ user_id = await peek_token(body.token)
+ user = await db.get(User, user_id) if user_id else None
if user is None:
- raise_api_error(400, "invalid_token", "reset token is invalid or expired")
+ raise_api_error(400, "invalid_token", "this link is invalid or has expired")
+ if user.totp_secret is not None:
+ if not body.totp:
+ raise_api_error(401, "totp_required", "a 2FA code is required")
+ if not totp_mod.verify(totp_mod.decrypt_totp_secret(user.totp_secret), body.totp):
+ await record_totp_failure(body.token)
+ raise_api_error(401, "totp_invalid", "incorrect 2FA code")
+ # Only now is the link spent — and only if nobody spent it in the meantime.
+ if await consume_token(body.token) != user.id:
+ raise_api_error(400, "invalid_token", "this link is invalid or has expired")
user.password_hash = hash_password(body.password)
await db.commit()
- await destroy_all_sessions(user_id) # force re-login everywhere after reset
+ await destroy_all_sessions(user.id) # force re-login everywhere after reset
return MessageOut()
+
+
+@router.put("/recovery-email", response_model=UserOut, dependencies=[Depends(require_csrf)])
+async def set_recovery_email(body: RecoveryEmailIn, user: CurrentUser, db: DbSession) -> UserOut:
+ """Add, change or remove the recovery address. Takes the password: whoever
+ controls this address can take the account over with a reset link."""
+ if not verify_password(user.password_hash, body.password):
+ raise_api_error(403, "invalid_password", "incorrect password")
+ email = (body.recovery_email or "").strip().lower() or None
+ if email is not None and not _EMAIL_RE.fullmatch(email):
+ raise_api_error(400, "invalid_email", "enter a valid email address")
+ user.recovery_email = email
+ await db.commit()
+ return _user_out(user)
diff --git a/backend/app/identity/schemas.py b/backend/app/identity/schemas.py
index f14a656..4778af5 100644
--- a/backend/app/identity/schemas.py
+++ b/backend/app/identity/schemas.py
@@ -3,6 +3,7 @@
from __future__ import annotations
import uuid
+from typing import Literal
from pydantic import BaseModel, Field
@@ -47,6 +48,23 @@ class PasswordResetRequestIn(BaseModel):
class PasswordResetIn(BaseModel):
token: str = Field(min_length=1)
password: str = Field(min_length=8, max_length=1024)
+ totp: str | None = None # required when the account has 2FA enabled
+
+
+class PasswordResetCheckIn(BaseModel):
+ token: str = Field(min_length=1)
+
+
+class PasswordResetCheckOut(BaseModel):
+ valid: bool
+ # Tells the page to expect the 2FA step; only the link's holder learns it.
+ totp_required: bool = False
+
+
+class RecoveryEmailIn(BaseModel):
+ password: str = Field(min_length=1, max_length=1024)
+ # None (or blank) removes the recovery address.
+ recovery_email: str | None = Field(default=None, max_length=320)
class UserOut(BaseModel):
@@ -79,5 +97,55 @@ class AdminSettingsIn(BaseModel):
default_quota_bytes: int | None = Field(default=None, gt=0)
+class AdminMailOut(BaseModel):
+ smtp_host: str
+ smtp_port: int
+ smtp_security: str
+ smtp_username: str
+ # The password itself never leaves the server.
+ has_password: bool
+ smtp_from: str
+ public_url: str
+ # Host, From address and public address are all set: reset mail can go out.
+ configured: bool
+
+
+class AdminMailIn(BaseModel):
+ """Merge semantics: omitted fields are left as they are."""
+
+ smtp_host: str | None = Field(default=None, max_length=255)
+ smtp_port: int | None = Field(default=None, ge=1, le=65535)
+ smtp_security: Literal["starttls", "ssl", "none"] | None = None
+ smtp_username: str | None = Field(default=None, max_length=320)
+ # Omitted keeps the stored password; an empty string removes it.
+ smtp_password: str | None = Field(default=None, max_length=1024)
+ smtp_from: str | None = Field(default=None, max_length=320)
+ public_url: str | None = Field(default=None, max_length=500)
+
+
+class AdminMailTestIn(BaseModel):
+ to: str = Field(min_length=3, max_length=320)
+
+
+class AdminUserOut(BaseModel):
+ id: uuid.UUID
+ handle: str
+ is_admin: bool
+ # ISO timestamp while blocked; null otherwise.
+ blocked_at: str | None
+ quota_bytes: int
+ used_bytes: int
+ created_at: str
+ # The user's connected mail accounts, so the admin can tell who this is.
+ accounts: list[str]
+
+
+class AdminUserPatch(BaseModel):
+ """Merge semantics: omitted fields are left as they are."""
+
+ quota_bytes: int | None = Field(default=None, gt=0)
+ blocked: bool | None = None
+
+
class MessageOut(BaseModel):
status: str = "ok"
diff --git a/backend/app/identity/service.py b/backend/app/identity/service.py
index da7ada9..69e26c0 100644
--- a/backend/app/identity/service.py
+++ b/backend/app/identity/service.py
@@ -53,6 +53,10 @@ class TotpInvalid(Exception):
"""Password verified but the supplied TOTP code was wrong."""
+class AccountBlocked(Exception):
+ """Password verified but an administrator has blocked the account."""
+
+
def resolve_handle(handle_input: str) -> str:
"""Normalize a login identifier to the full ``localpart@APP_DOMAIN`` form."""
text = handle_input.strip().lower()
@@ -164,6 +168,9 @@ async def authenticate(
ok = verify_password(stored_hash, password)
if user is None or not ok:
return None
+ # Only after the password: a stranger must not learn that a handle is blocked.
+ if user.blocked_at is not None:
+ raise AccountBlocked
if user.totp_secret is not None:
from app.identity.totp import decrypt_totp_secret
diff --git a/backend/app/identity/user_admin.py b/backend/app/identity/user_admin.py
new file mode 100644
index 0000000..0f81c62
--- /dev/null
+++ b/backend/app/identity/user_admin.py
@@ -0,0 +1,125 @@
+"""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)
diff --git a/backend/app/models.py b/backend/app/models.py
index 96fe85d..8114973 100644
--- a/backend/app/models.py
+++ b/backend/app/models.py
@@ -69,6 +69,19 @@ class AppSettings(Base):
default_quota_bytes: Mapped[int] = mapped_column(
BigInteger, nullable=False, server_default=text("5368709120")
)
+ # Outgoing system mail (password-reset links): the SMTP account the server
+ # sends from. The password is vault-encrypted (like Account.secret_ct).
+ smtp_host: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("''"))
+ smtp_port: Mapped[int] = mapped_column(Integer, nullable=False, server_default=text("587"))
+ smtp_security: Mapped[str] = mapped_column(
+ Text, nullable=False, server_default=text("'starttls'")
+ ) # starttls | ssl | none
+ smtp_username: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("''"))
+ smtp_secret_ct: Mapped[bytes | None] = mapped_column(BYTEA)
+ smtp_from: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("''"))
+ # Where users reach the app, e.g. https://mail.example.com — reset links are
+ # built from this, never from the request's Host header.
+ public_url: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("''"))
updated_at: Mapped[dt.datetime] = _now()
@@ -91,6 +104,9 @@ class User(Base):
# The first account created on a fresh install bootstraps as administrator;
# the admin panel can grant it to others later.
is_admin: Mapped[bool] = mapped_column(nullable=False, server_default=text("false"))
+ # Set while an administrator has blocked the account (no sign-in, sessions
+ # refused); NULL when not blocked.
+ blocked_at: Mapped[dt.datetime | None] = mapped_column(TIMESTAMP(timezone=True))
created_at: Mapped[dt.datetime] = _now()
diff --git a/backend/app/ws.py b/backend/app/ws.py
index 85e0e42..c0d9478 100644
--- a/backend/app/ws.py
+++ b/backend/app/ws.py
@@ -55,6 +55,10 @@ async def ws_endpoint(websocket: WebSocket) -> None:
payload = data.get("payload", {})
if str(payload.get("user_id")) == user_id:
await websocket.send_json(data)
+ if data.get("event") == "user.signed_out":
+ # The session this socket was opened with no longer exists.
+ await websocket.close(code=1008)
+ return
async def receive() -> None:
while True:
diff --git a/backend/migrations/versions/0019_user_blocked.py b/backend/migrations/versions/0019_user_blocked.py
new file mode 100644
index 0000000..821b4c4
--- /dev/null
+++ b/backend/migrations/versions/0019_user_blocked.py
@@ -0,0 +1,29 @@
+"""administrator can block a user
+
+``users.blocked_at`` is set while an administrator has blocked the account: it
+cannot sign in and its sessions stop working. NULL means not blocked. A
+timestamp rather than a flag so the admin panel can say since when.
+
+Revision ID: 0019
+Revises: 0018
+Create Date: 2026-09-30
+"""
+
+from __future__ import annotations
+
+from collections.abc import Sequence
+
+from alembic import op
+
+revision: str = "0019"
+down_revision: str | None = "0018"
+branch_labels: str | Sequence[str] | None = None
+depends_on: str | Sequence[str] | None = None
+
+
+def upgrade() -> None:
+ op.execute("ALTER TABLE users ADD COLUMN blocked_at timestamptz")
+
+
+def downgrade() -> None:
+ op.execute("ALTER TABLE users DROP COLUMN IF EXISTS blocked_at")
diff --git a/backend/migrations/versions/0020_system_mail.py b/backend/migrations/versions/0020_system_mail.py
new file mode 100644
index 0000000..c602450
--- /dev/null
+++ b/backend/migrations/versions/0020_system_mail.py
@@ -0,0 +1,48 @@
+"""outgoing system mail (password reset) configured in the admin panel
+
+The server sends its own mail — today only password-reset links — through an
+SMTP account the administrator sets up in the admin panel. The password is
+envelope-encrypted with MASTER_KEY like every mail-account secret. ``public_url``
+is where users reach the app; reset links are built from it, never from the
+request's Host header, which an attacker controls.
+
+Revision ID: 0020
+Revises: 0019
+Create Date: 2026-09-30
+"""
+
+from __future__ import annotations
+
+from collections.abc import Sequence
+
+from alembic import op
+
+revision: str = "0020"
+down_revision: str | None = "0019"
+branch_labels: str | Sequence[str] | None = None
+depends_on: str | Sequence[str] | None = None
+
+_COLUMNS = (
+ ("smtp_host", "text NOT NULL DEFAULT ''"),
+ ("smtp_port", "integer NOT NULL DEFAULT 587"),
+ ("smtp_security", "text NOT NULL DEFAULT 'starttls'"),
+ ("smtp_username", "text NOT NULL DEFAULT ''"),
+ ("smtp_secret_ct", "bytea"),
+ ("smtp_from", "text NOT NULL DEFAULT ''"),
+ ("public_url", "text NOT NULL DEFAULT ''"),
+)
+
+
+def upgrade() -> None:
+ for name, ddl in _COLUMNS:
+ op.execute(f"ALTER TABLE app_settings ADD COLUMN {name} {ddl}")
+ op.execute(
+ "ALTER TABLE app_settings ADD CONSTRAINT ck_app_settings_smtp_security "
+ "CHECK (smtp_security IN ('starttls', 'ssl', 'none'))"
+ )
+
+
+def downgrade() -> None:
+ op.execute("ALTER TABLE app_settings DROP CONSTRAINT IF EXISTS ck_app_settings_smtp_security")
+ for name, _ in reversed(_COLUMNS):
+ op.execute(f"ALTER TABLE app_settings DROP COLUMN IF EXISTS {name}")
diff --git a/backend/tests/api/test_admin_users.py b/backend/tests/api/test_admin_users.py
new file mode 100644
index 0000000..dcfafa4
--- /dev/null
+++ b/backend/tests/api/test_admin_users.py
@@ -0,0 +1,196 @@
+"""Admin user management (SPEC §6.1): search, quota, block, delete.
+
+Runs against the dev database with real sessions, so every user it creates is
+removed again on the way out.
+"""
+
+from __future__ import annotations
+
+import uuid
+from collections.abc import AsyncIterator
+
+import httpx
+import pytest
+import pytest_asyncio
+from httpx import ASGITransport
+from sqlalchemy import delete, select, text
+from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
+from sqlalchemy.pool import NullPool
+
+from app.config import get_settings
+from app.identity import user_admin
+from app.identity.passwords import hash_password
+from app.identity.sessions import create_session, resolve_session
+from app.main import create_app
+from app.models import Account, PluginKv, User
+from app.plugins.db import plugin_session
+
+CSRF = "csrf-token"
+PASSWORD = "hunter2pass"
+
+
+def _handle() -> str:
+ return f"adm{uuid.uuid4().hex[:8]}@test.local"
+
+
+class World:
+ def __init__(self, session: AsyncSession) -> None:
+ self.session = session
+ self.created: list[uuid.UUID] = []
+
+ async def user(self, *, admin: bool = False) -> User:
+ user = User(handle=_handle(), password_hash=hash_password(PASSWORD), is_admin=admin)
+ self.session.add(user)
+ await self.session.commit()
+ self.created.append(user.id)
+ return user
+
+ async def client(self, user: User) -> httpx.AsyncClient:
+ token = await create_session(user.id, "pytest")
+ return httpx.AsyncClient(
+ transport=ASGITransport(app=create_app()),
+ base_url="http://testserver",
+ cookies={"session": token, "csrf": CSRF},
+ headers={"X-CSRF": CSRF},
+ )
+
+
+@pytest_asyncio.fixture
+async def world() -> AsyncIterator[World]:
+ engine = create_async_engine(get_settings().database_url, poolclass=NullPool)
+ session = async_sessionmaker(engine, expire_on_commit=False)()
+ w = World(session)
+ try:
+ yield w
+ finally:
+ await session.rollback()
+ await session.execute(delete(User).where(User.id.in_(w.created)))
+ await session.commit()
+ await session.close()
+ await engine.dispose()
+
+
+def _account(user_id: uuid.UUID, email: str, name: str = "") -> Account:
+ return Account(
+ user_id=user_id,
+ email=email,
+ display_name=name,
+ imap_config={},
+ smtp_config={},
+ secret_ct=b"x",
+ discovery_src="manual",
+ state="live",
+ )
+
+
+async def test_only_an_admin_can_manage_users(world: World) -> None:
+ plain = await world.user()
+ async with await world.client(plain) as c:
+ assert (await c.get("/api/admin/users")).status_code == 403
+
+
+async def test_search_by_login_and_by_mail_account(world: World) -> None:
+ admin = await world.user(admin=True)
+ target = await world.user()
+ tag = uuid.uuid4().hex[:10]
+ world.session.add(_account(target.id, f"x{tag}@example.com", f"Zed {tag}"))
+ await world.session.commit()
+ async with await world.client(admin) as c:
+ by_login = (await c.get("/api/admin/users", params={"q": target.handle[:11]})).json()
+ assert [u["id"] for u in by_login] == [str(target.id)]
+ by_email = (await c.get("/api/admin/users", params={"q": f"x{tag}@"})).json()
+ assert [u["id"] for u in by_email] == [str(target.id)]
+ assert by_email[0]["accounts"] == [f"x{tag}@example.com"]
+ by_name = (await c.get("/api/admin/users", params={"q": f"zed {tag}"})).json()
+ assert [u["id"] for u in by_name] == [str(target.id)]
+ # A typed % is a character, not "match anything".
+ assert (await c.get("/api/admin/users", params={"q": f"{tag}%"})).json() == []
+
+
+async def test_change_quota(world: World) -> None:
+ admin = await world.user(admin=True)
+ target = await world.user()
+ async with await world.client(admin) as c:
+ resp = await c.patch(f"/api/admin/users/{target.id}", json={"quota_bytes": 123456789})
+ assert resp.status_code == 200
+ assert resp.json()["quota_bytes"] == 123456789
+ bad = await c.patch(f"/api/admin/users/{target.id}", json={"quota_bytes": 0})
+ assert bad.status_code == 422
+
+
+class _Bus:
+ def __init__(self) -> None:
+ self.published: list[tuple[str, dict[str, object]]] = []
+
+ async def publish(self, event: str, payload: dict[str, object]) -> None:
+ self.published.append((event, payload))
+
+
+@pytest.fixture
+def bus(monkeypatch: pytest.MonkeyPatch) -> _Bus:
+ fake = _Bus()
+ monkeypatch.setattr(user_admin, "get_event_bus", lambda: fake)
+ return fake
+
+
+async def test_block_refuses_login_and_ends_sessions_then_unblock(world: World, bus: _Bus) -> None:
+ admin = await world.user(admin=True)
+ target = await world.user()
+ token = await create_session(target.id, "pytest")
+ async with await world.client(admin) as c:
+ resp = await c.patch(f"/api/admin/users/{target.id}", json={"blocked": True})
+ assert resp.status_code == 200 and resp.json()["blocked_at"]
+ # The session the user held is gone, and their open tabs are told to leave.
+ assert await resolve_session(token) is None
+ assert ("user.signed_out", {"user_id": str(target.id)}) in bus.published
+ anon = httpx.AsyncClient(transport=ASGITransport(app=create_app()), base_url="http://t")
+ async with anon:
+ # ...and they cannot sign back in, told why only after a correct password.
+ login = {"handle": target.handle, "password": PASSWORD}
+ blocked = await anon.post("/api/auth/login", json=login)
+ assert blocked.status_code == 403
+ assert blocked.json()["error"]["code"] == "account_blocked"
+ wrong = await anon.post("/api/auth/login", json={**login, "password": "nope-nope"})
+ assert wrong.json()["error"]["code"] == "invalid_credentials"
+
+ async with await world.client(admin) as c:
+ resp = await c.patch(f"/api/admin/users/{target.id}", json={"blocked": False})
+ assert resp.json()["blocked_at"] is None
+ assert (await anon.post("/api/auth/login", json=login)).status_code == 200
+
+
+async def test_admin_cannot_block_or_delete_themselves(world: World) -> None:
+ admin = await world.user(admin=True)
+ async with await world.client(admin) as c:
+ assert (
+ await c.patch(f"/api/admin/users/{admin.id}", json={"blocked": True})
+ ).status_code == 409
+ assert (await c.delete(f"/api/admin/users/{admin.id}")).status_code == 409
+
+
+async def test_delete_removes_the_user_and_their_data(world: World, bus: _Bus) -> None:
+ admin = await world.user(admin=True)
+ target = await world.user()
+ world.session.add(_account(target.id, f"d{uuid.uuid4().hex[:8]}@example.com"))
+ world.session.add(PluginKv(plugin="labels", user_id=target.id, key="k", value={"a": 1}))
+ await world.session.commit()
+ # A plugin row: no foreign key to users, behind row-level security.
+ async with plugin_session(target.id) as ps:
+ await ps.execute(
+ text("INSERT INTO plg_contacts_books (user_id, name) VALUES (:u, 'Book')"),
+ {"u": target.id},
+ )
+ token = await create_session(target.id, "pytest")
+
+ async with await world.client(admin) as c:
+ assert (await c.delete(f"/api/admin/users/{target.id}")).status_code == 200
+
+ s = world.session
+ assert await s.scalar(select(User).where(User.id == target.id)) is None
+ assert await s.scalar(select(Account).where(Account.user_id == target.id)) is None
+ assert await s.scalar(select(PluginKv).where(PluginKv.user_id == target.id)) is None
+ async with plugin_session(target.id) as ps:
+ left = await ps.scalar(text("SELECT count(*) FROM plg_contacts_books"))
+ assert left == 0
+ assert await resolve_session(token) is None
+ assert ("user.signed_out", {"user_id": str(target.id)}) in bus.published
diff --git a/backend/tests/identity/test_identity_m1.py b/backend/tests/identity/test_identity_m1.py
index ba83b42..8327a00 100644
--- a/backend/tests/identity/test_identity_m1.py
+++ b/backend/tests/identity/test_identity_m1.py
@@ -1,4 +1,5 @@
-"""M1 identity: 2FA (TOTP), session list/revoke, password reset (SPEC §6.1/§9.1)."""
+"""M1 identity: 2FA (TOTP), session list/revoke (SPEC §6.1/§9.1). The password reset
+flow is in test_password_reset.py."""
from __future__ import annotations
@@ -19,7 +20,6 @@ from app.identity.passwords import hash_password
from app.identity.sessions import create_session
from app.main import create_app
from app.models import User
-from app.redis_client import get_redis
CSRF = "csrf-token"
PASSWORD = "hunter2pass"
@@ -145,43 +145,6 @@ async def test_sessions_list_and_revoke(ctx: Ctx) -> None:
assert me.status_code == 401
-async def test_password_reset_flow(ctx: Ctx) -> None:
- # request a reset; token is stored in redis (not returned)
- req = await ctx.client.post("/api/auth/password/reset-request", json={"handle": ctx.handle})
- assert req.status_code == 200
-
- redis = get_redis()
- keys = await redis.keys("pwreset:*")
- # Pick the token bound to THIS user (redis DB may hold others from prior runs).
- token = None
- for key in keys:
- if await redis.get(key) == str(ctx.user_id):
- token = key.split(":", 1)[1]
- break
- assert token is not None, "a reset token should have been issued for this user"
-
- new_password = "brandNewPass9"
- reset = await ctx.client.post(
- "/api/auth/password/reset", json={"token": token, "password": new_password}
- )
- assert reset.status_code == 200
-
- # token is single-use
- again = await ctx.client.post(
- "/api/auth/password/reset", json={"token": token, "password": new_password}
- )
- assert again.status_code == 400
-
- # new password works, old one does not
- async with _fresh_client(ctx.app) as anon:
- good = await anon.post(
- "/api/auth/login", json={"handle": ctx.handle, "password": new_password}
- )
- assert good.status_code == 200
- bad = await anon.post("/api/auth/login", json={"handle": ctx.handle, "password": PASSWORD})
- assert bad.status_code == 401
-
-
async def test_reset_request_unknown_handle_still_ok(ctx: Ctx) -> None:
# must not leak whether a handle exists
resp = await ctx.client.post("/api/auth/password/reset-request", json={"handle": "nobody-here"})
diff --git a/backend/tests/identity/test_password_reset.py b/backend/tests/identity/test_password_reset.py
new file mode 100644
index 0000000..ded426a
--- /dev/null
+++ b/backend/tests/identity/test_password_reset.py
@@ -0,0 +1,309 @@
+"""Password reset by email, recovery address, and the system mailer (SPEC §6.1).
+
+The SMTP send is replaced with a recorder, so nothing leaves the machine; the
+reset link it would have sent is read back from what was recorded.
+"""
+
+from __future__ import annotations
+
+import uuid
+from collections.abc import AsyncIterator
+from dataclasses import dataclass, field
+
+import httpx
+import pyotp
+import pytest
+import pytest_asyncio
+from httpx import ASGITransport
+from sqlalchemy import delete, update
+from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
+from sqlalchemy.pool import NullPool
+
+from app.api import admin as admin_api
+from app.config import get_settings
+from app.identity import routes
+from app.identity.mailer import MailerConfig
+from app.identity.passwords import hash_password
+from app.identity.reset import MAX_TOTP_FAILURES
+from app.identity.service import app_settings
+from app.identity.sessions import create_session
+from app.identity.totp import encrypt_totp_secret
+from app.main import create_app
+from app.models import AppSettings, User
+from app.redis_client import get_redis
+
+CSRF = "csrf-token"
+PASSWORD = "hunter2pass"
+NEW_PASSWORD = "brandNewPass9"
+CFG = MailerConfig(
+ host="smtp.example.com",
+ port=587,
+ security="starttls",
+ username="postmaster@example.com",
+ password="pw",
+ sender="postmaster@example.com",
+ public_url="https://mail.example.com",
+)
+
+
+@dataclass
+class Sent:
+ mails: list[tuple[str, str, str]] = field(default_factory=list) # (to, subject, text)
+
+ def token(self) -> str:
+ text = self.mails[-1][2]
+ link = next(w for w in text.split() if w.startswith("https://mail.example.com/"))
+ assert link.startswith("https://mail.example.com/reset-password#token=")
+ return link.split("#token=", 1)[1]
+
+
+@dataclass
+class Ctx:
+ session: AsyncSession
+ anon: httpx.AsyncClient
+ sent: Sent
+ created: list[uuid.UUID] = field(default_factory=list)
+
+ async def user(
+ self, *, recovery: str | None = "me@elsewhere.example", admin: bool = False
+ ) -> User:
+ user = User(
+ handle=f"pr{uuid.uuid4().hex[:8]}@test.local",
+ password_hash=hash_password(PASSWORD),
+ recovery_email=recovery,
+ is_admin=admin,
+ )
+ self.session.add(user)
+ await self.session.commit()
+ self.created.append(user.id)
+ await get_redis().delete(f"pwreset-rate:{user.id}")
+ return user
+
+ async def signed_in(self, user: User) -> httpx.AsyncClient:
+ token = await create_session(user.id, "pytest")
+ return httpx.AsyncClient(
+ transport=ASGITransport(app=create_app()),
+ base_url="http://testserver",
+ cookies={"session": token, "csrf": CSRF},
+ headers={"X-CSRF": CSRF},
+ )
+
+
+@pytest_asyncio.fixture
+async def ctx(monkeypatch: pytest.MonkeyPatch) -> AsyncIterator[Ctx]:
+ sent = Sent()
+
+ async def fake_send(cfg: MailerConfig, to: str, subject: str, text: str, html: str) -> None:
+ sent.mails.append((to, subject, text))
+
+ monkeypatch.setattr(routes, "send_mail", fake_send)
+ monkeypatch.setattr(admin_api, "send_mail", fake_send)
+ monkeypatch.setattr(routes, "mailer_config", lambda row: CFG)
+
+ engine = create_async_engine(get_settings().database_url, poolclass=NullPool)
+ session = async_sessionmaker(engine, expire_on_commit=False)()
+ anon = httpx.AsyncClient(transport=ASGITransport(app=create_app()), base_url="http://t")
+ c = Ctx(session=session, anon=anon, sent=sent)
+ try:
+ yield c
+ finally:
+ await anon.aclose()
+ await session.execute(delete(User).where(User.id.in_(c.created)))
+ await session.commit()
+ await session.close()
+ await engine.dispose()
+
+
+async def _request(ctx: Ctx, handle: str) -> httpx.Response:
+ return await ctx.anon.post("/api/auth/password/reset-request", json={"handle": handle})
+
+
+async def _login(ctx: Ctx, handle: str, password: str) -> int:
+ resp = await ctx.anon.post("/api/auth/login", json={"handle": handle, "password": password})
+ return resp.status_code
+
+
+# --- requesting a link -----------------------------------------------------------
+
+
+async def test_request_mails_a_one_time_link_to_the_recovery_address(ctx: Ctx) -> None:
+ user = await ctx.user()
+ assert (await _request(ctx, user.handle)).status_code == 200
+ assert [m[0] for m in ctx.sent.mails] == ["me@elsewhere.example"]
+ token = ctx.sent.token()
+
+ check = await ctx.anon.post("/api/auth/password/reset/check", json={"token": token})
+ assert check.json() == {"valid": True, "totp_required": False}
+
+ reset = await ctx.anon.post(
+ "/api/auth/password/reset", json={"token": token, "password": NEW_PASSWORD}
+ )
+ assert reset.status_code == 200
+ # Used up: a second go fails, and the page is told so up front.
+ again = await ctx.anon.post(
+ "/api/auth/password/reset", json={"token": token, "password": "anotherPass12"}
+ )
+ assert again.json()["error"]["code"] == "invalid_token"
+ check = await ctx.anon.post("/api/auth/password/reset/check", json={"token": token})
+ assert check.json()["valid"] is False
+
+ assert await _login(ctx, user.handle, NEW_PASSWORD) == 200
+ assert await _login(ctx, user.handle, PASSWORD) == 401
+
+
+async def test_the_answer_never_says_whether_the_account_exists(ctx: Ctx) -> None:
+ no_recovery = await ctx.user(recovery=None)
+ for handle in ("nobody-here-at-all", no_recovery.handle):
+ resp = await _request(ctx, handle)
+ assert resp.status_code == 200 and resp.json() == {"status": "ok"}
+ assert ctx.sent.mails == []
+
+
+async def test_no_mail_while_system_mail_is_not_set_up(
+ ctx: Ctx, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ monkeypatch.setattr(routes, "mailer_config", lambda row: None)
+ user = await ctx.user()
+ assert (await _request(ctx, user.handle)).status_code == 200
+ assert ctx.sent.mails == []
+
+
+async def test_requests_are_capped_per_hour(ctx: Ctx) -> None:
+ user = await ctx.user()
+ for _ in range(5):
+ assert (await _request(ctx, user.handle)).status_code == 200
+ assert len(ctx.sent.mails) == 3
+
+
+# --- two-step verification ---------------------------------------------------------
+
+
+async def test_2fa_code_is_required_before_the_password_changes(ctx: Ctx) -> None:
+ user = await ctx.user()
+ secret = pyotp.random_base32()
+ await ctx.session.execute(
+ update(User).where(User.id == user.id).values(totp_secret=encrypt_totp_secret(secret))
+ )
+ await ctx.session.commit()
+ await _request(ctx, user.handle)
+ token = ctx.sent.token()
+ body = {"token": token, "password": NEW_PASSWORD}
+
+ check = await ctx.anon.post("/api/auth/password/reset/check", json={"token": token})
+ assert check.json()["totp_required"] is True
+ missing = await ctx.anon.post("/api/auth/password/reset", json=body)
+ assert missing.json()["error"]["code"] == "totp_required"
+ wrong = await ctx.anon.post("/api/auth/password/reset", json={**body, "totp": "000000"})
+ assert wrong.json()["error"]["code"] == "totp_invalid"
+ # Nothing changed yet, and the link still works.
+ assert await _login(ctx, user.handle, PASSWORD) == 401 # 2FA account: needs a code
+ good = await ctx.anon.post(
+ "/api/auth/password/reset", json={**body, "totp": pyotp.TOTP(secret).now()}
+ )
+ assert good.status_code == 200
+ login = await ctx.anon.post(
+ "/api/auth/login",
+ json={"handle": user.handle, "password": NEW_PASSWORD, "totp": pyotp.TOTP(secret).now()},
+ )
+ assert login.status_code == 200
+
+
+async def test_too_many_wrong_codes_burn_the_link(ctx: Ctx) -> None:
+ user = await ctx.user()
+ secret = pyotp.random_base32()
+ await ctx.session.execute(
+ update(User).where(User.id == user.id).values(totp_secret=encrypt_totp_secret(secret))
+ )
+ await ctx.session.commit()
+ await _request(ctx, user.handle)
+ token = ctx.sent.token()
+ body = {"token": token, "password": NEW_PASSWORD, "totp": "000000"}
+ for _ in range(MAX_TOTP_FAILURES):
+ await ctx.anon.post("/api/auth/password/reset", json=body)
+ late = await ctx.anon.post(
+ "/api/auth/password/reset", json={**body, "totp": pyotp.TOTP(secret).now()}
+ )
+ assert late.json()["error"]["code"] == "invalid_token"
+
+
+# --- the recovery address ----------------------------------------------------------
+
+
+async def test_changing_the_recovery_email_takes_the_password(ctx: Ctx) -> None:
+ user = await ctx.user(recovery=None)
+ async with await ctx.signed_in(user) as c:
+ wrong = await c.put(
+ "/api/auth/recovery-email",
+ json={"password": "not-it", "recovery_email": "new@example.com"},
+ )
+ assert wrong.status_code == 403
+ bad = await c.put(
+ "/api/auth/recovery-email", json={"password": PASSWORD, "recovery_email": "nope"}
+ )
+ assert bad.json()["error"]["code"] == "invalid_email"
+ ok = await c.put(
+ "/api/auth/recovery-email",
+ json={"password": PASSWORD, "recovery_email": " New@Example.com "},
+ )
+ assert ok.json()["recovery_email"] == "new@example.com"
+ removed = await c.put(
+ "/api/auth/recovery-email", json={"password": PASSWORD, "recovery_email": ""}
+ )
+ assert removed.json()["recovery_email"] is None
+
+
+# --- admin: system mail settings -----------------------------------------------------
+
+
+@pytest_asyncio.fixture
+async def restore_settings(ctx: Ctx) -> AsyncIterator[None]:
+ row = await app_settings(ctx.session)
+ saved = {
+ k: getattr(row, k)
+ for k in (
+ "smtp_host",
+ "smtp_port",
+ "smtp_security",
+ "smtp_username",
+ "smtp_secret_ct",
+ "smtp_from",
+ "public_url",
+ )
+ }
+ await ctx.session.commit()
+ yield
+ await ctx.session.execute(update(AppSettings).values(**saved))
+ await ctx.session.commit()
+
+
+async def test_admin_configures_system_mail_without_ever_seeing_the_password(
+ ctx: Ctx, restore_settings: None
+) -> None:
+ admin = await ctx.user(admin=True)
+ async with await ctx.signed_in(admin) as c:
+ resp = await c.put(
+ "/api/admin/mail",
+ json={
+ "smtp_host": "smtp.example.com",
+ "smtp_port": 465,
+ "smtp_security": "ssl",
+ "smtp_username": "postmaster@example.com",
+ "smtp_password": "s3cret",
+ "smtp_from": "postmaster@example.com",
+ "public_url": "https://mail.example.com/",
+ },
+ )
+ out = resp.json()
+ assert out["has_password"] is True and out["configured"] is True
+ assert out["public_url"] == "https://mail.example.com"
+ assert "s3cret" not in resp.text
+ # Omitting the password keeps it; an empty one removes it.
+ kept = (await c.put("/api/admin/mail", json={"smtp_port": 587})).json()
+ assert kept["has_password"] is True
+ gone = (await c.put("/api/admin/mail", json={"smtp_password": ""})).json()
+ assert gone["has_password"] is False
+ bad = await c.put("/api/admin/mail", json={"public_url": "mail.example.com"})
+ assert bad.json()["error"]["code"] == "invalid_url"
+ test = await c.post("/api/admin/mail/test", json={"to": "me@example.com"})
+ assert test.status_code == 200
+ assert ctx.sent.mails[-1][:2] == ("me@example.com", "Ultimo Email test message")
diff --git a/frontend/admin.html b/frontend/admin.html
index f5e66f2..a6a2ab4 100644
--- a/frontend/admin.html
+++ b/frontend/admin.html
@@ -4,7 +4,7 @@
- Webmail admin
+ Ultimo Email admin
diff --git a/frontend/icons/ultimo-logo-bimi.svg b/frontend/icons/ultimo-logo-bimi.svg
new file mode 100644
index 0000000..808c5cd
--- /dev/null
+++ b/frontend/icons/ultimo-logo-bimi.svg
@@ -0,0 +1,14 @@
+
diff --git a/frontend/index.html b/frontend/index.html
index f40cb86..5d8022c 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -4,7 +4,7 @@
- Webmail
+ Ultimo Email
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 4930104..7ac4235 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -6,9 +6,11 @@ import { useMe } from "./lib/auth";
import { connectWs } from "./lib/ws";
import { PluginPage } from "./plugins/PluginPage";
import { ComposeWindow } from "./routes/ComposeWindow";
+import { ForgotPassword } from "./routes/ForgotPassword";
import { Login } from "./routes/Login";
import { MailLayout } from "./routes/MailLayout";
import { Register } from "./routes/Register";
+import { ResetPassword } from "./routes/ResetPassword";
import { Settings } from "./routes/settings/Settings";
function RequireAuth({ children }: { children: JSX.Element }) {
@@ -35,6 +37,8 @@ export function App() {
} />
} />
+ } />
+ } />
{t("admin.title")}
-
+ {/* Two columns once there is room: instance settings and users on the
+ left, outgoing system mail on the right. Signed out, it stays narrow. */}
+
{me.isPending &&
+ );
+}
diff --git a/frontend/src/components/TypedPasswordInput.tsx b/frontend/src/components/TypedPasswordInput.tsx
new file mode 100644
index 0000000..16b4f9c
--- /dev/null
+++ b/frontend/src/components/TypedPasswordInput.tsx
@@ -0,0 +1,66 @@
+import { useId, useState } from "react";
+
+// Keystrokes and IME composition; everything else that changes the field — a
+// browser or password-manager autofill (an input event without an inputType, or
+// "insertReplacementText"), a paste, a drop — is not the user typing.
+const TYPED = /^(insertText|insertCompositionText|insertLineBreak|delete)/;
+
+/**
+ * A password field that only accepts what is typed into it: for confirmations
+ * where the point is that the person present knows the password, which an
+ * autofilled saved password does not prove.
+ *
+ * `autoComplete="new-password"` and the password managers' opt-out attributes
+ * keep most autofill away to begin with; whatever still gets in is thrown out
+ * here, and the field says to type it.
+ */
+export function TypedPasswordInput({
+ value,
+ onChange,
+ className,
+ hint,
+}: {
+ value: string;
+ onChange: (value: string) => void;
+ className?: string;
+ /** Shown when a fill or paste was refused. */
+ hint: string;
+}) {
+ const id = useId();
+ const [refused, setRefused] = useState(false);
+ return (
+ <>
+ {
+ e.preventDefault();
+ setRefused(true);
+ }}
+ onDrop={(e) => {
+ e.preventDefault();
+ setRefused(true);
+ }}
+ onChange={(e) => {
+ const inputType = (e.nativeEvent as InputEvent).inputType ?? "";
+ if (!TYPED.test(inputType)) {
+ onChange("");
+ setRefused(true);
+ return;
+ }
+ setRefused(false);
+ onChange(e.target.value);
+ }}
+ />
+ {refused && {hint}}
+ >
+ );
+}
diff --git a/frontend/src/i18n/en.ts b/frontend/src/i18n/en.ts
index 02d6e9f..0f48d83 100644
--- a/frontend/src/i18n/en.ts
+++ b/frontend/src/i18n/en.ts
@@ -3,7 +3,7 @@
// Interpolate with {name} placeholders.
export const en = {
- "common.appName": "Webmail",
+ "common.appName": "Ultimo Email",
"common.loading": "Loading…",
"common.cancel": "Cancel",
"common.save": "Save",
@@ -34,6 +34,26 @@ export const en = {
"auth.create": "Create account",
"auth.creating": "Creating…",
"auth.noAccount": "No account?",
+ "auth.forgotPassword": "Forgot password?",
+ "auth.forgotTitle": "Reset your password",
+ "auth.forgotHint":
+ "Enter your user name. We'll email a link for choosing a new password to the recovery email address of the account.",
+ "auth.changePassword": "Change password",
+ "auth.sending": "Sending…",
+ "auth.forgotSent": "If this account exists you will receive an email to your recovery email address.",
+ "auth.backToSignIn": "Back to sign in",
+ "auth.resetTitle": "Choose a new password",
+ "auth.newPassword": "New password",
+ "auth.repeatPassword": "Repeat the new password",
+ "auth.passwordsDiffer": "The two passwords are different.",
+ "auth.passwordTooShort": "Use at least 8 characters.",
+ "auth.resetTotpPrompt":
+ "This account uses 2-step verification. Enter the 6-digit code from your authenticator app to finish.",
+ "auth.confirm": "Confirm",
+ "auth.resetDone": "Your password has been changed. You have been signed out everywhere — sign in with the new password.",
+ "auth.resetInvalid":
+ "This link is invalid, was already used, or has expired. Request a new one to change your password.",
+ "auth.requestNewLink": "Request a new link",
"auth.haveAccount": "Already have an account?",
"auth.register": "Register",
"auth.checking": "Checking…",
@@ -327,6 +347,15 @@ export const en = {
"settings.appearance": "Appearance",
"settings.handle": "Handle",
"settings.recoveryEmail": "Recovery email",
+ "settings.recoveryEmailHint":
+ "If you forget your password, a link to choose a new one is sent here.",
+ "settings.recoveryEmailNew": "Recovery email address",
+ "settings.recoveryEmailRemoveHint": "Leave empty to remove it.",
+ "settings.currentPassword": "Current password",
+ "settings.typePassword": "Type your password — for this confirmation it can't be filled in or pasted.",
+ "settings.change": "Change",
+ "settings.add": "Add",
+ "settings.saving": "Saving…",
"settings.twoFactor": "Two-factor authentication",
"settings.twoFactorOn": "2FA is enabled.",
"settings.twoFactorOff": "2FA is not enabled.",
@@ -412,7 +441,7 @@ export const en = {
"settings.alsoFile": "Also mark the message as read and file it",
"settings.integrations": "Integrations",
- "admin.title": "Webmail administration",
+ "admin.title": "Ultimo Email administration",
"admin.signInTitle": "Administrator sign-in",
"admin.signInHint": "Sign in with an administrator account to manage this server.",
"admin.signOut": "Sign out",
@@ -432,6 +461,47 @@ export const en = {
"admin.saving": "Saving…",
"admin.saved": "Saved",
"admin.quotaInvalid": "Enter a size greater than 0.",
+ "admin.mail": "Outgoing system mail",
+ "admin.mailHelp":
+ "The mail account the server sends password-reset links from. Without it, “Forgot password?” sends nothing.",
+ "admin.mailOn": "Set up — password-reset links can be sent.",
+ "admin.mailOff": "Not set up — password-reset links cannot be sent yet.",
+ "admin.smtpHost": "SMTP server",
+ "admin.smtpPort": "Port",
+ "admin.smtpSecurity": "Security",
+ "admin.smtpStarttls": "STARTTLS",
+ "admin.smtpSsl": "SSL/TLS",
+ "admin.smtpNone": "None",
+ "admin.smtpUsername": "Login",
+ "admin.smtpPassword": "Password",
+ "admin.smtpPasswordKept": "(stored — leave empty to keep)",
+ "admin.smtpPasswordRemove": "Remove the stored password",
+ "admin.smtpFrom": "From address",
+ "admin.publicUrl": "Public address of this server",
+ "admin.publicUrlHint": "Where users open Ultimo Email, e.g. https://mail.example.com — reset links point here.",
+ "admin.testTo": "Send a test email to",
+ "admin.sendTest": "Send test",
+ "admin.testSent": "Test email sent.",
+ "admin.users": "Users",
+ "admin.userSearch": "Search by login, email address or name…",
+ "admin.noUsers": "No users match.",
+ "admin.you": "you",
+ "admin.adminBadge": "admin",
+ "admin.blockedBadge": "blocked",
+ "admin.usage": "{used} of {quota}",
+ "admin.noMailAccounts": "No mail accounts connected",
+ "admin.userQuota": "Quota",
+ "admin.blockedSince": "Blocked since {date}. This user cannot sign in.",
+ "admin.block": "Block account",
+ "admin.unblock": "Unblock account",
+ "admin.blockConfirm":
+ "Block {handle}? They are signed out everywhere and cannot sign in until you unblock them. Their mail and data are kept.",
+ "admin.deleteUser": "Delete account",
+ "admin.deleteConfirm":
+ "Permanently delete {handle} and all of their mail, contacts, calendars and settings? This can't be undone.",
+ "admin.selfNote": "This is your own account: it cannot be blocked or deleted from here.",
+ "admin.cancel": "Cancel",
+ "admin.close": "Close",
"settings.jitsi": "Jitsi Meet",
"settings.jitsiHint":
"Server used to generate video-call links for calendar events. Leave empty to use the public https://meet.jit.si/ server.",
diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts
index de3ddc0..0c36fe1 100644
--- a/frontend/src/lib/api.ts
+++ b/frontend/src/lib/api.ts
@@ -3,7 +3,9 @@
import type {
AccountDetail,
+ AdminMail,
AdminSettings,
+ AdminUser,
AccountOut,
AttachmentOut,
CalendarOut,
@@ -83,6 +85,38 @@ export const api = {
// PUT merges: send only the fields you are changing.
saveAdminSettings: (body: Partial) =>
apiFetch("/admin/settings", { method: "PUT", body: JSON.stringify(body) }),
+ adminUsers: (q: string) => apiFetch(`/admin/users?q=${encodeURIComponent(q)}`),
+ // PATCH merges: send only the fields you are changing.
+ updateAdminUser: (id: string, body: { quota_bytes?: number; blocked?: boolean }) =>
+ apiFetch(`/admin/users/${id}`, { method: "PATCH", body: JSON.stringify(body) }),
+ deleteAdminUser: (id: string) =>
+ apiFetch<{ status: string }>(`/admin/users/${id}`, { method: "DELETE" }),
+ adminMail: () => apiFetch("/admin/mail"),
+ // PUT merges: omit smtp_password to keep the stored one, "" to remove it.
+ saveAdminMail: (body: Partial> & { smtp_password?: string }) =>
+ apiFetch("/admin/mail", { method: "PUT", body: JSON.stringify(body) }),
+ testAdminMail: (to: string) =>
+ apiFetch<{ status: string }>("/admin/mail/test", { method: "POST", body: JSON.stringify({ to }) }),
+ passwordResetRequest: (handle: string) =>
+ apiFetch<{ status: string }>("/auth/password/reset-request", {
+ method: "POST",
+ body: JSON.stringify({ handle }),
+ }),
+ passwordResetCheck: (token: string) =>
+ apiFetch<{ valid: boolean; totp_required: boolean }>("/auth/password/reset/check", {
+ method: "POST",
+ body: JSON.stringify({ token }),
+ }),
+ passwordReset: (token: string, password: string, totp?: string) =>
+ apiFetch<{ status: string }>("/auth/password/reset", {
+ method: "POST",
+ body: JSON.stringify({ token, password, totp }),
+ }),
+ setRecoveryEmail: (password: string, recovery_email: string | null) =>
+ apiFetch("/auth/recovery-email", {
+ method: "PUT",
+ body: JSON.stringify({ password, recovery_email }),
+ }),
registrationStatus: () => apiFetch("/auth/registration"),
register: (localpart: string, password: string) =>
apiFetch("/auth/register", {
diff --git a/frontend/src/lib/auth.ts b/frontend/src/lib/auth.ts
index a7fb1f8..10488d9 100644
--- a/frontend/src/lib/auth.ts
+++ b/frontend/src/lib/auth.ts
@@ -1,4 +1,4 @@
-import { useQuery } from "@tanstack/react-query";
+import { type QueryClient, useQuery } from "@tanstack/react-query";
import { api, ApiError } from "./api";
@@ -13,3 +13,21 @@ export function useMe() {
},
});
}
+
+/**
+ * The server no longer knows this session — signed out elsewhere, or an
+ * administrator blocked or deleted the account. Drop everything cached and let
+ * the auth guard send the tab to the sign-in screen.
+ */
+export function signedOut(queryClient: QueryClient) {
+ if (!queryClient.getQueryData(["me"])) return; // already signed out
+ // "me" first, in place: its watchers see it and route to sign-in. Clearing the
+ // whole cache first would orphan them from the query they watch.
+ queryClient.setQueryData(["me"], null);
+ queryClient.removeQueries({ predicate: (q) => q.queryKey[0] !== "me" });
+}
+
+/** Whether an error means "this session is gone" (vs. a wrong password, etc.). */
+export function isSessionGone(error: unknown): boolean {
+ return error instanceof ApiError && error.status === 401 && error.code === "not_authenticated";
+}
diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts
index c3de467..d719fb6 100644
--- a/frontend/src/lib/types.ts
+++ b/frontend/src/lib/types.ts
@@ -15,6 +15,31 @@ export interface UserOut {
is_admin: boolean;
}
+export interface AdminUser {
+ id: string;
+ handle: string;
+ is_admin: boolean;
+ /** ISO timestamp while an administrator has blocked the account. */
+ blocked_at: string | null;
+ quota_bytes: number;
+ used_bytes: number;
+ created_at: string;
+ /** The user's connected mail accounts. */
+ accounts: string[];
+}
+
+/** Outgoing system mail (password-reset links). The password is never sent back. */
+export interface AdminMail {
+ smtp_host: string;
+ smtp_port: number;
+ smtp_security: "starttls" | "ssl" | "none";
+ smtp_username: string;
+ has_password: boolean;
+ smtp_from: string;
+ public_url: string;
+ configured: boolean;
+}
+
export interface AdminSettings {
registration_open: boolean;
/** Quota given to users created from now on; existing mailboxes keep theirs. */
diff --git a/frontend/src/lib/ws.ts b/frontend/src/lib/ws.ts
index 18fc1f5..28de199 100644
--- a/frontend/src/lib/ws.ts
+++ b/frontend/src/lib/ws.ts
@@ -4,6 +4,8 @@
import type { QueryClient } from "@tanstack/react-query";
import { z } from "zod";
+import { signedOut } from "./auth";
+
const frameSchema = z.object({
event: z.string(),
payload: z.record(z.unknown()),
@@ -20,6 +22,7 @@ const ALLOWED_EVENTS = new Set([
"account.state_changed",
"draft.saved",
"outbox.state_changed",
+ "user.signed_out",
]);
// Lightweight in-process fan-out of WS frames to plugin subscribers (SPEC §11.3).
@@ -82,6 +85,12 @@ export function connectWs(queryClient: QueryClient): () => void {
}
}
if (!isCore) return;
+ if (name === "user.signed_out") {
+ // An administrator blocked or deleted this account: leave now, rather
+ // than keep showing mail that was already loaded.
+ signedOut(queryClient);
+ return;
+ }
// Coarse invalidation: refetch affected lists (SPEC §9.2 contract).
if (name === "sync.progress" || name.startsWith("message.") || name === "thread.updated") {
queryClient.invalidateQueries({ queryKey: ["threads"] });
diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx
index 33f080c..6a3537a 100644
--- a/frontend/src/main.tsx
+++ b/frontend/src/main.tsx
@@ -1,15 +1,24 @@
-import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { MutationCache, QueryCache, QueryClient, QueryClientProvider } from "@tanstack/react-query";
import React from "react";
import ReactDOM from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import { App } from "./App";
import { I18nProvider } from "./i18n";
+import { isSessionGone, signedOut } from "./lib/auth";
import { ThemeProvider } from "./lib/theme";
import "./index.css";
import "./plugins/plugin-ui.css"; // .wm-* classes for plugin bundles (SPEC §11.3)
+// Any request answered "not signed in" means the session is gone (signed out
+// elsewhere, or the account was blocked): go to sign-in instead of leaving the
+// page up with failing requests behind it.
+const onError = (error: unknown) => {
+ if (isSessionGone(error)) signedOut(queryClient);
+};
const queryClient = new QueryClient({
+ queryCache: new QueryCache({ onError }),
+ mutationCache: new MutationCache({ onError }),
defaultOptions: { queries: { retry: false, refetchOnWindowFocus: false } },
});
diff --git a/frontend/src/routes/ForgotPassword.tsx b/frontend/src/routes/ForgotPassword.tsx
new file mode 100644
index 0000000..5df76a3
--- /dev/null
+++ b/frontend/src/routes/ForgotPassword.tsx
@@ -0,0 +1,61 @@
+import { useMutation } from "@tanstack/react-query";
+import { useState } from "react";
+import { Link } from "react-router-dom";
+
+import { useT } from "../i18n";
+import { api, ApiError } from "../lib/api";
+
+const inputClass =
+ "w-full rounded-lg border border-app-border bg-app-surface px-3 py-2 text-app-fg placeholder:text-app-faint focus:border-app-accent focus:outline-none";
+
+// "Forgot password?": ask for the user name and have a reset link mailed to the
+// account's recovery address. The answer is the same whether or not the account
+// exists — the page never tells a stranger which user names are real.
+export function ForgotPassword() {
+ const t = useT();
+ const [handle, setHandle] = useState("");
+ const request = useMutation({ mutationFn: () => api.passwordResetRequest(handle.trim()) });
+
+ return (
+