Files
ping-mee-self-service-platform/portal/profile/services.py
T
Ebbe BaßandClaude Sonnet 5.5 94c1d39c9d 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
2026-10-19 10:05:41 +02:00

815 lines
29 KiB
Python

"""Password change, profile details and e-mail change (FR-5..FR-11; AD-3, AD-4, AD-8, AD-9).
The order is fixed: throttle check, policy read, verification of the current password, local
hints, then the directory change, the ended sessions, the audit entry and the queued mail in
one transaction. Passwords live only in local variables here: they are never logged, audited,
stored or put into an exception.
"""
from __future__ import annotations
import hashlib
import logging
import math
import secrets
from dataclasses import dataclass, replace
from datetime import timedelta
from zoneinfo import ZoneInfo
from django.conf import settings
from django.db import transaction
from django.utils import timezone
from django.utils.translation import gettext as _
from django.utils.translation import ngettext
from portal.accounts import throttle
from portal.accounts.sessions import SessionUser, end_sessions
from portal.adapters.directory import (
DirectoryUnavailable,
DirectoryUser,
Forbidden,
PasswordPolicy,
PasswordRejected,
ValidationFailed,
)
from portal.adapters.registry import get_directory
from portal.audit import services as audit
from portal.mailer import services as mailer
from .forms import DETAIL_FIELDS, check_details
from .models import PendingEmailChange
logger = logging.getLogger(__name__)
ENDPOINT = "password-change"
CHARACTER_GROUPS_REQUIRED = 3 # AD complexity: three of upper, lower, digit, other
class WrongCurrentPassword(Exception):
def __init__(self, policy: PasswordPolicy | None = None) -> None:
self.policy = policy
super().__init__("The current password is not correct")
class PasswordNotAccepted(Exception):
"""The new password breaks a rule. `reasons` holds keys for rules the portal could name."""
def __init__(self, reasons: list[str], policy: PasswordPolicy | None = None) -> None:
self.reasons = reasons
self.policy = policy
super().__init__("The new password is not accepted")
class OutcomeUnknown(Exception):
"""The directory accepted the new password but the portal could not finish its own part."""
@dataclass(frozen=True)
class Changed:
mail_queued: bool
other_sessions_ended: int
# -- the rules, in words ----------------------------------------------------------------------
def describe_policy(policy: PasswordPolicy) -> list[str]:
"""The user's password rules as sentences in the active language."""
rules = []
if policy.min_length:
rules.append(
ngettext(
"At least %(n)s character",
"At least %(n)s characters",
policy.min_length,
)
% {"n": policy.min_length}
)
if policy.complexity:
rules.append(
_(
"Characters from at least three of these groups: upper case letters, "
"lower case letters, digits and special characters"
)
)
rules.append(_("No part of your user name or display name"))
if policy.history_length:
rules.append(
ngettext(
"Not your last %(n)s password",
"Not one of your last %(n)s passwords",
policy.history_length,
)
% {"n": policy.history_length}
)
if policy.min_age:
rules.append(_min_age_rule(policy.min_age))
if policy.max_age:
days = max(1, round(policy.max_age.total_seconds() / 86400))
rules.append(
ngettext(
"A password is valid for %(n)s day",
"A password is valid for %(n)s days",
days,
)
% {"n": days}
)
return rules
def _min_age_rule(age: timedelta) -> str:
if age >= timedelta(days=1):
days = math.ceil(age.total_seconds() / 86400) # round up: too early is refused
return ngettext(
"A new password can be changed again after %(n)s day",
"A new password can be changed again after %(n)s days",
days,
) % {"n": days}
hours = max(1, math.ceil(age.total_seconds() / 3600))
return ngettext(
"A new password can be changed again after %(n)s hour",
"A new password can be changed again after %(n)s hours",
hours,
) % {"n": hours}
def _character_groups(password: str) -> int:
groups = set()
for char in password:
if char.isupper():
groups.add("upper")
elif char.islower():
groups.add("lower")
elif char.isdigit():
groups.add("digit")
else:
groups.add("other")
return len(groups)
def local_hints(new: str, policy: PasswordPolicy) -> list[str]:
"""Rule keys the portal can check itself; the directory stays the authority.
Nothing is claimed when a fine-grained policy applies but could not be read: the domain
values may be stricter or looser than the rules that really apply.
"""
if policy.fine_grained and not policy.fine_grained_readable:
return []
reasons = []
if len(new) < policy.min_length:
reasons.append("too_short")
if policy.complexity and _character_groups(new) < CHARACTER_GROUPS_REQUIRED:
reasons.append("too_few_groups")
return reasons
# -- the limit -----------------------------------------
def failure_limit(policy: PasswordPolicy) -> int:
"""Wrong current passwords allowed: below the directory's lockout threshold (FR-6)."""
threshold = policy.lockout_threshold
if threshold <= 0:
return throttle.THROTTLE_MAX_FAILURES
return max(1, min(throttle.THROTTLE_MAX_FAILURES, threshold - 1))
def pause_seconds(policy: PasswordPolicy) -> int:
window = int(policy.lockout_window.total_seconds()) if policy.lockout_window else 0
return max(throttle.THROTTLE_PAUSE_SECONDS, window)
# -- the change -----------------------------------------
def _threshold_note(policy: PasswordPolicy | None) -> dict:
"""Audit note for a lockout threshold of 1, which the portal cannot protect against."""
if policy is not None and policy.lockout_threshold == 1:
return {"lockout_protection": "none"}
return {}
def _record_failure(user: SessionUser, ip: str, reason: str, **detail) -> None:
audit.record_failure(
user.key, "password.change_failed", ip=ip, detail={"reason": reason, **detail}
)
def change_password(
user: SessionUser, current: str, new: str, *, ip: str, session_key: str | None
) -> Changed:
"""Change the signed-in user's password.
Raises Throttled, WrongCurrentPassword, PasswordNotAccepted, DirectoryUnavailable or
OutcomeUnknown. Must be called outside an open transaction.
"""
try:
throttle.check(ENDPOINT, user=user.key, ip=ip or None)
except throttle.Throttled:
audit.record_failure(user.key, "password.throttled", ip=ip)
raise
directory = get_directory()
try:
policy = directory.password_policy(user.sam)
if not directory.verify_password(user.sam, current):
throttle.hit(
ENDPOINT,
user=user.key,
ip=ip or None,
max_failures=failure_limit(policy),
pause_seconds=pause_seconds(policy),
)
_record_failure(user, ip, "wrong_current", **_threshold_note(policy))
raise WrongCurrentPassword(policy)
account = directory.find_user(user.sam)
except DirectoryUnavailable:
_record_failure(user, ip, "unavailable")
raise
reasons = local_hints(new, policy)
if reasons:
_record_failure(user, ip, "policy", rules=reasons)
raise PasswordNotAccepted(reasons, policy)
changed_in_directory = False
step = "directory"
try:
with transaction.atomic():
directory.change_password(user.sam, current, new)
changed_in_directory = True
step = "end_sessions"
ended = end_sessions(user, except_session_key=session_key)
step = "queue_mail"
mail_queued = _queue_mail(account)
step = "audit"
audit.record(
user.key,
"password.changed",
ip=ip,
detail={
"other_sessions_ended": ended,
"mail": "queued" if mail_queued else "skipped",
**_threshold_note(policy),
},
)
except PasswordRejected as exc:
if exc.reason == "wrong_current": # the password changed between the check and the change
throttle.hit(
ENDPOINT,
user=user.key,
ip=ip or None,
max_failures=failure_limit(policy),
pause_seconds=pause_seconds(policy),
)
_record_failure(user, ip, "wrong_current", **_threshold_note(policy))
raise WrongCurrentPassword(policy) from exc
_record_failure(user, ip, "policy")
raise PasswordNotAccepted([], policy) from exc
except DirectoryUnavailable:
_record_failure(user, ip, "unavailable")
raise
except Exception as exc:
if changed_in_directory:
# AD-4: the directory changed but the portal's part did not commit.
_after_unclear_outcome(user, ip, session_key, step, exc)
raise OutcomeUnknown("The password was changed but not recorded") from exc
raise
throttle.reset(ENDPOINT, user=user.key)
return Changed(mail_queued=mail_queued, other_sessions_ended=ended)
def _after_unclear_outcome(
user: SessionUser, ip: str, session_key: str | None, step: str, exc: Exception
) -> None:
"""The directory has the new password but the portal's part rolled back.
Log it with the change details (AD-4, never the exception text, which could hold input),
then do what can still be done: end the other sessions and write a failure entry. The
database may be the very thing that failed, so each attempt stands alone.
"""
logger.error(
"Password changed in the directory but the portal did not record it: "
"user=%s ip=%s time=%s step=%s error=%s",
user.key,
ip,
timezone.now().isoformat(),
step,
type(exc).__name__,
)
try:
end_sessions(user, except_session_key=session_key)
except Exception:
logger.error("The other sessions of %s could not be ended", user.key)
try:
_record_failure(user, ip, "unclear", step=step)
except Exception:
logger.error("The failure entry for %s could not be written", user.key)
def _queue_mail(account) -> bool:
"""Queue the confirmation e-mail inside the caller's transaction; False if there is none."""
address = (account.mail if account else "") or ""
if not mailer.is_valid_address(address):
return False
name = account.display_name or " ".join(p for p in (account.given_name, account.surname) if p)
now = timezone.now().astimezone(ZoneInfo(settings.DISPLAY_TIME_ZONE))
mailer.enqueue(
"password_changed",
address,
{"name": name or account.sam, "changed_at": now, "zone_name": settings.DISPLAY_TIME_ZONE},
)
return True
# -- profile details (FR-9) -------------------------------------------------------------------
# Form field -> (directory attribute, DirectoryUser field). Only these five are ever written.
DETAIL_ATTRIBUTES = {
"first_name": ("givenName", "given_name"),
"last_name": ("sn", "surname"),
"display_name": ("displayName", "display_name"),
"telephone": ("telephoneNumber", "telephone"),
"mobile": ("mobile", "mobile"),
}
class DetailsInvalid(Exception):
"""Values were refused before anything was written; `errors` maps a field to its message."""
def __init__(self, errors: dict[str, str], details: Details) -> None:
self.errors = errors
self.details = details # what the one directory read held, for the page
super().__init__("The details are not valid")
class DetailsNotAccepted(Exception):
"""The directory refused a value."""
def __init__(self, details: Details) -> None:
self.details = details
super().__init__("The directory refused a value")
@dataclass(frozen=True)
class Details:
username: str
mail: str
values: dict[str, str]
@dataclass(frozen=True)
class DetailsUpdated:
changed: tuple[str, ...] # form field names, in page order
account: DirectoryUser # the user as the directory now holds them
def _values_of(account: DirectoryUser) -> dict[str, str]:
return {name: getattr(account, attr) or "" for name, (_attr, attr) in DETAIL_ATTRIBUTES.items()}
def _read(user: SessionUser) -> DirectoryUser:
account = get_directory().find_user(user.sam)
if account is None:
raise DirectoryUnavailable("The account could not be read")
return account
def load_details(user: SessionUser) -> Details:
"""What the directory holds about the signed-in user. Raises DirectoryUnavailable."""
account = _read(user)
return Details(username=account.sam, mail=account.mail or "", values=_values_of(account))
def _record_details_failure(user: SessionUser, ip: str, reason: str, **detail) -> None:
audit.record_failure(
user.key, "profile.update_failed", ip=ip, detail={"reason": reason, **detail}
)
def update_details(user: SessionUser, values: dict[str, str], *, ip: str) -> DetailsUpdated:
"""Save the changed details of the signed-in user.
Reads the directory afresh, checks the values that differ and writes only those, with the
audit entry in the same transaction (AD-4). Raises DetailsInvalid, DetailsNotAccepted,
DirectoryUnavailable or OutcomeUnknown. Must be called outside an open transaction.
"""
directory = get_directory()
try:
account = _read(user)
except DirectoryUnavailable:
_record_details_failure(user, ip, "unavailable")
raise
current = _values_of(account)
read = Details(username=account.sam, mail=account.mail or "", values=current)
cleaned, errors = check_details(values, current)
if errors:
raise DetailsInvalid(errors, read)
changed = tuple(name for name in DETAIL_FIELDS if cleaned[name] != current[name])
if not changed:
return DetailsUpdated((), account)
changes = {DETAIL_ATTRIBUTES[name][0]: cleaned[name] for name in changed}
updated = replace(account, **{DETAIL_ATTRIBUTES[name][1]: cleaned[name] for name in changed})
changed_in_directory = False
step = "directory"
try:
with transaction.atomic():
directory.update_attributes(user.sam, changes)
changed_in_directory = True
step = "audit"
audit.record(user.key, "profile.updated", ip=ip, detail={"fields": list(changed)})
step = "commit"
except ValidationFailed as exc:
_record_details_failure(user, ip, "refused")
raise DetailsNotAccepted(read) from exc
except DirectoryUnavailable:
_record_details_failure(user, ip, "unavailable")
raise
except Forbidden as exc: # the service account lacks the right: the user sees "not possible"
logger.error(
"The directory refused a profile write: user=%s ip=%s fields=%s",
user.key,
ip,
",".join(changed),
)
_record_details_failure(user, ip, "forbidden")
raise DirectoryUnavailable("The directory refused the write") from exc
except Exception as exc:
if changed_in_directory:
# AD-4: the directory changed but the portal's part did not commit.
logger.error(
"Profile details changed in the directory but the portal did not record them: "
"user=%s ip=%s time=%s fields=%s step=%s error=%s",
user.key,
ip,
timezone.now().isoformat(),
",".join(changed),
step,
type(exc).__name__,
)
try:
_record_details_failure(user, ip, "unclear", step=step, fields=list(changed))
except Exception:
logger.error("The failure entry for %s could not be written", user.key)
raise OutcomeUnknown("The details were changed but not recorded") from exc
_record_details_failure(user, ip, "error")
raise
return DetailsUpdated(changed, updated)
# -- e-mail change (FR-10, FR-11) -------------------------------------------------------------
#
# Request: validate locally (the form), throttle.check, verify_password, then the pending row,
# the queued mail and the audit entry in one transaction. Confirm and undo: the token claim,
# the directory write, the other rows, the queued mail and the audit entry in one transaction.
# Addresses, tokens and passwords never go to a log line or an audit entry.
REQUEST_ENDPOINT = "email-change-request" # five requests per hour per user
REQUEST_LIMIT = 5
REQUEST_WINDOW_SECONDS = 60 * 60
CONFIRM_VALID_FOR = timedelta(hours=1)
UNDO_VALID_FOR = timedelta(hours=24)
MAX_TOKEN_LENGTH = 128
class EmailInvalid(Exception):
"""The new address was refused after the directory was read; `reason` is `same`."""
def __init__(self, reason: str) -> None:
self.reason = reason
super().__init__("The new e-mail address is not usable")
class LinkDead(Exception):
"""The token is unknown, used, expired or replaced. Never says which."""
class ChangedAgain(Exception):
"""The address in the directory is no longer the one that was confirmed."""
@dataclass(frozen=True)
class Confirmed:
notice_queued: bool
@dataclass(frozen=True)
class Undone:
admin_notified: bool
def new_token() -> tuple[str, str]:
"""(token for the link, hash for the database)."""
raw = secrets.token_urlsafe(32)
return raw, hash_token(raw)
def hash_token(raw: str) -> str:
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
def find_link(token: str, kind: str) -> PendingEmailChange | None:
"""The unused, unexpired row of this kind for the token, or None. Changes nothing."""
if not token or len(token) > MAX_TOKEN_LENGTH:
return None
return PendingEmailChange.objects.filter(
token_hash=hash_token(token),
kind=kind,
used_at__isnull=True,
expires_at__gt=timezone.now(),
).first()
def _link(path: str, token: str) -> str:
return f"{settings.OIDC.public_base_url}/email/{path}/{token}"
def _display_name(account: DirectoryUser) -> str:
name = account.display_name or " ".join(p for p in (account.given_name, account.surname) if p)
return name or account.sam
def _local_now():
return timezone.now().astimezone(ZoneInfo(settings.DISPLAY_TIME_ZONE))
def _email_failure(user: SessionUser, ip: str, action: str, reason: str, **detail) -> None:
audit.record_failure(user.key, action, ip=ip, detail={"reason": reason, **detail})
def request_email_change(user: SessionUser, new_address: str, current: str, *, ip: str) -> None:
"""Ask for a confirmation link at `new_address`. The directory stays unchanged.
`new_address` has passed the form's checks. Raises Throttled, WrongCurrentPassword,
EmailInvalid or DirectoryUnavailable. Must be called outside an open transaction.
"""
action = "email.change_failed"
try:
throttle.check(ENDPOINT, user=user.key, ip=ip or None)
throttle.check(REQUEST_ENDPOINT, user=user.key)
except throttle.Throttled:
audit.record_failure(user.key, "email.throttled", ip=ip)
raise
directory = get_directory()
try:
policy = directory.password_policy(user.sam)
if not directory.verify_password(user.sam, current):
throttle.hit(
ENDPOINT,
user=user.key,
ip=ip or None,
max_failures=failure_limit(policy),
pause_seconds=pause_seconds(policy),
)
_email_failure(user, ip, action, "wrong_current", **_threshold_note(policy))
raise WrongCurrentPassword(policy)
account = directory.find_user(user.sam)
if account is None:
raise DirectoryUnavailable("The account could not be read")
except DirectoryUnavailable:
_email_failure(user, ip, action, "unavailable")
raise
throttle.reset(ENDPOINT, user=user.key) # the directory forgets failures after a good bind
if new_address.casefold() == (account.mail or "").strip().casefold():
raise EmailInvalid("same")
raw, digest = new_token()
now = timezone.now()
try:
with transaction.atomic():
PendingEmailChange.objects.filter(
user_key=user.key, kind=PendingEmailChange.Kind.CONFIRM, used_at__isnull=True
).delete() # the newest request replaces the earlier one
PendingEmailChange.objects.create(
user_key=user.key,
sam=user.sam,
kind=PendingEmailChange.Kind.CONFIRM,
token_hash=digest,
old_address=(account.mail or "").strip(),
new_address=new_address,
created_at=now,
expires_at=now + CONFIRM_VALID_FOR,
)
mailer.enqueue(
"email_change_confirm",
new_address,
{"name": _display_name(account), "link": _link("confirm", raw)},
)
audit.record(user.key, "email.change_requested", ip=ip)
except Exception:
_email_failure(user, ip, action, "error")
raise
throttle.hit(
REQUEST_ENDPOINT,
user=user.key,
max_failures=REQUEST_LIMIT,
pause_seconds=REQUEST_WINDOW_SECONDS,
)
def _unclear(user: SessionUser, ip: str, action: str, what: str, step: str, exc: Exception):
"""The directory changed but the portal's part rolled back (AD-4): log, never throw."""
logger.error(
"E-mail %s in the directory but the portal did not record it: "
"user=%s ip=%s time=%s step=%s error=%s",
what,
user.key,
ip,
timezone.now().isoformat(),
step,
type(exc).__name__,
)
try:
_email_failure(user, ip, action, "unclear", step=step)
except Exception:
logger.error("The failure entry for %s could not be written", user.key)
def confirm_email_change(token: str, *, ip: str) -> Confirmed:
"""Write the new address to the directory and mail the old one an undo link.
Raises LinkDead, DirectoryUnavailable or OutcomeUnknown. Must be called outside an open
transaction.
"""
action = "email.change_failed"
row = find_link(token, PendingEmailChange.Kind.CONFIRM)
if row is None:
raise LinkDead
user = SessionUser(key=row.user_key, sam=row.sam)
directory = get_directory()
try:
account = directory.find_user(row.sam)
except DirectoryUnavailable:
_email_failure(user, ip, action, "unavailable", stage="confirm")
raise
if account is None:
raise LinkDead
previous = (account.mail or "").strip()
notice = mailer.is_valid_address(previous)
changed_in_directory = False
step = "claim"
try:
with transaction.atomic():
claimed = PendingEmailChange.objects.filter(pk=row.pk, used_at__isnull=True).update(
used_at=timezone.now()
)
if not claimed: # a parallel request used it first
raise LinkDead
step = "directory"
directory.update_attributes(row.sam, {"mail": row.new_address})
changed_in_directory = True
if notice:
step = "queue_notice"
raw, digest = new_token()
now = timezone.now()
PendingEmailChange.objects.create(
user_key=row.user_key,
sam=row.sam,
kind=PendingEmailChange.Kind.UNDO,
token_hash=digest,
old_address=previous,
new_address=row.new_address,
created_at=now,
expires_at=now + UNDO_VALID_FOR,
)
mailer.enqueue(
"email_changed_notice",
previous,
{
"name": _display_name(account),
"new_address": row.new_address,
"link": _link("undo", raw),
"changed_at": _local_now(),
"zone_name": settings.DISPLAY_TIME_ZONE,
},
)
step = "audit"
audit.record(
user.key,
"email.changed",
ip=ip,
detail={"notice": "queued" if notice else "skipped"},
)
except LinkDead:
raise
except DirectoryUnavailable:
_email_failure(user, ip, action, "unavailable", stage="confirm")
raise
except (ValidationFailed, Forbidden) as exc:
logger.error(
"The directory refused an e-mail write: user=%s ip=%s error=%s",
user.key,
ip,
type(exc).__name__,
)
_email_failure(user, ip, action, "refused", stage="confirm")
raise DirectoryUnavailable("The directory refused the write") from exc
except Exception as exc:
if changed_in_directory:
_unclear(user, ip, action, "address changed", step, exc)
raise OutcomeUnknown("The address was changed but not recorded") from exc
_email_failure(user, ip, action, "error", stage="confirm")
raise
return Confirmed(notice_queued=notice)
def undo_email_change(token: str, *, ip: str) -> Undone:
"""Restore the old address, end every session of the user and tell the Admin.
Raises LinkDead, ChangedAgain, DirectoryUnavailable or OutcomeUnknown. Must be called
outside an open transaction.
"""
action = "email.undo_failed"
row = find_link(token, PendingEmailChange.Kind.UNDO)
if row is None:
raise LinkDead
user = SessionUser(key=row.user_key, sam=row.sam)
directory = get_directory()
try:
account = directory.find_user(row.sam)
except DirectoryUnavailable:
_email_failure(user, ip, action, "unavailable")
raise
if account is None:
raise LinkDead
if (account.mail or "").strip().casefold() != row.new_address.casefold():
raise ChangedAgain
admin = settings.ADMIN_NOTIFY_EMAIL
changed_in_directory = False
step = "claim"
try:
with transaction.atomic():
claimed = PendingEmailChange.objects.filter(pk=row.pk, used_at__isnull=True).update(
used_at=timezone.now()
)
if not claimed:
raise LinkDead
step = "directory"
directory.update_attributes(row.sam, {"mail": row.old_address})
changed_in_directory = True
step = "end_sessions" # after the write, so a rolled-back write ends nothing
ended = end_sessions(user)
# A request made meanwhile (by whoever had the session) must not outlive the undo.
PendingEmailChange.objects.filter(
user_key=row.user_key, kind=PendingEmailChange.Kind.CONFIRM, used_at__isnull=True
).delete()
if admin:
step = "queue_admin_notice"
mailer.enqueue(
"email_undone_admin",
admin,
{
"username": row.sam,
"undone_at": _local_now(),
"zone_name": settings.DISPLAY_TIME_ZONE,
},
)
step = "audit"
audit.record(
user.key,
"email.undone",
ip=ip,
detail={
"sessions_ended": ended,
"admin_notice": "queued" if admin else "skipped",
},
)
except LinkDead:
raise
except DirectoryUnavailable:
_email_failure(user, ip, action, "unavailable")
raise
except (ValidationFailed, Forbidden) as exc:
logger.error(
"The directory refused an e-mail write: user=%s ip=%s error=%s",
user.key,
ip,
type(exc).__name__,
)
_email_failure(user, ip, action, "refused")
raise DirectoryUnavailable("The directory refused the write") from exc
except Exception as exc:
if changed_in_directory:
try:
end_sessions(user)
except Exception:
logger.error("The sessions of %s could not be ended", user.key)
_unclear(user, ip, action, "address restored", step, exc)
raise OutcomeUnknown("The address was restored but not recorded") from exc
_email_failure(user, ip, action, "error")
raise
if not admin:
logger.error(
"ADMIN_NOTIFY_EMAIL is not set: the Admin was not told that user=%s undid an "
"e-mail change",
user.key,
)
return Undone(admin_notified=bool(admin))