diff --git a/.env.example b/.env.example index 680c171..da82b52 100644 --- a/.env.example +++ b/.env.example @@ -13,6 +13,9 @@ JWT_SECRET=dev-insecure-secret-change-me-please-32b PUBLIC_BASE_URL=http://localhost:5173 # 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 SMTP_HOST= SMTP_PORT=587 @@ -20,7 +23,11 @@ SMTP_USER= SMTP_PASSWORD= SMTP_STARTTLS=true 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 +# Alternative OVH si le 587/STARTTLS échoue : SMTP_PORT=465, SMTP_SSL=true, +# SMTP_STARTTLS=false # Frontend (service `frontend` du docker-compose). # mock (défaut) : jeu de démonstration en mémoire, aucun backend requis. diff --git a/CLAUDE.md b/CLAUDE.md index bf3bcdf..39b3d5c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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) ⇒ les emails sont seulement journalisés (`app.email`, INFO, **sans destinataire 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 + ` — affiche la config effective et la cause exacte d'un échec. - **Lien magique** : `POST .../auth/magic-link` envoie l'email contenant `PUBLIC_BASE_URL` + `magic_link_path` + `?token=`. Sans SMTP, le jeton (non diff --git a/backend/app/api/auth.py b/backend/app/api/auth.py index 5b49e61..cf5f732 100644 --- a/backend/app/api/auth.py +++ b/backend/app/api/auth.py @@ -10,7 +10,7 @@ Deux systèmes, selon le compte : import logging import uuid -from fastapi import APIRouter, Depends, HTTPException, status +from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, status from sqlalchemy import func from sqlmodel import Session, select @@ -125,10 +125,12 @@ def change_password( def request_magic_link( edition_id: uuid.UUID, payload: MagicLinkRequest, + background: BackgroundTasks, session: Session = Depends(get_session), ) -> dict[str, str]: """É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) if account is not None and account.password_hash is None: settings = get_settings() @@ -138,7 +140,7 @@ def request_magic_link( link=build_magic_link_url(raw), minutes=settings.magic_link_ttl_minutes, ) - send(account.email, subject, body) + background.add_task(send, account.email, subject, body) if not settings.email_enabled: # Aide au dev sans SMTP : le jeton (non nominatif) est journalisé. logger.info( diff --git a/backend/app/config.py b/backend/app/config.py index 2189925..bb0f849 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -28,8 +28,9 @@ class Settings(BaseSettings): public_base_url: str = "http://localhost:5173" magic_link_path: str = "/connexion" - # Envoi d'emails (SMTP). email_enabled=False ⇒ les envois sont seulement - # journalisés (dev, tests). + # Envoi d'emails (SMTP). Ne jamais mettre de vraies valeurs ici : elles + # vivent dans .env (non versionné) ou l'environnement. + # email_enabled=False ⇒ les envois sont seulement journalisés (dev, tests). email_enabled: bool = False smtp_host: str = "localhost" smtp_port: int = 587 @@ -37,6 +38,8 @@ class Settings(BaseSettings): smtp_password: str = "" smtp_starttls: bool = True 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 " log_level: str = "INFO" diff --git a/backend/app/email/check.py b/backend/app/email/check.py new file mode 100644 index 0000000..3f5ad04 --- /dev/null +++ b/backend/app/email/check.py @@ -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 ") + 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()) diff --git a/backend/app/email/sender.py b/backend/app/email/sender.py index dfbf5b7..9329ea1 100644 --- a/backend/app/email/sender.py +++ b/backend/app/email/sender.py @@ -1,6 +1,9 @@ """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 `.""" import logging import smtplib @@ -8,7 +11,7 @@ import ssl from collections.abc import Iterable from email.message import EmailMessage -from app.config import get_settings +from app.config import Settings, get_settings logger = logging.getLogger("app.email") @@ -22,6 +25,29 @@ def build_message(to: str, subject: str, body: str) -> EmailMessage: 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: settings = get_settings() if not settings.email_enabled: @@ -34,25 +60,30 @@ def deliver_many(messages: list[EmailMessage]) -> None: if not messages: return - context = ssl.create_default_context() - if settings.smtp_ssl: - client = smtplib.SMTP_SSL( - settings.smtp_host, settings.smtp_port, timeout=30, context=context + try: + smtp = open_smtp(settings) + except (OSError, smtplib.SMTPException) as exc: + 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: - client = smtplib.SMTP( - settings.smtp_host, settings.smtp_port, timeout=30 - ) - 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) + return + + with smtp: for message in messages: try: smtp.send_message(message) - except smtplib.SMTPException: - logger.warning("Échec d'envoi d'un email (destinataire masqué)") + except smtplib.SMTPException as exc: + # 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: diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 73311ec..c9c2053 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -13,6 +13,16 @@ from app.main import app 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") def engine(): """Vraie base PostgreSQL de test (voir docker/postgres-init).""" diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py index 3c67604..0e1df38 100644 --- a/backend/tests/test_auth.py +++ b/backend/tests/test_auth.py @@ -1,5 +1,4 @@ import datetime as dt -import logging 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( - raw_client, session, edition, make_account, caplog + raw_client, session, edition, make_account, outbox ): make_account(Role.LEAD, email="ref@example.fr") - with caplog.at_level(logging.INFO, logger="app.auth"): - resp = raw_client.post( - _url(edition, "magic-link"), json={"email": "REF@example.fr"} - ) + resp = raw_client.post( + _url(edition, "magic-link"), json={"email": "REF@example.fr"} + ) assert resp.status_code == 202 - log_line = next(m for m in caplog.messages if "Lien magique" in m) - raw_token = log_line.split("jeton=")[-1].strip() + assert len(outbox) == 1 + assert outbox[0]["To"] == "ref@example.fr" + raw_token = outbox[0].get_content().split("?token=")[1].split()[0].strip() verified = raw_client.post( _url(edition, "verify"), json={"token": raw_token}