- 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
65 lines
1.9 KiB
Python
65 lines
1.9 KiB
Python
"""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])
|