Emails : robustesse SMTP + diagnostic

- config.py : retrait des identifiants OVH réels remis en dur ; défauts
  neutres (email_enabled=false). Les vraies valeurs vont dans .env.
- open_smtp : le port 465 force le SSL implicite (SMTPS) même si SMTP_SSL=false
  — c'était la cause des envois qui ne partaient pas (connexion en clair sur un
  port TLS -> timeout).
- deliver_many : les erreurs de connexion / auth / rejet sont journalisées
  avec le message du serveur et ne remontent plus (l'appel HTTP n'échoue pas).
- Lien magique : envoi en tâche de fond (BackgroundTasks) comme les
  notifications ; ne bloque plus la réponse.
- app/email/check.py : `python -m app.email.check <destinataire>` — affiche la
  config effective et envoie un email de test, avec la cause exacte en cas
  d'échec.
- Tests : fixture autouse forçant EMAIL_ENABLED=false (aucun test ne touche un
  vrai SMTP, quel que soit le .env local) ; test_full_flow lit l'email capturé
  au lieu des logs. 137 tests OK.
- .env.example : notes port 465 / From = boîte authentifiée / commande check.

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 19:31:02 +02:00
co-authored by Claude Sonnet 5
parent 6b08dcc953
commit b84f62dfab
8 changed files with 157 additions and 31 deletions
+7
View File
@@ -13,6 +13,9 @@ JWT_SECRET=dev-insecure-secret-change-me-please-32b
PUBLIC_BASE_URL=http://localhost:5173 PUBLIC_BASE_URL=http://localhost:5173
# Envoi d'emails. EMAIL_ENABLED=false ⇒ les emails sont seulement journalisés. # Envoi d'emails. EMAIL_ENABLED=false ⇒ les emails sont seulement journalisés.
# Renseigner ces valeurs dans .env (non versionné), JAMAIS dans le code.
# Test rapide de la config : docker compose run --rm backend \
# uv run python -m app.email.check destinataire@example.fr
EMAIL_ENABLED=false EMAIL_ENABLED=false
SMTP_HOST= SMTP_HOST=
SMTP_PORT=587 SMTP_PORT=587
@@ -20,7 +23,11 @@ SMTP_USER=
SMTP_PASSWORD= SMTP_PASSWORD=
SMTP_STARTTLS=true SMTP_STARTTLS=true
SMTP_SSL=false SMTP_SSL=false
# OVH & co : la From doit être la boîte authentifiée (ou un alias validé).
# Sinon le serveur rejette l'envoi. Le plus sûr : SMTP_FROM = SMTP_USER.
SMTP_FROM=Festival des bénévoles <no-reply@example.test> SMTP_FROM=Festival des bénévoles <no-reply@example.test>
# Alternative OVH si le 587/STARTTLS échoue : SMTP_PORT=465, SMTP_SSL=true,
# SMTP_STARTTLS=false
# Frontend (service `frontend` du docker-compose). # Frontend (service `frontend` du docker-compose).
# mock (défaut) : jeu de démonstration en mémoire, aucun backend requis. # mock (défaut) : jeu de démonstration en mémoire, aucun backend requis.
+12 -1
View File
@@ -147,7 +147,18 @@ La coordination crée les comptes via `POST /editions/{id}/accounts` : un
Envoi par **SMTP** (auto-hébergé). `EMAIL_ENABLED=false` (défaut dev/tests) ⇒ Envoi par **SMTP** (auto-hébergé). `EMAIL_ENABLED=false` (défaut dev/tests) ⇒
les emails sont seulement journalisés (`app.email`, INFO, **sans destinataire les emails sont seulement journalisés (`app.email`, INFO, **sans destinataire
ni corps** — RGPD). Config : `SMTP_HOST/PORT/USER/PASSWORD/STARTTLS/SSL/FROM`, ni corps** — RGPD). Config : `SMTP_HOST/PORT/USER/PASSWORD/STARTTLS/SSL/FROM`,
`PUBLIC_BASE_URL` (pour les liens). `PUBLIC_BASE_URL` (pour les liens). Ne jamais mettre de vraies valeurs dans
`config.py` : elles vivent dans `.env` (non versionné).
- Remise « best effort » : une erreur SMTP (connexion, auth, rejet) est
journalisée avec le message du serveur mais ne fait pas échouer l'appel HTTP.
- Le **port 465** implique le SSL (SMTPS) : `open_smtp` bascule
automatiquement, même si `SMTP_SSL=false` / `SMTP_STARTTLS=true`. Port 587 =
STARTTLS.
- Beaucoup de fournisseurs (OVH…) exigent une `From` = boîte authentifiée ou
alias validé.
- Diagnostic : `docker compose run --rm backend uv run python -m app.email.check
<destinataire>` — affiche la config effective et la cause exacte d'un échec.
- **Lien magique** : `POST .../auth/magic-link` envoie l'email contenant - **Lien magique** : `POST .../auth/magic-link` envoie l'email contenant
`PUBLIC_BASE_URL` + `magic_link_path` + `?token=`. Sans SMTP, le jeton (non `PUBLIC_BASE_URL` + `magic_link_path` + `?token=`. Sans SMTP, le jeton (non
+5 -3
View File
@@ -10,7 +10,7 @@ Deux systèmes, selon le compte :
import logging import logging
import uuid import uuid
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, status
from sqlalchemy import func from sqlalchemy import func
from sqlmodel import Session, select from sqlmodel import Session, select
@@ -125,10 +125,12 @@ def change_password(
def request_magic_link( def request_magic_link(
edition_id: uuid.UUID, edition_id: uuid.UUID,
payload: MagicLinkRequest, payload: MagicLinkRequest,
background: BackgroundTasks,
session: Session = Depends(get_session), session: Session = Depends(get_session),
) -> dict[str, str]: ) -> dict[str, str]:
"""Émet un lien magique si un compte **sans mot de passe** correspond. """Émet un lien magique si un compte **sans mot de passe** correspond.
Réponse identique dans tous les cas (pas de divulgation).""" Réponse identique dans tous les cas (pas de divulgation). L'email part en
tâche de fond (une erreur SMTP est journalisée, pas remontée)."""
account = _account_by_email(session, edition_id, payload.email) account = _account_by_email(session, edition_id, payload.email)
if account is not None and account.password_hash is None: if account is not None and account.password_hash is None:
settings = get_settings() settings = get_settings()
@@ -138,7 +140,7 @@ def request_magic_link(
link=build_magic_link_url(raw), link=build_magic_link_url(raw),
minutes=settings.magic_link_ttl_minutes, minutes=settings.magic_link_ttl_minutes,
) )
send(account.email, subject, body) background.add_task(send, account.email, subject, body)
if not settings.email_enabled: if not settings.email_enabled:
# Aide au dev sans SMTP : le jeton (non nominatif) est journalisé. # Aide au dev sans SMTP : le jeton (non nominatif) est journalisé.
logger.info( logger.info(
+5 -2
View File
@@ -28,8 +28,9 @@ class Settings(BaseSettings):
public_base_url: str = "http://localhost:5173" public_base_url: str = "http://localhost:5173"
magic_link_path: str = "/connexion" magic_link_path: str = "/connexion"
# Envoi d'emails (SMTP). email_enabled=False ⇒ les envois sont seulement # Envoi d'emails (SMTP). Ne jamais mettre de vraies valeurs ici : elles
# journalisés (dev, tests). # vivent dans .env (non versionné) ou l'environnement.
# email_enabled=False ⇒ les envois sont seulement journalisés (dev, tests).
email_enabled: bool = False email_enabled: bool = False
smtp_host: str = "localhost" smtp_host: str = "localhost"
smtp_port: int = 587 smtp_port: int = 587
@@ -37,6 +38,8 @@ class Settings(BaseSettings):
smtp_password: str = "" smtp_password: str = ""
smtp_starttls: bool = True smtp_starttls: bool = True
smtp_ssl: bool = False smtp_ssl: bool = False
# Beaucoup de fournisseurs (OVH…) exigent une From = boîte authentifiée
# (ou un alias validé). En cas de rejet, mettre SMTP_FROM = SMTP_USER.
smtp_from: str = "Festival des bénévoles <no-reply@example.test>" smtp_from: str = "Festival des bénévoles <no-reply@example.test>"
log_level: str = "INFO" log_level: str = "INFO"
+63
View File
@@ -0,0 +1,63 @@
"""Diagnostic de la configuration SMTP.
docker compose run --rm backend \\
uv run python -m app.email.check destinataire@exemple.fr
Affiche la configuration effective, ouvre une session SMTP et envoie un email
de test. En cas d'échec, imprime la cause exacte renvoyée par le serveur.
"""
import sys
from app.config import get_settings
from app.email.sender import build_message, open_smtp
def main() -> int:
if len(sys.argv) != 2:
print("Usage : python -m app.email.check <destinataire@exemple.fr>")
return 2
recipient = sys.argv[1]
settings = get_settings()
print("Configuration SMTP effective :")
print(f" EMAIL_ENABLED : {settings.email_enabled}")
print(f" SMTP_HOST : {settings.smtp_host}:{settings.smtp_port}")
print(f" SMTP_SSL : {settings.smtp_ssl}")
print(f" SMTP_STARTTLS : {settings.smtp_starttls}")
print(f" SMTP_USER : {settings.smtp_user or '(aucun)'}")
print(f" SMTP_FROM : {settings.smtp_from}")
print()
if not settings.email_enabled:
print("EMAIL_ENABLED est false : aucun envoi réel. Passe-le à true.")
return 1
try:
smtp = open_smtp(settings)
except Exception as exc:
print(f"ÉCHEC connexion / authentification : {exc!r}")
return 1
message = build_message(
recipient,
"Test SMTP — gestion des bénévoles",
"Cet email confirme que la configuration SMTP fonctionne.",
)
try:
with smtp:
smtp.send_message(message)
except Exception as exc:
print(f"ÉCHEC envoi (le serveur a refusé) : {exc!r}")
print(
"Piste fréquente (OVH…) : SMTP_FROM doit être la boîte "
"authentifiée (= SMTP_USER) ou un alias validé."
)
return 1
print(f"OK — email de test envoyé à {recipient}.")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+48 -17
View File
@@ -1,6 +1,9 @@
"""Construction et remise des emails. """Construction et remise des emails.
`deliver_many` est le point d'injection unique (remplacé dans les tests).""" `deliver_many` est le point d'injection unique (remplacé dans les tests).
La remise est « best effort » : une erreur SMTP est journalisée (le message du
serveur, pas de PII) et ne remonte pas — l'appel HTTP n'échoue pas pour ça.
Pour diagnostiquer : `python -m app.email.check <destinataire>`."""
import logging import logging
import smtplib import smtplib
@@ -8,7 +11,7 @@ import ssl
from collections.abc import Iterable from collections.abc import Iterable
from email.message import EmailMessage from email.message import EmailMessage
from app.config import get_settings from app.config import Settings, get_settings
logger = logging.getLogger("app.email") logger = logging.getLogger("app.email")
@@ -22,6 +25,29 @@ def build_message(to: str, subject: str, body: str) -> EmailMessage:
return message return message
def open_smtp(settings: Settings) -> smtplib.SMTP:
"""Ouvre une session SMTP authentifiée. Lève en cas d'échec."""
use_ssl = settings.smtp_ssl
use_starttls = settings.smtp_starttls
if settings.smtp_port == 465 and not use_ssl:
# Le port 465 (SMTPS, RFC 8314) parle TLS d'emblée : STARTTLS échoue.
logger.info("Port 465 → SSL implicite (SMTP_SSL/STARTTLS ignorés).")
use_ssl, use_starttls = True, False
context = ssl.create_default_context()
if use_ssl:
smtp: smtplib.SMTP = smtplib.SMTP_SSL(
settings.smtp_host, settings.smtp_port, timeout=30, context=context
)
else:
smtp = smtplib.SMTP(settings.smtp_host, settings.smtp_port, timeout=30)
if use_starttls:
smtp.starttls(context=context)
if settings.smtp_user:
smtp.login(settings.smtp_user, settings.smtp_password)
return smtp
def deliver_many(messages: list[EmailMessage]) -> None: def deliver_many(messages: list[EmailMessage]) -> None:
settings = get_settings() settings = get_settings()
if not settings.email_enabled: if not settings.email_enabled:
@@ -34,25 +60,30 @@ def deliver_many(messages: list[EmailMessage]) -> None:
if not messages: if not messages:
return return
context = ssl.create_default_context() try:
if settings.smtp_ssl: smtp = open_smtp(settings)
client = smtplib.SMTP_SSL( except (OSError, smtplib.SMTPException) as exc:
settings.smtp_host, settings.smtp_port, timeout=30, context=context logger.error(
"SMTP indisponible (%s:%s, ssl=%s starttls=%s) : %s",
settings.smtp_host,
settings.smtp_port,
settings.smtp_ssl,
settings.smtp_starttls,
exc,
) )
else: return
client = smtplib.SMTP(
settings.smtp_host, settings.smtp_port, timeout=30 with smtp:
)
with client as smtp:
if settings.smtp_starttls and not settings.smtp_ssl:
smtp.starttls(context=context)
if settings.smtp_user:
smtp.login(settings.smtp_user, settings.smtp_password)
for message in messages: for message in messages:
try: try:
smtp.send_message(message) smtp.send_message(message)
except smtplib.SMTPException: except smtplib.SMTPException as exc:
logger.warning("Échec d'envoi d'un email (destinataire masqué)") # Ex. « 550 sender not authorized », « 535 auth failed ».
logger.error(
"Email refusé par le serveur (From=%s) : %s",
message["From"],
exc,
)
def send(to: str, subject: str, body: str) -> None: def send(to: str, subject: str, body: str) -> None:
+10
View File
@@ -13,6 +13,16 @@ from app.main import app
from app.models import Account, DayPart, Edition, Role from app.models import Account, DayPart, Edition, Role
@pytest.fixture(autouse=True)
def _never_use_real_smtp(monkeypatch):
"""Quel que soit le `.env` local, les tests n'ouvrent jamais de connexion
SMTP : `EMAIL_ENABLED` est forcé à false."""
monkeypatch.setenv("EMAIL_ENABLED", "false")
get_settings.cache_clear()
yield
get_settings.cache_clear()
@pytest.fixture(scope="session") @pytest.fixture(scope="session")
def engine(): def engine():
"""Vraie base PostgreSQL de test (voir docker/postgres-init).""" """Vraie base PostgreSQL de test (voir docker/postgres-init)."""
+4 -5
View File
@@ -1,5 +1,4 @@
import datetime as dt import datetime as dt
import logging
from sqlmodel import select from sqlmodel import select
@@ -22,17 +21,17 @@ def test_magic_link_unknown_email_is_accepted_without_token(
def test_full_flow_magic_link_then_jwt_then_me( def test_full_flow_magic_link_then_jwt_then_me(
raw_client, session, edition, make_account, caplog raw_client, session, edition, make_account, outbox
): ):
make_account(Role.LEAD, email="ref@example.fr") make_account(Role.LEAD, email="ref@example.fr")
with caplog.at_level(logging.INFO, logger="app.auth"):
resp = raw_client.post( resp = raw_client.post(
_url(edition, "magic-link"), json={"email": "REF@example.fr"} _url(edition, "magic-link"), json={"email": "REF@example.fr"}
) )
assert resp.status_code == 202 assert resp.status_code == 202
log_line = next(m for m in caplog.messages if "Lien magique" in m) assert len(outbox) == 1
raw_token = log_line.split("jeton=")[-1].strip() assert outbox[0]["To"] == "ref@example.fr"
raw_token = outbox[0].get_content().split("?token=")[1].split()[0].strip()
verified = raw_client.post( verified = raw_client.post(
_url(edition, "verify"), json={"token": raw_token} _url(edition, "verify"), json={"token": raw_token}