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

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 pathlib import Path 

10from typing import Any 

11 

12from markupsafe import Markup 

13 

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 

23 

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

25 

26 

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] 

33 

34 

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) 

42 

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 ) 

47 

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 ) 

54 

55 return RenderedEmail( 

56 subject=subject, body_plaintext=body_plaintext, body_html=body_html, html_image_parts=related_parts 

57 ) 

58 

59 

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

63 

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

72 

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 

97 

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

99 

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

105 

106 return "".join(concat) 

107 

108 

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 

118 

119 

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

121 i18n = get_emails_i18next() 

122 

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) 

126 

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 } 

138 

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 ) 

151 

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

157 

158 return args 

159 

160 

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 ) 

173 

174 

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

176class TwoButtonHTMLBlock(EmailBlock): 

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

178 

179 text_1: str 

180 target_url_1: str 

181 text_2: str 

182 target_url_2: str 

183 

184 

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) 

194 

195 

196@dataclass 

197class HTMLRenderer: 

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

199 

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 

207 

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

218 

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 ) 

228 

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

256 

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

260 

261 return "\n".join(concats) 

262 

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

267 

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) 

280 

281 block_index += 1 

282 

283 return blocks 

284 

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) 

290 

291 @staticmethod 

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

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

294 

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} 

298 

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 )