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

121 statements  

« prev     ^ index     » next       coverage.py v7.13.4, created at 2026-02-24 17:34 +0000

1""" 

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

3""" 

4 

5import re 

6from dataclasses import dataclass 

7from functools import lru_cache 

8from html import unescape 

9from pathlib import Path 

10from typing import Any 

11 

12from markupsafe import Markup 

13 

14from couchers.i18n import LocalizationContext 

15from couchers.i18n.i18next import I18Next 

16from couchers.i18n.locales import load_locales 

17from couchers.templating import Jinja2Template, template_folder 

18from couchers.utils import now 

19 

20 

21@dataclass 

22class EmailBlock: 

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

24 

25 pass 

26 

27 

28@dataclass(kw_only=True) 

29class ParaBlock(EmailBlock): 

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

31 

32 text: str | Markup 

33 

34 

35@dataclass(kw_only=True) 

36class UserBlock(EmailBlock): 

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

38 

39 info: UserInfo 

40 comment: str | Markup | None 

41 

42 

43@dataclass(kw_only=True) 

44class UserInfo: 

45 name: str 

46 age: int 

47 city: str 

48 avatar_url: str 

49 profile_url: str 

50 

51 

52@dataclass(kw_only=True) 

53class QuoteBlock(EmailBlock): 

54 """A quoted message from another user. May not contain markup.""" 

55 

56 text: str 

57 

58 

59@dataclass(kw_only=True) 

60class ActionBlock(EmailBlock): 

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

62 

63 text: str 

64 target_url: str 

65 

66 

67@dataclass(kw_only=True) 

68class EmailFooter: 

69 copyright_year: int = now().year 

70 unsubscribe_info: UnsubscribeInfo | None 

71 

72 

73@dataclass(kw_only=True) 

74class UnsubscribeInfo: 

75 manage_notifications_url: str 

76 do_not_email_url: str 

77 topic_action_link: UnsubscribeLink 

78 topic_key_link: UnsubscribeLink | None = None 

79 

80 

81@dataclass(kw_only=True) 

82class UnsubscribeLink: 

83 text: str 

84 url: str 

85 

86 

87@lru_cache(maxsize=1) 

88def get_emails_i18next() -> I18Next: 

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

90 

91 

92def render_html_body( 

93 *, 

94 subject: str, 

95 preview: str | None, 

96 blocks: list[EmailBlock], 

97 footer: EmailFooter, 

98 loc_context: LocalizationContext, 

99) -> str: 

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

101 return HTMLRenderer.default().render( 

102 subject=subject, preview=preview, blocks=blocks, footer=footer, loc_context=loc_context 

103 ) 

104 

105 

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

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

108 concat: list[str] = [] 

109 

110 previous_block: EmailBlock | None = None 

111 for block in blocks: 

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

113 if previous_block is not None: 

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

115 concat.append("\n") 

116 else: 

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

118 

119 match block: 

120 case ParaBlock(): 

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

122 case UserBlock(): 

123 line = get_emails_i18next().localize( 

124 "plaintext_formats.user", 

125 loc_context.locale, 

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

127 ) 

128 concat.append(line) 

129 if block.comment: 

130 concat.append("\n") 

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

132 case QuoteBlock(): 

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

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

135 case ActionBlock(): 

136 line = get_emails_i18next().localize( 

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

138 ) 

139 concat.append(line) 

140 case _: 

141 raise AssertionError(f"Unexpected email block type: {block.__class__}") 

142 previous_block = block 

143 

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

145 

146 footer_template = Jinja2Template( 

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

148 ) 

149 footer_template_args = _get_footer_template_args(footer) 

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

151 

152 

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

154 """ 

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

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

157 """ 

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

159 return text 

160 

161 # Convert markup to its plaintext equivalent. 

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

163 

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

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

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

167 

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

169 text = re.sub( 

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

171 ) 

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

173 

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

175 return unescape(text) 

176 

177 

178def _get_footer_template_args(footer: EmailFooter) -> dict[str, Any]: 

179 args: dict[str, Any] = {} 

180 args["footer_copyright_year"] = footer.copyright_year 

181 

182 if unsubscribe_info := footer.unsubscribe_info: 

183 args["footer_manage_notifications_link"] = unsubscribe_info.manage_notifications_url 

184 args["footer_do_not_email_link"] = unsubscribe_info.do_not_email_url 

185 args["footer_notification_topic_action"] = unsubscribe_info.topic_action_link.text 

186 args["footer_notification_topic_action_link"] = unsubscribe_info.topic_action_link.url 

187 

188 if topic_key_link := unsubscribe_info.topic_key_link: 

189 args["footer_notification_topic_key"] = topic_key_link.text 

190 args["footer_notification_topic_key_link"] = topic_key_link.url 

191 

192 return args 

193 

194 

195@dataclass 

196class HTMLRenderer: 

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

198 

199 header_template: Jinja2Template 

200 footer_template: Jinja2Template 

201 para_block_template: Jinja2Template 

202 user_block_template: Jinja2Template 

203 quote_block_template: Jinja2Template 

204 action_block_template: Jinja2Template 

205 

206 def render( 

207 self, 

208 *, 

209 subject: str, 

210 preview: str | None, 

211 blocks: list[EmailBlock], 

212 footer: EmailFooter, 

213 loc_context: LocalizationContext, 

214 ) -> str: 

215 concats: list[str] = [] 

216 

217 # Render the header 

218 concats.append( 

219 self.header_template.render( 

220 { 

221 "header_subject": subject, 

222 "header_preview": preview or "", 

223 }, 

224 loc_context, 

225 ) 

226 ) 

227 

228 # Render each block 

229 for block in blocks: 

230 match block: 

231 case ParaBlock(): 

232 concats.append(self.para_block_template.render(block.__dict__, loc_context)) 

233 case UserBlock(): 

234 concats.append( 

235 self.user_block_template.render( 

236 { 

237 "name": block.info.name, 

238 "age": block.info.age, 

239 "city": block.info.city, 

240 "avatar_url": block.info.avatar_url, 

241 "comment": block.comment, 

242 }, 

243 loc_context, 

244 ) 

245 ) 

246 case QuoteBlock(): 

247 concats.append(self.quote_block_template.render(block.__dict__, loc_context)) 

248 case ActionBlock(): 

249 concats.append(self.action_block_template.render(block.__dict__, loc_context)) 

250 case _: 

251 raise AssertionError(f"Unexpected email block type: {block.__class__}") 

252 

253 # Render the footer 

254 footer_template_args = _get_footer_template_args(footer) 

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

256 

257 return "\n".join(concats) 

258 

259 @lru_cache(maxsize=1) 

260 @staticmethod 

261 def default() -> HTMLRenderer: 

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

263 return HTMLRenderer.from_template(template) 

264 

265 @staticmethod 

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

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

268 

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

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

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

272 

273 return HTMLRenderer( 

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

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

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

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

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

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

280 ) 

281 

282 

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

284_block_regex = re.compile( 

285 r""" 

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

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

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

289""".strip(), 

290 re.MULTILINE, 

291)