"""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 = '

' 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), "
") + "

" for marker, fragment in blocks.items(): html = html.replace(_html.escape(marker), f"

{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)}(?:
|\s)*

", "", 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'' f"{_html.escape(h)}" for h in headers ) rows = [f"{head}"] for day, lines in sections: rows.append( f'{_html.escape(day)}' ) for time, position, referent in lines: rows.append( f'' f"{_html.escape(time)}" f'' f"{_html.escape(position)}" f'{_html.escape(referent).replace(chr(10), "
")}' ) return ( '' + "".join(rows) + "
" ) 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'' ) else: header = ( f'{_html.escape(subject)}' ) return f"""
{header}
{_paragraphs(body, blocks or {})}
"""