{creneaux} devient un tableau par jour de festival (horaire, poste,
référent avec son téléphone), en HTML dans l'email et en lignes lisibles
dans la version texte — envoi réel et email de test.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
137 lines
5.6 KiB
Python
137 lines
5.6 KiB
Python
"""Version HTML des emails — même thème que l'appli (couleurs/typographie de
|
|
`frontend/tailwind.config.js`), logo de l'édition en en-tête si défini.
|
|
|
|
Chaque email reste envoyé en `multipart/alternative` (texte + HTML, voir
|
|
`app/email/sender.py:build_message`) : le texte brut reste la source de
|
|
vérité (accessibilité, clients sans HTML, RGPD — rien de plus dans le HTML
|
|
que dans le texte déjà journalisable). Pas de CSS externe ni de police web
|
|
(rarement chargées par les clients mail) : mise en page par tableau, styles
|
|
en ligne, polices système en repli du style « Archivo »/« DM Mono »."""
|
|
|
|
import html as _html
|
|
import re
|
|
from collections.abc import Mapping, Sequence
|
|
|
|
_INK = "#14110E"
|
|
_CREAM = "#F5EFE1"
|
|
_PAPER = "#FFFDF7"
|
|
_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"
|
|
|
|
|
|
_P_OPEN = '<p style="margin:0 0 14px;">'
|
|
|
|
|
|
def _paragraphs(body: str, blocks: Mapping[str, str]) -> str:
|
|
"""`\n\n` sépare les paragraphes, un simple `\n` reste un retour à la
|
|
ligne — même convention que les modèles texte (`app/email/templates.py`).
|
|
`blocks` : repère → HTML déjà construit (ex. tableau des créneaux),
|
|
inséré tel quel à la place du repère, hors de tout paragraphe."""
|
|
out = []
|
|
for block in body.strip().split("\n\n"):
|
|
if not block.strip():
|
|
continue
|
|
html = _P_OPEN + _html.escape(block).replace(chr(10), "<br>") + "</p>"
|
|
for marker, fragment in blocks.items():
|
|
html = html.replace(_html.escape(marker), f"</p>{fragment}{_P_OPEN}")
|
|
# Paragraphes vidés par l'insertion d'un bloc (repère seul, ou
|
|
# entouré de retours à la ligne).
|
|
html = re.sub(rf"{re.escape(_P_OPEN)}(?:<br>|\s)*</p>", "", html)
|
|
out.append(html)
|
|
return "".join(out)
|
|
|
|
|
|
_CELL = f"padding:6px 8px;border:1px solid {_INK};vertical-align:top;"
|
|
|
|
|
|
def render_planning_table(
|
|
sections: Sequence[tuple[str, Sequence[tuple[str, str, str]]]],
|
|
headers: tuple[str, str, str],
|
|
) -> str:
|
|
"""Tableau des créneaux d'un bénévole, comme sur les badges : le jour
|
|
en en-tête sur toute la largeur, puis une ligne par créneau (horaire,
|
|
poste, référent). `sections` = `[(jour, [(horaire, poste, référent)])]`."""
|
|
head = "".join(
|
|
f'<th align="left" style="{_CELL}background:{_INK};color:{_CREAM};'
|
|
f'font-family:{_FONT_DISPLAY};font-size:11px;text-transform:uppercase;">'
|
|
f"{_html.escape(h)}</th>"
|
|
for h in headers
|
|
)
|
|
rows = [f"<tr>{head}</tr>"]
|
|
for day, lines in sections:
|
|
rows.append(
|
|
f'<tr><td colspan="3" style="{_CELL}background:{_YELLOW};'
|
|
f'font-family:{_FONT_DISPLAY};font-weight:800;">{_html.escape(day)}</td></tr>'
|
|
)
|
|
for time, position, referent in lines:
|
|
rows.append(
|
|
f'<tr><td style="{_CELL}white-space:nowrap;font-weight:700;">'
|
|
f"{_html.escape(time)}</td>"
|
|
f'<td style="{_CELL}font-family:{_FONT_DISPLAY};font-weight:700;">'
|
|
f"{_html.escape(position)}</td>"
|
|
f'<td style="{_CELL}">{_html.escape(referent).replace(chr(10), "<br>")}</td></tr>'
|
|
)
|
|
return (
|
|
'<table role="presentation" width="100%" cellpadding="0" cellspacing="0" '
|
|
f'style="border-collapse:collapse;margin:0 0 14px;font-size:12px;'
|
|
f'font-family:{_FONT_MONO};background:{_PAPER};">' + "".join(rows) + "</table>"
|
|
)
|
|
|
|
|
|
def render_html(
|
|
*, subject: str, body: str, has_logo: bool, blocks: Mapping[str, str] | None = None
|
|
) -> 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). `blocks` : voir
|
|
`_paragraphs`."""
|
|
if has_logo:
|
|
header = (
|
|
f'<img src="cid:{LOGO_CID}" alt="" width="160" '
|
|
'style="max-height:64px;width:auto;display:block;margin:0 auto;'
|
|
'border:0;">'
|
|
)
|
|
else:
|
|
header = (
|
|
f'<span style="font-family:{_FONT_DISPLAY};font-weight:800;'
|
|
f"text-transform:uppercase;letter-spacing:1px;color:{_YELLOW};"
|
|
f'font-size:14px;">{_html.escape(subject)}</span>'
|
|
)
|
|
return f"""<!doctype html>
|
|
<html lang="fr">
|
|
<head><meta charset="utf-8"></head>
|
|
<body style="margin:0;padding:24px 16px;background:{_CREAM};">
|
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0">
|
|
<tr>
|
|
<td align="center">
|
|
<table role="presentation" width="520" cellpadding="0" cellspacing="0"
|
|
style="max-width:520px;width:100%;">
|
|
<tr>
|
|
<td style="background:{_INK};padding:18px;text-align:center;
|
|
border:3px solid {_INK};">
|
|
{header}
|
|
</td>
|
|
</tr>
|
|
<tr>
|
|
<td style="background:{_PAPER};border:3px solid {_INK};
|
|
border-top:none;padding:22px;font-size:13px;
|
|
line-height:1.6;color:{_INK};font-family:{_FONT_MONO};">
|
|
{_paragraphs(body, blocks or {})}
|
|
</td>
|
|
</tr>
|
|
</table>
|
|
</td>
|
|
</tr>
|
|
</table>
|
|
</body>
|
|
</html>"""
|