Skip to content

Commit d805a48

Browse files
Backend/emails: Port more emails to new system (#8697)
1 parent 778958b commit d805a48

28 files changed

Lines changed: 398 additions & 3503 deletions

app/backend/src/couchers/email/emails.py

Lines changed: 258 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,12 @@
77
from datetime import UTC, date, datetime
88
from typing import Self
99

10+
from couchers import urls
1011
from couchers.email.rendering import (
1112
EmailBlock,
1213
EmailBlocksBuilder,
1314
ParaBlock,
15+
UserInfo,
1416
get_emails_i18next,
1517
)
1618
from couchers.i18n import LocalizationContext
@@ -76,6 +78,77 @@ def _body_builder(self, loc_context: LocalizationContext) -> EmailBlocksBuilder:
7678
# Specific email definitions
7779

7880

81+
@dataclass(kw_only=True, slots=True)
82+
class AccountDeletionStartedEmail(EmailBase):
83+
"""Sent to a user to confirm their account deletion request."""
84+
85+
deletion_link: str
86+
87+
@property
88+
def string_key_prefix(self) -> str:
89+
return "account_deletion_started"
90+
91+
def build_body(self, builder: EmailBlocksBuilder, loc_context: LocalizationContext) -> None:
92+
builder.para("request_description")
93+
builder.para("confirmation_instructions")
94+
builder.action(self.deletion_link, "confirm_action")
95+
builder.security_warning_para()
96+
97+
@classmethod
98+
def dummy_data(cls) -> AccountDeletionStartedEmail:
99+
return AccountDeletionStartedEmail(
100+
user_name="Alice",
101+
deletion_link="https://couchers.org/delete-account?token=xxx",
102+
)
103+
104+
105+
@dataclass(kw_only=True, slots=True)
106+
class AccountDeletionCompletedEmail(EmailBase):
107+
"""Sent to a user after their account has been deleted."""
108+
109+
undelete_link: str
110+
days: int
111+
112+
@property
113+
def string_key_prefix(self) -> str:
114+
return "account_deletion_completed"
115+
116+
def build_body(self, builder: EmailBlocksBuilder, loc_context: LocalizationContext) -> None:
117+
builder.para("confirmation")
118+
builder.para("farewell")
119+
builder.para("recovery_instructions_days", {"count": self.days})
120+
builder.action(self.undelete_link, "recover_action")
121+
builder.security_warning_para()
122+
123+
@classmethod
124+
def dummy_data(cls) -> AccountDeletionCompletedEmail:
125+
return AccountDeletionCompletedEmail(
126+
user_name="Alice",
127+
undelete_link="https://couchers.org/recover-account?token=xxx",
128+
days=30,
129+
)
130+
131+
132+
@dataclass(kw_only=True, slots=True)
133+
class AccountDeletionRecoveredEmail(EmailBase):
134+
"""Sent to a user after their account deletion has been cancelled."""
135+
136+
@property
137+
def string_key_prefix(self) -> str:
138+
return "account_deletion_recovered"
139+
140+
def build_body(self, builder: EmailBlocksBuilder, loc_context: LocalizationContext) -> None:
141+
builder.para("confirmation")
142+
builder.para("login_instructions")
143+
builder.action(urls.app_link(), "login_action")
144+
builder.para("redelete_instructions")
145+
builder.security_warning_para()
146+
147+
@classmethod
148+
def dummy_data(cls) -> AccountDeletionRecoveredEmail:
149+
return AccountDeletionRecoveredEmail(user_name="Alice")
150+
151+
79152
@dataclass(kw_only=True, slots=True)
80153
class APIKeyIssuedEmail(EmailBase):
81154
"""Sent to a user to notify them that their API key was issued."""
@@ -89,7 +162,7 @@ def string_key_prefix(self) -> str:
89162

90163
def build_body(self, builder: EmailBlocksBuilder, loc_context: LocalizationContext) -> None:
91164
builder.para("header")
92-
builder.quote(self.api_key)
165+
builder.quote(self.api_key, markdown=False)
93166
builder.para("expiry", {"datetime": loc_context.localize_datetime(self.expiry)})
94167
builder.para("usage_warning")
95168
builder.para("policy_warning")
@@ -146,6 +219,92 @@ def dummy_data(cls) -> BirthdateChangedEmail:
146219
)
147220

148221

222+
@dataclass(kw_only=True, slots=True)
223+
class DiscussionCreatedEmail(EmailBase):
224+
"""Sent to a user when a new discussion is created in a community they follow."""
225+
226+
author: UserInfo
227+
title: str
228+
parent_context: str # Community or group name
229+
markdown_text: str
230+
view_link: str
231+
232+
@property
233+
def string_key_prefix(self) -> str:
234+
return "discussion_created"
235+
236+
def get_subject_line(self, loc_context: LocalizationContext) -> str:
237+
return self._localize(loc_context, "subject", {"author": self.author.name, "title": self.title})
238+
239+
def build_body(self, builder: EmailBlocksBuilder, loc_context: LocalizationContext) -> None:
240+
builder.para(
241+
"body",
242+
{
243+
"author": self.author.name,
244+
"title": self.title,
245+
"parent_context": self.parent_context,
246+
},
247+
)
248+
builder.user(self.author)
249+
builder.quote(self.markdown_text, markdown=True)
250+
builder.action(self.view_link, "view_action")
251+
252+
@classmethod
253+
def dummy_data(cls) -> DiscussionCreatedEmail:
254+
return DiscussionCreatedEmail(
255+
user_name="Alice",
256+
author=UserInfo.dummy_bob(),
257+
title="Best hiking trails near Berlin",
258+
parent_context="Berlin Community",
259+
markdown_text="I've been exploring the area and found some **great** spots...",
260+
view_link="https://couchers.org/discussions/123",
261+
)
262+
263+
264+
@dataclass(kw_only=True, slots=True)
265+
class DiscussionCommentEmail(EmailBase):
266+
"""Sent to a user when someone comments on a discussion they follow."""
267+
268+
author: UserInfo
269+
discussion_title: str
270+
discussion_parent_context: str # Community or group name
271+
markdown_text: str
272+
view_link: str
273+
274+
@property
275+
def string_key_prefix(self) -> str:
276+
return "discussion_comment"
277+
278+
def get_subject_line(self, loc_context: LocalizationContext) -> str:
279+
return self._localize(
280+
loc_context, "subject", {"author": self.author.name, "discussion_title": self.discussion_title}
281+
)
282+
283+
def build_body(self, builder: EmailBlocksBuilder, loc_context: LocalizationContext) -> None:
284+
builder.para(
285+
"body",
286+
{
287+
"author": self.author.name,
288+
"discussion_title": self.discussion_title,
289+
"parent_context": self.discussion_parent_context,
290+
},
291+
)
292+
builder.user(self.author)
293+
builder.quote(self.markdown_text, markdown=True)
294+
builder.action(self.view_link, "view_action")
295+
296+
@classmethod
297+
def dummy_data(cls) -> DiscussionCommentEmail:
298+
return DiscussionCommentEmail(
299+
user_name="Alice",
300+
author=UserInfo.dummy_bob(),
301+
discussion_title="Best hiking trails near Berlin",
302+
discussion_parent_context="Berlin Community",
303+
markdown_text="Great recommendations, I also **love** the Grünewald forest!",
304+
view_link="https://couchers.org/discussions/123",
305+
)
306+
307+
149308
@dataclass(kw_only=True, slots=True)
150309
class EmailAddressChangedEmail(EmailBase):
151310
"""Sent to a user to notify them that their email address was changed."""
@@ -182,6 +341,67 @@ def dummy_data(cls) -> EmailAddressVerifiedEmail:
182341
return EmailAddressVerifiedEmail(user_name="Alice")
183342

184343

344+
@dataclass(kw_only=True, slots=True)
345+
class FriendRequestReceivedEmail(EmailBase):
346+
"""Sent to a user when they receive a friend request."""
347+
348+
befriender: UserInfo
349+
350+
@property
351+
def string_key_prefix(self) -> str:
352+
return "friend_request_received"
353+
354+
def get_subject_line(self, loc_context: LocalizationContext) -> str:
355+
return self._localize(loc_context, "subject", {"name": self.befriender.name})
356+
357+
def get_preview_line(self, loc_context: LocalizationContext) -> str:
358+
return self._localize(loc_context, "body", {"name": self.befriender.name})
359+
360+
def build_body(self, builder: EmailBlocksBuilder, loc_context: LocalizationContext) -> None:
361+
builder.para("body", {"name": self.befriender.name})
362+
builder.user(self.befriender)
363+
builder.action(urls.friend_requests_link(), "view_action")
364+
builder.para("closing")
365+
builder.do_not_reply_request_para()
366+
367+
@classmethod
368+
def dummy_data(cls) -> FriendRequestReceivedEmail:
369+
return FriendRequestReceivedEmail(
370+
user_name="Alice",
371+
befriender=UserInfo.dummy_bob(),
372+
)
373+
374+
375+
@dataclass(kw_only=True, slots=True)
376+
class FriendRequestAcceptedEmail(EmailBase):
377+
"""Sent to a user when their friend request is accepted."""
378+
379+
new_friend: UserInfo
380+
381+
@property
382+
def string_key_prefix(self) -> str:
383+
return "friend_request_accepted"
384+
385+
def get_subject_line(self, loc_context: LocalizationContext) -> str:
386+
return self._localize(loc_context, "subject", {"name": self.new_friend.name})
387+
388+
def get_preview_line(self, loc_context: LocalizationContext) -> str:
389+
return self._localize(loc_context, "body", {"name": self.new_friend.name})
390+
391+
def build_body(self, builder: EmailBlocksBuilder, loc_context: LocalizationContext) -> None:
392+
builder.para("body", {"name": self.new_friend.name})
393+
builder.user(self.new_friend)
394+
builder.action(self.new_friend.profile_url, "view_action")
395+
builder.para("closing")
396+
397+
@classmethod
398+
def dummy_data(cls) -> FriendRequestAcceptedEmail:
399+
return FriendRequestAcceptedEmail(
400+
user_name="Alice",
401+
new_friend=UserInfo.dummy_bob(),
402+
)
403+
404+
185405
@dataclass(kw_only=True, slots=True)
186406
class GenderChangedEmail(EmailBase):
187407
"""Sent to a user to notify them that their gender was changed."""
@@ -400,8 +620,6 @@ def dummy_data(cls) -> StrongVerificationFailedEmail:
400620
class StrongVerificationSucceededEmail(EmailBase):
401621
"""Sent to a user when their strong verification has succeeded."""
402622

403-
donate_link: str
404-
405623
@property
406624
def string_key_prefix(self) -> str:
407625
return "strong_verification_succeeded"
@@ -411,11 +629,45 @@ def build_body(self, builder: EmailBlocksBuilder, loc_context: LocalizationConte
411629
builder.para("thanks_message")
412630
builder.para("cost_explanation")
413631
builder.para("donation_request")
414-
builder.action(self.donate_link, "donate_action")
632+
donate_link = urls.donation_url() + "?utm_source=strong-verification-email"
633+
builder.action(donate_link, "donate_action")
415634
builder.security_warning_para()
416635

417636
@classmethod
418637
def dummy_data(cls) -> StrongVerificationSucceededEmail:
419-
return StrongVerificationSucceededEmail(
420-
user_name="Alice", donate_link="https://couchers.org/donate?utm_source=strong-verification-email"
638+
return StrongVerificationSucceededEmail(user_name="Alice")
639+
640+
641+
@dataclass(kw_only=True, slots=True)
642+
class ThreadReplyEmail(EmailBase):
643+
"""Sent to a user when someone replies in a comment thread they participated in."""
644+
645+
author: UserInfo
646+
parent_context: str # Title of the event or discussion being replied in
647+
markdown_text: str
648+
view_link: str
649+
650+
@property
651+
def string_key_prefix(self) -> str:
652+
return "thread_reply"
653+
654+
def get_subject_line(self, loc_context: LocalizationContext) -> str:
655+
return self._localize(
656+
loc_context, "subject", {"author": self.author.name, "parent_context": self.parent_context}
657+
)
658+
659+
def build_body(self, builder: EmailBlocksBuilder, loc_context: LocalizationContext) -> None:
660+
builder.para("body", {"author": self.author.name, "parent_context": self.parent_context})
661+
builder.user(self.author)
662+
builder.quote(self.markdown_text, markdown=True)
663+
builder.action(self.view_link, "view_action")
664+
665+
@classmethod
666+
def dummy_data(cls) -> ThreadReplyEmail:
667+
return ThreadReplyEmail(
668+
user_name="Alice",
669+
author=UserInfo.dummy_bob(),
670+
parent_context="Best hiking trails near Berlin",
671+
markdown_text="I agree, the Grünewald is **amazing**!",
672+
view_link="https://couchers.org/discussions/123",
421673
)

0 commit comments

Comments
 (0)