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 13:40 +0000

1""" 

2Renders blocks-based emails to HTML or plaintext emails for a locale. 

3""" 

4 

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 

12 

13from markdown_it import MarkdownIt 

14from markupsafe import Markup 

15 

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 

24 

25template_folder = Path(__file__).parent.parent.parent.parent / "templates" / "v2" 

26 

27_markdown = MarkdownIt("zero", {"typographer": True}).enable( 

28 ["smartquotes", "heading", "hr", "list", "link", "emphasis"] 

29) 

30 

31 

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] 

38 

39 

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) 

47 

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 ) 

52 

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 ) 

59 

60 return RenderedEmail( 

61 subject=subject, body_plaintext=body_plaintext, body_html=body_html, html_image_parts=related_parts 

62 ) 

63 

64 

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] = [] 

68 

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") 

77 

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 

102 

103 concat.append("\n\n") 

104 

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)) 

110 

111 return "".join(concat) 

112 

113 

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 

121 

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. 

124 

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 

128 

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 

134 

135 # We've handled tags but still have escapes like "&gt;", convert those to plaintext. 

136 return unescape(text) 

137 

138 

139def _get_footer_template_args(footer: EmailFooter, loc_context: LocalizationContext) -> dict[str, Any]: 

140 i18n = get_emails_i18next() 

141 

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) 

145 

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 } 

157 

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 ) 

170 

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") 

176 

177 return args 

178 

179 

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 ) 

192 

193 

194@dataclass(kw_only=True, slots=True) 

195class TwoButtonHTMLBlock(EmailBlock): 

196 """An HTML-only block used internally for rendering as side-by-side buttons.""" 

197 

198 text_1: str 

199 target_url_1: str 

200 text_2: str 

201 target_url_2: str 

202 

203 

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) 

213 

214 

215@dataclass 

216class HTMLRenderer: 

217 """Renders an email as HTML using template snippets for the header, footer and each block.""" 

218 

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 

226 

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] = [] 

237 

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 ) 

247 

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__}") 

275 

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)) 

279 

280 return "\n".join(concats) 

281 

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() 

286 

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) 

299 

300 block_index += 1 

301 

302 return blocks 

303 

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) 

309 

310 @staticmethod 

311 def from_template(template: str) -> HTMLRenderer: 

312 section_matches = list(_block_regex.finditer(template)) 

313 

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} 

317 

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 )