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
« 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"""
6from abc import ABC, abstractmethod
7from dataclasses import dataclass
8from typing import Any, Self
10from markupsafe import Markup
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
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 """
27 user_name: str
29 @property
30 @abstractmethod
31 def string_key_base(self) -> str: ...
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")
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
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 ...
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
63 @classmethod
64 @abstractmethod
65 def test_instances(cls) -> list[Self]:
66 """
67 Returns dummy instances covering every distinct rendering variant of this email.
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 ...
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)
84@dataclass
85class EmailBlock:
86 """Base class for building blocks of an email body, HTML/plaintext-agnostic."""
88 pass
91@dataclass(kw_only=True, slots=True)
92class ParaBlock(EmailBlock):
93 """A paragraph of text which may contain span-level HTML."""
95 text: str | Markup
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."""
102 info: UserInfo
103 comment: str | Markup | None
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
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 )
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 )
135@dataclass(kw_only=True, slots=True)
136class QuoteBlock(EmailBlock):
137 """A quoted message, typically from another user. Either plaintext or markdown."""
139 text: str
140 markdown: bool
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."""
147 text: str
148 target_url: str
151class EmailBlocksBuilder:
152 """
153 Builder object for constructing a list of localized EmailBlock's to form the body of an email.
154 """
156 _locale: str
157 _string_key_base: str
158 _blocks: list[EmailBlock]
159 _epilogue: list[EmailBlock]
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 = []
167 def build(self) -> list[EmailBlock]:
168 return self._blocks + self._epilogue
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)
173 def quote(self, text: str, *, markdown: bool) -> Self:
174 return self.block(QuoteBlock(text=text, markdown=markdown))
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))
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))
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
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)
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)
204@dataclass(kw_only=True)
205class EmailFooter:
206 timezone_name: str
207 copyright_year: int = now().year
208 unsubscribe_info: UnsubscribeInfo | None
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 }
217 if unsubscribe_info := self.unsubscribe_info:
218 args.update(unsubscribe_info.to_template_args())
220 return args
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
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 }
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
242 return args
245@dataclass(kw_only=True)
246class UnsubscribeLink:
247 text: str
248 url: str