Coverage for app/backend/src/couchers/email/rendering.py: 95%
130 statements
« prev ^ index » next coverage.py v7.14.3, created at 2026-06-28 21:22 +0000
« prev ^ index » next coverage.py v7.14.3, created at 2026-06-28 21:22 +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 pathlib import Path
10from typing import Any
12from markupsafe import Markup
14from couchers.config import config
15from couchers.email.blocks import ActionBlock, EmailBase, EmailBlock, EmailFooter, ParaBlock, QuoteBlock, UserBlock
16from couchers.email.locales import get_emails_i18next
17from couchers.email.smtp import embed_html_relative_images
18from couchers.i18n import LocalizationContext
19from couchers.i18n.i18next import SubstitutionDict, full_string_key
20from couchers.markup import html_to_plaintext, markdown_to_html
21from couchers.proto.internal import jobs_pb2
22from couchers.templating import Jinja2Template
24template_folder = Path(__file__).parent.parent.parent.parent / "templates" / "v2"
27@dataclass(kw_only=True, slots=True)
28class RenderedEmail:
29 subject: str
30 body_plaintext: str
31 body_html: str
32 html_image_parts: list[jobs_pb2.EmailPart]
35def render_email(
36 email: EmailBase, footer: EmailFooter, loc_context: LocalizationContext, *, embed_images: bool = True
37) -> RenderedEmail:
38 """Renders an EmailBase object to subject and body strings."""
39 subject = email.get_subject_line(loc_context)
40 preview = email.get_preview_line(loc_context)
41 body_blocks = email.get_body_blocks(loc_context)
43 body_plaintext = render_plaintext_body(blocks=body_blocks, footer=footer, loc_context=loc_context)
44 body_html = render_html_body(
45 subject=subject, preview=preview, blocks=body_blocks, footer=footer, loc_context=loc_context
46 )
48 related_parts: list[jobs_pb2.EmailPart] = []
49 if embed_images:
50 content_id_domain = Address(addr_spec=config.NOTIFICATION_EMAIL_ADDRESS).domain
51 body_html, related_parts = embed_html_relative_images(
52 body_html, base_dir=template_folder, content_id_domain=content_id_domain
53 )
55 return RenderedEmail(
56 subject=subject, body_plaintext=body_plaintext, body_html=body_html, html_image_parts=related_parts
57 )
60def render_plaintext_body(*, blocks: list[EmailBlock], footer: EmailFooter, loc_context: LocalizationContext) -> str:
61 """Renders the body of an email as plaintext."""
62 concat: list[str] = []
64 previous_block: EmailBlock | None = None
65 for block in blocks:
66 # Blank line between every two blocks except subsequent actions.
67 if previous_block is not None:
68 if isinstance(block, ActionBlock) and isinstance(previous_block, ActionBlock):
69 concat.append("\n")
70 else:
71 concat.append("\n\n")
73 match block:
74 case ParaBlock():
75 concat.append(_to_plaintext(block.text))
76 case UserBlock():
77 line = get_emails_i18next().localize(
78 "plaintext_formats.user",
79 loc_context.locale,
80 {"name": block.info.name, "age": str(block.info.age), "city": block.info.city},
81 )
82 concat.append(line)
83 if block.comment:
84 concat.append("\n")
85 concat.append(_to_plaintext(block.comment))
86 case QuoteBlock():
87 for line in block.text.splitlines():
88 concat.append(f"> {line}")
89 case ActionBlock(): 89 ↛ 94line 89 didn't jump to line 94 because the pattern on line 89 always matched
90 line = get_emails_i18next().localize(
91 "plaintext_formats.action", loc_context.locale, {"text": block.text, "url": block.target_url}
92 )
93 concat.append(line)
94 case _:
95 raise TypeError(f"Unexpected email block type: {block.__class__}")
96 previous_block = block
98 concat.append("\n\n")
100 footer_template = Jinja2Template(
101 source=(template_folder / "_footer.txt").read_text(encoding="utf8").strip(), html=False
102 )
103 footer_template_args = _get_footer_template_args(footer, loc_context)
104 concat.append(footer_template.render(footer_template_args))
106 return "".join(concat)
109def _to_plaintext(text: str | Markup) -> str:
110 """
111 Converts any markup in its plaintext equivalent, allowing reuse of translations that have span-level markup
112 like <b> when formatting as plaintext email bodies.
113 """
114 if isinstance(text, Markup): 114 ↛ 117line 114 didn't jump to line 117 because the condition on line 114 was always true
115 return html_to_plaintext(text)
116 else:
117 return text
120def _get_footer_template_args(footer: EmailFooter, loc_context: LocalizationContext) -> dict[str, Any]:
121 i18n = get_emails_i18next()
123 def localize(key: str, substitutions: SubstitutionDict | None = None) -> Markup:
124 key = full_string_key(key, relative_base="generic.footer")
125 return i18n.localize_with_markup(key, loc_context.locale, substitutions)
127 args: dict[str, Any] = {
128 "received_because": localize(".received_because"),
129 "contact_support": localize(".contact_support"),
130 "timezone_note": localize(".timezone_note", {"timezone": footer.timezone_name}),
131 "copyright_year": footer.copyright_year,
132 "donate_link": localize(".donate_link"),
133 "volunteer_link": localize(".volunteer_link"),
134 "blog_link": localize(".blog_link"),
135 "nonprofit_note": localize(".nonprofit_note"),
136 "is_critical": footer.unsubscribe_info is None,
137 }
139 if unsubscribe_info := footer.unsubscribe_info:
140 # TODO(#7420): Localize "Turn off emails for: " text, avoiding string concatenations.
141 args.update(
142 {
143 "notification_settings_link": localize(".notification_settings_link"),
144 "manage_notifications_url": unsubscribe_info.manage_notifications_url,
145 "do_not_email_link": localize(".do_not_email_link"),
146 "do_not_email_url": unsubscribe_info.do_not_email_url,
147 "topic_action_description": unsubscribe_info.topic_action_link.text,
148 "unsubscribe_topic_action_url": unsubscribe_info.topic_action_link.url,
149 }
150 )
152 if topic_key_link := unsubscribe_info.topic_key_link:
153 args["topic_key_description"] = topic_key_link.text
154 args["unsubscribe_topic_key_url"] = topic_key_link.url
155 else:
156 args["security_email_note"] = localize(".security_email_note")
158 return args
161def render_html_body(
162 *,
163 subject: str,
164 preview: str | None,
165 blocks: list[EmailBlock],
166 footer: EmailFooter,
167 loc_context: LocalizationContext,
168) -> str:
169 """Renders the body of an email as HTML."""
170 return HTMLRenderer.default().render(
171 subject=subject, preview=preview, blocks=blocks, footer=footer, loc_context=loc_context
172 )
175@dataclass(kw_only=True, slots=True)
176class TwoButtonHTMLBlock(EmailBlock):
177 """An HTML-only block used internally for rendering as side-by-side buttons."""
179 text_1: str
180 target_url_1: str
181 text_2: str
182 target_url_2: str
185# Matches a begin-block / end-block pair of comments in the html file containing template
186_block_regex = re.compile(
187 r"""
188<!-- begin-block:(?P<name>[\w-]+) -->\s*
189(?P<snippet>[\s\S]*?)
190\s*<!-- end-block:(?P=name) -->
191""".strip(),
192 re.MULTILINE,
193)
196@dataclass
197class HTMLRenderer:
198 """Renders an email as HTML using template snippets for the header, footer and each block."""
200 header_template: Jinja2Template
201 footer_template: Jinja2Template
202 para_block_template: Jinja2Template
203 user_block_template: Jinja2Template
204 quote_block_template: Jinja2Template
205 action_block_template: Jinja2Template
206 two_buttons_block_template: Jinja2Template
208 def render(
209 self,
210 *,
211 subject: str,
212 preview: str | None,
213 blocks: list[EmailBlock],
214 footer: EmailFooter,
215 loc_context: LocalizationContext,
216 ) -> str:
217 concats: list[str] = []
219 # Render the header
220 concats.append(
221 self.header_template.render(
222 {
223 "header_subject": subject,
224 "header_preview": preview or "",
225 },
226 )
227 )
229 # Render each block
230 for block in type(self)._merge_action_blocks(blocks):
231 match block:
232 case ParaBlock():
233 concats.append(self.para_block_template.render(asdict(block)))
234 case UserBlock():
235 concats.append(
236 self.user_block_template.render(
237 {
238 "name": block.info.name,
239 "age": block.info.age,
240 "city": block.info.city,
241 "profile_url": block.info.profile_url,
242 "avatar_url": block.info.avatar_url,
243 "comment": block.comment,
244 },
245 )
246 )
247 case QuoteBlock():
248 args = {"text": Markup(markdown_to_html(block.text)) if block.markdown else block.text}
249 concats.append(self.quote_block_template.render(args))
250 case ActionBlock():
251 concats.append(self.action_block_template.render(asdict(block)))
252 case TwoButtonHTMLBlock(): 252 ↛ 254line 252 didn't jump to line 254 because the pattern on line 252 always matched
253 concats.append(self.two_buttons_block_template.render(asdict(block)))
254 case _:
255 raise TypeError(f"Unexpected email block type: {block.__class__}")
257 # Render the footer
258 footer_template_args = _get_footer_template_args(footer, loc_context)
259 concats.append(self.footer_template.render(footer_template_args))
261 return "\n".join(concats)
263 @staticmethod
264 def _merge_action_blocks(blocks: list[EmailBlock]) -> list[EmailBlock]:
265 """Merge any two subsequent action blocks into a single two-button block."""
266 blocks = blocks.copy()
268 block_index = 0
269 while block_index + 1 < len(blocks):
270 block = blocks[block_index]
271 next_block = blocks[block_index + 1]
272 if isinstance(block, ActionBlock) and isinstance(next_block, ActionBlock):
273 blocks[block_index] = TwoButtonHTMLBlock(
274 target_url_1=block.target_url,
275 text_1=block.text,
276 target_url_2=next_block.target_url,
277 text_2=next_block.text,
278 )
279 blocks.pop(block_index + 1)
281 block_index += 1
283 return blocks
285 @cache
286 @staticmethod
287 def default() -> HTMLRenderer:
288 template = (template_folder / "generated_html" / "blocks.html").read_text(encoding="utf8")
289 return HTMLRenderer.from_template(template)
291 @staticmethod
292 def from_template(template: str) -> HTMLRenderer:
293 section_matches = list(_block_regex.finditer(template))
295 header_template = template[: section_matches[0].start()]
296 footer_template = template[section_matches[-1].end() :]
297 block_templates = {match.group("name"): match.group("snippet") for match in section_matches}
299 return HTMLRenderer(
300 header_template=Jinja2Template(source=header_template, html=True),
301 footer_template=Jinja2Template(source=footer_template, html=True),
302 para_block_template=Jinja2Template(source=block_templates["para"], html=True),
303 user_block_template=Jinja2Template(source=block_templates["user"], html=True),
304 quote_block_template=Jinja2Template(source=block_templates["quote"], html=True),
305 action_block_template=Jinja2Template(source=block_templates["action"], html=True),
306 two_buttons_block_template=Jinja2Template(source=block_templates["two-buttons"], html=True),
307 )