Coverage for app/backend/src/couchers/email/rendering.py: 96%
136 statements
« prev ^ index » next coverage.py v7.14.3, created at 2026-06-28 00:29 +0000
« prev ^ index » next coverage.py v7.14.3, created at 2026-06-28 00:29 +0000
1"""
2Renders blocks-based emails to HTML or plaintext emails for a locale.
3"""
5import re
6from dataclasses import asdict, dataclass
7from email.headerregistry import Address
8from functools import cache
9from html import unescape
10from pathlib import Path
11from typing import Any
13from markdown_it import MarkdownIt
14from markupsafe import Markup
16from couchers.config import config
17from couchers.email.blocks import ActionBlock, EmailBase, EmailBlock, EmailFooter, ParaBlock, QuoteBlock, UserBlock
18from couchers.email.locales import get_emails_i18next
19from couchers.email.smtp import embed_html_relative_images
20from couchers.i18n import LocalizationContext
21from couchers.i18n.i18next import SubstitutionDict, full_string_key
22from couchers.proto.internal import jobs_pb2
23from couchers.templating import Jinja2Template
25template_folder = Path(__file__).parent.parent.parent.parent / "templates" / "v2"
27_markdown = MarkdownIt("zero", {"typographer": True}).enable(
28 ["smartquotes", "heading", "hr", "list", "link", "emphasis"]
29)
32@dataclass(kw_only=True, slots=True)
33class RenderedEmail:
34 subject: str
35 body_plaintext: str
36 body_html: str
37 html_image_parts: list[jobs_pb2.EmailPart]
40def render_email(
41 email: EmailBase, footer: EmailFooter, loc_context: LocalizationContext, *, embed_images: bool = True
42) -> RenderedEmail:
43 """Renders an EmailBase object to subject and body strings."""
44 subject = email.get_subject_line(loc_context)
45 preview = email.get_preview_line(loc_context)
46 body_blocks = email.get_body_blocks(loc_context)
48 body_plaintext = render_plaintext_body(blocks=body_blocks, footer=footer, loc_context=loc_context)
49 body_html = render_html_body(
50 subject=subject, preview=preview, blocks=body_blocks, footer=footer, loc_context=loc_context
51 )
53 related_parts: list[jobs_pb2.EmailPart] = []
54 if embed_images:
55 content_id_domain = Address(addr_spec=config.NOTIFICATION_EMAIL_ADDRESS).domain
56 body_html, related_parts = embed_html_relative_images(
57 body_html, base_dir=template_folder, content_id_domain=content_id_domain
58 )
60 return RenderedEmail(
61 subject=subject, body_plaintext=body_plaintext, body_html=body_html, html_image_parts=related_parts
62 )
65def render_plaintext_body(*, blocks: list[EmailBlock], footer: EmailFooter, loc_context: LocalizationContext) -> str:
66 """Renders the body of an email as plaintext."""
67 concat: list[str] = []
69 previous_block: EmailBlock | None = None
70 for block in blocks:
71 # Blank line between every two blocks except subsequent actions.
72 if previous_block is not None:
73 if isinstance(block, ActionBlock) and isinstance(previous_block, ActionBlock):
74 concat.append("\n")
75 else:
76 concat.append("\n\n")
78 match block:
79 case ParaBlock():
80 concat.append(_to_plaintext(block.text))
81 case UserBlock():
82 line = get_emails_i18next().localize(
83 "plaintext_formats.user",
84 loc_context.locale,
85 {"name": block.info.name, "age": str(block.info.age), "city": block.info.city},
86 )
87 concat.append(line)
88 if block.comment:
89 concat.append("\n")
90 concat.append(_to_plaintext(block.comment))
91 case QuoteBlock():
92 for line in block.text.splitlines():
93 concat.append(f"> {line}")
94 case ActionBlock(): 94 ↛ 99line 94 didn't jump to line 99 because the pattern on line 94 always matched
95 line = get_emails_i18next().localize(
96 "plaintext_formats.action", loc_context.locale, {"text": block.text, "url": block.target_url}
97 )
98 concat.append(line)
99 case _:
100 raise TypeError(f"Unexpected email block type: {block.__class__}")
101 previous_block = block
103 concat.append("\n\n")
105 footer_template = Jinja2Template(
106 source=(template_folder / "_footer.txt").read_text(encoding="utf8").strip(), html=False
107 )
108 footer_template_args = _get_footer_template_args(footer, loc_context)
109 concat.append(footer_template.render(footer_template_args))
111 return "".join(concat)
114def _to_plaintext(text: str | Markup) -> str:
115 """
116 Converts any markup in its plaintext equivalent, allowing reuse of translations that have span-level markup
117 like <b> when formatting as plaintext email bodies.
118 """
119 if not isinstance(text, Markup): # Markup derives from str so can't test for isinstance(, str) 119 ↛ 120line 119 didn't jump to line 120 because the condition on line 119 was never true
120 return text
122 # Convert markup to its plaintext equivalent.
123 # This code is not security-sensitive since we're producing a plaintext string where markup will not be evaluated.
125 # Strip/convert any markup since we can't render it in plaintext.
126 text = text.replace("\n", "") # Newlines are irrelevant in markup
127 text = re.sub(r"<br\s*/?>", "\n", text) # But <br>'s should be newlines in plaintext
129 # Keep the content of span-level markup (assume no nesting)
130 text = re.sub(
131 r"<(?P<name>\w+)(?P<attrs>[^>]*)>(?P<inner>.*?)</(?P=name)>", lambda match: match.group("inner"), text
132 )
133 text = re.sub(r"<\w+[^/>]*/>", "", text) # Remove any other self-closing tag
135 # We've handled tags but still have escapes like ">", convert those to plaintext.
136 return unescape(text)
139def _get_footer_template_args(footer: EmailFooter, loc_context: LocalizationContext) -> dict[str, Any]:
140 i18n = get_emails_i18next()
142 def localize(key: str, substitutions: SubstitutionDict | None = None) -> Markup:
143 key = full_string_key(key, relative_base="generic.footer")
144 return i18n.localize_with_markup(key, loc_context.locale, substitutions)
146 args: dict[str, Any] = {
147 "received_because": localize(".received_because"),
148 "contact_support": localize(".contact_support"),
149 "timezone_note": localize(".timezone_note", {"timezone": footer.timezone_name}),
150 "copyright_year": footer.copyright_year,
151 "donate_link": localize(".donate_link"),
152 "volunteer_link": localize(".volunteer_link"),
153 "blog_link": localize(".blog_link"),
154 "nonprofit_note": localize(".nonprofit_note"),
155 "is_critical": footer.unsubscribe_info is None,
156 }
158 if unsubscribe_info := footer.unsubscribe_info:
159 # TODO(#7420): Localize "Turn off emails for: " text, avoiding string concatenations.
160 args.update(
161 {
162 "notification_settings_link": localize(".notification_settings_link"),
163 "manage_notifications_url": unsubscribe_info.manage_notifications_url,
164 "do_not_email_link": localize(".do_not_email_link"),
165 "do_not_email_url": unsubscribe_info.do_not_email_url,
166 "topic_action_description": unsubscribe_info.topic_action_link.text,
167 "unsubscribe_topic_action_url": unsubscribe_info.topic_action_link.url,
168 }
169 )
171 if topic_key_link := unsubscribe_info.topic_key_link:
172 args["topic_key_description"] = topic_key_link.text
173 args["unsubscribe_topic_key_url"] = topic_key_link.url
174 else:
175 args["security_email_note"] = localize(".security_email_note")
177 return args
180def render_html_body(
181 *,
182 subject: str,
183 preview: str | None,
184 blocks: list[EmailBlock],
185 footer: EmailFooter,
186 loc_context: LocalizationContext,
187) -> str:
188 """Renders the body of an email as HTML."""
189 return HTMLRenderer.default().render(
190 subject=subject, preview=preview, blocks=blocks, footer=footer, loc_context=loc_context
191 )
194@dataclass(kw_only=True, slots=True)
195class TwoButtonHTMLBlock(EmailBlock):
196 """An HTML-only block used internally for rendering as side-by-side buttons."""
198 text_1: str
199 target_url_1: str
200 text_2: str
201 target_url_2: str
204# Matches a begin-block / end-block pair of comments in the html file containing template
205_block_regex = re.compile(
206 r"""
207<!-- begin-block:(?P<name>[\w-]+) -->\s*
208(?P<snippet>[\s\S]*?)
209\s*<!-- end-block:(?P=name) -->
210""".strip(),
211 re.MULTILINE,
212)
215@dataclass
216class HTMLRenderer:
217 """Renders an email as HTML using template snippets for the header, footer and each block."""
219 header_template: Jinja2Template
220 footer_template: Jinja2Template
221 para_block_template: Jinja2Template
222 user_block_template: Jinja2Template
223 quote_block_template: Jinja2Template
224 action_block_template: Jinja2Template
225 two_buttons_block_template: Jinja2Template
227 def render(
228 self,
229 *,
230 subject: str,
231 preview: str | None,
232 blocks: list[EmailBlock],
233 footer: EmailFooter,
234 loc_context: LocalizationContext,
235 ) -> str:
236 concats: list[str] = []
238 # Render the header
239 concats.append(
240 self.header_template.render(
241 {
242 "header_subject": subject,
243 "header_preview": preview or "",
244 },
245 )
246 )
248 # Render each block
249 for block in type(self)._merge_action_blocks(blocks):
250 match block:
251 case ParaBlock():
252 concats.append(self.para_block_template.render(asdict(block)))
253 case UserBlock():
254 concats.append(
255 self.user_block_template.render(
256 {
257 "name": block.info.name,
258 "age": block.info.age,
259 "city": block.info.city,
260 "profile_url": block.info.profile_url,
261 "avatar_url": block.info.avatar_url,
262 "comment": block.comment,
263 },
264 )
265 )
266 case QuoteBlock():
267 args = {"text": Markup(_markdown.render(block.text)) if block.markdown else block.text}
268 concats.append(self.quote_block_template.render(args))
269 case ActionBlock():
270 concats.append(self.action_block_template.render(asdict(block)))
271 case TwoButtonHTMLBlock(): 271 ↛ 273line 271 didn't jump to line 273 because the pattern on line 271 always matched
272 concats.append(self.two_buttons_block_template.render(asdict(block)))
273 case _:
274 raise TypeError(f"Unexpected email block type: {block.__class__}")
276 # Render the footer
277 footer_template_args = _get_footer_template_args(footer, loc_context)
278 concats.append(self.footer_template.render(footer_template_args))
280 return "\n".join(concats)
282 @staticmethod
283 def _merge_action_blocks(blocks: list[EmailBlock]) -> list[EmailBlock]:
284 """Merge any two subsequent action blocks into a single two-button block."""
285 blocks = blocks.copy()
287 block_index = 0
288 while block_index + 1 < len(blocks):
289 block = blocks[block_index]
290 next_block = blocks[block_index + 1]
291 if isinstance(block, ActionBlock) and isinstance(next_block, ActionBlock):
292 blocks[block_index] = TwoButtonHTMLBlock(
293 target_url_1=block.target_url,
294 text_1=block.text,
295 target_url_2=next_block.target_url,
296 text_2=next_block.text,
297 )
298 blocks.pop(block_index + 1)
300 block_index += 1
302 return blocks
304 @cache
305 @staticmethod
306 def default() -> HTMLRenderer:
307 template = (template_folder / "generated_html" / "blocks.html").read_text(encoding="utf8")
308 return HTMLRenderer.from_template(template)
310 @staticmethod
311 def from_template(template: str) -> HTMLRenderer:
312 section_matches = list(_block_regex.finditer(template))
314 header_template = template[: section_matches[0].start()]
315 footer_template = template[section_matches[-1].end() :]
316 block_templates = {match.group("name"): match.group("snippet") for match in section_matches}
318 return HTMLRenderer(
319 header_template=Jinja2Template(source=header_template, html=True),
320 footer_template=Jinja2Template(source=footer_template, html=True),
321 para_block_template=Jinja2Template(source=block_templates["para"], html=True),
322 user_block_template=Jinja2Template(source=block_templates["user"], html=True),
323 quote_block_template=Jinja2Template(source=block_templates["quote"], html=True),
324 action_block_template=Jinja2Template(source=block_templates["action"], html=True),
325 two_buttons_block_template=Jinja2Template(source=block_templates["two-buttons"], html=True),
326 )