Files
lukasandClaude Opus 4.8 a5ec0d21cb Initial commit: webmail client (M0/M1)
Three-pane webmail client.
- Backend: FastAPI + async SQLAlchemy + arq worker (Postgres/Redis), IMAP/SMTP
  sync, threading, search, drafts/outbox with undo, attachments, 2FA.
- Frontend: React 18 + Vite + TypeScript + TanStack Query + Tailwind; three-pane
  UI with rich-text composer, light/dark themes, resizable panels.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 23:20:34 +02:00

57 lines
1.9 KiB
Python

"""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}"