Files
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

97 lines
3.4 KiB
Python

# INTERFACE VERSION: 1
"""EventBus — Redis pub/sub fan-out + in-process durable consumers (SPEC §5.3, §8.3).
``publish`` fans out to the Redis channel ``evt:{event}`` (the WS hub relays these
to the owning user) and invokes any in-process subscribers. Payloads always
include ``user_id`` (SPEC §5.3). Only the event names in SPEC §8.3 are allowed.
NOTE(spec): the frozen contract also enqueues a durable arq job ``on_{event}`` for
out-of-process consumers (plugins). In M0 the only durable consumers (search index,
thread denormalization) run inline in the indexer per FR-30, so cross-process arq
fan-out is deferred to the plugin milestone (M3). The signatures here are frozen.
"""
from __future__ import annotations
import json
import logging
from collections.abc import Awaitable, Callable
from typing import Protocol
from app.redis_client import get_redis
logger = logging.getLogger(__name__)
Handler = Callable[[dict[str, object]], Awaitable[None]]
# The complete, allowed event catalog (SPEC §8.3). Plugin events (plugin.{name}.*)
# are validated separately by the plugin loader (M3).
ALLOWED_EVENTS: frozenset[str] = frozenset(
{
"message.new",
"message.changed",
"message.deleted",
"message.sent",
"flags.changed",
"thread.updated",
"sync.progress",
"account.state_changed",
"draft.saved",
"outbox.state_changed",
# 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",
}
)
def channel_for(event: str) -> str:
"""Return the Redis pub/sub channel name for an event."""
return f"evt:{event}"
class EventBusProtocol(Protocol):
async def publish(self, event: str, payload: dict[str, object]) -> None: ...
def subscribe(self, event: str, handler: Handler) -> None: ...
class EventBus:
"""Concrete EventBus backed by Redis pub/sub with in-process subscribers."""
def __init__(self) -> None:
self._handlers: dict[str, list[Handler]] = {}
def subscribe(self, event: str, handler: Handler) -> None:
"""Register an in-process durable consumer for ``event``."""
self._handlers.setdefault(event, []).append(handler)
async def publish(self, event: str, payload: dict[str, object]) -> None:
"""Fan out ``event`` to Redis subscribers and in-process handlers."""
if event not in ALLOWED_EVENTS and not event.startswith("plugin."):
raise ValueError(f"unknown event {event!r} (see SPEC §8.3)")
if "user_id" not in payload:
raise ValueError("event payload must include user_id (SPEC §5.3)")
message = json.dumps({"event": event, "payload": payload}, default=str)
await get_redis().publish(channel_for(event), message)
for handler in self._handlers.get(event, ()):
try:
await handler(payload)
except Exception: # a failing consumer must not break the publisher
logger.exception("event handler failed for %s", event)
_bus: EventBus | None = None
def get_event_bus() -> EventBus:
"""Return the process-wide EventBus singleton."""
global _bus
if _bus is None:
_bus = EventBus()
return _bus