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

120 statements  

« prev     ^ index     » next       coverage.py v7.14.2, created at 2026-06-22 15: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 html import unescape 

10from pathlib import Path 

11 

12from markdown_it import MarkdownIt 

13from markupsafe import Markup 

14 

15from couchers.config import config 

16from couchers.email.blocks import ActionBlock, EmailBase, EmailBlock, EmailFooter, ParaBlock, QuoteBlock, UserBlock 

17from couchers.email.locales import get_emails_i18next 

18from couchers.email.smtp import embed_html_relative_images 

19from couchers.i18n import LocalizationContext 

20from couchers.proto.internal import jobs_pb2 

21from couchers.templating import Jinja2Template 

22 

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

24 

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

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

27) 

28 

29 

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

31class RenderedEmail: 

32 subject: str 

33 body_plaintext: str 

34 body_html: str 

35 html_image_parts: list[jobs_pb2.EmailPart] 

36 

37 

38def render_email( 

39 email: EmailBase, footer: EmailFooter, loc_context: LocalizationContext, *, embed_images: bool = True 

40) -> RenderedEmail: 

41 """Renders an EmailBase object to subject and body strings.""" 

42 subject = email.get_subject_line(loc_context) 

43 preview = email.get_preview_line(loc_context) 

44 body_blocks = email.get_body_blocks(loc_context) 

45 

46 body_plaintext = render_plaintext_body(blocks=body_blocks, footer=footer, loc_context=loc_context) 

47 body_html = render_html_body( 

48 subject=subject, preview=preview, blocks=body_blocks, footer=footer, loc_context=loc_context 

49 ) 

50 

51 related_parts: list[jobs_pb2.EmailPart] = [] 

52 if embed_images: 

53 content_id_domain = Address(addr_spec=config.NOTIFICATION_EMAIL_ADDRESS).domain 

54 body_html, related_parts = embed_html_relative_images( 

55 body_html, base_dir=template_folder, content_id_domain=content_id_domain 

56 ) 

57 

58 return RenderedEmail( 

59 subject=subject, body_plaintext=body_plaintext, body_html=body_html, html_image_parts=related_parts 

60 ) 

61 

62 

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

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

65 concat: list[str] = [] 

66 

67 previous_block: EmailBlock | None = None 

68 for block in blocks: 

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

70 if previous_block is not None: 

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

72 concat.append("\n") 

73 else: 

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

75 

76 match block: 

77 case ParaBlock(): 

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

79 case UserBlock(): 

80 line = get_emails_i18next().localize( 

81 "plaintext_formats.user", 

82 loc_context.locale, 

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

84 ) 

85 concat.append(line) 

86 if block.comment: 

87 concat.append("\n") 

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

89 case QuoteBlock(): 

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

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

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

93 line = get_emails_i18next().localize( 

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

95 ) 

96 concat.append(line) 

97 case _: 

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

99 previous_block = block 

100 

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

102 

103 footer_template = Jinja2Template( 

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

105 ) 

106 footer_template_args = footer.to_template_args() 

107 return "".join(concat) + footer_template.render(footer_template_args) 

108 

109 

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

111 """ 

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

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

114 """ 

115 if not isinstance(text, Markup): # Markup derives from str so can't test for isinstance(, str) 115 ↛ 116line 115 didn't jump to line 116 because the condition on line 115 was never true

116 return text 

117 

118 # Convert markup to its plaintext equivalent. 

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

120 

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

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

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

124 

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

126 text = re.sub( 

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

128 ) 

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

130 

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

132 return unescape(text) 

133 

134 

135def render_html_body( 

136 *, 

137 subject: str, 

138 preview: str | None, 

139 blocks: list[EmailBlock], 

140 footer: EmailFooter, 

141 loc_context: LocalizationContext, 

142) -> str: 

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

144 return HTMLRenderer.default().render( 

145 subject=subject, preview=preview, blocks=blocks, footer=footer, loc_context=loc_context 

146 ) 

147 

148 

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

150class TwoButtonHTMLBlock(EmailBlock): 

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

152 

153 text_1: str 

154 target_url_1: str 

155 text_2: str 

156 target_url_2: str 

157 

158 

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

160_block_regex = re.compile( 

161 r""" 

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

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

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

165""".strip(), 

166 re.MULTILINE, 

167) 

168 

169 

170@dataclass 

171class HTMLRenderer: 

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

173 

174 header_template: Jinja2Template 

175 footer_template: Jinja2Template 

176 para_block_template: Jinja2Template 

177 user_block_template: Jinja2Template 

178 quote_block_template: Jinja2Template 

179 action_block_template: Jinja2Template 

180 two_buttons_block_template: Jinja2Template 

181 

182 def render( 

183 self, 

184 *, 

185 subject: str, 

186 preview: str | None, 

187 blocks: list[EmailBlock], 

188 footer: EmailFooter, 

189 loc_context: LocalizationContext, 

190 ) -> str: 

191 concats: list[str] = [] 

192 

193 # Render the header 

194 concats.append( 

195 self.header_template.render( 

196 { 

197 "header_subject": subject, 

198 "header_preview": preview or "", 

199 }, 

200 ) 

201 ) 

202 

203 # Render each block 

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

205 match block: 

206 case ParaBlock(): 

207 concats.append(self.para_block_template.render(asdict(block))) 

208 case UserBlock(): 

209 concats.append( 

210 self.user_block_template.render( 

211 { 

212 "name": block.info.name, 

213 "age": block.info.age, 

214 "city": block.info.city, 

215 "avatar_url": block.info.avatar_url, 

216 "comment": block.comment, 

217 }, 

218 ) 

219 ) 

220 case QuoteBlock(): 

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

222 concats.append(self.quote_block_template.render(args)) 

223 case ActionBlock(): 

224 concats.append(self.action_block_template.render(asdict(block))) 

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

226 concats.append(self.two_buttons_block_template.render(asdict(block))) 

227 case _: 

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

229 

230 # Render the footer 

231 footer_template_args = footer.to_template_args() 

232 concats.append(self.footer_template.render(footer_template_args)) 

233 

234 return "\n".join(concats) 

235 

236 @staticmethod 

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

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

239 blocks = blocks.copy() 

240 

241 block_index = 0 

242 while block_index + 1 < len(blocks): 

243 block = blocks[block_index] 

244 next_block = blocks[block_index + 1] 

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

246 blocks[block_index] = TwoButtonHTMLBlock( 

247 target_url_1=block.target_url, 

248 text_1=block.text, 

249 target_url_2=next_block.target_url, 

250 text_2=next_block.text, 

251 ) 

252 blocks.pop(block_index + 1) 

253 

254 block_index += 1 

255 

256 return blocks 

257 

258 @cache 

259 @staticmethod 

260 def default() -> HTMLRenderer: 

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

262 return HTMLRenderer.from_template(template) 

263 

264 @staticmethod 

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

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

267 

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

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

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

271 

272 return HTMLRenderer( 

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

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

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

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

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

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

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

280 )