Étape 10 : envoi d'emails par SMTP

- app/email/ : sender (smtplib, STARTTLS ou SSL, connexion réutilisée pour un
  lot) + templates (rendu, textes dans locales/fr.json). EMAIL_ENABLED=false
  (défaut) ⇒ journalisé seulement, sans destinataire ni corps (RGPD).
- Lien magique : POST .../auth/magic-link envoie désormais l'email
  (PUBLIC_BASE_URL + magic_link_path + ?token=). Helper app/auth/magic_link.py
  partagé. Sans SMTP, le jeton non nominatif reste journalisé pour le dev.
- Récapitulatif de planning : POST /editions/{id}/notifications/planning
  (COORDINATION). subject/body sont des modèles ({prenom}, {nom}, {nb_creneaux},
  {nb_heures}, {creneaux}, {lien_connexion} → lien magique personnel émis).
  Cible tous les bénévoles avec email ou une liste. Envoi en BackgroundTasks.
- Config : PUBLIC_BASE_URL, EMAIL_ENABLED, SMTP_* ; .env.example,
  docker-compose (dev + prod), deploy/README.
- 9 tests (sender activé/désactivé, rendu, notifications : contenu, gabarit par
  défaut, lien magique embarqué, sous-ensemble, réservé COORDINATION).
  137 tests au total ; ruff + alembic check verts.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017uUsRg7XzopkCPWimwg9ZM
This commit is contained in:
AdamThomas
2026-09-10 08:08:55 +02:00
co-authored by Claude Sonnet 5
parent 977bd4dc34
commit b268e308ab
19 changed files with 581 additions and 33 deletions
+10
View File
@@ -0,0 +1,10 @@
"""Envoi d'emails par SMTP.
`Settings.email_enabled=False` ⇒ les messages sont seulement journalisés
(sans destinataire ni corps — RGPD). Voir CLAUDE.md.
"""
from app.email.sender import send, send_many
from app.email.templates import render_magic_link, render_planning
__all__ = ["render_magic_link", "render_planning", "send", "send_many"]
+64
View File
@@ -0,0 +1,64 @@
"""Construction et remise des emails.
`deliver_many` est le point d'injection unique (remplacé dans les tests)."""
import logging
import smtplib
import ssl
from collections.abc import Iterable
from email.message import EmailMessage
from app.config import 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 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
context = ssl.create_default_context()
if settings.smtp_ssl:
client = smtplib.SMTP_SSL(
settings.smtp_host, settings.smtp_port, timeout=30, context=context
)
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)
for message in messages:
try:
smtp.send_message(message)
except smtplib.SMTPException:
logger.warning("Échec d'envoi d'un email (destinataire masqué)")
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])
+50
View File
@@ -0,0 +1,50 @@
"""Rendu des emails. Les textes vivent dans `locales/fr.json` (`email.*`)."""
from app.i18n import t
# Variables autorisées dans un modèle d'email de planning saisi par la
# coordination.
_PLANNING_PLACEHOLDERS = (
"prenom",
"nom",
"nb_creneaux",
"nb_heures",
"creneaux",
"lien_connexion",
)
def render_magic_link(*, link: str, minutes: int) -> tuple[str, str]:
return (
t("email.magic_link.subject"),
t("email.magic_link.body", link=link, minutes=minutes),
)
def _fill(template: str, values: dict[str, object]) -> str:
out = template
for key in _PLANNING_PLACEHOLDERS:
out = out.replace("{" + key + "}", str(values.get(key, "")))
return out
def render_planning(
*,
subject_template: str,
body_template: str,
first_name: str,
last_name: str,
shift_count: int,
hours: float,
shifts_text: str,
login_link: str,
) -> tuple[str, str]:
values: dict[str, object] = {
"prenom": first_name,
"nom": last_name,
"nb_creneaux": shift_count,
"nb_heures": hours,
"creneaux": shifts_text,
"lien_connexion": login_link,
}
return _fill(subject_template, values), _fill(body_template, values)