Coverage for app/backend/src/couchers/email/emails.py: 95%
1070 statements
« prev ^ index » next coverage.py v7.14.3, created at 2026-06-28 13:40 +0000
« prev ^ index » next coverage.py v7.14.3, created at 2026-06-28 13:40 +0000
1"""
2Defines data models for each email we sent out to users.
3"""
5import re
6from dataclasses import dataclass, replace
7from datetime import UTC, date, datetime
8from typing import Self, assert_never
10from markupsafe import Markup, escape
12from couchers import urls
13from couchers.config import config
14from couchers.constants import LATEST_RELEASE_BLOG_URL
15from couchers.email.blocks import (
16 ActionBlock,
17 EmailBase,
18 EmailBlock,
19 ParaBlock,
20 QuoteBlock,
21 UserInfo,
22)
23from couchers.email.locales import get_emails_i18next
24from couchers.i18n import LocalizationContext
25from couchers.i18n.localize import format_phone_number
26from couchers.notifications.quick_links import generate_quick_decline_link
27from couchers.proto import conversations_pb2, events_pb2, notification_data_pb2
28from couchers.utils import now, to_aware_datetime
30# Common string keys
31_do_not_reply_request_string_key = "generic.do_not_reply_request"
33# Specific email definitions
36@dataclass(kw_only=True, slots=True)
37class AccountDeletionStartedEmail(EmailBase):
38 """Sent to a user to confirm their account deletion request."""
40 deletion_link: str
42 @property
43 def string_key_base(self) -> str:
44 return "account_deletion.started"
46 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
47 builder = self._body_builder(loc_context, security_warning=True)
48 builder.para(".request_description")
49 builder.para(".confirmation_instructions")
50 builder.action(self.deletion_link, ".confirm_action")
51 return builder.build()
53 @classmethod
54 def from_notification(cls, data: notification_data_pb2.AccountDeletionStart, *, user_name: str) -> Self:
55 return cls(
56 user_name=user_name,
57 deletion_link=urls.delete_account_link(account_deletion_token=data.deletion_token),
58 )
60 @classmethod
61 def test_instances(cls) -> list[Self]:
62 return [
63 cls(
64 user_name="Alice",
65 deletion_link="https://couchers.org/delete-account?token=xxx",
66 )
67 ]
70@dataclass(kw_only=True, slots=True)
71class AccountDeletionCompletedEmail(EmailBase):
72 """Sent to a user after their account has been deleted."""
74 undelete_link: str
75 days: int
77 @property
78 def string_key_base(self) -> str:
79 return "account_deletion.completed"
81 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
82 builder = self._body_builder(loc_context, security_warning=True)
83 builder.para(".confirmation")
84 builder.para(".farewell")
85 builder.para(".recovery_instructions_days", {"count": self.days})
86 builder.action(self.undelete_link, ".recover_action")
87 return builder.build()
89 @classmethod
90 def from_notification(cls, data: notification_data_pb2.AccountDeletionComplete, *, user_name: str) -> Self:
91 return cls(
92 user_name=user_name,
93 undelete_link=urls.recover_account_link(account_undelete_token=data.undelete_token),
94 days=data.undelete_days,
95 )
97 @classmethod
98 def test_instances(cls) -> list[Self]:
99 return [
100 cls(
101 user_name="Alice",
102 undelete_link="https://couchers.org/recover-account?token=xxx",
103 days=30,
104 )
105 ]
108@dataclass(kw_only=True, slots=True)
109class AccountDeletionRecoveredEmail(EmailBase):
110 """Sent to a user after their account deletion has been cancelled."""
112 @property
113 def string_key_base(self) -> str:
114 return "account_deletion.recovered"
116 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
117 builder = self._body_builder(loc_context, security_warning=True)
118 builder.para(".confirmation")
119 builder.para(".login_instructions")
120 builder.action(urls.app_link(), ".login_action")
121 builder.para(".redelete_instructions")
122 return builder.build()
124 @classmethod
125 def test_instances(cls) -> list[Self]:
126 return [cls(user_name="Alice")]
129@dataclass(kw_only=True, slots=True)
130class ActivenessProbeEmail(EmailBase):
131 """Sent to a host to check if they are still open to hosting."""
133 days_left: int
135 @property
136 def string_key_base(self) -> str:
137 return "activeness_probe"
139 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
140 builder = self._body_builder(loc_context)
141 builder.para(".body")
142 builder.para(".instructions_days", {"count": self.days_left})
143 builder.action(urls.app_link(), ".login_action")
144 builder.para(".encouragement")
146 # Extract major.minor from the version string. "v1.3.18927" -> "1.3"
147 version = config.VERSION
148 if version_match := re.search(r"^v?(\d+\.\d+)\b", version): 148 ↛ 149line 148 didn't jump to line 149 because the condition on line 148 was never true
149 version = version_match[1]
151 builder.para(".latest_release", {"version": version})
152 builder.action(LATEST_RELEASE_BLOG_URL, ".read_blog_action")
153 return builder.build()
155 @classmethod
156 def from_notification(cls, data: notification_data_pb2.ActivenessProbe, *, user_name: str) -> Self:
157 days_left = (to_aware_datetime(data.deadline) - now()).days
158 return cls(user_name=user_name, days_left=days_left)
160 @classmethod
161 def test_instances(cls) -> list[Self]:
162 return [cls(user_name="Alice", days_left=7)]
165@dataclass(kw_only=True, slots=True)
166class APIKeyIssuedEmail(EmailBase):
167 """Sent to a user to notify them that their API key was issued."""
169 api_key: str
170 expiry: datetime
172 @property
173 def string_key_base(self) -> str:
174 return "api_key_issued"
176 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
177 builder = self._body_builder(loc_context, security_warning=True)
178 builder.para(".header")
179 builder.quote(self.api_key, markdown=False)
180 builder.para(".expiry", {"datetime": loc_context.localize_datetime(self.expiry)})
181 builder.para(".usage_warning")
182 builder.para(".policy_warning")
183 return builder.build()
185 @classmethod
186 def from_notification(cls, data: notification_data_pb2.ApiKeyCreate, *, user_name: str) -> Self:
187 return cls(user_name=user_name, api_key=data.api_key, expiry=data.expiry.ToDatetime(tzinfo=UTC))
189 @classmethod
190 def test_instances(cls) -> list[Self]:
191 return [cls(user_name="Alice", api_key="my_api_key_123", expiry=datetime(2099, 12, 31, 23, 59, 59, tzinfo=UTC))]
194@dataclass(kw_only=True, slots=True)
195class BadgeChangedEmail(EmailBase):
196 """Sent to a user to notify them that a badge was added or removed from their profile."""
198 badge_name: str
199 added: bool
201 @property
202 def string_key_base(self) -> str:
203 return "badges.added" if self.added else "badges.removed"
205 def get_subject_line(self, loc_context: LocalizationContext) -> str:
206 return self._localize(loc_context, ".subject", {"name": self.badge_name})
208 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
209 builder = self._body_builder(loc_context)
210 builder.para(".body", {"name": self.badge_name})
211 return builder.build()
213 @classmethod
214 def from_notification(
215 cls, data: notification_data_pb2.BadgeAdd | notification_data_pb2.BadgeRemove, *, user_name: str
216 ) -> Self:
217 return cls(
218 user_name=user_name, badge_name=data.badge_name, added=isinstance(data, notification_data_pb2.BadgeAdd)
219 )
221 @classmethod
222 def test_instances(cls) -> list[Self]:
223 prototype = cls(user_name="Alice", badge_name="Founder", added=True)
224 return [replace(prototype, added=True), replace(prototype, added=False)]
227@dataclass(kw_only=True, slots=True)
228class BirthdateChangedEmail(EmailBase):
229 """Sent to a user to notify them that their birthdate was changed."""
231 new_birthdate: date
233 @property
234 def string_key_base(self) -> str:
235 return "birthdate_changed"
237 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
238 builder = self._body_builder(loc_context, security_warning=True)
239 builder.para(".body", {"date": loc_context.localize_date(self.new_birthdate)})
240 return builder.build()
242 @classmethod
243 def from_notification(cls, data: notification_data_pb2.BirthdateChange, *, user_name: str) -> Self:
244 return cls(user_name=user_name, new_birthdate=date.fromisoformat(data.birthdate))
246 @classmethod
247 def test_instances(cls) -> list[Self]:
248 return [
249 cls(
250 user_name="Alice",
251 new_birthdate=date(1990, 1, 1),
252 )
253 ]
256@dataclass(kw_only=True, slots=True)
257class ChatMessageReceivedEmail(EmailBase):
258 """Sent to a user when they receive a new chat message."""
260 group_chat_title: str | None # None if direct message
261 author: UserInfo
262 text: str
263 view_url: str
265 @property
266 def string_key_base(self) -> str:
267 return f"chat_messages.received.{'direct' if self.group_chat_title is None else 'group'}"
269 def get_subject_line(self, loc_context: LocalizationContext) -> str:
270 return self._localize(
271 loc_context, ".subject", {"author": self.author.name, "group": self.group_chat_title or ""}
272 )
274 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
275 builder = self._body_builder(loc_context)
276 builder.para(".body", {"author": self.author.name, "group": self.group_chat_title or ""})
277 builder.user(self.author)
278 builder.quote(self.text, markdown=False)
279 builder.action(self.view_url, ".view_action")
280 return builder.build()
282 @classmethod
283 def from_notification(cls, data: notification_data_pb2.ChatMessage, *, user_name: str) -> Self:
284 return cls(
285 user_name,
286 author=UserInfo.from_protobuf(data.author),
287 text=data.text,
288 group_chat_title=data.group_chat_title or None,
289 view_url=urls.chat_link(chat_id=data.group_chat_id),
290 )
292 @classmethod
293 def test_instances(cls) -> list[Self]:
294 prototype = cls(
295 user_name="Alice",
296 group_chat_title=None,
297 author=UserInfo.dummy_bob(),
298 text="Hi Alice!",
299 view_url="https://couchers.org/messages/chats/123",
300 )
301 return [
302 replace(prototype, group_chat_title=None),
303 replace(prototype, group_chat_title="Best friends"),
304 ]
307@dataclass(kw_only=True, slots=True)
308class ChatMessagesMissedEmail(EmailBase):
309 """Sent to a user after they've missed new chat messages."""
311 @dataclass(kw_only=True, slots=True)
312 class Entry:
313 """Entry for each chat with missed messages."""
315 group_chat_title: str | None # None if direct message
316 missed_count: int
317 latest_message_author: UserInfo
318 latest_message_text: str
319 view_url: str
321 entries: list[Entry]
323 @property
324 def string_key_base(self) -> str:
325 return "chat_messages.missed"
327 def get_subject_line(self, loc_context: LocalizationContext) -> str:
328 return self._localize(loc_context, ".subject")
330 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
331 builder = self._body_builder(loc_context)
332 for entry in self.entries:
333 if entry.group_chat_title is None:
334 builder.para(".in_dm", {"count": entry.missed_count, "author": entry.latest_message_author.name})
335 else:
336 builder.para(".in_group", {"count": entry.missed_count, "group": entry.group_chat_title})
337 builder.user(entry.latest_message_author)
338 builder.quote(entry.latest_message_text, markdown=False)
339 builder.action(entry.view_url, ".view_action")
340 return builder.build()
342 @classmethod
343 def from_notification(cls, data: notification_data_pb2.ChatMissedMessages, *, user_name: str) -> Self:
344 missed_entries = [
345 cls.Entry(
346 group_chat_title=message.group_chat_title or None,
347 missed_count=message.unseen_count,
348 latest_message_author=UserInfo.from_protobuf(message.author),
349 latest_message_text=message.text,
350 view_url=urls.chat_link(chat_id=message.group_chat_id),
351 )
352 for message in data.messages
353 ]
355 return cls(user_name, entries=missed_entries)
357 @classmethod
358 def test_instances(cls) -> list[Self]:
359 entry_prototype = ChatMessagesMissedEmail.Entry(
360 group_chat_title=None,
361 missed_count=1,
362 latest_message_author=UserInfo.dummy_bob(),
363 latest_message_text="Hello!",
364 view_url="https://couchers.org/messages/chats/123",
365 )
366 return [
367 cls(
368 user_name="Alice",
369 entries=[
370 replace(entry_prototype, group_chat_title=None),
371 replace(entry_prototype, group_chat_title="Best friends"),
372 ],
373 )
374 ]
377@dataclass(kw_only=True, slots=True)
378class DiscussionCreatedEmail(EmailBase):
379 """Sent to a user when a new discussion is created in a community they follow."""
381 author: UserInfo
382 title: str
383 parent_context: str # Community or group name
384 markdown_text: str
385 view_link: str
387 @property
388 def string_key_base(self) -> str:
389 return "discussions.created"
391 def get_subject_line(self, loc_context: LocalizationContext) -> str:
392 return self._localize(loc_context, ".subject", {"author": self.author.name, "title": self.title})
394 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
395 builder = self._body_builder(loc_context)
396 builder.para(
397 ".body",
398 {
399 "author": self.author.name,
400 "title": self.title,
401 "parent_context": self.parent_context,
402 },
403 )
404 builder.user(self.author)
405 builder.quote(self.markdown_text, markdown=True)
406 builder.action(self.view_link, ".view_action")
407 return builder.build()
409 @classmethod
410 def from_notification(cls, data: notification_data_pb2.DiscussionCreate, *, user_name: str) -> Self:
411 discussion = data.discussion
412 return cls(
413 user_name=user_name,
414 author=UserInfo.from_protobuf(data.author),
415 title=discussion.title,
416 parent_context=discussion.owner_title,
417 markdown_text=discussion.content,
418 view_link=urls.discussion_link(discussion_id=discussion.discussion_id, slug=discussion.slug),
419 )
421 @classmethod
422 def test_instances(cls) -> list[Self]:
423 return [
424 cls(
425 user_name="Alice",
426 author=UserInfo.dummy_bob(),
427 title="Best hiking trails near Berlin",
428 parent_context="Berlin",
429 markdown_text="I've been exploring the area and found some **great** spots...",
430 view_link="https://couchers.org/discussions/123",
431 )
432 ]
435@dataclass(kw_only=True, slots=True)
436class DiscussionCommentEmail(EmailBase):
437 """Sent to a user when someone comments on a discussion they follow."""
439 author: UserInfo
440 discussion_title: str
441 discussion_parent_context: str # Community or group name
442 markdown_text: str
443 view_link: str
445 @property
446 def string_key_base(self) -> str:
447 return "discussions.comment"
449 def get_subject_line(self, loc_context: LocalizationContext) -> str:
450 return self._localize(
451 loc_context, ".subject", {"author": self.author.name, "discussion_title": self.discussion_title}
452 )
454 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
455 builder = self._body_builder(loc_context)
456 builder.para(
457 ".body",
458 {
459 "author": self.author.name,
460 "discussion_title": self.discussion_title,
461 "parent_context": self.discussion_parent_context,
462 },
463 )
464 builder.user(self.author)
465 builder.quote(self.markdown_text, markdown=True)
466 builder.action(self.view_link, ".view_action")
467 return builder.build()
469 @classmethod
470 def from_notification(cls, data: notification_data_pb2.DiscussionComment, *, user_name: str) -> Self:
471 discussion = data.discussion
472 return cls(
473 user_name=user_name,
474 author=UserInfo.from_protobuf(data.author),
475 discussion_title=discussion.title,
476 discussion_parent_context=discussion.owner_title,
477 markdown_text=data.reply.content,
478 view_link=urls.discussion_link(discussion_id=discussion.discussion_id, slug=discussion.slug),
479 )
481 @classmethod
482 def test_instances(cls) -> list[Self]:
483 return [
484 cls(
485 user_name="Alice",
486 author=UserInfo.dummy_bob(),
487 discussion_title="Best hiking trails near Berlin",
488 discussion_parent_context="Berlin",
489 markdown_text="Great recommendations, I also **love** the Grünewald forest!",
490 view_link="https://couchers.org/discussions/123",
491 )
492 ]
495@dataclass(kw_only=True, slots=True)
496class DonationReceivedEmail(EmailBase):
497 """Sent to a user to thank them for a donation."""
499 amount: int
500 receipt_url: str
502 @property
503 def string_key_base(self) -> str:
504 return "donation_received"
506 def get_preview_line(self, loc_context: LocalizationContext) -> str:
507 return self._localize(loc_context, ".thanks_amount", {"amount": self.amount})
509 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
510 builder = self._body_builder(loc_context, standard_closing=False)
511 builder.para(".thanks_amount", {"amount": self.amount})
512 builder.para(".purpose")
513 builder.para(".invoice_receipt_info")
514 builder.action(self.receipt_url, ".download_invoice")
515 builder.para(".tax_acknowledgment")
516 builder.para(".questions_contact")
517 builder.para(".generosity_helps")
518 builder.para(".thank_you")
519 builder.para("generic.founders_signature")
520 return builder.build()
522 @classmethod
523 def from_notification(cls, data: notification_data_pb2.DonationReceived, *, user_name: str) -> Self:
524 return cls(user_name=user_name, amount=data.amount, receipt_url=data.receipt_url)
526 @classmethod
527 def test_instances(cls) -> list[Self]:
528 return [cls(user_name="Alice", amount=25, receipt_url="https://couchers.org/receipts/123")]
531@dataclass(kw_only=True, slots=True)
532class EmailChangedEmail(EmailBase):
533 """Sent to a user to notify them that their email address was changed."""
535 new_email: str
537 @property
538 def string_key_base(self) -> str:
539 return "email_change.initiated"
541 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
542 builder = self._body_builder(loc_context, security_warning=True)
543 builder.para(".body", {"email_address": self.new_email})
544 return builder.build()
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 test_instances(cls) -> list[Self]:
552 return [cls(user_name="Alice", new_email="alice@example.com")]
555@dataclass(kw_only=True, slots=True)
556class EmailChangeConfirmationEmail(EmailBase):
557 """Sent to a user to confirm their new email address."""
559 old_email: str
560 confirm_url: str
562 @property
563 def string_key_base(self) -> str:
564 return "email_change.confirmation"
566 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
567 builder = self._body_builder(loc_context, security_warning=True)
568 builder.para(".context", {"old_email": self.old_email})
569 builder.para(".instructions")
570 builder.action(self.confirm_url, ".confirm_action")
571 return builder.build()
573 @classmethod
574 def test_instances(cls) -> list[Self]:
575 return [cls(user_name="Alice", old_email="alice@example.com", confirm_url="https://example.com")]
578@dataclass(kw_only=True, slots=True)
579class EmailVerifiedEmail(EmailBase):
580 """Sent to a user to notify them that their new email address has been verified."""
582 @property
583 def string_key_base(self) -> str:
584 return "email_change.verified"
586 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
587 builder = self._body_builder(loc_context, security_warning=True)
588 builder.para(".body")
589 return builder.build()
591 @classmethod
592 def test_instances(cls) -> list[Self]:
593 return [cls(user_name="Alice")]
596@dataclass(kw_only=True, slots=True)
597class EventInfo:
598 """Common display fields for an event, extracted from its proto representation."""
600 title: str
601 start_time: datetime
602 end_time: datetime
603 online_link: str | None
604 address: str | None
605 view_url: str
606 description_markdown: str
608 def get_details_block(self, loc_context: LocalizationContext) -> EmailBlock:
609 # TODO(#8695): Support localized time ranges
610 start_time_display = loc_context.localize_datetime(self.start_time, with_year=False, with_day_of_week=True)
611 end_time_display = loc_context.localize_datetime(self.end_time, with_year=False, with_day_of_week=True)
612 time_range_display = f"{start_time_display} - {end_time_display}"
614 # Format the following. Only "Online" is translated and it's on its own line,
615 # so the string concatenation is fine.
616 # **<title>**
617 # <datetime-range>
618 # *<address> / [Online](<online_link>)*
619 html = f"<b>{escape(self.title)}</b>"
620 html += "<br>"
621 html += time_range_display
622 if self.online_link: 622 ↛ 623line 622 didn't jump to line 623 because the condition on line 622 was never true
623 html += "<br>"
624 online_link_text = get_emails_i18next().localize("events.generic.online_link", loc_context.locale)
625 html += f'<i><a href="{escape(self.online_link)}">{escape(online_link_text)}</a></i>'
626 elif self.address:
627 html += "<br>"
628 html += f"<i>{escape(self.address)}</i>"
630 return ParaBlock(text=Markup(html))
632 def get_description_block(self) -> EmailBlock:
633 return QuoteBlock(text=Markup(self.description_markdown), markdown=True)
635 def get_view_action_block(self, loc_context: LocalizationContext) -> EmailBlock:
636 view_action_text = get_emails_i18next().localize("events.generic.view_action", loc_context.locale)
637 return ActionBlock(text=view_action_text, target_url=self.view_url)
639 @classmethod
640 def from_proto(cls, event: events_pb2.Event) -> EventInfo:
641 return cls(
642 title=event.title,
643 start_time=event.start_time.ToDatetime(tzinfo=UTC),
644 end_time=event.end_time.ToDatetime(tzinfo=UTC),
645 online_link=event.online_information.link or None,
646 address=event.offline_information.address or None,
647 view_url=urls.event_link(occurrence_id=event.event_id, slug=event.slug),
648 description_markdown=event.content or "",
649 )
651 @staticmethod
652 def dummy() -> EventInfo:
653 return EventInfo(
654 title="Berlin Meetup",
655 start_time=datetime(2025, 7, 15, 18, 0, 0, tzinfo=UTC),
656 end_time=datetime(2025, 7, 15, 21, 0, 0, tzinfo=UTC),
657 online_link=None,
658 address="Alexanderplatz, Berlin",
659 view_url="https://couchers.org/events/123/berlin-community-meetup",
660 description_markdown="Come join us for our monthly meetup!",
661 )
664@dataclass(kw_only=True, slots=True)
665class EventCreatedEmail(EmailBase):
666 """Sent when a user is invited to an event (create_approved) or a new event is created (create_any)."""
668 inviting_user: UserInfo
669 event_info: EventInfo
670 community_name: str | None
671 community_url: str | None
672 is_invite: bool # True = create_approved (invitation), False = create_any
674 @property
675 def string_key_base(self) -> str:
676 return f"events.created.{'invitation' if self.is_invite else 'notification'}"
678 def get_subject_line(self, loc_context: LocalizationContext) -> str:
679 return self._localize(
680 loc_context, ".subject", {"user": self.inviting_user.name, "title": self.event_info.title}
681 )
683 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
684 builder = self._body_builder(loc_context)
685 if self.community_name:
686 builder.para(".body_with_community", {"community": self.community_name})
687 else:
688 builder.para(".body_no_community")
689 builder.block(self.event_info.get_details_block(loc_context))
690 builder.user(self.inviting_user)
691 builder.block(self.event_info.get_description_block())
692 builder.block(self.event_info.get_view_action_block(loc_context))
693 return builder.build()
695 @classmethod
696 def from_notification(cls, data: notification_data_pb2.EventCreate, *, user_name: str, is_invite: bool) -> Self:
697 has_community = bool(data.in_community.community_id)
698 community_url = (
699 urls.community_link(node_id=data.in_community.community_id, slug=data.in_community.slug)
700 if has_community
701 else None
702 )
703 return cls(
704 user_name=user_name,
705 inviting_user=UserInfo.from_protobuf(data.inviting_user),
706 event_info=EventInfo.from_proto(data.event),
707 community_name=data.in_community.name if has_community else None,
708 community_url=community_url,
709 is_invite=is_invite,
710 )
712 @classmethod
713 def test_instances(cls) -> list[Self]:
714 prototype = cls(
715 user_name="Alice",
716 inviting_user=UserInfo.dummy_bob(),
717 event_info=EventInfo.dummy(),
718 community_name="Berlin",
719 community_url="https://couchers.org/community/1/berlin-community",
720 is_invite=True,
721 )
722 return [
723 replace(prototype, is_invite=True),
724 replace(prototype, is_invite=True, community_name=None, community_url=None),
725 replace(prototype, is_invite=False),
726 replace(prototype, is_invite=False, community_name=None, community_url=None),
727 ]
730@dataclass(kw_only=True, slots=True)
731class EventUpdatedEmail(EmailBase):
732 """Sent to subscribers when an event is updated."""
734 updating_user: UserInfo
735 event_info: EventInfo
736 updated_items: list[notification_data_pb2.EventUpdateItem.ValueType]
738 @property
739 def string_key_base(self) -> str:
740 return "events.updated"
742 def get_subject_line(self, loc_context: LocalizationContext) -> str:
743 return self._localize(
744 loc_context, ".subject", {"user": self.updating_user.name, "title": self.event_info.title}
745 )
747 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
748 builder = self._body_builder(loc_context)
749 builder.para(".body")
751 updated_items_string_keys = list(
752 filter(None, (type(self)._updated_item_to_string_key(i) for i in self.updated_items))
753 )
754 if updated_items_string_keys:
755 updated_items_text = loc_context.localize_list(
756 [self._localize(loc_context, key) for key in updated_items_string_keys]
757 )
758 builder.para(".updated_items", {"items_list": updated_items_text})
759 else:
760 builder.para(".updated_generic")
762 builder.block(self.event_info.get_details_block(loc_context))
763 builder.user(self.updating_user)
764 builder.block(self.event_info.get_description_block())
765 builder.block(self.event_info.get_view_action_block(loc_context))
766 return builder.build()
768 @classmethod
769 def from_notification(cls, data: notification_data_pb2.EventUpdate, *, user_name: str) -> Self:
770 updated_items: list[notification_data_pb2.EventUpdateItem.ValueType] = []
771 if data.updated_enum_items: 771 ↛ 773line 771 didn't jump to line 773 because the condition on line 771 was always true
772 updated_items.extend(data.updated_enum_items)
773 elif data.updated_str_items:
774 for updated_str_item in data.updated_str_items:
775 if updated_enum_item := cls._updated_item_str_to_enum(updated_str_item):
776 updated_items.append(updated_enum_item)
778 return cls(
779 user_name=user_name,
780 updating_user=UserInfo.from_protobuf(data.updating_user),
781 event_info=EventInfo.from_proto(data.event),
782 updated_items=updated_items,
783 )
785 # TODO(#9117): Backcompat. Remove update_str_items fallback once known unused.
786 @staticmethod
787 def _updated_item_str_to_enum(value: str) -> notification_data_pb2.EventUpdateItem.ValueType | None:
788 match value:
789 case "title":
790 return notification_data_pb2.EventUpdateItem.EVENT_UPDATE_ITEM_TITLE
791 case "content":
792 return notification_data_pb2.EventUpdateItem.EVENT_UPDATE_ITEM_CONTENT
793 case "location":
794 return notification_data_pb2.EventUpdateItem.EVENT_UPDATE_ITEM_LOCATION
795 case "start time":
796 return notification_data_pb2.EventUpdateItem.EVENT_UPDATE_ITEM_START_TIME
797 case "end time":
798 return notification_data_pb2.EventUpdateItem.EVENT_UPDATE_ITEM_END_TIME
799 case _:
800 return None
802 @staticmethod
803 def _updated_item_to_string_key(value: notification_data_pb2.EventUpdateItem.ValueType) -> str | None:
804 match value:
805 case notification_data_pb2.EventUpdateItem.EVENT_UPDATE_ITEM_TITLE:
806 return ".item_names.title"
807 case notification_data_pb2.EventUpdateItem.EVENT_UPDATE_ITEM_CONTENT:
808 return ".item_names.content"
809 case notification_data_pb2.EventUpdateItem.EVENT_UPDATE_ITEM_LOCATION:
810 return ".item_names.location"
811 case notification_data_pb2.EventUpdateItem.EVENT_UPDATE_ITEM_START_TIME:
812 return ".item_names.start_time"
813 case notification_data_pb2.EventUpdateItem.EVENT_UPDATE_ITEM_END_TIME:
814 return ".item_names.end_time"
815 case _:
816 return None
818 @classmethod
819 def test_instances(cls) -> list[Self]:
820 prototype = cls(
821 user_name="Alice", updating_user=UserInfo.dummy_bob(), event_info=EventInfo.dummy(), updated_items=[]
822 )
823 return [
824 replace(prototype, updated_items=[]),
825 replace(prototype, updated_items=notification_data_pb2.EventUpdateItem.values()),
826 ]
829@dataclass(kw_only=True, slots=True)
830class EventOrganizerInvitedEmail(EmailBase):
831 """Sent when a user is invited to co-organize an event."""
833 inviting_user: UserInfo
834 event_info: EventInfo
836 @property
837 def string_key_base(self) -> str:
838 return "events.organizer_invited"
840 def get_subject_line(self, loc_context: LocalizationContext) -> str:
841 return self._localize(
842 loc_context,
843 ".subject",
844 {"user": self.inviting_user.name, "title": self.event_info.title},
845 )
847 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
848 builder = self._body_builder(loc_context)
849 builder.para(".body", {"user": self.inviting_user.name, "title": self.event_info.title})
850 builder.block(self.event_info.get_details_block(loc_context))
851 builder.user(self.inviting_user, comment_key=".user_card_text")
852 builder.block(self.event_info.get_description_block())
853 builder.block(self.event_info.get_view_action_block(loc_context))
854 builder.para(_do_not_reply_request_string_key)
855 return builder.build()
857 @classmethod
858 def from_notification(cls, data: notification_data_pb2.EventInviteOrganizer, *, user_name: str) -> Self:
859 return cls(
860 user_name=user_name,
861 inviting_user=UserInfo.from_protobuf(data.inviting_user),
862 event_info=EventInfo.from_proto(data.event),
863 )
865 @classmethod
866 def test_instances(cls) -> list[Self]:
867 return [cls(user_name="Alice", inviting_user=UserInfo.dummy_bob(), event_info=EventInfo.dummy())]
870@dataclass(kw_only=True, slots=True)
871class EventCommentEmail(EmailBase):
872 """Sent to subscribers when someone comments on an event."""
874 author: UserInfo
875 event_info: EventInfo
876 comment_markdown: str
878 @property
879 def string_key_base(self) -> str:
880 return "events.comment"
882 def get_subject_line(self, loc_context: LocalizationContext) -> str:
883 return self._localize(loc_context, ".subject", {"author": self.author.name, "title": self.event_info.title})
885 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
886 builder = self._body_builder(loc_context)
887 builder.para(".body", {"author": self.author.name, "title": self.event_info.title})
888 builder.user(self.author)
889 builder.quote(self.comment_markdown, markdown=True)
890 builder.para(".event_details")
891 builder.block(self.event_info.get_details_block(loc_context))
892 builder.block(self.event_info.get_view_action_block(loc_context))
893 return builder.build()
895 @classmethod
896 def from_notification(cls, data: notification_data_pb2.EventComment, *, user_name: str) -> Self:
897 return cls(
898 user_name=user_name,
899 author=UserInfo.from_protobuf(data.author),
900 event_info=EventInfo.from_proto(data.event),
901 comment_markdown=data.reply.content,
902 )
904 @classmethod
905 def test_instances(cls) -> list[Self]:
906 return [
907 cls(
908 user_name="Alice",
909 author=UserInfo.dummy_bob(),
910 event_info=EventInfo.dummy(),
911 comment_markdown="Looking forward to it, see you all there!",
912 )
913 ]
916@dataclass(kw_only=True, slots=True)
917class EventReminderEmail(EmailBase):
918 """Sent to subscribers as a reminder that an event starts soon."""
920 event_info: EventInfo
922 @property
923 def string_key_base(self) -> str:
924 return "events.reminder"
926 def get_subject_line(self, loc_context: LocalizationContext) -> str:
927 return self._localize(loc_context, ".subject", {"title": self.event_info.title})
929 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
930 builder = self._body_builder(loc_context)
931 builder.para(".body")
932 builder.block(self.event_info.get_details_block(loc_context))
933 builder.block(self.event_info.get_description_block())
934 builder.block(self.event_info.get_view_action_block(loc_context))
935 return builder.build()
937 @classmethod
938 def from_notification(cls, data: notification_data_pb2.EventReminder, *, user_name: str) -> Self:
939 return cls(
940 user_name=user_name,
941 event_info=EventInfo.from_proto(data.event),
942 )
944 @classmethod
945 def test_instances(cls) -> list[Self]:
946 return [cls(user_name="Alice", event_info=EventInfo.dummy())]
949@dataclass(kw_only=True, slots=True)
950class EventCancelledEmail(EmailBase):
951 """Sent to subscribers when an event is cancelled."""
953 cancelling_user: UserInfo
954 event_info: EventInfo
956 @property
957 def string_key_base(self) -> str:
958 return "events.cancel"
960 def get_subject_line(self, loc_context: LocalizationContext) -> str:
961 return self._localize(
962 loc_context,
963 ".subject",
964 {"user": self.cancelling_user.name, "title": self.event_info.title},
965 )
967 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
968 builder = self._body_builder(loc_context)
969 builder.para(".body")
970 builder.block(self.event_info.get_details_block(loc_context))
971 builder.user(self.cancelling_user, ".user_card_text")
972 builder.quote(self.event_info.description_markdown, markdown=True)
973 builder.block(self.event_info.get_view_action_block(loc_context))
974 return builder.build()
976 @classmethod
977 def from_notification(cls, data: notification_data_pb2.EventCancel, *, user_name: str) -> Self:
978 return cls(
979 user_name=user_name,
980 cancelling_user=UserInfo.from_protobuf(data.cancelling_user),
981 event_info=EventInfo.from_proto(data.event),
982 )
984 @classmethod
985 def test_instances(cls) -> list[Self]:
986 return [cls(user_name="Alice", cancelling_user=UserInfo.dummy_bob(), event_info=EventInfo.dummy())]
989@dataclass(kw_only=True, slots=True)
990class EventDeletedEmail(EmailBase):
991 """Sent to subscribers when a moderator deletes an event."""
993 event_info: EventInfo
995 @property
996 def string_key_base(self) -> str:
997 return "events.deleted"
999 def get_subject_line(self, loc_context: LocalizationContext) -> str:
1000 return self._localize(loc_context, ".subject", {"title": self.event_info.title})
1002 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
1003 builder = self._body_builder(loc_context)
1004 builder.para(".body")
1005 builder.block(self.event_info.get_details_block(loc_context))
1006 return builder.build()
1008 @classmethod
1009 def from_notification(cls, data: notification_data_pb2.EventDelete, *, user_name: str) -> Self:
1010 return cls(
1011 user_name=user_name,
1012 event_info=EventInfo.from_proto(data.event),
1013 )
1015 @classmethod
1016 def test_instances(cls) -> list[Self]:
1017 return [
1018 cls(
1019 user_name="Alice",
1020 event_info=EventInfo.dummy(),
1021 )
1022 ]
1025@dataclass(kw_only=True, slots=True)
1026class FriendReferenceReceivedEmail(EmailBase):
1027 """Sent to a user when they receive a friend reference."""
1029 from_user: UserInfo
1030 text: str
1032 @property
1033 def string_key_base(self) -> str:
1034 return "references.received.friend"
1036 def get_subject_line(self, loc_context: LocalizationContext) -> str:
1037 return self._localize(loc_context, ".subject", {"name": self.from_user.name})
1039 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
1040 builder = self._body_builder(loc_context)
1041 builder.para(".body", {"name": self.from_user.name})
1042 builder.user(self.from_user)
1043 builder.quote(self.text, markdown=False)
1044 builder.action(urls.profile_references_link(), "references.received.view_action")
1045 return builder.build()
1047 @classmethod
1048 def from_notification(cls, data: notification_data_pb2.ReferenceReceiveFriend, *, user_name: str) -> Self:
1049 return cls(user_name=user_name, from_user=UserInfo.from_protobuf(data.from_user), text=data.text)
1051 @classmethod
1052 def test_instances(cls) -> list[Self]:
1053 return [
1054 cls(
1055 user_name="Alice",
1056 from_user=UserInfo.dummy_bob(),
1057 text="Alice is a wonderful person and a great travel companion!",
1058 )
1059 ]
1062@dataclass(kw_only=True, slots=True)
1063class FriendRequestReceivedEmail(EmailBase):
1064 """Sent to a user when they receive a friend request."""
1066 befriender: UserInfo
1068 @property
1069 def string_key_base(self) -> str:
1070 return "friend_requests.received"
1072 def get_subject_line(self, loc_context: LocalizationContext) -> str:
1073 return self._localize(loc_context, ".subject", {"name": self.befriender.name})
1075 def get_preview_line(self, loc_context: LocalizationContext) -> str:
1076 return self._localize(loc_context, ".body", {"name": self.befriender.name})
1078 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
1079 builder = self._body_builder(loc_context)
1080 builder.para(".body", {"name": self.befriender.name})
1081 builder.user(self.befriender)
1082 builder.action(urls.friend_requests_link(), ".view_action")
1083 builder.para(".closing")
1084 builder.para(_do_not_reply_request_string_key)
1085 return builder.build()
1087 @classmethod
1088 def from_notification(cls, data: notification_data_pb2.FriendRequestCreate, *, user_name: str) -> Self:
1089 return cls(user_name=user_name, befriender=UserInfo.from_protobuf(data.other_user))
1091 @classmethod
1092 def test_instances(cls) -> list[Self]:
1093 return [
1094 cls(
1095 user_name="Alice",
1096 befriender=UserInfo.dummy_bob(),
1097 )
1098 ]
1101@dataclass(kw_only=True, slots=True)
1102class FriendRequestAcceptedEmail(EmailBase):
1103 """Sent to a user when their friend request is accepted."""
1105 new_friend: UserInfo
1107 @property
1108 def string_key_base(self) -> str:
1109 return "friend_requests.accepted"
1111 def get_subject_line(self, loc_context: LocalizationContext) -> str:
1112 return self._localize(loc_context, ".subject", {"name": self.new_friend.name})
1114 def get_preview_line(self, loc_context: LocalizationContext) -> str:
1115 return self._localize(loc_context, ".body", {"name": self.new_friend.name})
1117 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
1118 builder = self._body_builder(loc_context)
1119 builder.para(".body", {"name": self.new_friend.name})
1120 builder.user(self.new_friend)
1121 builder.action(self.new_friend.profile_url, ".view_action")
1122 builder.para(".closing")
1123 return builder.build()
1125 @classmethod
1126 def from_notification(cls, data: notification_data_pb2.FriendRequestAccept, *, user_name: str) -> Self:
1127 return cls(user_name=user_name, new_friend=UserInfo.from_protobuf(data.other_user))
1129 @classmethod
1130 def test_instances(cls) -> list[Self]:
1131 return [
1132 cls(
1133 user_name="Alice",
1134 new_friend=UserInfo.dummy_bob(),
1135 )
1136 ]
1139@dataclass(kw_only=True, slots=True)
1140class GenderChangedEmail(EmailBase):
1141 """Sent to a user to notify them that their gender was changed."""
1143 new_gender: str
1145 @property
1146 def string_key_base(self) -> str:
1147 return "gender_changed"
1149 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
1150 builder = self._body_builder(loc_context, security_warning=True)
1151 builder.para(".body", {"gender": self.new_gender})
1152 return builder.build()
1154 @classmethod
1155 def from_notification(cls, data: notification_data_pb2.GenderChange, *, user_name: str) -> Self:
1156 return cls(user_name=user_name, new_gender=data.gender)
1158 @classmethod
1159 def test_instances(cls) -> list[Self]:
1160 return [
1161 cls(
1162 user_name="Alice",
1163 new_gender="Male",
1164 )
1165 ]
1168@dataclass(kw_only=True, slots=True)
1169class HostRequestCreatedEmail(EmailBase):
1170 """Sent to a host when a surfer sends them a new host request."""
1172 surfer: UserInfo
1173 from_date: date
1174 to_date: date
1175 text: str
1176 quick_decline_link: str
1177 view_link: str
1179 @property
1180 def string_key_base(self) -> str:
1181 return "host_requests.created"
1183 def get_subject_line(self, loc_context: LocalizationContext) -> str:
1184 return self._localize(loc_context, ".subject", {"surfer_name": self.surfer.name})
1186 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
1187 builder = self._body_builder(loc_context)
1188 builder.para(".body", {"surfer_name": self.surfer.name})
1189 builder.user(
1190 self.surfer,
1191 "host_requests.generic.date_range",
1192 {
1193 "from_date": _localize_host_request_date(self.from_date, loc_context),
1194 "to_date": _localize_host_request_date(self.to_date, loc_context),
1195 },
1196 )
1197 builder.quote(self.text, markdown=False)
1198 builder.action(self.view_link, "host_requests.generic.view_action")
1199 builder.action(self.quick_decline_link, ".quick_decline_action")
1200 builder.para(".respond_encouragement")
1201 builder.para(_do_not_reply_request_string_key)
1202 return builder.build()
1204 @classmethod
1205 def from_notification(cls, data: notification_data_pb2.HostRequestCreate, *, user_name: str) -> Self:
1206 return cls(
1207 user_name,
1208 surfer=UserInfo.from_protobuf(data.surfer),
1209 from_date=date.fromisoformat(data.host_request.from_date),
1210 to_date=date.fromisoformat(data.host_request.to_date),
1211 text=data.text,
1212 quick_decline_link=generate_quick_decline_link(data.host_request),
1213 view_link=urls.host_request(host_request_id=data.host_request.host_request_id),
1214 )
1216 @classmethod
1217 def test_instances(cls) -> list[Self]:
1218 return [
1219 cls(
1220 user_name="Alice",
1221 surfer=UserInfo.dummy_bob(),
1222 from_date=date(2025, 6, 1),
1223 to_date=date(2025, 6, 7),
1224 text="Hey, I'd love to stay for a few nights!",
1225 quick_decline_link="https://couchers.org/requests/123/decline?token=xxx",
1226 view_link="https://couchers.org/requests/123",
1227 )
1228 ]
1231@dataclass(kw_only=True, slots=True)
1232class HostRequestReminderEmail(EmailBase):
1233 """Sent to a host as a reminder to respond to a pending host request."""
1235 surfer: UserInfo
1236 from_date: date
1237 to_date: date
1238 view_link: str
1240 @property
1241 def string_key_base(self) -> str:
1242 return "host_requests.reminder"
1244 def get_subject_line(self, loc_context: LocalizationContext) -> str:
1245 return self._localize(loc_context, ".subject", {"surfer_name": self.surfer.name})
1247 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
1248 builder = self._body_builder(loc_context)
1249 builder.para(".body")
1250 builder.user(
1251 self.surfer,
1252 "host_requests.generic.date_range",
1253 {
1254 "from_date": _localize_host_request_date(self.from_date, loc_context),
1255 "to_date": _localize_host_request_date(self.to_date, loc_context),
1256 },
1257 )
1258 builder.action(self.view_link, "host_requests.generic.view_action")
1259 builder.para(_do_not_reply_request_string_key)
1260 return builder.build()
1262 @classmethod
1263 def from_notification(cls, data: notification_data_pb2.HostRequestReminder, *, user_name: str) -> Self:
1264 return cls(
1265 user_name,
1266 surfer=UserInfo.from_protobuf(data.surfer),
1267 from_date=date.fromisoformat(data.host_request.from_date),
1268 to_date=date.fromisoformat(data.host_request.to_date),
1269 view_link=urls.host_request(host_request_id=data.host_request.host_request_id),
1270 )
1272 @classmethod
1273 def test_instances(cls) -> list[Self]:
1274 return [
1275 cls(
1276 user_name="Alice",
1277 surfer=UserInfo.dummy_bob(),
1278 from_date=date(2025, 6, 1),
1279 to_date=date(2025, 6, 7),
1280 view_link="https://couchers.org/requests/123",
1281 )
1282 ]
1285@dataclass(kw_only=True, slots=True)
1286class HostRequestMessageEmail(EmailBase):
1287 """Sent when a user sends a message in an existing host request."""
1289 other_user: UserInfo
1290 from_date: date
1291 to_date: date
1292 text: str
1293 from_host: bool
1294 view_link: str
1296 @property
1297 def string_key_base(self) -> str:
1298 variant = "from_host" if self.from_host else "from_surfer"
1299 return f"host_requests.message.{variant}"
1301 def get_subject_line(self, loc_context: LocalizationContext) -> str:
1302 return self._localize(loc_context, ".subject", {"other_name": self.other_user.name})
1304 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
1305 builder = self._body_builder(loc_context)
1306 builder.para(".body", {"other_name": self.other_user.name})
1307 builder.user(
1308 self.other_user,
1309 "host_requests.generic.date_range",
1310 {
1311 "from_date": _localize_host_request_date(self.from_date, loc_context),
1312 "to_date": _localize_host_request_date(self.to_date, loc_context),
1313 },
1314 )
1315 builder.quote(self.text, markdown=False)
1316 builder.action(self.view_link, "host_requests.generic.view_action")
1317 builder.para(_do_not_reply_request_string_key)
1318 return builder.build()
1320 @classmethod
1321 def from_notification(cls, data: notification_data_pb2.HostRequestMessage, *, user_name: str) -> Self:
1322 return cls(
1323 user_name,
1324 other_user=UserInfo.from_protobuf(data.user),
1325 from_date=date.fromisoformat(data.host_request.from_date),
1326 to_date=date.fromisoformat(data.host_request.to_date),
1327 text=data.text,
1328 from_host=not data.am_host,
1329 view_link=urls.host_request(host_request_id=data.host_request.host_request_id),
1330 )
1332 @classmethod
1333 def test_instances(cls) -> list[Self]:
1334 prototype = cls(
1335 user_name="Alice",
1336 other_user=UserInfo.dummy_bob(),
1337 from_date=date(2025, 6, 1),
1338 to_date=date(2025, 6, 7),
1339 text="Looking forward to it, see you soon!",
1340 from_host=True,
1341 view_link="https://couchers.org/requests/123",
1342 )
1343 return [replace(prototype, from_host=True), replace(prototype, from_host=False)]
1346@dataclass(kw_only=True, slots=True)
1347class HostRequestMissedMessagesEmail(EmailBase):
1348 """Sent as a digest when a user has missed messages in a host request."""
1350 other_user: UserInfo
1351 from_date: date
1352 to_date: date
1353 from_host: bool
1354 view_link: str
1356 @property
1357 def string_key_base(self) -> str:
1358 variant = "from_host" if self.from_host else "from_surfer"
1359 return f"host_requests.missed_messages.{variant}"
1361 def get_subject_line(self, loc_context: LocalizationContext) -> str:
1362 return self._localize(loc_context, ".subject", {"other_name": self.other_user.name})
1364 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
1365 builder = self._body_builder(loc_context)
1366 builder.para(".body", {"other_name": self.other_user.name})
1367 builder.user(
1368 self.other_user,
1369 "host_requests.generic.date_range",
1370 {
1371 "from_date": _localize_host_request_date(self.from_date, loc_context),
1372 "to_date": _localize_host_request_date(self.to_date, loc_context),
1373 },
1374 )
1375 builder.action(self.view_link, "host_requests.generic.view_action")
1376 builder.para(_do_not_reply_request_string_key)
1377 return builder.build()
1379 @classmethod
1380 def from_notification(cls, data: notification_data_pb2.HostRequestMissedMessages, *, user_name: str) -> Self:
1381 return cls(
1382 user_name,
1383 other_user=UserInfo.from_protobuf(data.user),
1384 from_date=date.fromisoformat(data.host_request.from_date),
1385 to_date=date.fromisoformat(data.host_request.to_date),
1386 from_host=not data.am_host,
1387 view_link=urls.host_request(host_request_id=data.host_request.host_request_id),
1388 )
1390 @classmethod
1391 def test_instances(cls) -> list[Self]:
1392 prototype = cls(
1393 user_name="Alice",
1394 other_user=UserInfo.dummy_bob(),
1395 from_date=date(2025, 6, 1),
1396 to_date=date(2025, 6, 7),
1397 from_host=True,
1398 view_link="https://couchers.org/requests/123",
1399 )
1400 return [replace(prototype, from_host=True), replace(prototype, from_host=False)]
1403@dataclass(kw_only=True, slots=True)
1404class HostRequestStatusChangedEmail(EmailBase):
1405 """Sent when a host request is accepted, declined, confirmed, or cancelled."""
1407 other_user: UserInfo
1408 from_date: date
1409 to_date: date
1410 new_status: conversations_pb2.HostRequestStatus.ValueType
1411 view_link: str
1413 @property
1414 def string_key_base(self) -> str:
1415 base_key = "host_requests.status_changed"
1416 match self.new_status:
1417 case conversations_pb2.HOST_REQUEST_STATUS_ACCEPTED:
1418 return f"{base_key}.accepted_by_host"
1419 case conversations_pb2.HOST_REQUEST_STATUS_REJECTED:
1420 return f"{base_key}.declined_by_host"
1421 case conversations_pb2.HOST_REQUEST_STATUS_CONFIRMED:
1422 return f"{base_key}.confirmed_by_surfer"
1423 case conversations_pb2.HOST_REQUEST_STATUS_CANCELLED: 1423 ↛ 1425line 1423 didn't jump to line 1425 because the pattern on line 1423 always matched
1424 return f"{base_key}.cancelled_by_surfer"
1425 case _:
1426 raise ValueError(f"Unexpected host request status: {self.new_status}")
1428 def get_subject_line(self, loc_context: LocalizationContext) -> str:
1429 return self._localize(loc_context, ".subject", {"other_name": self.other_user.name})
1431 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
1432 builder = self._body_builder(loc_context)
1433 builder.para(".body", {"other_name": self.other_user.name})
1434 builder.user(
1435 self.other_user,
1436 "host_requests.generic.date_range",
1437 {
1438 "from_date": _localize_host_request_date(self.from_date, loc_context),
1439 "to_date": _localize_host_request_date(self.to_date, loc_context),
1440 },
1441 )
1442 builder.action(self.view_link, "host_requests.generic.view_action")
1443 builder.para(_do_not_reply_request_string_key)
1444 return builder.build()
1446 @classmethod
1447 def from_notification(
1448 cls,
1449 data: notification_data_pb2.HostRequestAccept
1450 | notification_data_pb2.HostRequestReject
1451 | notification_data_pb2.HostRequestConfirm
1452 | notification_data_pb2.HostRequestCancel,
1453 *,
1454 user_name: str,
1455 ) -> Self:
1456 other_user: UserInfo
1457 new_status: conversations_pb2.HostRequestStatus.ValueType
1458 match data:
1459 case notification_data_pb2.HostRequestAccept():
1460 other_user = UserInfo.from_protobuf(data.host)
1461 new_status = conversations_pb2.HostRequestStatus.HOST_REQUEST_STATUS_ACCEPTED
1462 case notification_data_pb2.HostRequestReject(): 1462 ↛ 1463line 1462 didn't jump to line 1463 because the pattern on line 1462 never matched
1463 other_user = UserInfo.from_protobuf(data.host)
1464 new_status = conversations_pb2.HostRequestStatus.HOST_REQUEST_STATUS_REJECTED
1465 case notification_data_pb2.HostRequestConfirm():
1466 other_user = UserInfo.from_protobuf(data.surfer)
1467 new_status = conversations_pb2.HostRequestStatus.HOST_REQUEST_STATUS_CONFIRMED
1468 case notification_data_pb2.HostRequestCancel(): 1468 ↛ 1471line 1468 didn't jump to line 1471 because the pattern on line 1468 always matched
1469 other_user = UserInfo.from_protobuf(data.surfer)
1470 new_status = conversations_pb2.HostRequestStatus.HOST_REQUEST_STATUS_CANCELLED
1471 case _:
1472 # Enable mypy's exhaustiveness checking
1473 assert_never("Unexpected host request status changed notification data type.")
1475 return cls(
1476 user_name,
1477 other_user=other_user,
1478 from_date=date.fromisoformat(data.host_request.from_date),
1479 to_date=date.fromisoformat(data.host_request.to_date),
1480 new_status=new_status,
1481 view_link=urls.host_request(host_request_id=data.host_request.host_request_id),
1482 )
1484 @classmethod
1485 def test_instances(cls) -> list[Self]:
1486 prototype = cls(
1487 user_name="Alice",
1488 other_user=UserInfo.dummy_bob(),
1489 from_date=date(2025, 6, 1),
1490 to_date=date(2025, 6, 7),
1491 new_status=conversations_pb2.HOST_REQUEST_STATUS_ACCEPTED,
1492 view_link="https://couchers.org/requests/123",
1493 )
1494 return [
1495 replace(prototype, new_status=conversations_pb2.HOST_REQUEST_STATUS_ACCEPTED),
1496 replace(prototype, new_status=conversations_pb2.HOST_REQUEST_STATUS_REJECTED),
1497 replace(prototype, new_status=conversations_pb2.HOST_REQUEST_STATUS_CONFIRMED),
1498 replace(prototype, new_status=conversations_pb2.HOST_REQUEST_STATUS_CANCELLED),
1499 ]
1502@dataclass(kw_only=True, slots=True)
1503class HostReferenceReceivedEmail(EmailBase):
1504 """Sent to a user when they receive a reference from a past host or surfer."""
1506 from_user: UserInfo
1507 text: str | None # None if hidden because receiver hasn't written their reference yet.
1508 surfed: bool # True if I was the surfer, False if I was the host
1509 leave_reference_url: str
1511 @property
1512 def string_key_base(self) -> str:
1513 return "references.received"
1515 @property
1516 def string_role_subkey(self) -> str:
1517 return "surfed" if self.surfed else "hosted"
1519 def get_subject_line(self, loc_context: LocalizationContext) -> str:
1520 return self._localize(loc_context, ".subject", {"name": self.from_user.name})
1522 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
1523 builder = self._body_builder(loc_context)
1524 builder.para(f".{self.string_role_subkey}.body", {"name": self.from_user.name})
1525 builder.user(self.from_user)
1526 if self.text:
1527 builder.para(".before_quote")
1528 builder.quote(self.text, markdown=False)
1529 builder.action(urls.profile_references_link(), ".view_action")
1530 else:
1531 builder.para(".reciprocate_encouragement", {"name": self.from_user.name})
1532 builder.action(self.leave_reference_url, "references.write_action", {"name": self.from_user.name})
1533 return builder.build()
1535 @classmethod
1536 def from_notification(
1537 cls, data: notification_data_pb2.ReferenceReceiveHostRequest, *, user_name: str, surfed: bool
1538 ) -> Self:
1539 return cls(
1540 user_name=user_name,
1541 from_user=UserInfo.from_protobuf(data.from_user),
1542 text=data.text or None,
1543 surfed=surfed,
1544 leave_reference_url=urls.leave_reference_link(
1545 reference_type="surfed" if surfed else "hosted",
1546 to_user_id=str(data.from_user.user_id),
1547 host_request_id=str(data.host_request_id),
1548 ),
1549 )
1551 @classmethod
1552 def test_instances(cls) -> list[Self]:
1553 prototype = cls(
1554 user_name="Alice",
1555 from_user=UserInfo.dummy_bob(),
1556 text="Alice was a fantastic guest!",
1557 surfed=True,
1558 leave_reference_url="https://couchers.org/leave-reference/123",
1559 )
1560 return [
1561 replace(prototype, surfed=True, text="Alice was a fantastic guest!"),
1562 replace(prototype, surfed=True, text=None),
1563 replace(prototype, surfed=False, text="Bob was a wonderful host!"),
1564 replace(prototype, surfed=False, text=None),
1565 ]
1568@dataclass(kw_only=True, slots=True)
1569class HostReferenceReminderEmail(EmailBase):
1570 """Sent as a reminder to write a reference after a stay."""
1572 other_user: UserInfo
1573 days_left: int
1574 surfed: bool # True if I was the surfer, False if I was the host
1575 leave_reference_url: str
1577 @property
1578 def string_key_base(self) -> str:
1579 return "references.reminder"
1581 @property
1582 def string_role_subkey(self) -> str:
1583 return "surfed" if self.surfed else "hosted"
1585 def get_subject_line(self, loc_context: LocalizationContext) -> str:
1586 return self._localize(
1587 loc_context,
1588 ".subject_days",
1589 {"name": self.other_user.name, "count": self.days_left},
1590 )
1592 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
1593 builder = self._body_builder(loc_context)
1594 builder.para(f".{self.string_role_subkey}.body_days", {"name": self.other_user.name, "count": self.days_left})
1595 builder.para(".no_meeting_note", {"name": self.other_user.name})
1596 builder.user(self.other_user)
1597 builder.action(
1598 self.leave_reference_url,
1599 "references.write_action",
1600 {"name": self.other_user.name},
1601 )
1602 builder.para(".via_messaging_note")
1603 builder.para(".importance_note")
1604 builder.para(".visibility_note")
1605 return builder.build()
1607 @classmethod
1608 def from_notification(cls, data: notification_data_pb2.ReferenceReminder, *, user_name: str, surfed: bool) -> Self:
1609 return cls(
1610 user_name=user_name,
1611 other_user=UserInfo.from_protobuf(data.other_user),
1612 days_left=data.days_left,
1613 surfed=surfed,
1614 leave_reference_url=urls.leave_reference_link(
1615 reference_type="surfed" if surfed else "hosted",
1616 to_user_id=str(data.other_user.user_id),
1617 host_request_id=str(data.host_request_id),
1618 ),
1619 )
1621 @classmethod
1622 def test_instances(cls) -> list[Self]:
1623 prototype = cls(
1624 user_name="Alice",
1625 other_user=UserInfo.dummy_bob(),
1626 days_left=7,
1627 surfed=True,
1628 leave_reference_url="https://couchers.org/leave-reference/123",
1629 )
1630 return [replace(prototype, surfed=True), replace(prototype, surfed=False)]
1633@dataclass(kw_only=True, slots=True)
1634class ModeratorNoteEmail(EmailBase):
1635 """Sent to a user to notify them they have received a moderator note."""
1637 @property
1638 def string_key_base(self) -> str:
1639 return "moderator_note"
1641 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
1642 builder = self._body_builder(loc_context)
1643 builder.para(".body")
1644 return builder.build()
1646 @classmethod
1647 def test_instances(cls) -> list[Self]:
1648 return [cls(user_name="Alice")]
1651@dataclass(kw_only=True, slots=True)
1652class NewBlogPostEmail(EmailBase):
1653 """Sent to notify users of a new blog post."""
1655 title: str
1656 blurb: str
1657 url: str
1659 @property
1660 def string_key_base(self) -> str:
1661 return "new_blog_post"
1663 def get_subject_line(self, loc_context: LocalizationContext) -> str:
1664 return self._localize(loc_context, ".subject", {"title": self.title})
1666 def get_preview_line(self, loc_context: LocalizationContext) -> str | None:
1667 return self.blurb
1669 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
1670 builder = self._body_builder(loc_context)
1671 builder.para(".intro")
1672 builder.para(".post_title", {"title": self.title})
1673 builder.quote(self.blurb, markdown=False)
1674 builder.action(self.url, ".read_action")
1675 return builder.build()
1677 @classmethod
1678 def from_notification(cls, data: notification_data_pb2.GeneralNewBlogPost, *, user_name: str) -> Self:
1679 return cls(user_name=user_name, title=data.title, blurb=data.blurb, url=data.url)
1681 @classmethod
1682 def test_instances(cls) -> list[Self]:
1683 return [
1684 cls(
1685 user_name="Alice",
1686 title="Exciting new features on Couchers.org",
1687 blurb="We've launched some great new features including improved messaging and event discovery.",
1688 url="https://couchers.org/blog/2025/01/01/new-features",
1689 )
1690 ]
1693@dataclass(kw_only=True, slots=True)
1694class OnboardingReminderEmail(EmailBase):
1695 """Onboarding email sent to new users; initial=True for the first email, False for the second."""
1697 initial: bool
1699 @property
1700 def string_key_base(self) -> str:
1701 return f"onboarding_reminder.{'initial' if self.initial else 'follow_up'}"
1703 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
1704 builder = self._body_builder(loc_context, standard_closing=False)
1705 edit_profile_url = urls.edit_profile_link()
1706 if self.initial:
1707 builder.para(".welcome")
1708 builder.para(".early_user_role")
1709 builder.para(".fill_in_profile")
1710 builder.para(".edit_profile_prompt")
1711 builder.action(edit_profile_url, ".edit_profile_action")
1712 builder.para(".share_with_friends")
1713 builder.para(".link", {"url": urls.app_link()})
1714 builder.para(".platform_under_development")
1715 builder.para(".thanks_for_joining")
1716 builder.para(".signature")
1717 else:
1718 builder.para(".intro")
1719 builder.para(".fill_in_profile")
1720 builder.action(edit_profile_url, ".edit_profile_action")
1721 builder.para(".no_empty_accounts")
1722 builder.para(".profile_importance")
1723 builder.para(".signature")
1724 return builder.build()
1726 @classmethod
1727 def test_instances(cls) -> list[Self]:
1728 prototype = cls(user_name="Alice", initial=True)
1729 return [replace(prototype, initial=True), replace(prototype, initial=False)]
1732@dataclass(kw_only=True, slots=True)
1733class PasswordChangedEmail(EmailBase):
1734 """Sent to a user to notify them that their login password was changed."""
1736 @property
1737 def string_key_base(self) -> str:
1738 return "password_changed"
1740 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
1741 builder = self._body_builder(loc_context, security_warning=True)
1742 builder.para(".body")
1743 return builder.build()
1745 @classmethod
1746 def test_instances(cls) -> list[Self]:
1747 return [cls(user_name="Alice")]
1750@dataclass(kw_only=True, slots=True)
1751class PasswordResetCompletedEmail(EmailBase):
1752 """Sent to a user to confirm their password was successfully reset."""
1754 @property
1755 def string_key_base(self) -> str:
1756 return "password_reset.completed"
1758 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
1759 builder = self._body_builder(loc_context, security_warning=True)
1760 builder.para(".body")
1761 return builder.build()
1763 @classmethod
1764 def test_instances(cls) -> list[Self]:
1765 return [cls(user_name="Alice")]
1768@dataclass(kw_only=True, slots=True)
1769class PasswordResetStartedEmail(EmailBase):
1770 """Sent to a user with a link to complete their password reset."""
1772 password_reset_link: str
1774 @property
1775 def string_key_base(self) -> str:
1776 return "password_reset.started"
1778 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
1779 builder = self._body_builder(loc_context, security_warning=True)
1780 builder.para(".request_description")
1781 builder.para(".confirmation_instructions")
1782 builder.action(self.password_reset_link, ".reset_action")
1783 return builder.build()
1785 @classmethod
1786 def from_notification(cls, data: notification_data_pb2.PasswordResetStart, *, user_name: str) -> Self:
1787 return cls(
1788 user_name=user_name,
1789 password_reset_link=urls.password_reset_link(password_reset_token=data.password_reset_token),
1790 )
1792 @classmethod
1793 def test_instances(cls) -> list[Self]:
1794 return [cls(user_name="Alice", password_reset_link="https://couchers.org/reset-password")]
1797@dataclass(kw_only=True, slots=True)
1798class PhoneNumberChangeEmail(EmailBase):
1799 """Sent to a user to notify them that their phone number verification status was changed."""
1801 new_phone_number: str
1802 completed: bool # False = started, True = completed
1804 @property
1805 def string_key_base(self) -> str:
1806 return "phone_number_verification.verified" if self.completed else "phone_number_verification.started"
1808 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
1809 builder = self._body_builder(loc_context, security_warning=True)
1810 builder.para(".body", {"phone_number": format_phone_number(self.new_phone_number)})
1811 return builder.build()
1813 @classmethod
1814 def from_change_notification(cls, data: notification_data_pb2.PhoneNumberChange, *, user_name: str) -> Self:
1815 return cls(user_name=user_name, new_phone_number=data.phone, completed=False)
1817 @classmethod
1818 def from_verify_notification(cls, data: notification_data_pb2.PhoneNumberVerify, *, user_name: str) -> Self:
1819 return cls(user_name=user_name, new_phone_number=data.phone, completed=True)
1821 @classmethod
1822 def test_instances(cls) -> list[Self]:
1823 prototype = cls(
1824 user_name="Alice",
1825 new_phone_number="+12223334444",
1826 completed=False,
1827 )
1828 return [replace(prototype, completed=False), replace(prototype, completed=True)]
1831@dataclass(kw_only=True, slots=True)
1832class PostalVerificationFailedEmail(EmailBase):
1833 """Sent to a user when their postal verification attempt has failed."""
1835 reason: notification_data_pb2.PostalVerificationFailReason.ValueType
1837 @property
1838 def string_key_base(self) -> str:
1839 return "postal_verification.failed"
1841 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
1842 builder = self._body_builder(loc_context, security_warning=True)
1843 match self.reason:
1844 case notification_data_pb2.POSTAL_VERIFICATION_FAIL_REASON_CODE_EXPIRED:
1845 reason_string_key = ".reason_code_expired"
1846 case notification_data_pb2.POSTAL_VERIFICATION_FAIL_REASON_TOO_MANY_ATTEMPTS:
1847 reason_string_key = ".reason_too_many_attempts"
1848 case _:
1849 reason_string_key = ".reason_unknown"
1850 builder.para(reason_string_key)
1851 return builder.build()
1853 @classmethod
1854 def from_notification(cls, data: notification_data_pb2.PostalVerificationFailed, *, user_name: str) -> Self:
1855 return cls(user_name=user_name, reason=data.reason)
1857 @classmethod
1858 def test_instances(cls) -> list[Self]:
1859 prototype = cls(
1860 user_name="Alice",
1861 reason=notification_data_pb2.POSTAL_VERIFICATION_FAIL_REASON_CODE_EXPIRED,
1862 )
1863 return [
1864 replace(prototype, reason=notification_data_pb2.POSTAL_VERIFICATION_FAIL_REASON_CODE_EXPIRED),
1865 replace(prototype, reason=notification_data_pb2.POSTAL_VERIFICATION_FAIL_REASON_TOO_MANY_ATTEMPTS),
1866 replace(prototype, reason=notification_data_pb2.POSTAL_VERIFICATION_FAIL_REASON_UNKNOWN),
1867 ]
1870@dataclass(kw_only=True, slots=True)
1871class PostalVerificationPostcardSentEmail(EmailBase):
1872 """Sent to a user to notify them that their verification postcard has been sent."""
1874 city: str
1875 country: str
1877 @property
1878 def string_key_base(self) -> str:
1879 return "postal_verification.postcard_sent"
1881 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
1882 builder = self._body_builder(loc_context, security_warning=True)
1883 builder.para(".body", {"city": self.city, "country": self.country})
1884 return builder.build()
1886 @classmethod
1887 def from_notification(cls, data: notification_data_pb2.PostalVerificationPostcardSent, *, user_name: str) -> Self:
1888 return cls(user_name=user_name, city=data.city, country=data.country)
1890 @classmethod
1891 def test_instances(cls) -> list[Self]:
1892 return [cls(user_name="Alice", city="New York", country="United States")]
1895@dataclass(kw_only=True, slots=True)
1896class PostalVerificationSucceededEmail(EmailBase):
1897 """Sent to a user when their postal verification has succeeded."""
1899 @property
1900 def string_key_base(self) -> str:
1901 return "postal_verification.succeeded"
1903 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
1904 builder = self._body_builder(loc_context, security_warning=True)
1905 builder.para(".body")
1906 return builder.build()
1908 @classmethod
1909 def test_instances(cls) -> list[Self]:
1910 return [cls(user_name="Alice")]
1913@dataclass(kw_only=True, slots=True)
1914class SignupVerifyEmail(EmailBase):
1915 """Sent to a user to verify their email address."""
1917 verify_url: str
1919 @property
1920 def string_key_base(self) -> str:
1921 return "signup.verify"
1923 def get_subject_line(self, loc_context: LocalizationContext) -> str:
1924 return self._localize(loc_context, "signup.subject")
1926 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
1927 builder = self._body_builder(loc_context)
1928 builder.para(".thanks")
1929 builder.para(".instructions")
1930 builder.action(self.verify_url, ".confirm_action")
1931 builder.para("signup.closing")
1932 return builder.build()
1934 @classmethod
1935 def test_instances(cls) -> list[Self]:
1936 return [cls(user_name="Alice", verify_url="https://example.com")]
1939@dataclass(kw_only=True, slots=True)
1940class SignupContinueEmail(EmailBase):
1941 """Sent to a user to ask them to continue the signup process."""
1943 continue_url: str
1945 @property
1946 def string_key_base(self) -> str:
1947 return "signup.continue"
1949 def get_subject_line(self, loc_context: LocalizationContext) -> str:
1950 return self._localize(loc_context, "signup.subject")
1952 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
1953 builder = self._body_builder(loc_context)
1954 builder.para(".request")
1955 builder.para(".instructions")
1956 builder.action(self.continue_url, ".continue_action")
1957 builder.para("signup.closing")
1958 builder.para(".ignore_if_unexpected")
1959 return builder.build()
1961 @classmethod
1962 def test_instances(cls) -> list[Self]:
1963 return [cls(user_name="Alice", continue_url="https://example.com")]
1966@dataclass(kw_only=True, slots=True)
1967class StrongVerificationFailedEmail(EmailBase):
1968 """Sent to a user when their strong verification attempt has failed."""
1970 reason: notification_data_pb2.SVFailReason.ValueType
1972 @property
1973 def string_key_base(self) -> str:
1974 return "strong_verification.failed"
1976 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
1977 builder = self._body_builder(loc_context, security_warning=True)
1978 match self.reason:
1979 case notification_data_pb2.SV_FAIL_REASON_WRONG_BIRTHDATE_OR_GENDER:
1980 reason_string_key = ".reason_wrong_birthdate_or_gender"
1981 case notification_data_pb2.SV_FAIL_REASON_NOT_A_PASSPORT:
1982 reason_string_key = ".reason_not_a_passport"
1983 case notification_data_pb2.SV_FAIL_REASON_DUPLICATE: 1983 ↛ 1985line 1983 didn't jump to line 1985 because the pattern on line 1983 always matched
1984 reason_string_key = ".reason_duplicate"
1985 case _:
1986 raise Exception("Shouldn't get here")
1987 builder.para(reason_string_key)
1988 return builder.build()
1990 @classmethod
1991 def from_notification(cls, data: notification_data_pb2.VerificationSVFail, *, user_name: str) -> Self:
1992 return cls(user_name=user_name, reason=data.reason)
1994 @classmethod
1995 def test_instances(cls) -> list[Self]:
1996 prototype = cls(
1997 user_name="Alice",
1998 reason=notification_data_pb2.SV_FAIL_REASON_NOT_A_PASSPORT,
1999 )
2000 return [
2001 replace(prototype, reason=notification_data_pb2.SV_FAIL_REASON_WRONG_BIRTHDATE_OR_GENDER),
2002 replace(prototype, reason=notification_data_pb2.SV_FAIL_REASON_NOT_A_PASSPORT),
2003 replace(prototype, reason=notification_data_pb2.SV_FAIL_REASON_DUPLICATE),
2004 ]
2007@dataclass(kw_only=True, slots=True)
2008class StrongVerificationSucceededEmail(EmailBase):
2009 """Sent to a user when their strong verification has succeeded."""
2011 @property
2012 def string_key_base(self) -> str:
2013 return "strong_verification.succeeded"
2015 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
2016 builder = self._body_builder(loc_context, security_warning=True)
2017 builder.para(".success_message")
2018 builder.para(".thanks_message")
2019 builder.para(".cost_explanation")
2020 builder.para(".donation_request")
2021 donate_link = urls.donation_url() + "?utm_source=strong-verification-email"
2022 builder.action(donate_link, ".donate_action")
2023 return builder.build()
2025 @classmethod
2026 def test_instances(cls) -> list[Self]:
2027 return [cls(user_name="Alice")]
2030@dataclass(kw_only=True, slots=True)
2031class ThreadReplyEmail(EmailBase):
2032 """Sent to a user when someone replies in a comment thread they participated in."""
2034 author: UserInfo
2035 parent_context: str # Title of the event or discussion being replied in
2036 markdown_text: str
2037 view_link: str
2039 @property
2040 def string_key_base(self) -> str:
2041 return "thread_reply"
2043 def get_subject_line(self, loc_context: LocalizationContext) -> str:
2044 return self._localize(
2045 loc_context, ".subject", {"author": self.author.name, "parent_context": self.parent_context}
2046 )
2048 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
2049 builder = self._body_builder(loc_context)
2050 builder.para(".body", {"author": self.author.name, "parent_context": self.parent_context})
2051 builder.user(self.author)
2052 builder.quote(self.markdown_text, markdown=True)
2053 builder.action(self.view_link, ".view_action")
2054 return builder.build()
2056 @classmethod
2057 def from_notification(cls, data: notification_data_pb2.ThreadReply, *, user_name: str) -> Self:
2058 parent = data.WhichOneof("reply_parent")
2059 if parent == "event":
2060 parent_context = data.event.title
2061 view_link = urls.event_link(occurrence_id=data.event.event_id, slug=data.event.slug)
2062 elif parent == "discussion": 2062 ↛ 2066line 2062 didn't jump to line 2066 because the condition on line 2062 was always true
2063 parent_context = data.discussion.title
2064 view_link = urls.discussion_link(discussion_id=data.discussion.discussion_id, slug=data.discussion.slug)
2065 else:
2066 raise Exception("Can only do replies to events and discussions")
2067 return cls(
2068 user_name=user_name,
2069 author=UserInfo.from_protobuf(data.author),
2070 parent_context=parent_context,
2071 markdown_text=data.reply.content,
2072 view_link=view_link,
2073 )
2075 @classmethod
2076 def test_instances(cls) -> list[Self]:
2077 return [
2078 cls(
2079 user_name="Alice",
2080 author=UserInfo.dummy_bob(),
2081 parent_context="Best hiking trails near Berlin",
2082 markdown_text="I agree, the Grünewald is **amazing**!",
2083 view_link="https://couchers.org/discussions/123",
2084 )
2085 ]
2088def _localize_host_request_date(value: date, loc_context: LocalizationContext) -> str:
2089 return loc_context.localize_date(value, with_year=False, with_day_of_week=True)