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
+48
-17
@@ -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 <destinataire>`."""
|
||||
|
||||
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:
|
||||
|
||||
Reference in New Issue
Block a user