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

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_mailto_link, 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 = 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 

99 

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

101 

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

107 

108 return "".join(concat) 

109 

110 

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 

120 

121 

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

123 i18n = get_emails_i18next() 

124 

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) 

128 

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 } 

140 

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 ) 

153 

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

159 

160 return args 

161 

162 

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 ) 

175 

176 

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

178class TwoButtonHTMLBlock(EmailBlock): 

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

180 

181 text_1: str 

182 target_url_1: str 

183 text_2: str 

184 target_url_2: str 

185 

186 

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) 

196 

197 

198@dataclass 

199class HTMLRenderer: 

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

201 

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 

209 

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

220 

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 ) 

230 

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

258 

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

262 

263 return "\n".join(concats) 

264 

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

269 

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) 

282 

283 block_index += 1 

284 

285 return blocks 

286 

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) 

292 

293 @staticmethod 

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

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

296 

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} 

300 

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 )