Coverage for app/backend/src/couchers/email/rendering.py: 95%
120 statements
« prev ^ index » next coverage.py v7.14.3, created at 2026-06-25 17:29 +0000
« prev ^ index » next coverage.py v7.14.3, created at 2026-06-25 17: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
12from markdown_it import MarkdownIt
13from markupsafe import Markup
15from couchers.config import config
16from couchers.email.blocks import ActionBlock, EmailBase, EmailBlock, EmailFooter, ParaBlock, QuoteBlock, UserBlock
17from couchers.email.locales import get_emails_i18next
18from couchers.email.smtp import embed_html_relative_images
19from couchers.i18n import LocalizationContext
20from couchers.proto.internal import jobs_pb2
21from couchers.templating import Jinja2Template
23template_folder = Path(__file__).parent.parent.parent.parent / "templates" / "v2"
25_markdown = MarkdownIt("zero", {"typographer": True}).enable(
26 ["smartquotes", "heading", "hr", "list", "link", "emphasis"]
27)
30@dataclass(kw_only=True, slots=True)
31class RenderedEmail:
32 subject: str
33 body_plaintext: str
34 body_html: str
35 html_image_parts: list[jobs_pb2.EmailPart]
38def render_email(
39 email: EmailBase, footer: EmailFooter, loc_context: LocalizationContext, *, embed_images: bool = True
40) -> RenderedEmail:
41 """Renders an EmailBase object to subject and body strings."""
42 subject = email.get_subject_line(loc_context)
43 preview = email.get_preview_line(loc_context)
44 body_blocks = email.get_body_blocks(loc_context)
46 body_plaintext = render_plaintext_body(blocks=body_blocks, footer=footer, loc_context=loc_context)
47 body_html = render_html_body(
48 subject=subject, preview=preview, blocks=body_blocks, footer=footer, loc_context=loc_context
49 )
51 related_parts: list[jobs_pb2.EmailPart] = []
52 if embed_images:
53 content_id_domain = Address(addr_spec=config.NOTIFICATION_EMAIL_ADDRESS).domain
54 body_html, related_parts = embed_html_relative_images(
55 body_html, base_dir=template_folder, content_id_domain=content_id_domain
56 )
58 return RenderedEmail(
59 subject=subject, body_plaintext=body_plaintext, body_html=body_html, html_image_parts=related_parts
60 )
63def render_plaintext_body(*, blocks: list[EmailBlock], footer: EmailFooter, loc_context: LocalizationContext) -> str:
64 """Renders the body of an email as plaintext."""
65 concat: list[str] = []
67 previous_block: EmailBlock | None = None
68 for block in blocks:
69 # Blank line between every two blocks except subsequent actions.
70 if previous_block is not None:
71 if isinstance(block, ActionBlock) and isinstance(previous_block, ActionBlock):
72 concat.append("\n")
73 else:
74 concat.append("\n\n")
76 match block:
77 case ParaBlock():
78 concat.append(_to_plaintext(block.text))
79 case UserBlock():
80 line = get_emails_i18next().localize(
81 "plaintext_formats.user",
82 loc_context.locale,
83 {"name": block.info.name, "age": str(block.info.age), "city": block.info.city},
84 )
85 concat.append(line)
86 if block.comment:
87 concat.append("\n")
88 concat.append(_to_plaintext(block.comment))
89 case QuoteBlock():
90 for line in block.text.splitlines():
91 concat.append(f"> {line}")
92 case ActionBlock(): 92 ↛ 97line 92 didn't jump to line 97 because the pattern on line 92 always matched
93 line = get_emails_i18next().localize(
94 "plaintext_formats.action", loc_context.locale, {"text": block.text, "url": block.target_url}
95 )
96 concat.append(line)
97 case _:
98 raise TypeError(f"Unexpected email block type: {block.__class__}")
99 previous_block = block
101 concat.append("\n\n")
103 footer_template = Jinja2Template(
104 source=(template_folder / "_footer.txt").read_text(encoding="utf8").strip(), html=False
105 )
106 footer_template_args = footer.to_template_args()
107 return "".join(concat) + footer_template.render(footer_template_args)
110def _to_plaintext(text: str | Markup) -> str:
111 """
112 Converts any markup in its plaintext equivalent, allowing reuse of translations that have span-level markup
113 like <b> when formatting as plaintext email bodies.
114 """
115 if not isinstance(text, Markup): # Markup derives from str so can't test for isinstance(, str) 115 ↛ 116line 115 didn't jump to line 116 because the condition on line 115 was never true
116 return text
118 # Convert markup to its plaintext equivalent.
119 # This code is not security-sensitive since we're producing a plaintext string where markup will not be evaluated.
121 # Strip/convert any markup since we can't render it in plaintext.
122 text = text.replace("\n", "") # Newlines are irrelevant in markup
123 text = re.sub(r"<br\s*/?>", "\n", text) # But <br>'s should be newlines in plaintext
125 # Keep the content of span-level markup (assume no nesting)
126 text = re.sub(
127 r"<(?P<name>\w+)(?P<attrs>[^>]*)>(?P<inner>.*?)</(?P=name)>", lambda match: match.group("inner"), text
128 )
129 text = re.sub(r"<\w+[^/>]*/>", "", text) # Remove any other self-closing tag
131 # We've handled tags but still have escapes like ">", convert those to plaintext.
132 return unescape(text)
135def render_html_body(
136 *,
137 subject: str,
138 preview: str | None,
139 blocks: list[EmailBlock],
140 footer: EmailFooter,
141 loc_context: LocalizationContext,
142) -> str:
143 """Renders the body of an email as HTML."""
144 return HTMLRenderer.default().render(
145 subject=subject, preview=preview, blocks=blocks, footer=footer, loc_context=loc_context
146 )
149@dataclass(kw_only=True, slots=True)
150class TwoButtonHTMLBlock(EmailBlock):
151 """An HTML-only block used internally for rendering as side-by-side buttons."""
153 text_1: str
154 target_url_1: str
155 text_2: str
156 target_url_2: str
159# Matches a begin-block / end-block pair of comments in the html file containing template
160_block_regex = re.compile(
161 r"""
162<!-- begin-block:(?P<name>[\w-]+) -->\s*
163(?P<snippet>[\s\S]*?)
164\s*<!-- end-block:(?P=name) -->
165""".strip(),
166 re.MULTILINE,
167)
170@dataclass
171class HTMLRenderer:
172 """Renders an email as HTML using template snippets for the header, footer and each block."""
174 header_template: Jinja2Template
175 footer_template: Jinja2Template
176 para_block_template: Jinja2Template
177 user_block_template: Jinja2Template
178 quote_block_template: Jinja2Template
179 action_block_template: Jinja2Template
180 two_buttons_block_template: Jinja2Template
182 def render(
183 self,
184 *,
185 subject: str,
186 preview: str | None,
187 blocks: list[EmailBlock],
188 footer: EmailFooter,
189 loc_context: LocalizationContext,
190 ) -> str:
191 concats: list[str] = []
193 # Render the header
194 concats.append(
195 self.header_template.render(
196 {
197 "header_subject": subject,
198 "header_preview": preview or "",
199 },
200 )
201 )
203 # Render each block
204 for block in type(self)._merge_action_blocks(blocks):
205 match block:
206 case ParaBlock():
207 concats.append(self.para_block_template.render(asdict(block)))
208 case UserBlock():
209 concats.append(
210 self.user_block_template.render(
211 {
212 "name": block.info.name,
213 "age": block.info.age,
214 "city": block.info.city,
215 "avatar_url": block.info.avatar_url,
216 "comment": block.comment,
217 },
218 )
219 )
220 case QuoteBlock():
221 args = {"text": Markup(_markdown.render(block.text)) if block.markdown else block.text}
222 concats.append(self.quote_block_template.render(args))
223 case ActionBlock():
224 concats.append(self.action_block_template.render(asdict(block)))
225 case TwoButtonHTMLBlock(): 225 ↛ 227line 225 didn't jump to line 227 because the pattern on line 225 always matched
226 concats.append(self.two_buttons_block_template.render(asdict(block)))
227 case _:
228 raise TypeError(f"Unexpected email block type: {block.__class__}")
230 # Render the footer
231 footer_template_args = footer.to_template_args()
232 concats.append(self.footer_template.render(footer_template_args))
234 return "\n".join(concats)
236 @staticmethod
237 def _merge_action_blocks(blocks: list[EmailBlock]) -> list[EmailBlock]:
238 """Merge any two subsequent action blocks into a single two-button block."""
239 blocks = blocks.copy()
241 block_index = 0
242 while block_index + 1 < len(blocks):
243 block = blocks[block_index]
244 next_block = blocks[block_index + 1]
245 if isinstance(block, ActionBlock) and isinstance(next_block, ActionBlock):
246 blocks[block_index] = TwoButtonHTMLBlock(
247 target_url_1=block.target_url,
248 text_1=block.text,
249 target_url_2=next_block.target_url,
250 text_2=next_block.text,
251 )
252 blocks.pop(block_index + 1)
254 block_index += 1
256 return blocks
258 @cache
259 @staticmethod
260 def default() -> HTMLRenderer:
261 template = (template_folder / "generated_html" / "blocks.html").read_text(encoding="utf8")
262 return HTMLRenderer.from_template(template)
264 @staticmethod
265 def from_template(template: str) -> HTMLRenderer:
266 section_matches = list(_block_regex.finditer(template))
268 header_template = template[: section_matches[0].start()]
269 footer_template = template[section_matches[-1].end() :]
270 block_templates = {match.group("name"): match.group("snippet") for match in section_matches}
272 return HTMLRenderer(
273 header_template=Jinja2Template(source=header_template, html=True),
274 footer_template=Jinja2Template(source=footer_template, html=True),
275 para_block_template=Jinja2Template(source=block_templates["para"], html=True),
276 user_block_template=Jinja2Template(source=block_templates["user"], html=True),
277 quote_block_template=Jinja2Template(source=block_templates["quote"], html=True),
278 action_block_template=Jinja2Template(source=block_templates["action"], html=True),
279 two_buttons_block_template=Jinja2Template(source=block_templates["two-buttons"], html=True),
280 )