"""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 `.""" 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])