Files
benevoles/backend/app/email/sender.py
T
AdamThomasandClaude Sonnet 5 a2b748566b
ci-cd / build-backend (push) Successful in 30s
ci-cd / build-frontend (push) Successful in 18s
ci-cd / deploy (push) Successful in 12s
Inscription en deux temps, emails de confirmation et export Excel des bénévoles
Formulaire public — paiement en ligne :
- Le bénévole n'est plus compté en base tant qu'un paiement en ligne choisi
  n'est pas confirmé (PendingFormSubmission, promu en Volunteer à la
  confirmation, webhook ou vérification en direct).
- Type de paiement « mécénat » : étape d'adresse postale avant le paiement,
  conservée sur le bénévole pour un reçu fiscal (cerfa) ultérieur.
- Page de confirmation au retour de SumUp, avec relance possible après
  échec/expiration sans ressaisir le formulaire.
- « Je paierai sur place » remplace « je ne participe pas » ; tous les
  champs activés par la coordination sont désormais obligatoires.
- La fiche bénévole n'affiche plus l'état des cases à cocher, seulement le
  résultat réel du paiement (libellé, montant, statut).

Emails :
- Email de confirmation d'inscription à la création du bénévole (tient lieu
  de reçu de paiement quand une cotisation est payée en ligne).
- Mise en forme HTML au thème de l'application (logo de l'édition en
  en-tête) pour tous les emails envoyés, texte brut conservé en repli.

Export Excel des bénévoles (COORDINATION) : coordonnées, vœux résolus en
noms lisibles, cotisation et mécénat.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-30 07:41:58 +02:00

134 lines
4.8 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
from app.email.html import render_html
logger = logging.getLogger("app.email")
def _smtp_error_summary(exc: smtplib.SMTPException) -> str:
"""Résumé RGPD-safe d'une erreur SMTP : codes/messages serveur seulement.
Certaines exceptions de `smtplib` embarquent l'adresse du destinataire
dans leurs `args` (notamment `SMTPRecipientsRefused.recipients`, un
`{adresse: (code, message)}`) — `str(exc)` les journaliserait telles
quelles. On n'en garde que les codes/messages, jamais les adresses.
"""
if isinstance(exc, smtplib.SMTPRecipientsRefused):
codes = sorted({code for code, _msg in exc.recipients.values()})
return f"destinataire(s) refusé(s), code(s) {codes}"
if isinstance(exc, smtplib.SMTPResponseException):
return f"{exc.smtp_code} {exc.smtp_error!r}"
return exc.__class__.__name__
def build_message(
to: str, subject: str, body: str, *, logo_url: str | None = None
) -> EmailMessage:
"""`logo_url` (voir `app/email/branding.py:edition_logo_url`) : ajoute une
version HTML au thème de l'appli (`app/email/html.py`) en alternative au
texte brut — le texte reste la version de référence, envoyée telle
quelle si aucun client ne préfère le HTML."""
message = EmailMessage()
message["From"] = get_settings().smtp_from
message["To"] = to
message["Subject"] = subject
message.set_content(body)
message.add_alternative(
render_html(subject=subject, body=body, logo_url=logo_url), subtype="html"
)
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"],
_smtp_error_summary(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, *, logo_url: str | None = None) -> None:
deliver_many([build_message(to, subject, body, logo_url=logo_url)])
def send_many(
items: Iterable[tuple[str, str, str]], *, logo_url: str | None = None
) -> None:
"""`items` = itérable de `(destinataire, sujet, corps)` — même logo pour
tous (un seul appel par édition, voir `app/api/notifications.py`)."""
deliver_many([build_message(*item, logo_url=logo_url) for item in items])