Coverage for app/backend/src/couchers/email/blocks.py: 99%
89 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-19 00:32 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-19 00:32 +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 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.markup import html_mailto_link
17from couchers.proto import api_pb2
18from couchers.utils import now
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 """
28 user_name: str
30 @property
31 @abstractmethod
32 def string_key_base(self) -> str: ...
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")
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
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 ...
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
69 @classmethod
70 @abstractmethod
71 def test_instances(cls) -> list[Self]:
72 """
73 Returns dummy instances covering every distinct rendering variant of this email.
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 ...
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)
90@dataclass
91class EmailBlock:
92 """Base class for building blocks of an email body, HTML/plaintext-agnostic."""
94 pass
97@dataclass(kw_only=True, slots=True)
98class ParaBlock(EmailBlock):
99 """A paragraph of text which may contain span-level HTML."""
101 text: str | Markup
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."""
108 info: UserInfo
109 comment: str | Markup | None
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
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 )
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 )
141@dataclass(kw_only=True, slots=True)
142class QuoteBlock(EmailBlock):
143 """A quoted message, typically from another user. Either plaintext or markdown."""
145 text: str
146 markdown: bool
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."""
153 text: str
154 target_url: str
157class EmailBlocksBuilder:
158 """
159 Builder object for constructing a list of localized EmailBlock's to form the body of an email.
160 """
162 _locales: list[str]
163 _string_key_base: str
164 _blocks: list[EmailBlock]
165 _epilogue: list[EmailBlock]
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 = []
173 def build(self) -> list[EmailBlock]:
174 return self._blocks + self._epilogue
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)
179 def quote(self, text: str, *, markdown: bool) -> Self:
180 return self.block(QuoteBlock(text=text, markdown=markdown))
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))
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))
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
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)
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)
210@dataclass(kw_only=True)
211class EmailFooter:
212 timezone_name: str
213 copyright_year: int = now().year
214 unsubscribe_info: UnsubscribeInfo | None
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
225@dataclass(kw_only=True)
226class UnsubscribeLink:
227 text: str
228 url: str