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
+9
View File
@@ -0,0 +1,9 @@
"""The one rule for a usable e-mail address. No Django imports: the config reads it at startup."""
import re
_ADDRESS = re.compile(r"^[^@\s<>,;\"()\[\]\\]+@[^@\s<>,;\"()\[\]\\]+\.[^@\s<>,;\"()\[\]\\]+$")
def is_valid_address(value: str) -> bool:
return len(value) <= 254 and bool(_ADDRESS.match(value))
+1 -7
View File
@@ -9,7 +9,6 @@ duplicate one for a password or sign-in notice.
from __future__ import annotations
import logging
import re
from dataclasses import dataclass
from datetime import datetime, timedelta
@@ -21,6 +20,7 @@ from django.utils import timezone, translation
from portal.adapters.mail import MailMessage, MailRejected, MailUnavailable
from portal.adapters.registry import get_mailer
from .address import is_valid_address
from .models import OutboxMail
logger = logging.getLogger(__name__)
@@ -31,8 +31,6 @@ RATE_PER_MINUTE = 20
LEASE_SECONDS = 10 * 60 # a row that has been `sending` this long is given up on
STOP_AFTER_CONSECUTIVE_OUTAGES = 3
_ADDRESS = re.compile(r"^[^@\s<>,;\"()\[\]\\]+@[^@\s<>,;\"()\[\]\\]+\.[^@\s<>,;\"()\[\]\\]+$")
@dataclass(frozen=True)
class FlushResult:
@@ -44,10 +42,6 @@ class FlushResult:
return f"sent={self.sent} retried={self.retried} failed={self.failed}"
def is_valid_address(value: str) -> bool:
return len(value) <= 254 and bool(_ADDRESS.match(value))
def enqueue(template: str, recipient: str, context: dict | None = None) -> OutboxMail:
"""Render the e-mail in the portal-wide language and queue it.