Coverage for app / backend / src / couchers / email / rendering.py: 0%
121 statements
« prev ^ index » next coverage.py v7.13.4, created at 2026-02-24 17:34 +0000
« prev ^ index » next coverage.py v7.13.4, created at 2026-02-24 17:34 +0000
1"""
2Renders HTML and plaintext emails out of well-known blocks.
3"""
5import re
6from dataclasses import dataclass
7from functools import lru_cache
8from html import unescape
9from pathlib import Path
10from typing import Any
12from markupsafe import Markup
14from couchers.i18n import LocalizationContext
15from couchers.i18n.i18next import I18Next
16from couchers.i18n.locales import load_locales
17from couchers.templating import Jinja2Template, template_folder
18from couchers.utils import now
21@dataclass
22class EmailBlock:
23 """Base class for building blocks of an email body, HTML/plaintext-agnostic."""
25 pass
28@dataclass(kw_only=True)
29class ParaBlock(EmailBlock):
30 """A paragraph of text which may contain span-level HTML."""
32 text: str | Markup
35@dataclass(kw_only=True)
36class UserBlock(EmailBlock):
37 """A banner with another user's profile information, for example preceding a quoted message."""
39 info: UserInfo
40 comment: str | Markup | None
43@dataclass(kw_only=True)
44class UserInfo:
45 name: str
46 age: int
47 city: str
48 avatar_url: str
49 profile_url: str
52@dataclass(kw_only=True)
53class QuoteBlock(EmailBlock):
54 """A quoted message from another user. May not contain markup."""
56 text: str
59@dataclass(kw_only=True)
60class ActionBlock(EmailBlock):
61 """An action that can be performed by the user in response to the email."""
63 text: str
64 target_url: str
67@dataclass(kw_only=True)
68class EmailFooter:
69 copyright_year: int = now().year
70 unsubscribe_info: UnsubscribeInfo | None
73@dataclass(kw_only=True)
74class UnsubscribeInfo:
75 manage_notifications_url: str
76 do_not_email_url: str
77 topic_action_link: UnsubscribeLink
78 topic_key_link: UnsubscribeLink | None = None
81@dataclass(kw_only=True)
82class UnsubscribeLink:
83 text: str
84 url: str
87@lru_cache(maxsize=1)
88def get_emails_i18next() -> I18Next:
89 return load_locales(Path(__file__).parent / "locales")
92def render_html_body(
93 *,
94 subject: str,
95 preview: str | None,
96 blocks: list[EmailBlock],
97 footer: EmailFooter,
98 loc_context: LocalizationContext,
99) -> str:
100 """Renders the body of an email as HTML."""
101 return HTMLRenderer.default().render(
102 subject=subject, preview=preview, blocks=blocks, footer=footer, loc_context=loc_context
103 )
106def render_plaintext_body(*, blocks: list[EmailBlock], footer: EmailFooter, loc_context: LocalizationContext) -> str:
107 """Renders the body of an email as plaintext."""
108 concat: list[str] = []
110 previous_block: EmailBlock | None = None
111 for block in blocks:
112 # Blank line between every two blocks except subsequent actions.
113 if previous_block is not None:
114 if isinstance(block, ActionBlock) and isinstance(previous_block, ActionBlock):
115 concat.append("\n")
116 else:
117 concat.append("\n\n")
119 match block:
120 case ParaBlock():
121 concat.append(_to_plaintext(block.text))
122 case UserBlock():
123 line = get_emails_i18next().localize(
124 "plaintext_formats.user",
125 loc_context.locale,
126 {"name": block.info.name, "age": str(block.info.age), "city": block.info.city},
127 )
128 concat.append(line)
129 if block.comment:
130 concat.append("\n")
131 concat.append(_to_plaintext(block.comment))
132 case QuoteBlock():
133 for line in block.text.splitlines():
134 concat.append(f"> {line}")
135 case ActionBlock():
136 line = get_emails_i18next().localize(
137 "plaintext_formats.action", loc_context.locale, {"text": block.text, "url": block.target_url}
138 )
139 concat.append(line)
140 case _:
141 raise AssertionError(f"Unexpected email block type: {block.__class__}")
142 previous_block = block
144 concat.append("\n\n")
146 footer_template = Jinja2Template(
147 source=(template_folder / "_footer.txt").read_text(encoding="utf8").strip(), html=False
148 )
149 footer_template_args = _get_footer_template_args(footer)
150 return "".join(concat) + footer_template.render(footer_template_args, loc_context)
153def _to_plaintext(text: str | Markup) -> str:
154 """
155 Converts any markup in its plaintext equivalent, allowing reuse of translations that have span-level markup
156 like <b> when formatting as plaintext email bodies.
157 """
158 if not isinstance(text, Markup): # Markup derives from str so can't test for isinstance(, str)
159 return text
161 # Convert markup to its plaintext equivalent.
162 # This code is not security-sensitive since we're producing a plaintext string where markup will not be evaluated.
164 # Strip/convert any markup since we can't render it in plaintext.
165 text = text.replace("\n", "") # Newlines are irrelevant in markup
166 text = re.sub(r"<br\s*/?>", "\n", text) # But <br>'s should be newlines in plaintext
168 # Keep the content of span-level markup (assume no nesting)
169 text = re.sub(
170 r"<(?P<name>\w+)(?P<attrs>[^>]*)>(?P<inner>.*?)</(?P=name)>", lambda match: match.group("inner"), text
171 )
172 text = re.sub(r"<\w+[^/>]*/>", "", text) # Remove any other self-closing tag
174 # We've handled tags but still have escapes like ">", convert those to plaintext.
175 return unescape(text)
178def _get_footer_template_args(footer: EmailFooter) -> dict[str, Any]:
179 args: dict[str, Any] = {}
180 args["footer_copyright_year"] = footer.copyright_year
182 if unsubscribe_info := footer.unsubscribe_info:
183 args["footer_manage_notifications_link"] = unsubscribe_info.manage_notifications_url
184 args["footer_do_not_email_link"] = unsubscribe_info.do_not_email_url
185 args["footer_notification_topic_action"] = unsubscribe_info.topic_action_link.text
186 args["footer_notification_topic_action_link"] = unsubscribe_info.topic_action_link.url
188 if topic_key_link := unsubscribe_info.topic_key_link:
189 args["footer_notification_topic_key"] = topic_key_link.text
190 args["footer_notification_topic_key_link"] = topic_key_link.url
192 return args
195@dataclass
196class HTMLRenderer:
197 """Renders an email as HTML using template snippets for the header, footer and each block."""
199 header_template: Jinja2Template
200 footer_template: Jinja2Template
201 para_block_template: Jinja2Template
202 user_block_template: Jinja2Template
203 quote_block_template: Jinja2Template
204 action_block_template: Jinja2Template
206 def render(
207 self,
208 *,
209 subject: str,
210 preview: str | None,
211 blocks: list[EmailBlock],
212 footer: EmailFooter,
213 loc_context: LocalizationContext,
214 ) -> str:
215 concats: list[str] = []
217 # Render the header
218 concats.append(
219 self.header_template.render(
220 {
221 "header_subject": subject,
222 "header_preview": preview or "",
223 },
224 loc_context,
225 )
226 )
228 # Render each block
229 for block in blocks:
230 match block:
231 case ParaBlock():
232 concats.append(self.para_block_template.render(block.__dict__, loc_context))
233 case UserBlock():
234 concats.append(
235 self.user_block_template.render(
236 {
237 "name": block.info.name,
238 "age": block.info.age,
239 "city": block.info.city,
240 "avatar_url": block.info.avatar_url,
241 "comment": block.comment,
242 },
243 loc_context,
244 )
245 )
246 case QuoteBlock():
247 concats.append(self.quote_block_template.render(block.__dict__, loc_context))
248 case ActionBlock():
249 concats.append(self.action_block_template.render(block.__dict__, loc_context))
250 case _:
251 raise AssertionError(f"Unexpected email block type: {block.__class__}")
253 # Render the footer
254 footer_template_args = _get_footer_template_args(footer)
255 concats.append(self.footer_template.render(footer_template_args, loc_context))
257 return "\n".join(concats)
259 @lru_cache(maxsize=1)
260 @staticmethod
261 def default() -> HTMLRenderer:
262 template = (template_folder / "generated_html" / "blocks.html").read_text(encoding="utf8")
263 return HTMLRenderer.from_template(template)
265 @staticmethod
266 def from_template(template: str) -> HTMLRenderer:
267 section_matches = list(_block_regex.finditer(template))
269 header_template = template[: section_matches[0].start()]
270 footer_template = template[section_matches[-1].end() :]
271 block_templates = {match.group("name"): match.group("snippet") for match in section_matches}
273 return HTMLRenderer(
274 header_template=Jinja2Template(source=header_template, html=True),
275 footer_template=Jinja2Template(source=footer_template, html=True),
276 para_block_template=Jinja2Template(source=block_templates["para"], html=True),
277 user_block_template=Jinja2Template(source=block_templates["user"], html=True),
278 quote_block_template=Jinja2Template(source=block_templates["quote"], html=True),
279 action_block_template=Jinja2Template(source=block_templates["action"], html=True),
280 )
283# Matches a begin-block / end-block pair of comments in the html file containing template blocks.
284_block_regex = re.compile(
285 r"""
286<!-- begin-block:(?P<name>\w+) -->\s*
287(?P<snippet>[\s\S]*?)
288\s*<!-- end-block:(?P=name) -->
289""".strip(),
290 re.MULTILINE,
291)