Files
benevoles/backend/app/email/sender.py
T
AdamThomasandClaude Sonnet 5 b84f62dfab 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
2026-09-10 19:31:02 +02:00

96 lines
3.1 KiB
Python

"""Construction et remise des emails.
`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
import ssl
from collections.abc import Iterable
from email.message import EmailMessage
from app.config import Settings, get_settings
logger = logging.getLogger("app.email")
def build_message(to: str, subject: str, body: str) -> EmailMessage:
message = EmailMessage()
message["From"] = get_settings().smtp_from
message["To"] = to
message["Subject"] = subject
message.set_content(body)
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:
# RGPD : ni destinataire ni corps dans les logs.
logger.info(
"Emails non envoyés (EMAIL_ENABLED=false) — %d message(s)",
len(messages),
)
return
if not messages:
return
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,
)
return
with smtp:
for message in messages:
try:
smtp.send_message(message)
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:
deliver_many([build_message(to, subject, body)])
def send_many(items: Iterable[tuple[str, str, str]]) -> None:
"""`items` = itérable de `(destinataire, sujet, corps)`."""
deliver_many([build_message(*item) for item in items])