"""Handle (localpart) validation per SPEC §4. Rules (enforced in code, mail-grade from day one): - localpart: 3-30 chars, characters ``[a-z0-9._-]`` - no leading/trailing dot, no double dot - case-insensitive (normalize to lowercase) - not in the reserved list (:mod:`app.identity.reserved`) The stored handle is the full form ``localpart@{APP_DOMAIN}``. """ from __future__ import annotations import re from app.config import get_settings from app.identity.reserved import is_reserved _LOCALPART_RE = re.compile(r"^[a-z0-9._-]+$") _MIN_LEN = 3 _MAX_LEN = 30 class InvalidHandle(ValueError): """Raised when a proposed localpart violates the SPEC §4 rules.""" def __init__(self, code: str, message: str) -> None: super().__init__(message) self.code = code self.message = message def normalize_localpart(localpart: str) -> str: """Lowercase and strip surrounding whitespace (case-insensitive handles).""" return localpart.strip().lower() def validate_localpart(localpart: str) -> str: """Validate and normalize ``localpart``; return it or raise :class:`InvalidHandle`.""" lp = normalize_localpart(localpart) if not (_MIN_LEN <= len(lp) <= _MAX_LEN): raise InvalidHandle("length", f"handle must be {_MIN_LEN}-{_MAX_LEN} characters") if not _LOCALPART_RE.match(lp): raise InvalidHandle("charset", "handle may contain only a-z, 0-9, '.', '_', '-'") if lp.startswith(".") or lp.endswith("."): raise InvalidHandle("dot_edge", "handle may not start or end with a dot") if ".." in lp: raise InvalidHandle("double_dot", "handle may not contain consecutive dots") if is_reserved(lp): raise InvalidHandle("reserved", "that handle is reserved") return lp def full_handle(localpart: str) -> str: """Return the full handle ``localpart@{APP_DOMAIN}`` for a validated localpart.""" return f"{validate_localpart(localpart)}@{get_settings().app_domain}"