Coverage for app/backend/src/couchers/email/rendering.py: 95%
130 statements
« prev ^ index » next coverage.py v7.15.1, created at 2026-07-14 20:06 +0000
« prev ^ index » next coverage.py v7.15.1, created at 2026-07-14 20:06 +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_mailto_link, 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 = loc_context.localize_string(
78 "plaintext_formats.user",
79 i18next=get_emails_i18next(),
80 substitutions={"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 ↛ 96line 89 didn't jump to line 96 because the pattern on line 89 always matched
90 line = loc_context.localize_string(
91 "plaintext_formats.action",
92 i18next=get_emails_i18next(),
93 substitutions={"text": block.text, "url": block.target_url},
94 )
95 concat.append(line)
96 case _:
97 raise TypeError(f"Unexpected email block type: {block.__class__}")
98 previous_block = block
100 concat.append("\n\n")
102 footer_template = Jinja2Template(
103 source=(template_folder / "_footer.txt").read_text(encoding="utf8").strip(), html=False
104 )
105 footer_template_args = _get_footer_template_args(footer, loc_context)
106 concat.append(footer_template.render(footer_template_args))
108 return "".join(concat)
111def _to_plaintext(text: str | Markup) -> str:
112 """
113 Converts any markup in its plaintext equivalent, allowing reuse of translations that have span-level markup
114 like <b> when formatting as plaintext email bodies.
115 """
116 if isinstance(text, Markup): 116 ↛ 119line 116 didn't jump to line 119 because the condition on line 116 was always true
117 return html_to_plaintext(text)
118 else:
119 return text
122def _get_footer_template_args(footer: EmailFooter, loc_context: LocalizationContext) -> dict[str, Any]:
123 i18n = get_emails_i18next()
125 def localize(key: str, substitutions: SubstitutionDict | None = None) -> Markup:
126 key = full_string_key(key, relative_base="generic.footer")
127 return i18n.localize_with_markup(key, loc_context.locale_list, substitutions)
129 args: dict[str, Any] = {
130 "received_because": localize(".received_because"),
131 "contact_support": localize(".contact_support", {"email_link": html_mailto_link("support@couchers.org")}),
132 "timezone_note": localize(".timezone_note", {"timezone": footer.timezone_name}),
133 "copyright_year": footer.copyright_year,
134 "donate_link": localize(".donate_link"),
135 "volunteer_link": localize(".volunteer_link"),
136 "blog_link": localize(".blog_link"),
137 "nonprofit_note": localize(".nonprofit_note"),
138 "is_critical": footer.unsubscribe_info is None,
139 }
141 if unsubscribe_info := footer.unsubscribe_info:
142 # TODO(#7420): Localize "Turn off emails for: " text, avoiding string concatenations.
143 args.update(
144 {
145 "notification_settings_link": localize(".notification_settings_link"),
146 "manage_notifications_url": unsubscribe_info.manage_notifications_url,
147 "do_not_email_link": localize(".do_not_email_link"),
148 "do_not_email_url": unsubscribe_info.do_not_email_url,
149 "topic_action_description": unsubscribe_info.topic_action_link.text,
150 "unsubscribe_topic_action_url": unsubscribe_info.topic_action_link.url,
151 }
152 )
154 if topic_key_link := unsubscribe_info.topic_key_link:
155 args["topic_key_description"] = topic_key_link.text
156 args["unsubscribe_topic_key_url"] = topic_key_link.url
157 else:
158 args["security_email_note"] = localize(".security_email_note")
160 return args
163def render_html_body(
164 *,
165 subject: str,
166 preview: str | None,
167 blocks: list[EmailBlock],
168 footer: EmailFooter,
169 loc_context: LocalizationContext,
170) -> str:
171 """Renders the body of an email as HTML."""
172 return HTMLRenderer.default().render(
173 subject=subject, preview=preview, blocks=blocks, footer=footer, loc_context=loc_context
174 )
177@dataclass(kw_only=True, slots=True)
178class TwoButtonHTMLBlock(EmailBlock):
179 """An HTML-only block used internally for rendering as side-by-side buttons."""
181 text_1: str
182 target_url_1: str
183 text_2: str
184 target_url_2: str
187# Matches a begin-block / end-block pair of comments in the html file containing template
188_block_regex = re.compile(
189 r"""
190<!-- begin-block:(?P<name>[\w-]+) -->\s*
191(?P<snippet>[\s\S]*?)
192\s*<!-- end-block:(?P=name) -->
193""".strip(),
194 re.MULTILINE,
195)
198@dataclass
199class HTMLRenderer:
200 """Renders an email as HTML using template snippets for the header, footer and each block."""
202 header_template: Jinja2Template
203 footer_template: Jinja2Template
204 para_block_template: Jinja2Template
205 user_block_template: Jinja2Template
206 quote_block_template: Jinja2Template
207 action_block_template: Jinja2Template
208 two_buttons_block_template: Jinja2Template
210 def render(
211 self,
212 *,
213 subject: str,
214 preview: str | None,
215 blocks: list[EmailBlock],
216 footer: EmailFooter,
217 loc_context: LocalizationContext,
218 ) -> str:
219 concats: list[str] = []
221 # Render the header
222 concats.append(
223 self.header_template.render(
224 {
225 "header_subject": subject,
226 "header_preview": preview or "",
227 },
228 )
229 )
231 # Render each block
232 for block in type(self)._merge_action_blocks(blocks):
233 match block:
234 case ParaBlock():
235 concats.append(self.para_block_template.render(asdict(block)))
236 case UserBlock():
237 concats.append(
238 self.user_block_template.render(
239 {
240 "name": block.info.name,
241 "age": block.info.age,
242 "city": block.info.city,
243 "profile_url": block.info.profile_url,
244 "avatar_url": block.info.avatar_url,
245 "comment": block.comment,
246 },
247 )
248 )
249 case QuoteBlock():
250 args = {"text": Markup(markdown_to_html(block.text)) if block.markdown else block.text}
251 concats.append(self.quote_block_template.render(args))
252 case ActionBlock():
253 concats.append(self.action_block_template.render(asdict(block)))
254 case TwoButtonHTMLBlock(): 254 ↛ 256line 254 didn't jump to line 256 because the pattern on line 254 always matched
255 concats.append(self.two_buttons_block_template.render(asdict(block)))
256 case _:
257 raise TypeError(f"Unexpected email block type: {block.__class__}")
259 # Render the footer
260 footer_template_args = _get_footer_template_args(footer, loc_context)
261 concats.append(self.footer_template.render(footer_template_args))
263 return "\n".join(concats)
265 @staticmethod
266 def _merge_action_blocks(blocks: list[EmailBlock]) -> list[EmailBlock]:
267 """Merge any two subsequent action blocks into a single two-button block."""
268 blocks = blocks.copy()
270 block_index = 0
271 while block_index + 1 < len(blocks):
272 block = blocks[block_index]
273 next_block = blocks[block_index + 1]
274 if isinstance(block, ActionBlock) and isinstance(next_block, ActionBlock):
275 blocks[block_index] = TwoButtonHTMLBlock(
276 target_url_1=block.target_url,
277 text_1=block.text,
278 target_url_2=next_block.target_url,
279 text_2=next_block.text,
280 )
281 blocks.pop(block_index + 1)
283 block_index += 1
285 return blocks
287 @cache
288 @staticmethod
289 def default() -> HTMLRenderer:
290 template = (template_folder / "generated_html" / "blocks.html").read_text(encoding="utf8")
291 return HTMLRenderer.from_template(template)
293 @staticmethod
294 def from_template(template: str) -> HTMLRenderer:
295 section_matches = list(_block_regex.finditer(template))
297 header_template = template[: section_matches[0].start()]
298 footer_template = template[section_matches[-1].end() :]
299 block_templates = {match.group("name"): match.group("snippet") for match in section_matches}
301 return HTMLRenderer(
302 header_template=Jinja2Template(source=header_template, html=True),
303 footer_template=Jinja2Template(source=footer_template, html=True),
304 para_block_template=Jinja2Template(source=block_templates["para"], html=True),
305 user_block_template=Jinja2Template(source=block_templates["user"], html=True),
306 quote_block_template=Jinja2Template(source=block_templates["quote"], html=True),
307 action_block_template=Jinja2Template(source=block_templates["action"], html=True),
308 two_buttons_block_template=Jinja2Template(source=block_templates["two-buttons"], html=True),
309 )