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>
57 lines
1.9 KiB
Python
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}"
|