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>
This commit is contained in:
2026-07-17 23:20:34 +02:00
co-authored by Claude Opus 4.8
commit a5ec0d21cb
174 changed files with 22239 additions and 0 deletions
+124
View File
@@ -0,0 +1,124 @@
"""Registration, authentication, and login rate limiting (SPEC §6.1)."""
from __future__ import annotations
from dataclasses import dataclass
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import get_settings
from app.identity.handles import InvalidHandle, full_handle, normalize_localpart
from app.identity.passwords import hash_password, verify_password
from app.models import User
from app.redis_client import get_redis
# SPEC §6.1: 10 attempts / 15 min per handle AND per IP.
_RL_MAX = 10
_RL_WINDOW = 15 * 60
class RegistrationError(Exception):
"""Registration failed for a user-actionable reason."""
def __init__(self, code: str, message: str) -> None:
super().__init__(message)
self.code = code
self.message = message
@dataclass(frozen=True)
class RateLimited(Exception):
"""Raised when the login rate limit is exceeded."""
retry_after: int
class TotpRequired(Exception):
"""Password verified but the account has 2FA enabled and no code was given."""
class TotpInvalid(Exception):
"""Password verified but the supplied TOTP code was wrong."""
def resolve_handle(handle_input: str) -> str:
"""Normalize a login identifier to the full ``localpart@APP_DOMAIN`` form."""
text = handle_input.strip().lower()
if "@" in text:
return text
return f"{normalize_localpart(text)}@{get_settings().app_domain}"
async def register_user(
db: AsyncSession, *, localpart: str, password: str, recovery_email: str | None
) -> User:
"""Create a new user. Raises :class:`RegistrationError` on validation/uniqueness."""
try:
handle = full_handle(localpart)
except InvalidHandle as exc:
raise RegistrationError(exc.code, exc.message) from exc
user = User(
handle=handle,
password_hash=hash_password(password),
recovery_email=(recovery_email.strip().lower() if recovery_email else None),
)
db.add(user)
try:
await db.flush()
except IntegrityError as exc:
await db.rollback()
raise RegistrationError("handle_taken", "that handle is already registered") from exc
return user
async def _bump_rate_limit(key: str) -> int:
redis = get_redis()
count = await redis.incr(key)
if count == 1:
await redis.expire(key, _RL_WINDOW)
return int(count)
async def _check_rate_limit(handle: str, ip: str) -> None:
for key in (f"rl:login:handle:{handle}", f"rl:login:ip:{ip}"):
count = await _bump_rate_limit(key)
if count > _RL_MAX:
ttl = await get_redis().ttl(key)
raise RateLimited(retry_after=max(ttl, 1))
async def authenticate(
db: AsyncSession, *, handle_input: str, password: str, ip: str, totp: str | None = None
) -> User | None:
"""Verify credentials (constant-time). Returns the user or None; may rate-limit.
When the account has TOTP enabled, raises :class:`TotpRequired` (no code given)
or :class:`TotpInvalid` (wrong code) after the password check passes.
"""
handle = resolve_handle(handle_input)
await _check_rate_limit(handle, ip)
user = await db.scalar(select(User).where(User.handle == handle))
# Always run a verify to keep timing uniform whether or not the user exists.
stored_hash = user.password_hash if user else _DUMMY_HASH
ok = verify_password(stored_hash, password)
if user is None or not ok:
return None
if user.totp_secret is not None:
from app.identity.totp import decrypt_totp_secret
from app.identity.totp import verify as verify_totp
if not totp:
raise TotpRequired
if not verify_totp(decrypt_totp_secret(user.totp_secret), totp):
raise TotpInvalid
return user
# A precomputed hash so authentication of a non-existent handle still spends
# argon2 time (mitigates user-enumeration via timing, SPEC §6.1 constant-time).
_DUMMY_HASH = hash_password("x" * 24)