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:
co-authored by
Claude Sonnet 5
parent
6b08dcc953
commit
b84f62dfab
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
@@ -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:
|
||||||
|
|||||||
@@ -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)."""
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
Reference in New Issue
Block a user