Étape 10 : envoi d'emails par SMTP

- app/email/ : sender (smtplib, STARTTLS ou SSL, connexion réutilisée pour un
  lot) + templates (rendu, textes dans locales/fr.json). EMAIL_ENABLED=false
  (défaut) ⇒ journalisé seulement, sans destinataire ni corps (RGPD).
- Lien magique : POST .../auth/magic-link envoie désormais l'email
  (PUBLIC_BASE_URL + magic_link_path + ?token=). Helper app/auth/magic_link.py
  partagé. Sans SMTP, le jeton non nominatif reste journalisé pour le dev.
- Récapitulatif de planning : POST /editions/{id}/notifications/planning
  (COORDINATION). subject/body sont des modèles ({prenom}, {nom}, {nb_creneaux},
  {nb_heures}, {creneaux}, {lien_connexion} → lien magique personnel émis).
  Cible tous les bénévoles avec email ou une liste. Envoi en BackgroundTasks.
- Config : PUBLIC_BASE_URL, EMAIL_ENABLED, SMTP_* ; .env.example,
  docker-compose (dev + prod), deploy/README.
- 9 tests (sender activé/désactivé, rendu, notifications : contenu, gabarit par
  défaut, lien magique embarqué, sous-ensemble, réservé COORDINATION).
  137 tests au total ; ruff + alembic check verts.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017uUsRg7XzopkCPWimwg9ZM
This commit is contained in:
AdamThomas
2026-09-10 08:08:55 +02:00
co-authored by Claude Sonnet 5
parent 977bd4dc34
commit b268e308ab
19 changed files with 581 additions and 33 deletions
+32
View File
@@ -0,0 +1,32 @@
"""Émission d'un jeton de lien magique et construction de l'URL publique.
Partagé par l'endpoint d'authentification et par les emails de planning."""
import datetime as dt
from sqlmodel import Session
from app.auth.tokens import hash_token, new_raw_token
from app.config import get_settings
from app.models import Account, MagicLinkToken, local_now
def issue_magic_link_token(session: Session, account: Account) -> str:
"""Crée un `MagicLinkToken` pour le compte et renvoie le jeton en clair.
N'effectue pas de commit."""
raw = new_raw_token()
session.add(
MagicLinkToken(
account_id=account.id,
token_hash=hash_token(raw),
expires_at=local_now()
+ dt.timedelta(minutes=get_settings().magic_link_ttl_minutes),
)
)
return raw
def build_magic_link_url(raw_token: str) -> str:
settings = get_settings()
base = settings.public_base_url.rstrip("/")
return f"{base}{settings.magic_link_path}?token={raw_token}"