Lien d'inscription protégé par jeton, reçus fiscaux mécénat, infobulles et revue de sécurité
ci-cd / build-backend (push) Successful in 1m12s
ci-cd / build-frontend (push) Successful in 18s
ci-cd / deploy (push) Successful in 12s

- 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>
This commit is contained in:
AdamThomas
2026-10-02 07:47:17 +02:00
co-authored by Claude Sonnet 5
parent a2b748566b
commit 096b7cd26a
44 changed files with 1228 additions and 156 deletions
+28 -18
View File
@@ -12,7 +12,7 @@ from collections.abc import Iterable
from email.message import EmailMessage
from app.config import Settings, get_settings
from app.email.html import render_html
from app.email.html import LOGO_CID, render_html
logger = logging.getLogger("app.email")
@@ -34,20 +34,32 @@ def _smtp_error_summary(exc: smtplib.SMTPException) -> str:
def build_message(
to: str, subject: str, body: str, *, logo_url: str | None = None
to: str, subject: str, body: str, *, logo: tuple[bytes, 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."""
"""`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, logo_url=logo_url), subtype="html"
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
@@ -112,22 +124,20 @@ def deliver_many(messages: list[EmailMessage]) -> None:
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"],
)
# 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_url: str | None = None) -> None:
deliver_many([build_message(to, subject, body, logo_url=logo_url)])
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_url: str | None = None
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_url=logo_url) for item in items])
deliver_many([build_message(*item, logo=logo) for item in items])