Coverage for app/backend/src/couchers/email/emails.py: 96%
636 statements
« prev ^ index » next coverage.py v7.14.1, created at 2026-05-29 04:01 +0000
« prev ^ index » next coverage.py v7.14.1, created at 2026-05-29 04:01 +0000
1"""
2Defines data models for each email we sent out to users.
3"""
5import re
6from abc import ABC, abstractmethod
7from dataclasses import dataclass, replace
8from datetime import UTC, date, datetime
9from typing import Self, assert_never
11from couchers import urls
12from couchers.email.rendering import (
13 EmailBlock,
14 EmailBlocksBuilder,
15 ParaBlock,
16 UserInfo,
17 get_emails_i18next,
18)
19from couchers.i18n import LocalizationContext
20from couchers.i18n.i18next import SubstitutionDict
21from couchers.i18n.localize import format_phone_number
22from couchers.notifications.quick_links import generate_quick_decline_link
23from couchers.proto import conversations_pb2, notification_data_pb2
26@dataclass
27class EmailBase(ABC):
28 """
29 Base class for email data models, which capture all the data required to render
30 an email's subject line and body as HTML or plaintext, in any locale.
31 """
33 user_name: str
35 @property
36 @abstractmethod
37 def string_key_prefix(self) -> str: ...
39 def get_subject_line(self, loc_context: LocalizationContext) -> str:
40 """Gets the subject line header of the email."""
41 return self._localize(loc_context, "subject")
43 def get_preview_line(self, loc_context: LocalizationContext) -> str | None:
44 """Gets the line that gets shown as a preview next to the title in users' inboxes."""
45 return None
47 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
48 """Gets the blocks that form the body of the email."""
50 # Delegate to build_body, but wrap with greetings and closing lines common to all emails.
51 i18next = get_emails_i18next()
52 builder = EmailBlocksBuilder(locale=loc_context.locale, string_key_prefix=self.string_key_prefix)
53 builder.block(
54 ParaBlock(text=i18next.localize("generic.greeting_line", loc_context.locale, {"name": self.user_name}))
55 )
56 self.build_body(builder, loc_context)
57 builder.block(ParaBlock(text=i18next.localize("generic.closing_line", loc_context.locale)))
58 return builder.blocks
60 @abstractmethod
61 def build_body(self, builder: EmailBlocksBuilder, loc_context: LocalizationContext) -> None: ...
63 @classmethod
64 @abstractmethod
65 def dummy_data(cls) -> Self:
66 """Returns an instance filled with dummy data that can be used for testing."""
67 ...
69 @classmethod
70 def dummy_variants(cls) -> list[Self]:
71 """
72 Returns dummy instances covering every distinct rendering variant of this email.
74 Emails whose subject or body depends on internal state (e.g. a status enum or a
75 boolean) build their localization keys dynamically, so a single dummy instance only
76 exercises one branch. Such emails override this to return one instance per branch,
77 ensuring the rendering tests resolve every localization key the class can produce.
78 """
79 return [cls.dummy_data()]
81 # Helpers for localizing email-specific strings
82 def _localize(
83 self, loc_context: LocalizationContext, key: str, substitutions: SubstitutionDict | None = None
84 ) -> str:
85 key = f"{self.string_key_prefix}.{key}"
86 return get_emails_i18next().localize(key, loc_context.locale, substitutions)
88 def _body_builder(self, loc_context: LocalizationContext) -> EmailBlocksBuilder:
89 return EmailBlocksBuilder(locale=loc_context.locale, string_key_prefix=self.string_key_prefix)
92# Specific email definitions
95@dataclass(kw_only=True, slots=True)
96class AccountDeletionStartedEmail(EmailBase):
97 """Sent to a user to confirm their account deletion request."""
99 deletion_link: str
101 @property
102 def string_key_prefix(self) -> str:
103 return "account_deletion_started"
105 def build_body(self, builder: EmailBlocksBuilder, loc_context: LocalizationContext) -> None:
106 builder.para("request_description")
107 builder.para("confirmation_instructions")
108 builder.action(self.deletion_link, "confirm_action")
109 builder.security_warning_para()
111 @classmethod
112 def from_notification(cls, data: notification_data_pb2.AccountDeletionStart, *, user_name: str) -> Self:
113 return cls(
114 user_name=user_name,
115 deletion_link=urls.delete_account_link(account_deletion_token=data.deletion_token),
116 )
118 @classmethod
119 def dummy_data(cls) -> AccountDeletionStartedEmail:
120 return AccountDeletionStartedEmail(
121 user_name="Alice",
122 deletion_link="https://couchers.org/delete-account?token=xxx",
123 )
126@dataclass(kw_only=True, slots=True)
127class AccountDeletionCompletedEmail(EmailBase):
128 """Sent to a user after their account has been deleted."""
130 undelete_link: str
131 days: int
133 @property
134 def string_key_prefix(self) -> str:
135 return "account_deletion_completed"
137 def build_body(self, builder: EmailBlocksBuilder, loc_context: LocalizationContext) -> None:
138 builder.para("confirmation")
139 builder.para("farewell")
140 builder.para("recovery_instructions_days", {"count": self.days})
141 builder.action(self.undelete_link, "recover_action")
142 builder.security_warning_para()
144 @classmethod
145 def from_notification(cls, data: notification_data_pb2.AccountDeletionComplete, *, user_name: str) -> Self:
146 return cls(
147 user_name=user_name,
148 undelete_link=urls.recover_account_link(account_undelete_token=data.undelete_token),
149 days=data.undelete_days,
150 )
152 @classmethod
153 def dummy_data(cls) -> AccountDeletionCompletedEmail:
154 return AccountDeletionCompletedEmail(
155 user_name="Alice",
156 undelete_link="https://couchers.org/recover-account?token=xxx",
157 days=30,
158 )
161@dataclass(kw_only=True, slots=True)
162class AccountDeletionRecoveredEmail(EmailBase):
163 """Sent to a user after their account deletion has been cancelled."""
165 @property
166 def string_key_prefix(self) -> str:
167 return "account_deletion_recovered"
169 def build_body(self, builder: EmailBlocksBuilder, loc_context: LocalizationContext) -> None:
170 builder.para("confirmation")
171 builder.para("login_instructions")
172 builder.action(urls.app_link(), "login_action")
173 builder.para("redelete_instructions")
174 builder.security_warning_para()
176 @classmethod
177 def dummy_data(cls) -> AccountDeletionRecoveredEmail:
178 return AccountDeletionRecoveredEmail(user_name="Alice")
181@dataclass(kw_only=True, slots=True)
182class APIKeyIssuedEmail(EmailBase):
183 """Sent to a user to notify them that their API key was issued."""
185 api_key: str
186 expiry: datetime
188 @property
189 def string_key_prefix(self) -> str:
190 return "api_key_issued"
192 def build_body(self, builder: EmailBlocksBuilder, loc_context: LocalizationContext) -> None:
193 builder.para("header")
194 builder.quote(self.api_key, markdown=False)
195 builder.para("expiry", {"datetime": loc_context.localize_datetime(self.expiry)})
196 builder.para("usage_warning")
197 builder.para("policy_warning")
198 builder.security_warning_para()
200 @classmethod
201 def from_notification(cls, data: notification_data_pb2.ApiKeyCreate, *, user_name: str) -> Self:
202 return cls(user_name=user_name, api_key=data.api_key, expiry=data.expiry.ToDatetime(tzinfo=UTC))
204 @classmethod
205 def dummy_data(cls) -> APIKeyIssuedEmail:
206 return APIKeyIssuedEmail(
207 user_name="Alice", api_key="my_api_key_123", expiry=datetime(2099, 12, 31, 23, 59, 59, tzinfo=UTC)
208 )
211@dataclass(kw_only=True, slots=True)
212class BadgeChangedEmail(EmailBase):
213 """Sent to a user to notify them that a badge was added or removed from their profile."""
215 badge_name: str
216 added: bool
218 @property
219 def string_key_prefix(self) -> str:
220 return "badge_added" if self.added else "badge_removed"
222 def get_subject_line(self, loc_context: LocalizationContext) -> str:
223 return self._localize(loc_context, "subject", {"badge_name": self.badge_name})
225 def build_body(self, builder: EmailBlocksBuilder, loc_context: LocalizationContext) -> None:
226 builder.para("body", {"badge_name": self.badge_name})
228 @classmethod
229 def from_notification(
230 cls, data: notification_data_pb2.BadgeAdd | notification_data_pb2.BadgeRemove, *, user_name: str
231 ) -> Self:
232 return cls(
233 user_name=user_name, badge_name=data.badge_name, added=isinstance(data, notification_data_pb2.BadgeAdd)
234 )
236 @classmethod
237 def dummy_data(cls) -> BadgeChangedEmail:
238 return BadgeChangedEmail(user_name="Alice", badge_name="Founder", added=True)
240 @classmethod
241 def dummy_variants(cls) -> list[BadgeChangedEmail]:
242 base = cls.dummy_data()
243 return [replace(base, added=True), replace(base, added=False)]
246@dataclass(kw_only=True, slots=True)
247class BirthdateChangedEmail(EmailBase):
248 """Sent to a user to notify them that their birthdate was changed."""
250 new_birthdate: date
252 @property
253 def string_key_prefix(self) -> str:
254 return "birthdate_changed"
256 def build_body(self, builder: EmailBlocksBuilder, loc_context: LocalizationContext) -> None:
257 builder.para("body", {"date": loc_context.localize_date(self.new_birthdate)})
258 builder.security_warning_para()
260 @classmethod
261 def from_notification(cls, data: notification_data_pb2.BirthdateChange, *, user_name: str) -> Self:
262 return cls(user_name=user_name, new_birthdate=date.fromisoformat(data.birthdate))
264 @classmethod
265 def dummy_data(cls) -> BirthdateChangedEmail:
266 return BirthdateChangedEmail(
267 user_name="Alice",
268 new_birthdate=date(1990, 1, 1),
269 )
272@dataclass(kw_only=True, slots=True)
273class ChatMessageReceivedEmail(EmailBase):
274 """Sent to a user when they receive a new chat message."""
276 group_chat_title: str | None # None if direct message
277 author: UserInfo
278 text: str
279 view_url: str
281 @property
282 def string_key_prefix(self) -> str:
283 return f"chat_message_received.{'direct' if self.group_chat_title is None else 'group'}"
285 def get_subject_line(self, loc_context: LocalizationContext) -> str:
286 return self._localize(
287 loc_context, "subject", {"author": self.author.name, "group": self.group_chat_title or ""}
288 )
290 def build_body(self, builder: EmailBlocksBuilder, loc_context: LocalizationContext) -> None:
291 builder.para("body", {"author": self.author.name, "group": self.group_chat_title or ""})
292 builder.user(self.author)
293 builder.quote(self.text, markdown=False)
294 builder.action(self.view_url, "view_action")
296 @classmethod
297 def from_notification(cls, data: notification_data_pb2.ChatMessage, *, user_name: str) -> Self:
298 group_chat_title: str | None = data.group_chat_title
299 if not group_chat_title: 299 ↛ 307line 299 didn't jump to line 307 because the condition on line 299 was always true
300 # Backcompat (2026-05): The group name previously was formatted in the message string
301 # msg = f"{message.author.name} sent a message in {group_chat.title}"
302 if match := re.search(" sent a message in (.+)$", data.message or ""): 302 ↛ 303line 302 didn't jump to line 303 because the condition on line 302 was never true
303 group_chat_title = match[1]
304 else:
305 group_chat_title = None
307 return cls(
308 user_name,
309 author=UserInfo.from_protobuf(data.author),
310 text=data.text,
311 group_chat_title=group_chat_title,
312 view_url=urls.chat_link(chat_id=data.group_chat_id),
313 )
315 @classmethod
316 def dummy_data(cls) -> ChatMessageReceivedEmail:
317 return ChatMessageReceivedEmail(
318 user_name="Alice",
319 group_chat_title=None,
320 author=UserInfo.dummy_bob(),
321 text="Hi Alice!",
322 view_url="https://couchers.org/messages/chats/123",
323 )
325 @classmethod
326 def dummy_variants(cls) -> list[ChatMessageReceivedEmail]:
327 base = cls.dummy_data()
328 return [
329 replace(base, group_chat_title=None),
330 replace(base, group_chat_title="Best friends"),
331 ]
334@dataclass(kw_only=True, slots=True)
335class ChatMessagesMissedEmail(EmailBase):
336 """Sent to a user after they've missed new chat messages."""
338 @dataclass(kw_only=True, slots=True)
339 class Entry:
340 """Entry for each chat with missed messages."""
342 group_chat_title: str | None # None if direct message
343 missed_count: int
344 latest_message_author: UserInfo
345 latest_message_text: str
346 view_url: str
348 entries: list[Entry]
350 @property
351 def string_key_prefix(self) -> str:
352 return "chat_messages_missed"
354 def get_subject_line(self, loc_context: LocalizationContext) -> str:
355 return self._localize(loc_context, "subject")
357 def build_body(self, builder: EmailBlocksBuilder, loc_context: LocalizationContext) -> None:
358 for entry in self.entries:
359 if entry.group_chat_title:
360 builder.para("in_group", {"count": entry.missed_count, "group": entry.group_chat_title})
361 else:
362 builder.para("in_dm", {"count": entry.missed_count, "author": entry.latest_message_author.name})
363 builder.user(entry.latest_message_author)
364 builder.quote(entry.latest_message_text, markdown=False)
365 builder.action(entry.view_url, "view_action")
367 @classmethod
368 def from_notification(cls, data: notification_data_pb2.ChatMissedMessages, *, user_name: str) -> Self:
369 missed_entries = []
370 for message in data.messages:
371 group_chat_title: str | None = message.group_chat_title
372 missed_count: int = message.unseen_count
374 # Backcompat (2026-05): The group name and unseen count were previously was formatted in the message string
375 # msg = f"You missed {unseen_count} message(s) in {group_chat.title}"
376 if not group_chat_title or not missed_count: 376 ↛ 387line 376 didn't jump to line 387 because the condition on line 376 was always true
377 if match := re.search(" message(s) in (.+)$", message.message or ""): 377 ↛ 378line 377 didn't jump to line 378 because the condition on line 377 was never true
378 group_chat_title = match[1]
379 else:
380 group_chat_title = None
382 if match := re.search(r"^You missed (\d+) message(s)", message.message or ""): 382 ↛ 383line 382 didn't jump to line 383 because the condition on line 382 was never true
383 missed_count = int(match[1])
384 else:
385 missed_count = 1
387 missed_entries.append(
388 cls.Entry(
389 group_chat_title=group_chat_title,
390 missed_count=missed_count,
391 latest_message_author=UserInfo.from_protobuf(message.author),
392 latest_message_text=message.text,
393 view_url=urls.chat_link(chat_id=message.group_chat_id),
394 )
395 )
397 return cls(user_name, entries=missed_entries)
399 @classmethod
400 def dummy_data(cls) -> ChatMessagesMissedEmail:
401 return ChatMessagesMissedEmail(
402 user_name="Alice",
403 entries=[
404 ChatMessagesMissedEmail.Entry(
405 group_chat_title=None,
406 missed_count=1,
407 latest_message_author=UserInfo.dummy_bob(),
408 latest_message_text="Hi Alice!",
409 view_url="https://couchers.org/messages/chats/123",
410 ),
411 ChatMessagesMissedEmail.Entry(
412 group_chat_title="Best friends",
413 missed_count=2,
414 latest_message_author=UserInfo.dummy_bob(),
415 latest_message_text="Hi y'all!",
416 view_url="https://couchers.org/messages/chats/124",
417 ),
418 ],
419 )
422@dataclass(kw_only=True, slots=True)
423class DiscussionCreatedEmail(EmailBase):
424 """Sent to a user when a new discussion is created in a community they follow."""
426 author: UserInfo
427 title: str
428 parent_context: str # Community or group name
429 markdown_text: str
430 view_link: str
432 @property
433 def string_key_prefix(self) -> str:
434 return "discussion_created"
436 def get_subject_line(self, loc_context: LocalizationContext) -> str:
437 return self._localize(loc_context, "subject", {"author": self.author.name, "title": self.title})
439 def build_body(self, builder: EmailBlocksBuilder, loc_context: LocalizationContext) -> None:
440 builder.para(
441 "body",
442 {
443 "author": self.author.name,
444 "title": self.title,
445 "parent_context": self.parent_context,
446 },
447 )
448 builder.user(self.author)
449 builder.quote(self.markdown_text, markdown=True)
450 builder.action(self.view_link, "view_action")
452 @classmethod
453 def from_notification(cls, data: notification_data_pb2.DiscussionCreate, *, user_name: str) -> Self:
454 discussion = data.discussion
455 return cls(
456 user_name=user_name,
457 author=UserInfo.from_protobuf(data.author),
458 title=discussion.title,
459 parent_context=discussion.owner_title,
460 markdown_text=discussion.content,
461 view_link=urls.discussion_link(discussion_id=discussion.discussion_id, slug=discussion.slug),
462 )
464 @classmethod
465 def dummy_data(cls) -> DiscussionCreatedEmail:
466 return DiscussionCreatedEmail(
467 user_name="Alice",
468 author=UserInfo.dummy_bob(),
469 title="Best hiking trails near Berlin",
470 parent_context="Berlin Community",
471 markdown_text="I've been exploring the area and found some **great** spots...",
472 view_link="https://couchers.org/discussions/123",
473 )
476@dataclass(kw_only=True, slots=True)
477class DiscussionCommentEmail(EmailBase):
478 """Sent to a user when someone comments on a discussion they follow."""
480 author: UserInfo
481 discussion_title: str
482 discussion_parent_context: str # Community or group name
483 markdown_text: str
484 view_link: str
486 @property
487 def string_key_prefix(self) -> str:
488 return "discussion_comment"
490 def get_subject_line(self, loc_context: LocalizationContext) -> str:
491 return self._localize(
492 loc_context, "subject", {"author": self.author.name, "discussion_title": self.discussion_title}
493 )
495 def build_body(self, builder: EmailBlocksBuilder, loc_context: LocalizationContext) -> None:
496 builder.para(
497 "body",
498 {
499 "author": self.author.name,
500 "discussion_title": self.discussion_title,
501 "parent_context": self.discussion_parent_context,
502 },
503 )
504 builder.user(self.author)
505 builder.quote(self.markdown_text, markdown=True)
506 builder.action(self.view_link, "view_action")
508 @classmethod
509 def from_notification(cls, data: notification_data_pb2.DiscussionComment, *, user_name: str) -> Self:
510 discussion = data.discussion
511 return cls(
512 user_name=user_name,
513 author=UserInfo.from_protobuf(data.author),
514 discussion_title=discussion.title,
515 discussion_parent_context=discussion.owner_title,
516 markdown_text=data.reply.content,
517 view_link=urls.discussion_link(discussion_id=discussion.discussion_id, slug=discussion.slug),
518 )
520 @classmethod
521 def dummy_data(cls) -> DiscussionCommentEmail:
522 return DiscussionCommentEmail(
523 user_name="Alice",
524 author=UserInfo.dummy_bob(),
525 discussion_title="Best hiking trails near Berlin",
526 discussion_parent_context="Berlin Community",
527 markdown_text="Great recommendations, I also **love** the Grünewald forest!",
528 view_link="https://couchers.org/discussions/123",
529 )
532@dataclass(kw_only=True, slots=True)
533class EmailAddressChangedEmail(EmailBase):
534 """Sent to a user to notify them that their email address was changed."""
536 new_email: str
538 @property
539 def string_key_prefix(self) -> str:
540 return "email_address_change_initiated"
542 def build_body(self, builder: EmailBlocksBuilder, loc_context: LocalizationContext) -> None:
543 builder.para("body", {"email_address": self.new_email})
544 builder.security_warning_para()
546 @classmethod
547 def from_notification(cls, data: notification_data_pb2.EmailAddressChange, *, user_name: str) -> Self:
548 return cls(user_name=user_name, new_email=data.new_email)
550 @classmethod
551 def dummy_data(cls) -> EmailAddressChangedEmail:
552 return EmailAddressChangedEmail(user_name="Alice", new_email="alice@example.com")
555@dataclass(kw_only=True, slots=True)
556class EmailAddressVerifiedEmail(EmailBase):
557 """Sent to a user to notify them that their new email address has been verified."""
559 @property
560 def string_key_prefix(self) -> str:
561 return "email_address_verified"
563 def build_body(self, builder: EmailBlocksBuilder, loc_context: LocalizationContext) -> None:
564 builder.para("body")
565 builder.security_warning_para()
567 @classmethod
568 def dummy_data(cls) -> EmailAddressVerifiedEmail:
569 return EmailAddressVerifiedEmail(user_name="Alice")
572@dataclass(kw_only=True, slots=True)
573class FriendRequestReceivedEmail(EmailBase):
574 """Sent to a user when they receive a friend request."""
576 befriender: UserInfo
578 @property
579 def string_key_prefix(self) -> str:
580 return "friend_request_received"
582 def get_subject_line(self, loc_context: LocalizationContext) -> str:
583 return self._localize(loc_context, "subject", {"name": self.befriender.name})
585 def get_preview_line(self, loc_context: LocalizationContext) -> str:
586 return self._localize(loc_context, "body", {"name": self.befriender.name})
588 def build_body(self, builder: EmailBlocksBuilder, loc_context: LocalizationContext) -> None:
589 builder.para("body", {"name": self.befriender.name})
590 builder.user(self.befriender)
591 builder.action(urls.friend_requests_link(), "view_action")
592 builder.para("closing")
593 builder.do_not_reply_request_para()
595 @classmethod
596 def from_notification(cls, data: notification_data_pb2.FriendRequestCreate, *, user_name: str) -> Self:
597 return cls(user_name=user_name, befriender=UserInfo.from_protobuf(data.other_user))
599 @classmethod
600 def dummy_data(cls) -> FriendRequestReceivedEmail:
601 return FriendRequestReceivedEmail(
602 user_name="Alice",
603 befriender=UserInfo.dummy_bob(),
604 )
607@dataclass(kw_only=True, slots=True)
608class FriendRequestAcceptedEmail(EmailBase):
609 """Sent to a user when their friend request is accepted."""
611 new_friend: UserInfo
613 @property
614 def string_key_prefix(self) -> str:
615 return "friend_request_accepted"
617 def get_subject_line(self, loc_context: LocalizationContext) -> str:
618 return self._localize(loc_context, "subject", {"name": self.new_friend.name})
620 def get_preview_line(self, loc_context: LocalizationContext) -> str:
621 return self._localize(loc_context, "body", {"name": self.new_friend.name})
623 def build_body(self, builder: EmailBlocksBuilder, loc_context: LocalizationContext) -> None:
624 builder.para("body", {"name": self.new_friend.name})
625 builder.user(self.new_friend)
626 builder.action(self.new_friend.profile_url, "view_action")
627 builder.para("closing")
629 @classmethod
630 def from_notification(cls, data: notification_data_pb2.FriendRequestAccept, *, user_name: str) -> Self:
631 return cls(user_name=user_name, new_friend=UserInfo.from_protobuf(data.other_user))
633 @classmethod
634 def dummy_data(cls) -> FriendRequestAcceptedEmail:
635 return FriendRequestAcceptedEmail(
636 user_name="Alice",
637 new_friend=UserInfo.dummy_bob(),
638 )
641@dataclass(kw_only=True, slots=True)
642class GenderChangedEmail(EmailBase):
643 """Sent to a user to notify them that their gender was changed."""
645 new_gender: str
647 @property
648 def string_key_prefix(self) -> str:
649 return "gender_changed"
651 def build_body(self, builder: EmailBlocksBuilder, loc_context: LocalizationContext) -> None:
652 builder.para("body", {"gender": self.new_gender})
653 builder.security_warning_para()
655 @classmethod
656 def from_notification(cls, data: notification_data_pb2.GenderChange, *, user_name: str) -> Self:
657 return cls(user_name=user_name, new_gender=data.gender)
659 @classmethod
660 def dummy_data(cls) -> GenderChangedEmail:
661 return GenderChangedEmail(
662 user_name="Alice",
663 new_gender="Male",
664 )
667@dataclass(kw_only=True, slots=True)
668class HostRequestCreatedEmail(EmailBase):
669 """Sent to a host when a surfer sends them a new host request."""
671 surfer: UserInfo
672 from_date: date
673 to_date: date
674 text: str
675 quick_decline_link: str
676 view_link: str
678 @property
679 def string_key_prefix(self) -> str:
680 return "host_request_created"
682 def get_subject_line(self, loc_context: LocalizationContext) -> str:
683 return self._localize(loc_context, "subject", {"surfer_name": self.surfer.name})
685 def build_body(self, builder: EmailBlocksBuilder, loc_context: LocalizationContext) -> None:
686 builder.para("body", {"surfer_name": self.surfer.name})
687 builder.user(
688 self.surfer,
689 "date_range",
690 {
691 "from_date": loc_context.localize_date(self.from_date),
692 "to_date": loc_context.localize_date(self.to_date),
693 },
694 )
695 builder.quote(self.text, markdown=False)
696 builder.action(self.view_link, "view_action")
697 builder.action(self.quick_decline_link, "quick_decline_action")
698 builder.para("respond_encouragement")
699 builder.do_not_reply_request_para()
701 @classmethod
702 def from_notification(cls, data: notification_data_pb2.HostRequestCreate, *, user_name: str) -> Self:
703 return cls(
704 user_name,
705 surfer=UserInfo.from_protobuf(data.surfer),
706 from_date=date.fromisoformat(data.host_request.from_date),
707 to_date=date.fromisoformat(data.host_request.to_date),
708 text=data.text,
709 quick_decline_link=generate_quick_decline_link(data.host_request),
710 view_link=urls.host_request(host_request_id=data.host_request.host_request_id),
711 )
713 @classmethod
714 def dummy_data(cls) -> HostRequestCreatedEmail:
715 return HostRequestCreatedEmail(
716 user_name="Alice",
717 surfer=UserInfo.dummy_bob(),
718 from_date=date(2025, 6, 1),
719 to_date=date(2025, 6, 7),
720 text="Hey, I'd love to stay for a few nights!",
721 quick_decline_link="https://couchers.org/requests/123/decline?token=xxx",
722 view_link="https://couchers.org/requests/123",
723 )
726@dataclass(kw_only=True, slots=True)
727class HostRequestReminderEmail(EmailBase):
728 """Sent to a host as a reminder to respond to a pending host request."""
730 surfer: UserInfo
731 from_date: date
732 to_date: date
733 view_link: str
735 @property
736 def string_key_prefix(self) -> str:
737 return "host_request_reminder"
739 def get_subject_line(self, loc_context: LocalizationContext) -> str:
740 return self._localize(loc_context, "subject", {"surfer_name": self.surfer.name})
742 def build_body(self, builder: EmailBlocksBuilder, loc_context: LocalizationContext) -> None:
743 builder.para("body")
744 builder.user(
745 self.surfer,
746 ".host_request_generic.date_range",
747 {
748 "from_date": loc_context.localize_date(self.from_date),
749 "to_date": loc_context.localize_date(self.to_date),
750 },
751 )
752 builder.action(self.view_link, ".host_request_generic.view_action")
753 builder.do_not_reply_request_para()
755 @classmethod
756 def from_notification(cls, data: notification_data_pb2.HostRequestReminder, *, user_name: str) -> Self:
757 return cls(
758 user_name,
759 surfer=UserInfo.from_protobuf(data.surfer),
760 from_date=date.fromisoformat(data.host_request.from_date),
761 to_date=date.fromisoformat(data.host_request.to_date),
762 view_link=urls.host_request(host_request_id=data.host_request.host_request_id),
763 )
765 @classmethod
766 def dummy_data(cls) -> HostRequestReminderEmail:
767 return HostRequestReminderEmail(
768 user_name="Alice",
769 surfer=UserInfo.dummy_bob(),
770 from_date=date(2025, 6, 1),
771 to_date=date(2025, 6, 7),
772 view_link="https://couchers.org/requests/123",
773 )
776@dataclass(kw_only=True, slots=True)
777class HostRequestMessageEmail(EmailBase):
778 """Sent when a user sends a message in an existing host request."""
780 other_user: UserInfo
781 from_date: date
782 to_date: date
783 text: str
784 from_host: bool
785 view_link: str
787 @property
788 def string_key_prefix(self) -> str:
789 variant = "from_host" if self.from_host else "from_surfer"
790 return f"host_request_message.{variant}"
792 def get_subject_line(self, loc_context: LocalizationContext) -> str:
793 return self._localize(loc_context, "subject", {"other_name": self.other_user.name})
795 def build_body(self, builder: EmailBlocksBuilder, loc_context: LocalizationContext) -> None:
796 builder.para("body", {"other_name": self.other_user.name})
797 builder.user(
798 self.other_user,
799 ".host_request_generic.date_range",
800 {
801 "from_date": loc_context.localize_date(self.from_date),
802 "to_date": loc_context.localize_date(self.to_date),
803 },
804 )
805 builder.quote(self.text, markdown=False)
806 builder.action(self.view_link, ".host_request_generic.view_action")
807 builder.do_not_reply_request_para()
809 @classmethod
810 def from_notification(cls, data: notification_data_pb2.HostRequestMessage, *, user_name: str) -> Self:
811 return cls(
812 user_name,
813 other_user=UserInfo.from_protobuf(data.user),
814 from_date=date.fromisoformat(data.host_request.from_date),
815 to_date=date.fromisoformat(data.host_request.to_date),
816 text=data.text,
817 from_host=not data.am_host,
818 view_link=urls.host_request(host_request_id=data.host_request.host_request_id),
819 )
821 @classmethod
822 def dummy_data(cls) -> HostRequestMessageEmail:
823 return HostRequestMessageEmail(
824 user_name="Alice",
825 other_user=UserInfo.dummy_bob(),
826 from_date=date(2025, 6, 1),
827 to_date=date(2025, 6, 7),
828 text="Looking forward to it, see you soon!",
829 from_host=True,
830 view_link="https://couchers.org/requests/123",
831 )
833 @classmethod
834 def dummy_variants(cls) -> list[HostRequestMessageEmail]:
835 base = cls.dummy_data()
836 return [replace(base, from_host=True), replace(base, from_host=False)]
839@dataclass(kw_only=True, slots=True)
840class HostRequestMissedMessagesEmail(EmailBase):
841 """Sent as a digest when a user has missed messages in a host request."""
843 other_user: UserInfo
844 from_date: date
845 to_date: date
846 from_host: bool
847 view_link: str
849 @property
850 def string_key_prefix(self) -> str:
851 variant = "from_host" if self.from_host else "from_surfer"
852 return f"host_request_missed_messages.{variant}"
854 def get_subject_line(self, loc_context: LocalizationContext) -> str:
855 return self._localize(loc_context, "subject", {"other_name": self.other_user.name})
857 def build_body(self, builder: EmailBlocksBuilder, loc_context: LocalizationContext) -> None:
858 builder.para("body", {"other_name": self.other_user.name})
859 builder.user(
860 self.other_user,
861 ".host_request_generic.date_range",
862 {
863 "from_date": loc_context.localize_date(self.from_date),
864 "to_date": loc_context.localize_date(self.to_date),
865 },
866 )
867 builder.action(self.view_link, ".host_request_generic.view_action")
868 builder.do_not_reply_request_para()
870 @classmethod
871 def from_notification(cls, data: notification_data_pb2.HostRequestMissedMessages, *, user_name: str) -> Self:
872 return cls(
873 user_name,
874 other_user=UserInfo.from_protobuf(data.user),
875 from_date=date.fromisoformat(data.host_request.from_date),
876 to_date=date.fromisoformat(data.host_request.to_date),
877 from_host=not data.am_host,
878 view_link=urls.host_request(host_request_id=data.host_request.host_request_id),
879 )
881 @classmethod
882 def dummy_data(cls) -> HostRequestMissedMessagesEmail:
883 return HostRequestMissedMessagesEmail(
884 user_name="Alice",
885 other_user=UserInfo.dummy_bob(),
886 from_date=date(2025, 6, 1),
887 to_date=date(2025, 6, 7),
888 from_host=True,
889 view_link="https://couchers.org/requests/123",
890 )
892 @classmethod
893 def dummy_variants(cls) -> list[HostRequestMissedMessagesEmail]:
894 base = cls.dummy_data()
895 return [replace(base, from_host=True), replace(base, from_host=False)]
898@dataclass(kw_only=True, slots=True)
899class HostRequestStatusChangedEmail(EmailBase):
900 """Sent when a host request is accepted, declined, confirmed, or cancelled."""
902 other_user: UserInfo
903 from_date: date
904 to_date: date
905 new_status: conversations_pb2.HostRequestStatus.ValueType
906 view_link: str
908 @property
909 def string_key_prefix(self) -> str:
910 base_key = "host_request_status_changed"
911 match self.new_status:
912 case conversations_pb2.HOST_REQUEST_STATUS_ACCEPTED:
913 return f"{base_key}.accepted_by_host"
914 case conversations_pb2.HOST_REQUEST_STATUS_REJECTED:
915 return f"{base_key}.declined_by_host"
916 case conversations_pb2.HOST_REQUEST_STATUS_CONFIRMED:
917 return f"{base_key}.confirmed_by_surfer"
918 case conversations_pb2.HOST_REQUEST_STATUS_CANCELLED: 918 ↛ 920line 918 didn't jump to line 920 because the pattern on line 918 always matched
919 return f"{base_key}.cancelled_by_surfer"
920 case _:
921 raise ValueError(f"Unexpected host request status: {self.new_status}")
923 def get_subject_line(self, loc_context: LocalizationContext) -> str:
924 return self._localize(loc_context, "subject", {"other_name": self.other_user.name})
926 def build_body(self, builder: EmailBlocksBuilder, loc_context: LocalizationContext) -> None:
927 builder.para("body", {"other_name": self.other_user.name})
928 builder.user(
929 self.other_user,
930 ".host_request_generic.date_range",
931 {
932 "from_date": loc_context.localize_date(self.from_date),
933 "to_date": loc_context.localize_date(self.to_date),
934 },
935 )
936 builder.action(self.view_link, ".host_request_generic.view_action")
937 builder.do_not_reply_request_para()
939 @classmethod
940 def from_notification(
941 cls,
942 data: notification_data_pb2.HostRequestAccept
943 | notification_data_pb2.HostRequestReject
944 | notification_data_pb2.HostRequestConfirm
945 | notification_data_pb2.HostRequestCancel,
946 *,
947 user_name: str,
948 ) -> Self:
949 other_user: UserInfo
950 new_status: conversations_pb2.HostRequestStatus.ValueType
951 match data:
952 case notification_data_pb2.HostRequestAccept():
953 other_user = UserInfo.from_protobuf(data.host)
954 new_status = conversations_pb2.HostRequestStatus.HOST_REQUEST_STATUS_ACCEPTED
955 case notification_data_pb2.HostRequestReject(): 955 ↛ 956line 955 didn't jump to line 956 because the pattern on line 955 never matched
956 other_user = UserInfo.from_protobuf(data.host)
957 new_status = conversations_pb2.HostRequestStatus.HOST_REQUEST_STATUS_REJECTED
958 case notification_data_pb2.HostRequestConfirm():
959 other_user = UserInfo.from_protobuf(data.surfer)
960 new_status = conversations_pb2.HostRequestStatus.HOST_REQUEST_STATUS_CONFIRMED
961 case notification_data_pb2.HostRequestCancel(): 961 ↛ 964line 961 didn't jump to line 964 because the pattern on line 961 always matched
962 other_user = UserInfo.from_protobuf(data.surfer)
963 new_status = conversations_pb2.HostRequestStatus.HOST_REQUEST_STATUS_CANCELLED
964 case _:
965 # Enable mypy's exhaustiveness checking
966 assert_never("Unexpected host request status changed notification data type.")
968 return cls(
969 user_name,
970 other_user=other_user,
971 from_date=date.fromisoformat(data.host_request.from_date),
972 to_date=date.fromisoformat(data.host_request.to_date),
973 new_status=new_status,
974 view_link=urls.host_request(host_request_id=data.host_request.host_request_id),
975 )
977 @classmethod
978 def dummy_data(cls) -> HostRequestStatusChangedEmail:
979 return HostRequestStatusChangedEmail(
980 user_name="Alice",
981 other_user=UserInfo.dummy_bob(),
982 from_date=date(2025, 6, 1),
983 to_date=date(2025, 6, 7),
984 new_status=conversations_pb2.HOST_REQUEST_STATUS_ACCEPTED,
985 view_link="https://couchers.org/requests/123",
986 )
988 @classmethod
989 def dummy_variants(cls) -> list[HostRequestStatusChangedEmail]:
990 base = cls.dummy_data()
991 return [
992 replace(base, new_status=conversations_pb2.HOST_REQUEST_STATUS_ACCEPTED),
993 replace(base, new_status=conversations_pb2.HOST_REQUEST_STATUS_REJECTED),
994 replace(base, new_status=conversations_pb2.HOST_REQUEST_STATUS_CONFIRMED),
995 replace(base, new_status=conversations_pb2.HOST_REQUEST_STATUS_CANCELLED),
996 ]
999@dataclass(kw_only=True, slots=True)
1000class ModeratorNoteEmail(EmailBase):
1001 """Sent to a user to notify them they have received a moderator note."""
1003 @property
1004 def string_key_prefix(self) -> str:
1005 return "moderator_note"
1007 def build_body(self, builder: EmailBlocksBuilder, loc_context: LocalizationContext) -> None:
1008 builder.para("body")
1010 @classmethod
1011 def dummy_data(cls) -> ModeratorNoteEmail:
1012 return ModeratorNoteEmail(user_name="Alice")
1015@dataclass(kw_only=True, slots=True)
1016class PasswordChangedEmail(EmailBase):
1017 """Sent to a user to notify them that their login password was changed."""
1019 @property
1020 def string_key_prefix(self) -> str:
1021 return "password_changed"
1023 def build_body(self, builder: EmailBlocksBuilder, loc_context: LocalizationContext) -> None:
1024 builder.para("body")
1025 builder.security_warning_para()
1027 @classmethod
1028 def dummy_data(cls) -> PasswordChangedEmail:
1029 return PasswordChangedEmail(user_name="Alice")
1032@dataclass(kw_only=True, slots=True)
1033class PasswordResetCompletedEmail(EmailBase):
1034 """Sent to a user to confirm their password was successfully reset."""
1036 @property
1037 def string_key_prefix(self) -> str:
1038 return "password_reset_completed"
1040 def build_body(self, builder: EmailBlocksBuilder, loc_context: LocalizationContext) -> None:
1041 builder.para("body")
1042 builder.security_warning_para()
1044 @classmethod
1045 def dummy_data(cls) -> PasswordResetCompletedEmail:
1046 return PasswordResetCompletedEmail(user_name="Alice")
1049@dataclass(kw_only=True, slots=True)
1050class PasswordResetStartedEmail(EmailBase):
1051 """Sent to a user with a link to complete their password reset."""
1053 password_reset_link: str
1055 @property
1056 def string_key_prefix(self) -> str:
1057 return "password_reset_started"
1059 def build_body(self, builder: EmailBlocksBuilder, loc_context: LocalizationContext) -> None:
1060 builder.para("request_description")
1061 builder.para("confirmation_instructions")
1062 builder.action(self.password_reset_link, "reset_action")
1063 builder.security_warning_para()
1065 @classmethod
1066 def from_notification(cls, data: notification_data_pb2.PasswordResetStart, *, user_name: str) -> Self:
1067 return cls(
1068 user_name=user_name,
1069 password_reset_link=urls.password_reset_link(password_reset_token=data.password_reset_token),
1070 )
1072 @classmethod
1073 def dummy_data(cls) -> PasswordResetStartedEmail:
1074 return PasswordResetStartedEmail(user_name="Alice", password_reset_link="https://couchers.org/reset-password")
1077@dataclass(kw_only=True, slots=True)
1078class PhoneNumberChangeEmail(EmailBase):
1079 """Sent to a user to notify them that their phone number verification status was changed."""
1081 new_phone_number: str
1082 completed: bool # False = started, True = completed
1084 @property
1085 def string_key_prefix(self) -> str:
1086 return "phone_number_verified" if self.completed else "phone_number_verification_started"
1088 def build_body(self, builder: EmailBlocksBuilder, loc_context: LocalizationContext) -> None:
1089 builder.para("body", {"phone_number": format_phone_number(self.new_phone_number)})
1090 builder.security_warning_para()
1092 @classmethod
1093 def from_change_notification(cls, data: notification_data_pb2.PhoneNumberChange, *, user_name: str) -> Self:
1094 return cls(user_name=user_name, new_phone_number=data.phone, completed=False)
1096 @classmethod
1097 def from_verify_notification(cls, data: notification_data_pb2.PhoneNumberVerify, *, user_name: str) -> Self:
1098 return cls(user_name=user_name, new_phone_number=data.phone, completed=True)
1100 @classmethod
1101 def dummy_data(cls) -> PhoneNumberChangeEmail:
1102 return PhoneNumberChangeEmail(
1103 user_name="Alice",
1104 new_phone_number="+12223334444",
1105 completed=False,
1106 )
1108 @classmethod
1109 def dummy_variants(cls) -> list[PhoneNumberChangeEmail]:
1110 base = cls.dummy_data()
1111 return [replace(base, completed=False), replace(base, completed=True)]
1114@dataclass(kw_only=True, slots=True)
1115class PostalVerificationFailedEmail(EmailBase):
1116 """Sent to a user when their postal verification attempt has failed."""
1118 reason: notification_data_pb2.PostalVerificationFailReason.ValueType
1120 @property
1121 def string_key_prefix(self) -> str:
1122 return "postal_verification_failed"
1124 def build_body(self, builder: EmailBlocksBuilder, loc_context: LocalizationContext) -> None:
1125 match self.reason:
1126 case notification_data_pb2.POSTAL_VERIFICATION_FAIL_REASON_CODE_EXPIRED:
1127 reason_string_key = "reason_code_expired"
1128 case notification_data_pb2.POSTAL_VERIFICATION_FAIL_REASON_TOO_MANY_ATTEMPTS:
1129 reason_string_key = "reason_too_many_attempts"
1130 case _:
1131 reason_string_key = "reason_unknown"
1132 builder.para(reason_string_key)
1133 builder.security_warning_para()
1135 @classmethod
1136 def from_notification(cls, data: notification_data_pb2.PostalVerificationFailed, *, user_name: str) -> Self:
1137 return cls(user_name=user_name, reason=data.reason)
1139 @classmethod
1140 def dummy_data(cls) -> PostalVerificationFailedEmail:
1141 return PostalVerificationFailedEmail(
1142 user_name="Alice",
1143 reason=notification_data_pb2.POSTAL_VERIFICATION_FAIL_REASON_CODE_EXPIRED,
1144 )
1146 @classmethod
1147 def dummy_variants(cls) -> list[PostalVerificationFailedEmail]:
1148 base = cls.dummy_data()
1149 return [
1150 replace(base, reason=notification_data_pb2.POSTAL_VERIFICATION_FAIL_REASON_CODE_EXPIRED),
1151 replace(base, reason=notification_data_pb2.POSTAL_VERIFICATION_FAIL_REASON_TOO_MANY_ATTEMPTS),
1152 replace(base, reason=notification_data_pb2.POSTAL_VERIFICATION_FAIL_REASON_UNKNOWN),
1153 ]
1156@dataclass(kw_only=True, slots=True)
1157class PostalVerificationPostcardSentEmail(EmailBase):
1158 """Sent to a user to notify them that their verification postcard has been sent."""
1160 city: str
1161 country: str
1163 @property
1164 def string_key_prefix(self) -> str:
1165 return "postal_verification_postcard_sent"
1167 def build_body(self, builder: EmailBlocksBuilder, loc_context: LocalizationContext) -> None:
1168 builder.para("body", {"city": self.city, "country": self.country})
1169 builder.security_warning_para()
1171 @classmethod
1172 def from_notification(cls, data: notification_data_pb2.PostalVerificationPostcardSent, *, user_name: str) -> Self:
1173 return cls(user_name=user_name, city=data.city, country=data.country)
1175 @classmethod
1176 def dummy_data(cls) -> PostalVerificationPostcardSentEmail:
1177 return PostalVerificationPostcardSentEmail(user_name="Alice", city="New York", country="United States")
1180@dataclass(kw_only=True, slots=True)
1181class PostalVerificationSucceededEmail(EmailBase):
1182 """Sent to a user when their postal verification has succeeded."""
1184 @property
1185 def string_key_prefix(self) -> str:
1186 return "postal_verification_succeeded"
1188 def build_body(self, builder: EmailBlocksBuilder, loc_context: LocalizationContext) -> None:
1189 builder.para("body")
1190 builder.security_warning_para()
1192 @classmethod
1193 def dummy_data(cls) -> PostalVerificationSucceededEmail:
1194 return PostalVerificationSucceededEmail(user_name="Alice")
1197@dataclass(kw_only=True, slots=True)
1198class StrongVerificationFailedEmail(EmailBase):
1199 """Sent to a user when their strong verification attempt has failed."""
1201 reason: notification_data_pb2.SVFailReason.ValueType
1203 @property
1204 def string_key_prefix(self) -> str:
1205 return "strong_verification_failed"
1207 def build_body(self, builder: EmailBlocksBuilder, loc_context: LocalizationContext) -> None:
1208 match self.reason:
1209 case notification_data_pb2.SV_FAIL_REASON_WRONG_BIRTHDATE_OR_GENDER:
1210 reason_string_key = "reason_wrong_birthdate_or_gender"
1211 case notification_data_pb2.SV_FAIL_REASON_NOT_A_PASSPORT:
1212 reason_string_key = "reason_not_a_passport"
1213 case notification_data_pb2.SV_FAIL_REASON_DUPLICATE: 1213 ↛ 1215line 1213 didn't jump to line 1215 because the pattern on line 1213 always matched
1214 reason_string_key = "reason_duplicate"
1215 case _:
1216 raise Exception("Shouldn't get here")
1217 builder.para(reason_string_key)
1218 builder.security_warning_para()
1220 @classmethod
1221 def from_notification(cls, data: notification_data_pb2.VerificationSVFail, *, user_name: str) -> Self:
1222 return cls(user_name=user_name, reason=data.reason)
1224 @classmethod
1225 def dummy_data(cls) -> StrongVerificationFailedEmail:
1226 return StrongVerificationFailedEmail(
1227 user_name="Alice",
1228 reason=notification_data_pb2.SV_FAIL_REASON_NOT_A_PASSPORT,
1229 )
1231 @classmethod
1232 def dummy_variants(cls) -> list[StrongVerificationFailedEmail]:
1233 base = cls.dummy_data()
1234 return [
1235 replace(base, reason=notification_data_pb2.SV_FAIL_REASON_WRONG_BIRTHDATE_OR_GENDER),
1236 replace(base, reason=notification_data_pb2.SV_FAIL_REASON_NOT_A_PASSPORT),
1237 replace(base, reason=notification_data_pb2.SV_FAIL_REASON_DUPLICATE),
1238 ]
1241@dataclass(kw_only=True, slots=True)
1242class StrongVerificationSucceededEmail(EmailBase):
1243 """Sent to a user when their strong verification has succeeded."""
1245 @property
1246 def string_key_prefix(self) -> str:
1247 return "strong_verification_succeeded"
1249 def build_body(self, builder: EmailBlocksBuilder, loc_context: LocalizationContext) -> None:
1250 builder.para("success_message")
1251 builder.para("thanks_message")
1252 builder.para("cost_explanation")
1253 builder.para("donation_request")
1254 donate_link = urls.donation_url() + "?utm_source=strong-verification-email"
1255 builder.action(donate_link, "donate_action")
1256 builder.security_warning_para()
1258 @classmethod
1259 def dummy_data(cls) -> StrongVerificationSucceededEmail:
1260 return StrongVerificationSucceededEmail(user_name="Alice")
1263@dataclass(kw_only=True, slots=True)
1264class ThreadReplyEmail(EmailBase):
1265 """Sent to a user when someone replies in a comment thread they participated in."""
1267 author: UserInfo
1268 parent_context: str # Title of the event or discussion being replied in
1269 markdown_text: str
1270 view_link: str
1272 @property
1273 def string_key_prefix(self) -> str:
1274 return "thread_reply"
1276 def get_subject_line(self, loc_context: LocalizationContext) -> str:
1277 return self._localize(
1278 loc_context, "subject", {"author": self.author.name, "parent_context": self.parent_context}
1279 )
1281 def build_body(self, builder: EmailBlocksBuilder, loc_context: LocalizationContext) -> None:
1282 builder.para("body", {"author": self.author.name, "parent_context": self.parent_context})
1283 builder.user(self.author)
1284 builder.quote(self.markdown_text, markdown=True)
1285 builder.action(self.view_link, "view_action")
1287 @classmethod
1288 def from_notification(cls, data: notification_data_pb2.ThreadReply, *, user_name: str) -> Self:
1289 parent = data.WhichOneof("reply_parent")
1290 if parent == "event":
1291 parent_context = data.event.title
1292 view_link = urls.event_link(occurrence_id=data.event.event_id, slug=data.event.slug)
1293 elif parent == "discussion": 1293 ↛ 1297line 1293 didn't jump to line 1297 because the condition on line 1293 was always true
1294 parent_context = data.discussion.title
1295 view_link = urls.discussion_link(discussion_id=data.discussion.discussion_id, slug=data.discussion.slug)
1296 else:
1297 raise Exception("Can only do replies to events and discussions")
1298 return cls(
1299 user_name=user_name,
1300 author=UserInfo.from_protobuf(data.author),
1301 parent_context=parent_context,
1302 markdown_text=data.reply.content,
1303 view_link=view_link,
1304 )
1306 @classmethod
1307 def dummy_data(cls) -> ThreadReplyEmail:
1308 return ThreadReplyEmail(
1309 user_name="Alice",
1310 author=UserInfo.dummy_bob(),
1311 parent_context="Best hiking trails near Berlin",
1312 markdown_text="I agree, the Grünewald is **amazing**!",
1313 view_link="https://couchers.org/discussions/123",
1314 )