feat: add e-mail change with confirmation and undo

Page at /email asks for the new address and the current password (shared failure counter with the password page, five requests per hour) and mails a one-hour confirmation link to the new address. Confirming writes the address and mails the old address a 24-hour undo link; undoing restores it, ends every session of the user and notifies the Admin (ADMIN_NOTIFY_EMAIL). Link pages show on GET and apply on POST; tokens are stored only as hashes, single use, and are blanked from every log line. New table owned by profile, German text, tests; review findings logged in the plan.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FocPJ75XPma7TcuYEMy8zm
This commit is contained in:
Ebbe Baß
2026-10-19 10:05:41 +02:00
co-authored by Claude Sonnet 5.5
parent 3fd8f27127
commit 94c1d39c9d
35 changed files with 1792 additions and 14 deletions
+17 -1
View File
@@ -10,6 +10,8 @@ from pathlib import Path
from urllib.parse import urlsplit
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from portal.mailer.address import is_valid_address
SMTP_SECURITY_MODES = ("starttls", "ssl")
SMTP_DEFAULT_PORTS = {"starttls": 587, "ssl": 465}
LOG_LEVELS = ("DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL")
@@ -80,6 +82,7 @@ class Config:
directory: DirectoryConfig
oidc: OidcConfig
smtp: SmtpConfig
admin_notify_email: str = "" # where the notice of an undone e-mail change goes; may be empty
class _Reader:
@@ -251,12 +254,25 @@ def _read_smtp(r: _Reader) -> SmtpConfig:
)
def _read_admin_notify_email(r: _Reader) -> str:
value = r.raw("ADMIN_NOTIFY_EMAIL")
if value is None:
return ""
if not is_valid_address(value):
r.problem("ADMIN_NOTIFY_EMAIL: must be an e-mail address, or left empty")
return ""
return value
def load_config(environ: Mapping[str, str]) -> Config:
reader = _Reader(environ)
core = _read_core(reader)
directory = _read_directory(reader)
oidc = _read_oidc(reader, debug=core["debug"])
smtp = _read_smtp(reader)
admin_notify_email = _read_admin_notify_email(reader)
if reader.problems:
raise ConfigError(reader.problems)
return Config(**core, directory=directory, oidc=oidc, smtp=smtp)
return Config(
**core, directory=directory, oidc=oidc, smtp=smtp, admin_notify_email=admin_notify_email
)