- Lien d'inscription protégé par un jeton (FormTemplate.access_token, /inscription/<token>), avec régénération depuis l'écran de configuration. - Reçus fiscaux mécénat (cerfa 11580*05) : un PDF par bénévole ayant payé en mécénat, donateur/montant (chiffres et toutes lettres en français, app/mecenat/number_words.py)/date complétés par-dessus le modèle officiel. - Jeu de démonstration : bénévoles avec cotisation classique ou mécénat payée. - Infobulles (HelpTip et détail de cellule de la grille des besoins) repositionnées en position: fixed via JS, bornées à l'écran. - Revue de sécurité : logo des emails en pièce jointe inline au lieu d'une URL (fuyait le jeton d'inscription), sujet retiré du log de remise des emails (pouvait porter un nom de bénévole), photo du formulaire public revalidée par décodage réel avant stockage, export Excel protégé contre l'injection de formule, sondage du statut de paiement limité en fréquence, pyjwt mis à jour. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
144 lines
5.5 KiB
Python
144 lines
5.5 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 LOGO_CID, 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: tuple[bytes, str] | None = None
|
|
) -> EmailMessage:
|
|
"""`logo` = (octets, content-type) (voir `app/email/branding.py:
|
|
edition_logo`) : 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. Le logo, s'il y en a un, est intégré en pièce jointe « inline »
|
|
(Content-ID) référencée par la partie HTML — jamais une URL externe, qui
|
|
aurait exposé le jeton d'accès du formulaire public à quiconque lit la
|
|
source du message (voir `app/email/branding.py`)."""
|
|
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, has_logo=logo is not None),
|
|
subtype="html",
|
|
)
|
|
if logo is not None:
|
|
content, content_type = logo
|
|
maintype, _, subtype = content_type.partition("/")
|
|
html_part = message.get_payload()[-1]
|
|
html_part.add_related(
|
|
content, maintype or "image", subtype or "png", cid=f"<{LOGO_CID}>"
|
|
)
|
|
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, ni sujet — un sujet de récapitulatif
|
|
# de planning (`app/api/notifications.py`) peut être personnalisé par la
|
|
# coordination avec le nom du bénévole (`{prenom}`/`{nom}`), ce ne serait
|
|
# alors plus un simple texte d'interface.
|
|
logger.info("Emails remis : %d/%d", sent, len(messages))
|
|
|
|
|
|
def send(to: str, subject: str, body: str, *, logo: tuple[bytes, str] | None = None) -> None:
|
|
deliver_many([build_message(to, subject, body, logo=logo)])
|
|
|
|
|
|
def send_many(
|
|
items: Iterable[tuple[str, str, str]], *, logo: tuple[bytes, 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=logo) for item in items])
|