- deliver_many : log INFO « Emails remis : N/M — sujet=… » sur chaque lot (sans destinataire ni corps). Avant, un envoi réussi ne journalisait rien. - configure_logging : basicConfig(force=True) pour que les logs app.* sortent dans la sortie du conteneur même quand uvicorn configure le logging avant l'import de app.main. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017uUsRg7XzopkCPWimwg9ZM
105 lines
3.3 KiB
Python
105 lines
3.3 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
|
|
|
|
sent = 0
|
|
with smtp:
|
|
for message in messages:
|
|
try:
|
|
smtp.send_message(message)
|
|
sent += 1
|
|
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,
|
|
)
|
|
# RGPD : ni destinataire ni corps, seulement le compte et le sujet.
|
|
logger.info(
|
|
"Emails remis : %d/%d — sujet=%r",
|
|
sent,
|
|
len(messages),
|
|
messages[0]["Subject"],
|
|
)
|
|
|
|
|
|
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])
|