Lien d'inscription protégé par jeton, reçus fiscaux mécénat, infobulles et revue de sécurité
- 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:
co-authored by
Claude Sonnet 5
parent
a2b748566b
commit
096b7cd26a
@@ -4,7 +4,7 @@
|
||||
(sans destinataire ni corps — RGPD). Voir CLAUDE.md.
|
||||
"""
|
||||
|
||||
from app.email.branding import edition_logo_url
|
||||
from app.email.branding import edition_logo
|
||||
from app.email.sender import send, send_many
|
||||
from app.email.templates import (
|
||||
render_magic_link,
|
||||
@@ -14,7 +14,7 @@ from app.email.templates import (
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"edition_logo_url",
|
||||
"edition_logo",
|
||||
"render_magic_link",
|
||||
"render_planning",
|
||||
"render_registration_confirmation",
|
||||
|
||||
@@ -1,20 +1,25 @@
|
||||
"""URL publique du logo d'une édition, pour l'en-tête des emails (voir
|
||||
`app/email/html.py`) — `None` tant qu'aucun logo n'a été importé sur le
|
||||
formulaire public (`FormTemplate.logo`, `app/api/forms.py:set_logo`)."""
|
||||
"""Logo d'une édition pour l'en-tête des emails (voir `app/email/html.py`) —
|
||||
`None` tant qu'aucun logo n'a été importé sur le formulaire public
|
||||
(`FormTemplate.logo`, `app/api/forms.py:set_logo`).
|
||||
|
||||
Renvoie les octets (intégrés en pièce jointe « inline », voir
|
||||
`app/email/sender.py:build_message`), jamais une URL : `GET .../form/logo`
|
||||
exige désormais le `access_token` du formulaire public
|
||||
(`app/api/forms.py:_require_access_token`) — une URL externe dans un email
|
||||
aurait exposé ce jeton à quiconque lit la source du message, pas seulement
|
||||
la coordination."""
|
||||
|
||||
import uuid
|
||||
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from app.config import get_settings
|
||||
from app.models import FormTemplate
|
||||
|
||||
|
||||
def edition_logo_url(session: Session, edition_id: uuid.UUID) -> str | None:
|
||||
def edition_logo(session: Session, edition_id: uuid.UUID) -> tuple[bytes, str] | None:
|
||||
template = session.exec(
|
||||
select(FormTemplate).where(FormTemplate.edition_id == edition_id)
|
||||
).first()
|
||||
if template is None or template.logo is None:
|
||||
return None
|
||||
base = get_settings().public_base_url.rstrip("/")
|
||||
return f"{base}/api/editions/{edition_id}/form/logo"
|
||||
return template.logo, template.logo_content_type or "image/png"
|
||||
|
||||
@@ -18,6 +18,13 @@ _YELLOW = "#FFD23F"
|
||||
_FONT_DISPLAY = "Arial, Helvetica, sans-serif"
|
||||
_FONT_MONO = "'Courier New', Courier, monospace"
|
||||
|
||||
# Identifiant de la pièce jointe « inline » du logo (voir
|
||||
# `app/email/sender.py:build_message`) — jamais une URL externe avec le
|
||||
# `access_token` du formulaire public en requête : n'importe quel
|
||||
# destinataire d'un email (lien magique, confirmation d'inscription…) aurait
|
||||
# pu lire ce jeton depuis la source du message, pas seulement la coordination.
|
||||
LOGO_CID = "edition-logo"
|
||||
|
||||
|
||||
def _paragraphs(body: str) -> str:
|
||||
"""`\n\n` sépare les paragraphes, un simple `\n` reste un retour à la
|
||||
@@ -30,13 +37,13 @@ def _paragraphs(body: str) -> str:
|
||||
)
|
||||
|
||||
|
||||
def render_html(*, subject: str, body: str, logo_url: str | None) -> str:
|
||||
"""`logo_url` : voir `app/email/branding.py:edition_logo_url` — `None`
|
||||
tant que l'édition n'a pas importé de logo, l'en-tête retombe alors sur
|
||||
un simple bandeau texte (le sujet, en capitales)."""
|
||||
if logo_url:
|
||||
def render_html(*, subject: str, body: str, has_logo: bool) -> str:
|
||||
"""`has_logo` : voir `app/email/branding.py:edition_logo` — `False` tant
|
||||
que l'édition n'a pas importé de logo, l'en-tête retombe alors sur un
|
||||
simple bandeau texte (le sujet, en capitales)."""
|
||||
if has_logo:
|
||||
header = (
|
||||
f'<img src="{_html.escape(logo_url)}" alt="" width="160" '
|
||||
f'<img src="cid:{LOGO_CID}" alt="" width="160" '
|
||||
'style="max-height:64px;width:auto;display:block;margin:0 auto;'
|
||||
'border:0;">'
|
||||
)
|
||||
|
||||
+28
-18
@@ -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])
|
||||
|
||||
Reference in New Issue
Block a user