Coverage for app/backend/src/couchers/email/rendering.py: 97%
178 statements
« prev ^ index » next coverage.py v7.14.1, created at 2026-05-29 04:01 +0000
« prev ^ index » next coverage.py v7.14.1, created at 2026-05-29 04:01 +0000
1"""
2Renders HTML and plaintext emails out of well-known blocks.
3"""
5import re
6from dataclasses import asdict, dataclass
7from functools import lru_cache
8from html import unescape
9from pathlib import Path
10from typing import Any, Self
12from markupsafe import Markup
14from couchers import urls
15from couchers.i18n import LocalizationContext
16from couchers.i18n.i18next import I18Next, SubstitutionDict
17from couchers.i18n.locales import load_locales
18from couchers.proto import api_pb2
19from couchers.templating import Jinja2Template, _markdown, template_folder
20from couchers.utils import now
23@dataclass
24class EmailBlock:
25 """Base class for building blocks of an email body, HTML/plaintext-agnostic."""
27 pass
30@dataclass(kw_only=True, slots=True)
31class ParaBlock(EmailBlock):
32 """A paragraph of text which may contain span-level HTML."""
34 text: str | Markup
37@dataclass(kw_only=True, slots=True)
38class UserBlock(EmailBlock):
39 """A banner with another user's profile information, for example preceding a quoted message."""
41 info: UserInfo
42 comment: str | Markup | None
45@dataclass(kw_only=True, slots=True)
46class UserInfo:
47 name: str
48 age: int
49 city: str
50 avatar_url: str
51 profile_url: str
53 @classmethod
54 def from_protobuf(cls, user: api_pb2.User) -> Self:
55 return cls(
56 name=user.name,
57 age=user.age,
58 city=user.city,
59 avatar_url=user.avatar_thumbnail_url or urls.icon_url(),
60 profile_url=urls.user_link(username=user.username),
61 )
63 @staticmethod
64 def dummy_bob() -> UserInfo:
65 return UserInfo(
66 name="Bob",
67 age=30,
68 city="Berlin",
69 avatar_url="https://couchers.org/img/icon.png",
70 profile_url="https://couchers.org/user/bob",
71 )
74@dataclass(kw_only=True, slots=True)
75class QuoteBlock(EmailBlock):
76 """A quoted message, typically from another user. Either plaintext or markdown."""
78 text: str
79 markdown: bool
82@dataclass(kw_only=True, slots=True)
83class ActionBlock(EmailBlock):
84 """An action that can be performed by the user in response to the email."""
86 text: str
87 target_url: str
90@dataclass(kw_only=True, slots=True)
91class TwoButtonHTMLBlock(EmailBlock):
92 """An HTML-only block for rendering as side-by-side buttons."""
94 text_1: str
95 target_url_1: str
96 text_2: str
97 target_url_2: str
100class EmailBlocksBuilder:
101 """
102 Builder object for constructing a list of EmailBlock's to form the body of an email.
103 """
105 _locale: str
106 _string_key_prefix: str
107 blocks: list[EmailBlock]
109 def __init__(self, locale: str, string_key_prefix: str):
110 self.blocks = []
111 self._locale = locale
112 self._string_key_prefix = string_key_prefix
114 def para(self, key: str, substitutions: SubstitutionDict | None = None) -> Self:
115 return self.block(ParaBlock(text=self._markup(key, substitutions)))
117 def quote(self, text: str, *, markdown: bool) -> Self:
118 return self.block(QuoteBlock(text=text, markdown=markdown))
120 def user(
121 self,
122 info: UserInfo,
123 comment_key: str | None = None,
124 substitutions: SubstitutionDict | None = None,
125 ) -> Self:
126 comment = self._markup(comment_key, substitutions) if comment_key else None
127 return self.block(UserBlock(info=info, comment=comment))
129 def action(self, url: str, text_key: str, substitutions: SubstitutionDict | None = None) -> Self:
130 return self.block(ActionBlock(text=self._text(text_key, substitutions), target_url=url))
132 def do_not_reply_request_para(self) -> Self:
133 line = get_emails_i18next().localize_with_markup("generic.do_not_reply_request", self._locale)
134 return self.block(ParaBlock(text=line))
136 def security_warning_para(self) -> Self:
137 line = get_emails_i18next().localize_with_markup("generic.security_warning_contact_support", self._locale)
138 return self.block(ParaBlock(text=line))
140 def block(self, block: EmailBlock) -> Self:
141 self.blocks.append(block)
142 return self
144 def _text(self, key: str, substitutions: SubstitutionDict | None = None) -> str:
145 full_key = self._to_full_string_key(key)
146 return get_emails_i18next().localize(full_key, self._locale, substitutions)
148 def _markup(self, key: str, substitutions: SubstitutionDict | None = None) -> Markup:
149 full_key = self._to_full_string_key(key)
150 return get_emails_i18next().localize_with_markup(full_key, self._locale, substitutions)
152 def _to_full_string_key(self, key: str) -> str:
153 # It's convenient to have a default key prefix,
154 # but sometimes we need to access something outside of it.
155 if key.startswith("."): # Escape hatch. Think rooted '/dir/file' paths in unix.
156 return key[1:]
157 else:
158 return f"{self._string_key_prefix}.{key}"
161@dataclass(kw_only=True)
162class EmailFooter:
163 timezone_name: str
164 copyright_year: int = now().year
165 unsubscribe_info: UnsubscribeInfo | None
167 def to_template_args(self) -> dict[str, Any]:
168 args: dict[str, Any] = {
169 "footer_timezone_name": self.timezone_name,
170 "footer_copyright_year": self.copyright_year,
171 "footer_email_is_critical": self.unsubscribe_info is None,
172 }
174 if unsubscribe_info := self.unsubscribe_info:
175 args.update(unsubscribe_info.to_template_args())
177 return args
180@dataclass(kw_only=True)
181class UnsubscribeInfo:
182 manage_notifications_url: str
183 do_not_email_url: str
184 topic_action_link: UnsubscribeLink
185 topic_key_link: UnsubscribeLink | None = None
187 def to_template_args(self) -> dict[str, Any]:
188 args: dict[str, Any] = {
189 "footer_manage_notifications_link": self.manage_notifications_url,
190 "footer_do_not_email_link": self.do_not_email_url,
191 "footer_notification_topic_action": self.topic_action_link.text,
192 "footer_notification_topic_action_link": self.topic_action_link.url,
193 }
195 if topic_key_link := self.topic_key_link:
196 args["footer_notification_topic_key"] = topic_key_link.text
197 args["footer_notification_topic_key_link"] = topic_key_link.url
199 return args
202@dataclass(kw_only=True)
203class UnsubscribeLink:
204 text: str
205 url: str
208@lru_cache(maxsize=1)
209def get_emails_i18next() -> I18Next:
210 return load_locales(Path(__file__).parent / "locales")
213def render_html_body(
214 *,
215 subject: str,
216 preview: str | None,
217 blocks: list[EmailBlock],
218 footer: EmailFooter,
219 loc_context: LocalizationContext,
220) -> str:
221 """Renders the body of an email as HTML."""
222 return HTMLRenderer.default().render(
223 subject=subject, preview=preview, blocks=blocks, footer=footer, loc_context=loc_context
224 )
227def render_plaintext_body(*, blocks: list[EmailBlock], footer: EmailFooter, loc_context: LocalizationContext) -> str:
228 """Renders the body of an email as plaintext."""
229 concat: list[str] = []
231 previous_block: EmailBlock | None = None
232 for block in blocks:
233 # Blank line between every two blocks except subsequent actions.
234 if previous_block is not None:
235 if isinstance(block, ActionBlock) and isinstance(previous_block, ActionBlock):
236 concat.append("\n")
237 else:
238 concat.append("\n\n")
240 match block:
241 case ParaBlock():
242 concat.append(_to_plaintext(block.text))
243 case UserBlock():
244 line = get_emails_i18next().localize(
245 "plaintext_formats.user",
246 loc_context.locale,
247 {"name": block.info.name, "age": str(block.info.age), "city": block.info.city},
248 )
249 concat.append(line)
250 if block.comment:
251 concat.append("\n")
252 concat.append(_to_plaintext(block.comment))
253 case QuoteBlock():
254 for line in block.text.splitlines():
255 concat.append(f"> {line}")
256 case ActionBlock(): 256 ↛ 261line 256 didn't jump to line 261 because the pattern on line 256 always matched
257 line = get_emails_i18next().localize(
258 "plaintext_formats.action", loc_context.locale, {"text": block.text, "url": block.target_url}
259 )
260 concat.append(line)
261 case _:
262 raise TypeError(f"Unexpected email block type: {block.__class__}")
263 previous_block = block
265 concat.append("\n\n")
267 footer_template = Jinja2Template(
268 source=(template_folder / "_footer.txt").read_text(encoding="utf8").strip(), html=False
269 )
270 footer_template_args = footer.to_template_args()
271 return "".join(concat) + footer_template.render(footer_template_args, loc_context)
274def _to_plaintext(text: str | Markup) -> str:
275 """
276 Converts any markup in its plaintext equivalent, allowing reuse of translations that have span-level markup
277 like <b> when formatting as plaintext email bodies.
278 """
279 if not isinstance(text, Markup): # Markup derives from str so can't test for isinstance(, str)
280 return text
282 # Convert markup to its plaintext equivalent.
283 # This code is not security-sensitive since we're producing a plaintext string where markup will not be evaluated.
285 # Strip/convert any markup since we can't render it in plaintext.
286 text = text.replace("\n", "") # Newlines are irrelevant in markup
287 text = re.sub(r"<br\s*/?>", "\n", text) # But <br>'s should be newlines in plaintext
289 # Keep the content of span-level markup (assume no nesting)
290 text = re.sub(
291 r"<(?P<name>\w+)(?P<attrs>[^>]*)>(?P<inner>.*?)</(?P=name)>", lambda match: match.group("inner"), text
292 )
293 text = re.sub(r"<\w+[^/>]*/>", "", text) # Remove any other self-closing tag
295 # We've handled tags but still have escapes like ">", convert those to plaintext.
296 return unescape(text)
299@dataclass
300class HTMLRenderer:
301 """Renders an email as HTML using template snippets for the header, footer and each block."""
303 header_template: Jinja2Template
304 footer_template: Jinja2Template
305 para_block_template: Jinja2Template
306 user_block_template: Jinja2Template
307 quote_block_template: Jinja2Template
308 action_block_template: Jinja2Template
309 two_buttons_block_template: Jinja2Template
311 def render(
312 self,
313 *,
314 subject: str,
315 preview: str | None,
316 blocks: list[EmailBlock],
317 footer: EmailFooter,
318 loc_context: LocalizationContext,
319 ) -> str:
320 concats: list[str] = []
322 # Render the header
323 concats.append(
324 self.header_template.render(
325 {
326 "header_subject": subject,
327 "header_preview": preview or "",
328 },
329 loc_context,
330 )
331 )
333 # Render each block
334 for block in type(self)._merge_action_blocks(blocks):
335 match block:
336 case ParaBlock():
337 concats.append(self.para_block_template.render(asdict(block), loc_context))
338 case UserBlock():
339 concats.append(
340 self.user_block_template.render(
341 {
342 "name": block.info.name,
343 "age": block.info.age,
344 "city": block.info.city,
345 "avatar_url": block.info.avatar_url,
346 "comment": block.comment,
347 },
348 loc_context,
349 )
350 )
351 case QuoteBlock():
352 args = {"text": Markup(_markdown.render(block.text)) if block.markdown else block.text}
353 concats.append(self.quote_block_template.render(args, loc_context))
354 case ActionBlock():
355 concats.append(self.action_block_template.render(asdict(block), loc_context))
356 case TwoButtonHTMLBlock(): 356 ↛ 358line 356 didn't jump to line 358 because the pattern on line 356 always matched
357 concats.append(self.two_buttons_block_template.render(asdict(block), loc_context))
358 case _:
359 raise TypeError(f"Unexpected email block type: {block.__class__}")
361 # Render the footer
362 footer_template_args = footer.to_template_args()
363 concats.append(self.footer_template.render(footer_template_args, loc_context))
365 return "\n".join(concats)
367 @staticmethod
368 def _merge_action_blocks(blocks: list[EmailBlock]) -> list[EmailBlock]:
369 """Merge any two subsequent action blocks into a single two-button block."""
370 blocks = blocks.copy()
372 block_index = 0
373 while block_index + 1 < len(blocks):
374 block = blocks[block_index]
375 next_block = blocks[block_index + 1]
376 if isinstance(block, ActionBlock) and isinstance(next_block, ActionBlock):
377 blocks[block_index] = TwoButtonHTMLBlock(
378 target_url_1=block.target_url,
379 text_1=block.text,
380 target_url_2=next_block.target_url,
381 text_2=next_block.text,
382 )
383 blocks.pop(block_index + 1)
385 block_index += 1
387 return blocks
389 @lru_cache(maxsize=1)
390 @staticmethod
391 def default() -> HTMLRenderer:
392 template = (template_folder / "generated_html" / "blocks.html").read_text(encoding="utf8")
393 return HTMLRenderer.from_template(template)
395 @staticmethod
396 def from_template(template: str) -> HTMLRenderer:
397 section_matches = list(_block_regex.finditer(template))
399 header_template = template[: section_matches[0].start()]
400 footer_template = template[section_matches[-1].end() :]
401 block_templates = {match.group("name"): match.group("snippet") for match in section_matches}
403 return HTMLRenderer(
404 header_template=Jinja2Template(source=header_template, html=True),
405 footer_template=Jinja2Template(source=footer_template, html=True),
406 para_block_template=Jinja2Template(source=block_templates["para"], html=True),
407 user_block_template=Jinja2Template(source=block_templates["user"], html=True),
408 quote_block_template=Jinja2Template(source=block_templates["quote"], html=True),
409 action_block_template=Jinja2Template(source=block_templates["action"], html=True),
410 two_buttons_block_template=Jinja2Template(source=block_templates["two-buttons"], html=True),
411 )
414# Matches a begin-block / end-block pair of comments in the html file containing template blocks.
415_block_regex = re.compile(
416 r"""
417<!-- begin-block:(?P<name>[\w-]+) -->\s*
418(?P<snippet>[\s\S]*?)
419\s*<!-- end-block:(?P=name) -->
420""".strip(),
421 re.MULTILINE,
422)