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

89 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-07-21 21:52 +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 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.markup import html_mailto_link 

17from couchers.proto import api_pb2 

18from couchers.utils import now 

19 

20 

21@dataclass 

22class EmailBase(ABC): 

23 """ 

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

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

26 """ 

27 

28 user_name: str 

29 

30 @property 

31 @abstractmethod 

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

33 

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

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

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

37 

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

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

40 return None 

41 

42 @abstractmethod 

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

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

45 ... 

46 

47 def _body_builder( 

48 self, 

49 loc_context: LocalizationContext, 

50 *, 

51 default_greeting: bool = True, 

52 default_closing: bool = True, 

53 security_warning: bool = False, 

54 ) -> EmailBlocksBuilder: 

55 builder = EmailBlocksBuilder(locales=loc_context.locale_list, string_key_base=self.string_key_base) 

56 if default_greeting: 56 ↛ 58line 56 didn't jump to line 58 because the condition on line 56 was always true

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

58 if security_warning: 

59 # Not localizing the suggested subject line to encourage folks to use English if they can. 

60 builder.para( 

61 "generic.security_warning_contact_support", 

62 {"email_link": html_mailto_link("support@couchers.org", subject="Action not initiated by me")}, 

63 epilogue=True, 

64 ) 

65 if default_closing: 

66 builder.para("generic.closing_lines.default", epilogue=True) 

67 return builder 

68 

69 @classmethod 

70 @abstractmethod 

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

72 """ 

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

74 

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

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

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

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

79 """ 

80 ... 

81 

82 # Helpers for localizing email-specific strings 

83 def _localize( 

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

85 ) -> str: 

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

87 return loc_context.localize_string(key, i18next=get_emails_i18next(), substitutions=substitutions) 

88 

89 

90@dataclass 

91class EmailBlock: 

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

93 

94 pass 

95 

96 

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

98class ParaBlock(EmailBlock): 

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

100 

101 text: str | Markup 

102 

103 

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

105class UserBlock(EmailBlock): 

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

107 

108 info: UserInfo 

109 comment: str | Markup | None 

110 

111 

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

113class UserInfo: 

114 name: str 

115 age: int 

116 city: str 

117 avatar_url: str 

118 profile_url: str 

119 

120 @classmethod 

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

122 return cls( 

123 name=user.name, 

124 age=user.age, 

125 city=user.city, 

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

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

128 ) 

129 

130 @staticmethod 

131 def dummy_bob() -> UserInfo: 

132 return UserInfo( 

133 name="Bob", 

134 age=30, 

135 city="Berlin, Germany", 

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

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

138 ) 

139 

140 

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

142class QuoteBlock(EmailBlock): 

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

144 

145 text: str 

146 markdown: bool 

147 

148 

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

150class ActionBlock(EmailBlock): 

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

152 

153 text: str 

154 target_url: str 

155 

156 

157class EmailBlocksBuilder: 

158 """ 

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

160 """ 

161 

162 _locales: list[str] 

163 _string_key_base: str 

164 _blocks: list[EmailBlock] 

165 _epilogue: list[EmailBlock] 

166 

167 def __init__(self, locales: list[str], string_key_base: str): 

168 self._locales = locales 

169 self._string_key_base = string_key_base 

170 self._blocks = [] 

171 self._epilogue = [] 

172 

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

174 return self._blocks + self._epilogue 

175 

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

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

178 

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

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

181 

182 def user( 

183 self, 

184 info: UserInfo, 

185 comment_key: str | None = None, 

186 substitutions: SubstitutionDict | None = None, 

187 ) -> Self: 

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

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

190 

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

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

193 

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

195 if epilogue: 

196 self._epilogue.append(block) 

197 else: 

198 self._blocks.append(block) 

199 return self 

200 

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

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

203 return get_emails_i18next().localize(key, self._locales, substitutions) 

204 

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

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

207 return get_emails_i18next().localize_with_markup(key, self._locales, substitutions) 

208 

209 

210@dataclass(kw_only=True) 

211class EmailFooter: 

212 timezone_name: str 

213 copyright_year: int = now().year 

214 unsubscribe_info: UnsubscribeInfo | None 

215 

216 

217@dataclass(kw_only=True) 

218class UnsubscribeInfo: 

219 manage_notifications_url: str 

220 do_not_email_url: str 

221 topic_action_link: UnsubscribeLink 

222 topic_key_link: UnsubscribeLink | None = None 

223 

224 

225@dataclass(kw_only=True) 

226class UnsubscribeLink: 

227 text: str 

228 url: str