Coverage for app/backend/src/couchers/email/blocks.py: 99%

99 statements  

« prev     ^ index     » next       coverage.py v7.14.1, created at 2026-06-12 13:59 +0000

1""" 

2Data model for emails built out of well-known blocks, 

3that can be rendered HTML and plaintext for any locale. 

4""" 

5 

6from abc import ABC, abstractmethod 

7from dataclasses import dataclass 

8from typing import Any, Self 

9 

10from markupsafe import Markup 

11 

12from couchers import urls 

13from couchers.email.locales import get_emails_i18next 

14from couchers.i18n import LocalizationContext 

15from couchers.i18n.i18next import SubstitutionDict, full_string_key 

16from couchers.proto import api_pb2 

17from couchers.utils import now 

18 

19 

20@dataclass 

21class EmailBase(ABC): 

22 """ 

23 Base class for email data models, which capture all the data required to render 

24 an email's subject line and body as HTML or plaintext, in any locale. 

25 """ 

26 

27 user_name: str 

28 

29 @property 

30 @abstractmethod 

31 def string_key_base(self) -> str: ... 

32 

33 def get_subject_line(self, loc_context: LocalizationContext) -> str: 

34 """Gets the subject line header of the email.""" 

35 return self._localize(loc_context, ".subject") 

36 

37 def get_preview_line(self, loc_context: LocalizationContext) -> str | None: 

38 """Gets the line that gets shown as a preview next to the title in users' inboxes.""" 

39 return None 

40 

41 @abstractmethod 

42 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]: 

43 """Gets the blocks that form the body of the email.""" 

44 ... 

45 

46 def _body_builder( 

47 self, 

48 loc_context: LocalizationContext, 

49 *, 

50 standard_greeting: bool = True, 

51 standard_closing: bool = True, 

52 security_warning: bool = False, 

53 ) -> EmailBlocksBuilder: 

54 builder = EmailBlocksBuilder(locale=loc_context.locale, string_key_base=self.string_key_base) 

55 if standard_greeting: 55 ↛ 57line 55 didn't jump to line 57 because the condition on line 55 was always true

56 builder.para("generic.greeting_line", {"name": self.user_name}) 

57 if standard_closing: 

58 builder.para("generic.closing_line", epilogue=True) 

59 if security_warning: 

60 builder.para("generic.security_warning_contact_support", epilogue=True) 

61 return builder 

62 

63 @classmethod 

64 @abstractmethod 

65 def test_instances(cls) -> list[Self]: 

66 """ 

67 Returns dummy instances covering every distinct rendering variant of this email. 

68 

69 Emails whose subject or body depends on internal state (e.g. a status enum or a 

70 boolean) build their localization keys dynamically, so a single dummy instance only 

71 exercises one branch. Such emails override this to return one instance per branch, 

72 ensuring the rendering tests resolve every localization key the class can produce. 

73 """ 

74 ... 

75 

76 # Helpers for localizing email-specific strings 

77 def _localize( 

78 self, loc_context: LocalizationContext, key: str, substitutions: SubstitutionDict | None = None 

79 ) -> str: 

80 key = full_string_key(key, relative_base=self.string_key_base) 

81 return get_emails_i18next().localize(key, loc_context.locale, substitutions) 

82 

83 

84@dataclass 

85class EmailBlock: 

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

87 

88 pass 

89 

90 

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

92class ParaBlock(EmailBlock): 

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

94 

95 text: str | Markup 

96 

97 

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

99class UserBlock(EmailBlock): 

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

101 

102 info: UserInfo 

103 comment: str | Markup | None 

104 

105 

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

107class UserInfo: 

108 name: str 

109 age: int 

110 city: str 

111 avatar_url: str 

112 profile_url: str 

113 

114 @classmethod 

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

116 return cls( 

117 name=user.name, 

118 age=user.age, 

119 city=user.city, 

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

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

122 ) 

123 

124 @staticmethod 

125 def dummy_bob() -> UserInfo: 

126 return UserInfo( 

127 name="Bob", 

128 age=30, 

129 city="Berlin", 

130 avatar_url="https://couchers.org/logo512.png", 

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

132 ) 

133 

134 

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

136class QuoteBlock(EmailBlock): 

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

138 

139 text: str 

140 markdown: bool 

141 

142 

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

144class ActionBlock(EmailBlock): 

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

146 

147 text: str 

148 target_url: str 

149 

150 

151class EmailBlocksBuilder: 

152 """ 

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

154 """ 

155 

156 _locale: str 

157 _string_key_base: str 

158 _blocks: list[EmailBlock] 

159 _epilogue: list[EmailBlock] 

160 

161 def __init__(self, locale: str, string_key_base: str): 

162 self._locale = locale 

163 self._string_key_base = string_key_base 

164 self._blocks = [] 

165 self._epilogue = [] 

166 

167 def build(self) -> list[EmailBlock]: 

168 return self._blocks + self._epilogue 

169 

170 def para(self, key: str, substitutions: SubstitutionDict | None = None, epilogue: bool = False) -> Self: 

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

172 

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

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

175 

176 def user( 

177 self, 

178 info: UserInfo, 

179 comment_key: str | None = None, 

180 substitutions: SubstitutionDict | None = None, 

181 ) -> Self: 

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

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

184 

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

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

187 

188 def block(self, block: EmailBlock, epilogue: bool = False) -> Self: 

189 if epilogue: 

190 self._epilogue.append(block) 

191 else: 

192 self._blocks.append(block) 

193 return self 

194 

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

196 key = full_string_key(key, relative_base=self._string_key_base) 

197 return get_emails_i18next().localize(key, self._locale, substitutions) 

198 

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

200 key = full_string_key(key, relative_base=self._string_key_base) 

201 return get_emails_i18next().localize_with_markup(key, self._locale, substitutions) 

202 

203 

204@dataclass(kw_only=True) 

205class EmailFooter: 

206 timezone_name: str 

207 copyright_year: int = now().year 

208 unsubscribe_info: UnsubscribeInfo | None 

209 

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

211 args: dict[str, Any] = { 

212 "footer_timezone_name": self.timezone_name, 

213 "footer_copyright_year": self.copyright_year, 

214 "footer_email_is_critical": self.unsubscribe_info is None, 

215 } 

216 

217 if unsubscribe_info := self.unsubscribe_info: 

218 args.update(unsubscribe_info.to_template_args()) 

219 

220 return args 

221 

222 

223@dataclass(kw_only=True) 

224class UnsubscribeInfo: 

225 manage_notifications_url: str 

226 do_not_email_url: str 

227 topic_action_link: UnsubscribeLink 

228 topic_key_link: UnsubscribeLink | None = None 

229 

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

231 args: dict[str, Any] = { 

232 "footer_manage_notifications_link": self.manage_notifications_url, 

233 "footer_do_not_email_link": self.do_not_email_url, 

234 "footer_notification_topic_action": self.topic_action_link.text, 

235 "footer_notification_topic_action_link": self.topic_action_link.url, 

236 } 

237 

238 if topic_key_link := self.topic_key_link: 

239 args["footer_notification_topic_key"] = topic_key_link.text 

240 args["footer_notification_topic_key_link"] = topic_key_link.url 

241 

242 return args 

243 

244 

245@dataclass(kw_only=True) 

246class UnsubscribeLink: 

247 text: str 

248 url: str