Coverage for app/backend/src/couchers/email/rendering.py: 97%

178 statements  

« prev     ^ index     » next       coverage.py v7.14.1, created at 2026-05-29 04:01 +0000

1""" 

2Renders HTML and plaintext emails out of well-known blocks. 

3""" 

4 

5import re 

6from dataclasses import asdict, dataclass 

7from functools import lru_cache 

8from html import unescape 

9from pathlib import Path 

10from typing import Any, Self 

11 

12from markupsafe import Markup 

13 

14from couchers import urls 

15from couchers.i18n import LocalizationContext 

16from couchers.i18n.i18next import I18Next, SubstitutionDict 

17from couchers.i18n.locales import load_locales 

18from couchers.proto import api_pb2 

19from couchers.templating import Jinja2Template, _markdown, template_folder 

20from couchers.utils import now 

21 

22 

23@dataclass 

24class EmailBlock: 

25 """Base class for building blocks of an email body, HTML/plaintext-agnostic.""" 

26 

27 pass 

28 

29 

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

31class ParaBlock(EmailBlock): 

32 """A paragraph of text which may contain span-level HTML.""" 

33 

34 text: str | Markup 

35 

36 

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

38class UserBlock(EmailBlock): 

39 """A banner with another user's profile information, for example preceding a quoted message.""" 

40 

41 info: UserInfo 

42 comment: str | Markup | None 

43 

44 

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

46class UserInfo: 

47 name: str 

48 age: int 

49 city: str 

50 avatar_url: str 

51 profile_url: str 

52 

53 @classmethod 

54 def from_protobuf(cls, user: api_pb2.User) -> Self: 

55 return cls( 

56 name=user.name, 

57 age=user.age, 

58 city=user.city, 

59 avatar_url=user.avatar_thumbnail_url or urls.icon_url(), 

60 profile_url=urls.user_link(username=user.username), 

61 ) 

62 

63 @staticmethod 

64 def dummy_bob() -> UserInfo: 

65 return UserInfo( 

66 name="Bob", 

67 age=30, 

68 city="Berlin", 

69 avatar_url="https://couchers.org/img/icon.png", 

70 profile_url="https://couchers.org/user/bob", 

71 ) 

72 

73 

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

75class QuoteBlock(EmailBlock): 

76 """A quoted message, typically from another user. Either plaintext or markdown.""" 

77 

78 text: str 

79 markdown: bool 

80 

81 

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

83class ActionBlock(EmailBlock): 

84 """An action that can be performed by the user in response to the email.""" 

85 

86 text: str 

87 target_url: str 

88 

89 

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

91class TwoButtonHTMLBlock(EmailBlock): 

92 """An HTML-only block for rendering as side-by-side buttons.""" 

93 

94 text_1: str 

95 target_url_1: str 

96 text_2: str 

97 target_url_2: str 

98 

99 

100class EmailBlocksBuilder: 

101 """ 

102 Builder object for constructing a list of EmailBlock's to form the body of an email. 

103 """ 

104 

105 _locale: str 

106 _string_key_prefix: str 

107 blocks: list[EmailBlock] 

108 

109 def __init__(self, locale: str, string_key_prefix: str): 

110 self.blocks = [] 

111 self._locale = locale 

112 self._string_key_prefix = string_key_prefix 

113 

114 def para(self, key: str, substitutions: SubstitutionDict | None = None) -> Self: 

115 return self.block(ParaBlock(text=self._markup(key, substitutions))) 

116 

117 def quote(self, text: str, *, markdown: bool) -> Self: 

118 return self.block(QuoteBlock(text=text, markdown=markdown)) 

119 

120 def user( 

121 self, 

122 info: UserInfo, 

123 comment_key: str | None = None, 

124 substitutions: SubstitutionDict | None = None, 

125 ) -> Self: 

126 comment = self._markup(comment_key, substitutions) if comment_key else None 

127 return self.block(UserBlock(info=info, comment=comment)) 

128 

129 def action(self, url: str, text_key: str, substitutions: SubstitutionDict | None = None) -> Self: 

130 return self.block(ActionBlock(text=self._text(text_key, substitutions), target_url=url)) 

131 

132 def do_not_reply_request_para(self) -> Self: 

133 line = get_emails_i18next().localize_with_markup("generic.do_not_reply_request", self._locale) 

134 return self.block(ParaBlock(text=line)) 

135 

136 def security_warning_para(self) -> Self: 

137 line = get_emails_i18next().localize_with_markup("generic.security_warning_contact_support", self._locale) 

138 return self.block(ParaBlock(text=line)) 

139 

140 def block(self, block: EmailBlock) -> Self: 

141 self.blocks.append(block) 

142 return self 

143 

144 def _text(self, key: str, substitutions: SubstitutionDict | None = None) -> str: 

145 full_key = self._to_full_string_key(key) 

146 return get_emails_i18next().localize(full_key, self._locale, substitutions) 

147 

148 def _markup(self, key: str, substitutions: SubstitutionDict | None = None) -> Markup: 

149 full_key = self._to_full_string_key(key) 

150 return get_emails_i18next().localize_with_markup(full_key, self._locale, substitutions) 

151 

152 def _to_full_string_key(self, key: str) -> str: 

153 # It's convenient to have a default key prefix, 

154 # but sometimes we need to access something outside of it. 

155 if key.startswith("."): # Escape hatch. Think rooted '/dir/file' paths in unix. 

156 return key[1:] 

157 else: 

158 return f"{self._string_key_prefix}.{key}" 

159 

160 

161@dataclass(kw_only=True) 

162class EmailFooter: 

163 timezone_name: str 

164 copyright_year: int = now().year 

165 unsubscribe_info: UnsubscribeInfo | None 

166 

167 def to_template_args(self) -> dict[str, Any]: 

168 args: dict[str, Any] = { 

169 "footer_timezone_name": self.timezone_name, 

170 "footer_copyright_year": self.copyright_year, 

171 "footer_email_is_critical": self.unsubscribe_info is None, 

172 } 

173 

174 if unsubscribe_info := self.unsubscribe_info: 

175 args.update(unsubscribe_info.to_template_args()) 

176 

177 return args 

178 

179 

180@dataclass(kw_only=True) 

181class UnsubscribeInfo: 

182 manage_notifications_url: str 

183 do_not_email_url: str 

184 topic_action_link: UnsubscribeLink 

185 topic_key_link: UnsubscribeLink | None = None 

186 

187 def to_template_args(self) -> dict[str, Any]: 

188 args: dict[str, Any] = { 

189 "footer_manage_notifications_link": self.manage_notifications_url, 

190 "footer_do_not_email_link": self.do_not_email_url, 

191 "footer_notification_topic_action": self.topic_action_link.text, 

192 "footer_notification_topic_action_link": self.topic_action_link.url, 

193 } 

194 

195 if topic_key_link := self.topic_key_link: 

196 args["footer_notification_topic_key"] = topic_key_link.text 

197 args["footer_notification_topic_key_link"] = topic_key_link.url 

198 

199 return args 

200 

201 

202@dataclass(kw_only=True) 

203class UnsubscribeLink: 

204 text: str 

205 url: str 

206 

207 

208@lru_cache(maxsize=1) 

209def get_emails_i18next() -> I18Next: 

210 return load_locales(Path(__file__).parent / "locales") 

211 

212 

213def render_html_body( 

214 *, 

215 subject: str, 

216 preview: str | None, 

217 blocks: list[EmailBlock], 

218 footer: EmailFooter, 

219 loc_context: LocalizationContext, 

220) -> str: 

221 """Renders the body of an email as HTML.""" 

222 return HTMLRenderer.default().render( 

223 subject=subject, preview=preview, blocks=blocks, footer=footer, loc_context=loc_context 

224 ) 

225 

226 

227def render_plaintext_body(*, blocks: list[EmailBlock], footer: EmailFooter, loc_context: LocalizationContext) -> str: 

228 """Renders the body of an email as plaintext.""" 

229 concat: list[str] = [] 

230 

231 previous_block: EmailBlock | None = None 

232 for block in blocks: 

233 # Blank line between every two blocks except subsequent actions. 

234 if previous_block is not None: 

235 if isinstance(block, ActionBlock) and isinstance(previous_block, ActionBlock): 

236 concat.append("\n") 

237 else: 

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

239 

240 match block: 

241 case ParaBlock(): 

242 concat.append(_to_plaintext(block.text)) 

243 case UserBlock(): 

244 line = get_emails_i18next().localize( 

245 "plaintext_formats.user", 

246 loc_context.locale, 

247 {"name": block.info.name, "age": str(block.info.age), "city": block.info.city}, 

248 ) 

249 concat.append(line) 

250 if block.comment: 

251 concat.append("\n") 

252 concat.append(_to_plaintext(block.comment)) 

253 case QuoteBlock(): 

254 for line in block.text.splitlines(): 

255 concat.append(f"> {line}") 

256 case ActionBlock(): 256 ↛ 261line 256 didn't jump to line 261 because the pattern on line 256 always matched

257 line = get_emails_i18next().localize( 

258 "plaintext_formats.action", loc_context.locale, {"text": block.text, "url": block.target_url} 

259 ) 

260 concat.append(line) 

261 case _: 

262 raise TypeError(f"Unexpected email block type: {block.__class__}") 

263 previous_block = block 

264 

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

266 

267 footer_template = Jinja2Template( 

268 source=(template_folder / "_footer.txt").read_text(encoding="utf8").strip(), html=False 

269 ) 

270 footer_template_args = footer.to_template_args() 

271 return "".join(concat) + footer_template.render(footer_template_args, loc_context) 

272 

273 

274def _to_plaintext(text: str | Markup) -> str: 

275 """ 

276 Converts any markup in its plaintext equivalent, allowing reuse of translations that have span-level markup 

277 like <b> when formatting as plaintext email bodies. 

278 """ 

279 if not isinstance(text, Markup): # Markup derives from str so can't test for isinstance(, str) 

280 return text 

281 

282 # Convert markup to its plaintext equivalent. 

283 # This code is not security-sensitive since we're producing a plaintext string where markup will not be evaluated. 

284 

285 # Strip/convert any markup since we can't render it in plaintext. 

286 text = text.replace("\n", "") # Newlines are irrelevant in markup 

287 text = re.sub(r"<br\s*/?>", "\n", text) # But <br>'s should be newlines in plaintext 

288 

289 # Keep the content of span-level markup (assume no nesting) 

290 text = re.sub( 

291 r"<(?P<name>\w+)(?P<attrs>[^>]*)>(?P<inner>.*?)</(?P=name)>", lambda match: match.group("inner"), text 

292 ) 

293 text = re.sub(r"<\w+[^/>]*/>", "", text) # Remove any other self-closing tag 

294 

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

296 return unescape(text) 

297 

298 

299@dataclass 

300class HTMLRenderer: 

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

302 

303 header_template: Jinja2Template 

304 footer_template: Jinja2Template 

305 para_block_template: Jinja2Template 

306 user_block_template: Jinja2Template 

307 quote_block_template: Jinja2Template 

308 action_block_template: Jinja2Template 

309 two_buttons_block_template: Jinja2Template 

310 

311 def render( 

312 self, 

313 *, 

314 subject: str, 

315 preview: str | None, 

316 blocks: list[EmailBlock], 

317 footer: EmailFooter, 

318 loc_context: LocalizationContext, 

319 ) -> str: 

320 concats: list[str] = [] 

321 

322 # Render the header 

323 concats.append( 

324 self.header_template.render( 

325 { 

326 "header_subject": subject, 

327 "header_preview": preview or "", 

328 }, 

329 loc_context, 

330 ) 

331 ) 

332 

333 # Render each block 

334 for block in type(self)._merge_action_blocks(blocks): 

335 match block: 

336 case ParaBlock(): 

337 concats.append(self.para_block_template.render(asdict(block), loc_context)) 

338 case UserBlock(): 

339 concats.append( 

340 self.user_block_template.render( 

341 { 

342 "name": block.info.name, 

343 "age": block.info.age, 

344 "city": block.info.city, 

345 "avatar_url": block.info.avatar_url, 

346 "comment": block.comment, 

347 }, 

348 loc_context, 

349 ) 

350 ) 

351 case QuoteBlock(): 

352 args = {"text": Markup(_markdown.render(block.text)) if block.markdown else block.text} 

353 concats.append(self.quote_block_template.render(args, loc_context)) 

354 case ActionBlock(): 

355 concats.append(self.action_block_template.render(asdict(block), loc_context)) 

356 case TwoButtonHTMLBlock(): 356 ↛ 358line 356 didn't jump to line 358 because the pattern on line 356 always matched

357 concats.append(self.two_buttons_block_template.render(asdict(block), loc_context)) 

358 case _: 

359 raise TypeError(f"Unexpected email block type: {block.__class__}") 

360 

361 # Render the footer 

362 footer_template_args = footer.to_template_args() 

363 concats.append(self.footer_template.render(footer_template_args, loc_context)) 

364 

365 return "\n".join(concats) 

366 

367 @staticmethod 

368 def _merge_action_blocks(blocks: list[EmailBlock]) -> list[EmailBlock]: 

369 """Merge any two subsequent action blocks into a single two-button block.""" 

370 blocks = blocks.copy() 

371 

372 block_index = 0 

373 while block_index + 1 < len(blocks): 

374 block = blocks[block_index] 

375 next_block = blocks[block_index + 1] 

376 if isinstance(block, ActionBlock) and isinstance(next_block, ActionBlock): 

377 blocks[block_index] = TwoButtonHTMLBlock( 

378 target_url_1=block.target_url, 

379 text_1=block.text, 

380 target_url_2=next_block.target_url, 

381 text_2=next_block.text, 

382 ) 

383 blocks.pop(block_index + 1) 

384 

385 block_index += 1 

386 

387 return blocks 

388 

389 @lru_cache(maxsize=1) 

390 @staticmethod 

391 def default() -> HTMLRenderer: 

392 template = (template_folder / "generated_html" / "blocks.html").read_text(encoding="utf8") 

393 return HTMLRenderer.from_template(template) 

394 

395 @staticmethod 

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

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

398 

399 header_template = template[: section_matches[0].start()] 

400 footer_template = template[section_matches[-1].end() :] 

401 block_templates = {match.group("name"): match.group("snippet") for match in section_matches} 

402 

403 return HTMLRenderer( 

404 header_template=Jinja2Template(source=header_template, html=True), 

405 footer_template=Jinja2Template(source=footer_template, html=True), 

406 para_block_template=Jinja2Template(source=block_templates["para"], html=True), 

407 user_block_template=Jinja2Template(source=block_templates["user"], html=True), 

408 quote_block_template=Jinja2Template(source=block_templates["quote"], html=True), 

409 action_block_template=Jinja2Template(source=block_templates["action"], html=True), 

410 two_buttons_block_template=Jinja2Template(source=block_templates["two-buttons"], html=True), 

411 ) 

412 

413 

414# Matches a begin-block / end-block pair of comments in the html file containing template blocks. 

415_block_regex = re.compile( 

416 r""" 

417<!-- begin-block:(?P<name>[\w-]+) -->\s* 

418(?P<snippet>[\s\S]*?) 

419\s*<!-- end-block:(?P=name) --> 

420""".strip(), 

421 re.MULTILINE, 

422)