Coverage for app/backend/src/couchers/email/emails.py: 95%
1084 statements
« prev ^ index » next coverage.py v7.14.3, created at 2026-06-30 14:05 +0000
« prev ^ index » next coverage.py v7.14.3, created at 2026-06-30 14:05 +0000
1"""
2Defines data models for each email we sent out to users.
4Email writing style guidelines
6Subject line:
7 Single sentence of the form "who did what" (avoid passive voice when possible)
8 No punctuation*.
9 Do not quote people, community, group or event names.
10 No need to refer to "Couchers.org", the sender name already does.
12Preview line:
13 Quoted user content (e.g. comment/reference text), otherwise none.
14 Don't repeat or paraphrase the subject.
16Purpose line of the body (first paragraph after the greeting):
17 Usually a single sentence stating the purpose of the email.
18 Similar or identical to the subject but may include additional info (e.g. dates).
19 Should be punctuated with a period*, or end with a colon (':') if we then quote user content.
21Other instructions for body text:
22 A single purpose line is enough for day-to-day notifications, no need for further prose.
23 Highlight key pieces of info (names, locations, dates) using <b> tags.
24 Highlight important passages using <strong> tags.
25 Provide a link or instructions if the user has follow-up actions.
27* Some key emails like new accounts might use an exclamation mark (limit to 1) and more personal prose.
28"""
30import re
31from dataclasses import dataclass, replace
32from datetime import UTC, date, datetime
33from typing import Self, assert_never
35from markupsafe import Markup, escape
37from couchers import urls
38from couchers.config import config
39from couchers.constants import LATEST_RELEASE_BLOG_URL
40from couchers.email.blocks import (
41 ActionBlock,
42 EmailBase,
43 EmailBlock,
44 ParaBlock,
45 QuoteBlock,
46 UserInfo,
47)
48from couchers.email.locales import get_emails_i18next
49from couchers.i18n import LocalizationContext
50from couchers.i18n.localize import format_phone_number
51from couchers.markup import markdown_to_plaintext
52from couchers.notifications.quick_links import generate_quick_decline_link
53from couchers.proto import conversations_pb2, events_pb2, notification_data_pb2
54from couchers.utils import now, to_aware_datetime
56# Common string keys
57_do_not_reply_request_string_key = "generic.do_not_reply_request"
59# Specific email definitions
62@dataclass(kw_only=True, slots=True)
63class AccountDeletionStartedEmail(EmailBase):
64 """Sent to a user to confirm their account deletion request."""
66 deletion_link: str
68 @property
69 def string_key_base(self) -> str:
70 return "account_deletion.started"
72 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
73 builder = self._body_builder(loc_context, security_warning=True)
74 builder.para(".purpose")
75 builder.action(self.deletion_link, ".confirm_action")
76 return builder.build()
78 @classmethod
79 def from_notification(cls, data: notification_data_pb2.AccountDeletionStart, *, user_name: str) -> Self:
80 return cls(
81 user_name=user_name,
82 deletion_link=urls.delete_account_link(account_deletion_token=data.deletion_token),
83 )
85 @classmethod
86 def test_instances(cls) -> list[Self]:
87 return [
88 cls(
89 user_name="Alice",
90 deletion_link="https://couchers.org/delete-account?token=xxx",
91 )
92 ]
95@dataclass(kw_only=True, slots=True)
96class AccountDeletionCompletedEmail(EmailBase):
97 """Sent to a user after their account has been deleted."""
99 undelete_link: str
100 days: int
102 @property
103 def string_key_base(self) -> str:
104 return "account_deletion.completed"
106 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
107 builder = self._body_builder(loc_context, security_warning=True)
108 builder.para(".purpose")
109 builder.para(".farewell")
110 builder.para(".recovery_instructions_days", {"count": self.days})
111 builder.action(self.undelete_link, ".recover_action")
112 return builder.build()
114 @classmethod
115 def from_notification(cls, data: notification_data_pb2.AccountDeletionComplete, *, user_name: str) -> Self:
116 return cls(
117 user_name=user_name,
118 undelete_link=urls.recover_account_link(account_undelete_token=data.undelete_token),
119 days=data.undelete_days,
120 )
122 @classmethod
123 def test_instances(cls) -> list[Self]:
124 return [
125 cls(
126 user_name="Alice",
127 undelete_link="https://couchers.org/recover-account?token=xxx",
128 days=30,
129 )
130 ]
133@dataclass(kw_only=True, slots=True)
134class AccountDeletionRecoveredEmail(EmailBase):
135 """Sent to a user after their account deletion has been cancelled."""
137 @property
138 def string_key_base(self) -> str:
139 return "account_deletion.recovered"
141 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
142 builder = self._body_builder(loc_context, security_warning=True)
143 builder.para(".confirmation")
144 builder.para(".login_instructions")
145 builder.action(urls.app_link(), ".login_action")
146 builder.para(".redelete_instructions")
147 return builder.build()
149 @classmethod
150 def test_instances(cls) -> list[Self]:
151 return [cls(user_name="Alice")]
154@dataclass(kw_only=True, slots=True)
155class ActivenessProbeEmail(EmailBase):
156 """Sent to a host to check if they are still open to hosting."""
158 days_left: int
160 @property
161 def string_key_base(self) -> str:
162 return "activeness_probe"
164 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
165 builder = self._body_builder(loc_context)
166 builder.para(".purpose")
167 builder.para(".instructions_days", {"count": self.days_left})
168 builder.action(urls.app_link(), ".login_action")
169 builder.para(".encouragement")
171 # Extract major.minor from the version string. "v1.3.18927" -> "1.3"
172 version = config.VERSION
173 if version_match := re.search(r"^v?(\d+\.\d+)\b", version): 173 ↛ 174line 173 didn't jump to line 174 because the condition on line 173 was never true
174 version = version_match[1]
176 builder.para(".latest_release", {"version": version, "blog_url": LATEST_RELEASE_BLOG_URL})
177 return builder.build()
179 @classmethod
180 def from_notification(cls, data: notification_data_pb2.ActivenessProbe, *, user_name: str) -> Self:
181 days_left = (to_aware_datetime(data.deadline) - now()).days
182 return cls(user_name=user_name, days_left=days_left)
184 @classmethod
185 def test_instances(cls) -> list[Self]:
186 return [cls(user_name="Alice", days_left=7)]
189@dataclass(kw_only=True, slots=True)
190class APIKeyIssuedEmail(EmailBase):
191 """Sent to a user to notify them that their API key was issued."""
193 api_key: str
194 expiry: datetime
196 @property
197 def string_key_base(self) -> str:
198 return "api_key_issued"
200 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
201 builder = self._body_builder(loc_context, security_warning=True)
202 builder.para(".header")
203 builder.quote(self.api_key, markdown=False)
204 builder.para(".expiry", {"datetime": loc_context.localize_datetime(self.expiry)})
205 builder.para(".usage_warning")
206 builder.para(".policy_warning")
207 return builder.build()
209 @classmethod
210 def from_notification(cls, data: notification_data_pb2.ApiKeyCreate, *, user_name: str) -> Self:
211 return cls(user_name=user_name, api_key=data.api_key, expiry=data.expiry.ToDatetime(tzinfo=UTC))
213 @classmethod
214 def test_instances(cls) -> list[Self]:
215 return [cls(user_name="Alice", api_key="my_api_key_123", expiry=datetime(2099, 12, 31, 23, 59, 59, tzinfo=UTC))]
218@dataclass(kw_only=True, slots=True)
219class BadgeChangedEmail(EmailBase):
220 """Sent to a user to notify them that a badge was added or removed from their profile."""
222 badge_name: str
223 added: bool
225 @property
226 def string_key_base(self) -> str:
227 return "badges.added" if self.added else "badges.removed"
229 def get_subject_line(self, loc_context: LocalizationContext) -> str:
230 return self._localize(loc_context, ".subject", {"name": self.badge_name})
232 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
233 builder = self._body_builder(loc_context)
234 builder.para(".purpose", {"name": self.badge_name})
235 return builder.build()
237 @classmethod
238 def from_notification(
239 cls, data: notification_data_pb2.BadgeAdd | notification_data_pb2.BadgeRemove, *, user_name: str
240 ) -> Self:
241 return cls(
242 user_name=user_name, badge_name=data.badge_name, added=isinstance(data, notification_data_pb2.BadgeAdd)
243 )
245 @classmethod
246 def test_instances(cls) -> list[Self]:
247 prototype = cls(user_name="Alice", badge_name="Founder", added=True)
248 return [replace(prototype, added=True), replace(prototype, added=False)]
251@dataclass(kw_only=True, slots=True)
252class BirthdateChangedEmail(EmailBase):
253 """Sent to a user to notify them that their birthdate was changed."""
255 new_birthdate: date
257 @property
258 def string_key_base(self) -> str:
259 return "birthdate_changed"
261 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
262 builder = self._body_builder(loc_context, security_warning=True)
263 builder.para(".purpose", {"date": loc_context.localize_date(self.new_birthdate)})
264 return builder.build()
266 @classmethod
267 def from_notification(cls, data: notification_data_pb2.BirthdateChange, *, user_name: str) -> Self:
268 return cls(user_name=user_name, new_birthdate=date.fromisoformat(data.birthdate))
270 @classmethod
271 def test_instances(cls) -> list[Self]:
272 return [
273 cls(
274 user_name="Alice",
275 new_birthdate=date(1990, 1, 1),
276 )
277 ]
280@dataclass(kw_only=True, slots=True)
281class ChatMessageReceivedEmail(EmailBase):
282 """Sent to a user when they receive a new chat message."""
284 group_chat_title: str | None # None if direct message
285 author: UserInfo
286 text: str
287 view_url: str
289 @property
290 def string_key_base(self) -> str:
291 return f"chat_messages.received.{'direct' if self.group_chat_title is None else 'group'}"
293 def get_subject_line(self, loc_context: LocalizationContext) -> str:
294 return self._localize(
295 loc_context, ".subject", {"author": self.author.name, "group": self.group_chat_title or ""}
296 )
298 def get_preview_line(self, loc_context: LocalizationContext) -> str | None:
299 return self.text
301 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
302 builder = self._body_builder(loc_context)
303 builder.para(".purpose", {"author": self.author.name, "group": self.group_chat_title or ""})
304 builder.user(self.author)
305 builder.quote(self.text, markdown=False)
306 builder.action(self.view_url, ".view_action")
307 return builder.build()
309 @classmethod
310 def from_notification(cls, data: notification_data_pb2.ChatMessage, *, user_name: str) -> Self:
311 return cls(
312 user_name,
313 author=UserInfo.from_protobuf(data.author),
314 text=data.text,
315 group_chat_title=data.group_chat_title or None,
316 view_url=urls.chat_link(chat_id=data.group_chat_id),
317 )
319 @classmethod
320 def test_instances(cls) -> list[Self]:
321 prototype = cls(
322 user_name="Alice",
323 group_chat_title=None,
324 author=UserInfo.dummy_bob(),
325 text="Hi Alice!",
326 view_url="https://couchers.org/messages/chats/123",
327 )
328 return [
329 replace(prototype, group_chat_title=None),
330 replace(prototype, 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_base(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 get_preview_line(self, loc_context: LocalizationContext) -> str | None:
358 if len(self.entries) != 1:
359 return None
360 return self.entries[0].latest_message_text
362 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
363 builder = self._body_builder(loc_context)
364 builder.para(".purpose")
365 for entry in self.entries:
366 if entry.group_chat_title is None:
367 builder.para(".count_in_dm", {"count": entry.missed_count, "author": entry.latest_message_author.name})
368 else:
369 builder.para(".count_in_group", {"count": entry.missed_count, "group": entry.group_chat_title})
370 builder.user(entry.latest_message_author)
371 builder.quote(entry.latest_message_text, markdown=False)
372 builder.action(entry.view_url, ".view_action")
373 return builder.build()
375 @classmethod
376 def from_notification(cls, data: notification_data_pb2.ChatMissedMessages, *, user_name: str) -> Self:
377 missed_entries = [
378 cls.Entry(
379 group_chat_title=message.group_chat_title or None,
380 missed_count=message.unseen_count,
381 latest_message_author=UserInfo.from_protobuf(message.author),
382 latest_message_text=message.text,
383 view_url=urls.chat_link(chat_id=message.group_chat_id),
384 )
385 for message in data.messages
386 ]
388 return cls(user_name, entries=missed_entries)
390 @classmethod
391 def test_instances(cls) -> list[Self]:
392 entry_prototype = ChatMessagesMissedEmail.Entry(
393 group_chat_title=None,
394 missed_count=1,
395 latest_message_author=UserInfo.dummy_bob(),
396 latest_message_text="Hello!",
397 view_url="https://couchers.org/messages/chats/123",
398 )
399 return [
400 cls(
401 user_name="Alice",
402 entries=[
403 replace(entry_prototype, group_chat_title=None),
404 replace(entry_prototype, group_chat_title="Best friends"),
405 ],
406 )
407 ]
410@dataclass(kw_only=True, slots=True)
411class DiscussionCreatedEmail(EmailBase):
412 """Sent to a user when a new discussion is created in a community they follow."""
414 author: UserInfo
415 title: str
416 parent_context: str # Community or group name
417 markdown_text: str
418 view_link: str
420 @property
421 def string_key_base(self) -> str:
422 return "discussions.created"
424 def get_subject_line(self, loc_context: LocalizationContext) -> str:
425 return self._localize(loc_context, ".subject", {"author": self.author.name, "title": self.title})
427 def get_preview_line(self, loc_context: LocalizationContext) -> str | None:
428 return markdown_to_plaintext(self.markdown_text)
430 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
431 builder = self._body_builder(loc_context)
432 builder.para(
433 ".purpose",
434 {
435 "author": self.author.name,
436 "parent_context": self.parent_context,
437 },
438 )
439 builder.user(self.author)
440 builder.block(ParaBlock(text=Markup(f"<b>{escape(self.title)}</b>")))
441 builder.quote(self.markdown_text, markdown=True)
442 builder.action(self.view_link, ".view_action")
443 return builder.build()
445 @classmethod
446 def from_notification(cls, data: notification_data_pb2.DiscussionCreate, *, user_name: str) -> Self:
447 discussion = data.discussion
448 return cls(
449 user_name=user_name,
450 author=UserInfo.from_protobuf(data.author),
451 title=discussion.title,
452 parent_context=discussion.owner_title,
453 markdown_text=discussion.content,
454 view_link=urls.discussion_link(discussion_id=discussion.discussion_id, slug=discussion.slug),
455 )
457 @classmethod
458 def test_instances(cls) -> list[Self]:
459 return [
460 cls(
461 user_name="Alice",
462 author=UserInfo.dummy_bob(),
463 title="Best hiking trails near Berlin",
464 parent_context="Berlin",
465 markdown_text="I've been exploring the area and found some **great** spots...",
466 view_link="https://couchers.org/discussions/123",
467 )
468 ]
471@dataclass(kw_only=True, slots=True)
472class DiscussionCommentEmail(EmailBase):
473 """Sent to a user when someone comments on a discussion they follow."""
475 author: UserInfo
476 discussion_title: str
477 discussion_parent_context: str # Community or group name
478 markdown_text: str
479 view_link: str
481 @property
482 def string_key_base(self) -> str:
483 return "discussions.comment"
485 def get_subject_line(self, loc_context: LocalizationContext) -> str:
486 return self._localize(
487 loc_context, ".subject", {"author": self.author.name, "discussion_title": self.discussion_title}
488 )
490 def get_preview_line(self, loc_context: LocalizationContext) -> str | None:
491 return markdown_to_plaintext(self.markdown_text)
493 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
494 builder = self._body_builder(loc_context)
495 builder.para(
496 ".purpose",
497 {
498 "author": self.author.name,
499 "discussion_title": self.discussion_title,
500 "parent_context": self.discussion_parent_context,
501 },
502 )
503 builder.user(self.author)
504 builder.quote(self.markdown_text, markdown=True)
505 builder.action(self.view_link, ".view_action")
506 return builder.build()
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 test_instances(cls) -> list[Self]:
522 return [
523 cls(
524 user_name="Alice",
525 author=UserInfo.dummy_bob(),
526 discussion_title="Best hiking trails near Berlin",
527 discussion_parent_context="Berlin",
528 markdown_text="Great recommendations, I also **love** the Grünewald forest!",
529 view_link="https://couchers.org/discussions/123",
530 )
531 ]
534@dataclass(kw_only=True, slots=True)
535class DonationReceivedEmail(EmailBase):
536 """Sent to a user to thank them for a donation."""
538 amount: int
539 receipt_url: str
541 @property
542 def string_key_base(self) -> str:
543 return "donation_received"
545 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
546 builder = self._body_builder(loc_context, default_closing=False)
547 builder.para(".purpose", {"amount_with_currency": f"${self.amount}"})
548 builder.para(".contribution_impact")
549 builder.para(".invoice_receipt_info")
550 builder.action(self.receipt_url, ".download_invoice")
551 builder.para(".tax_acknowledgment")
552 builder.para(".questions_contact")
553 builder.para("generic.thanks")
554 builder.para("generic.closing_lines.founders")
555 return builder.build()
557 @classmethod
558 def from_notification(cls, data: notification_data_pb2.DonationReceived, *, user_name: str) -> Self:
559 return cls(user_name=user_name, amount=data.amount, receipt_url=data.receipt_url)
561 @classmethod
562 def test_instances(cls) -> list[Self]:
563 return [cls(user_name="Alice", amount=25, receipt_url="https://couchers.org/receipts/123")]
566@dataclass(kw_only=True, slots=True)
567class EmailChangedEmail(EmailBase):
568 """Sent to a user to notify them that their email address was changed."""
570 new_email: str
572 @property
573 def string_key_base(self) -> str:
574 return "email_change.initiated"
576 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
577 builder = self._body_builder(loc_context, security_warning=True)
578 builder.para(".purpose", {"email_address": self.new_email})
579 return builder.build()
581 @classmethod
582 def from_notification(cls, data: notification_data_pb2.EmailAddressChange, *, user_name: str) -> Self:
583 return cls(user_name=user_name, new_email=data.new_email)
585 @classmethod
586 def test_instances(cls) -> list[Self]:
587 return [cls(user_name="Alice", new_email="alice@example.com")]
590@dataclass(kw_only=True, slots=True)
591class EmailChangeConfirmationEmail(EmailBase):
592 """Sent to a user to confirm their new email address."""
594 old_email: str
595 confirm_url: str
597 @property
598 def string_key_base(self) -> str:
599 return "email_change.confirmation"
601 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
602 builder = self._body_builder(loc_context, security_warning=True)
603 builder.para(".purpose", {"old_email": self.old_email})
604 builder.action(self.confirm_url, ".confirm_action")
605 return builder.build()
607 @classmethod
608 def test_instances(cls) -> list[Self]:
609 return [cls(user_name="Alice", old_email="alice@example.com", confirm_url="https://example.com")]
612@dataclass(kw_only=True, slots=True)
613class EmailVerifiedEmail(EmailBase):
614 """Sent to a user to notify them that their new email address has been verified."""
616 @property
617 def string_key_base(self) -> str:
618 return "email_change.verified"
620 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
621 builder = self._body_builder(loc_context, security_warning=True)
622 builder.para(".purpose")
623 return builder.build()
625 @classmethod
626 def test_instances(cls) -> list[Self]:
627 return [cls(user_name="Alice")]
630@dataclass(kw_only=True, slots=True)
631class EventInfo:
632 """Common display fields for an event, extracted from its proto representation."""
634 title: str
635 start_time: datetime
636 end_time: datetime
637 online_link: str | None
638 address: str | None
639 view_url: str
640 description_markdown: str
642 def get_details_block(self, loc_context: LocalizationContext) -> EmailBlock:
643 # TODO(#8695): Support localized time ranges
644 start_time_display = loc_context.localize_datetime(self.start_time, with_year=False, with_day_of_week=True)
645 end_time_display = loc_context.localize_datetime(self.end_time, with_year=False, with_day_of_week=True)
646 time_range_display = f"{start_time_display} - {end_time_display}"
648 # Format the following. Only "Online" is translated and it's on its own line,
649 # so the string concatenation is fine.
650 # **<title>**
651 # <datetime-range>
652 # *<address> / [Online](<online_link>)*
653 html = f"<b>{escape(self.title)}</b>"
654 html += "<br>"
655 html += time_range_display
656 if self.online_link: 656 ↛ 657line 656 didn't jump to line 657 because the condition on line 656 was never true
657 html += "<br>"
658 online_link_text = loc_context.localize_string("events.generic.online_link", i18next=get_emails_i18next())
659 html += f'<i><a href="{escape(self.online_link)}">{escape(online_link_text)}</a></i>'
660 elif self.address:
661 html += "<br>"
662 html += f"<i>{escape(self.address)}</i>"
664 return ParaBlock(text=Markup(html))
666 def get_description_block(self) -> EmailBlock:
667 return QuoteBlock(text=Markup(self.description_markdown), markdown=True)
669 def get_view_action_block(self, loc_context: LocalizationContext) -> EmailBlock:
670 view_action_text = loc_context.localize_string("events.generic.view_action", i18next=get_emails_i18next())
671 return ActionBlock(text=view_action_text, target_url=self.view_url)
673 @classmethod
674 def from_proto(cls, event: events_pb2.Event) -> EventInfo:
675 return cls(
676 title=event.title,
677 start_time=event.start_time.ToDatetime(tzinfo=UTC),
678 end_time=event.end_time.ToDatetime(tzinfo=UTC),
679 online_link=event.online_information.link or None,
680 address=event.offline_information.address or None,
681 view_url=urls.event_link(occurrence_id=event.event_id, slug=event.slug),
682 description_markdown=event.content or "",
683 )
685 @staticmethod
686 def dummy() -> EventInfo:
687 return EventInfo(
688 title="Berlin Meetup",
689 start_time=datetime(2025, 7, 15, 18, 0, 0, tzinfo=UTC),
690 end_time=datetime(2025, 7, 15, 21, 0, 0, tzinfo=UTC),
691 online_link=None,
692 address="Alexanderplatz, Berlin",
693 view_url="https://couchers.org/events/123/berlin-community-meetup",
694 description_markdown="Come join us for our monthly meetup!",
695 )
698@dataclass(kw_only=True, slots=True)
699class EventCreatedEmail(EmailBase):
700 """Sent when a user is invited to an event (create_approved) or a new event is created (create_any)."""
702 inviting_user: UserInfo
703 event_info: EventInfo
704 community_name: str | None
705 community_url: str | None
706 is_invite: bool # True = create_approved (invitation), False = create_any
708 @property
709 def string_key_base(self) -> str:
710 return f"events.created.{'invitation' if self.is_invite else 'notification'}"
712 def get_subject_line(self, loc_context: LocalizationContext) -> str:
713 return self._localize(
714 loc_context, ".subject", {"user": self.inviting_user.name, "title": self.event_info.title}
715 )
717 def get_preview_line(self, loc_context: LocalizationContext) -> str | None:
718 return markdown_to_plaintext(self.event_info.description_markdown)
720 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
721 builder = self._body_builder(loc_context)
722 if self.community_name:
723 builder.para(".purpose_with_community", {"user": self.inviting_user.name, "community": self.community_name})
724 else:
725 builder.para(".purpose_no_community", {"user": self.inviting_user.name})
726 builder.block(self.event_info.get_details_block(loc_context))
727 builder.user(self.inviting_user)
728 builder.block(self.event_info.get_description_block())
729 builder.block(self.event_info.get_view_action_block(loc_context))
730 return builder.build()
732 @classmethod
733 def from_notification(cls, data: notification_data_pb2.EventCreate, *, user_name: str, is_invite: bool) -> Self:
734 has_community = bool(data.in_community.community_id)
735 community_url = (
736 urls.community_link(node_id=data.in_community.community_id, slug=data.in_community.slug)
737 if has_community
738 else None
739 )
740 return cls(
741 user_name=user_name,
742 inviting_user=UserInfo.from_protobuf(data.inviting_user),
743 event_info=EventInfo.from_proto(data.event),
744 community_name=data.in_community.name if has_community else None,
745 community_url=community_url,
746 is_invite=is_invite,
747 )
749 @classmethod
750 def test_instances(cls) -> list[Self]:
751 prototype = cls(
752 user_name="Alice",
753 inviting_user=UserInfo.dummy_bob(),
754 event_info=EventInfo.dummy(),
755 community_name="Berlin",
756 community_url="https://couchers.org/community/1/berlin-community",
757 is_invite=True,
758 )
759 return [
760 replace(prototype, is_invite=True),
761 replace(prototype, is_invite=True, community_name=None, community_url=None),
762 replace(prototype, is_invite=False),
763 replace(prototype, is_invite=False, community_name=None, community_url=None),
764 ]
767@dataclass(kw_only=True, slots=True)
768class EventUpdatedEmail(EmailBase):
769 """Sent to subscribers when an event is updated."""
771 updating_user: UserInfo
772 event_info: EventInfo
773 updated_items: list[notification_data_pb2.EventUpdateItem.ValueType]
775 @property
776 def string_key_base(self) -> str:
777 return "events.updated"
779 def get_subject_line(self, loc_context: LocalizationContext) -> str:
780 return self._localize(
781 loc_context, ".subject", {"user": self.updating_user.name, "title": self.event_info.title}
782 )
784 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
785 builder = self._body_builder(loc_context)
787 updated_items_string_keys = list(
788 filter(None, (type(self)._updated_item_to_string_key(i) for i in self.updated_items))
789 )
790 if updated_items_string_keys:
791 updated_items_text = loc_context.localize_list(
792 [self._localize(loc_context, key) for key in updated_items_string_keys]
793 )
794 builder.para(".purpose_with_items", {"user": self.updating_user.name, "items_list": updated_items_text})
795 else:
796 builder.para(".purpose_generic", {"user": self.updating_user.name})
798 builder.block(self.event_info.get_details_block(loc_context))
799 builder.user(self.updating_user)
800 builder.block(self.event_info.get_description_block())
801 builder.block(self.event_info.get_view_action_block(loc_context))
802 return builder.build()
804 @classmethod
805 def from_notification(cls, data: notification_data_pb2.EventUpdate, *, user_name: str) -> Self:
806 updated_items: list[notification_data_pb2.EventUpdateItem.ValueType] = []
807 if data.updated_enum_items: 807 ↛ 809line 807 didn't jump to line 809 because the condition on line 807 was always true
808 updated_items.extend(data.updated_enum_items)
809 elif data.updated_str_items:
810 for updated_str_item in data.updated_str_items:
811 if updated_enum_item := cls._updated_item_str_to_enum(updated_str_item):
812 updated_items.append(updated_enum_item)
814 return cls(
815 user_name=user_name,
816 updating_user=UserInfo.from_protobuf(data.updating_user),
817 event_info=EventInfo.from_proto(data.event),
818 updated_items=updated_items,
819 )
821 # TODO(#9117): Backcompat. Remove update_str_items fallback once known unused.
822 @staticmethod
823 def _updated_item_str_to_enum(value: str) -> notification_data_pb2.EventUpdateItem.ValueType | None:
824 match value:
825 case "title":
826 return notification_data_pb2.EventUpdateItem.EVENT_UPDATE_ITEM_TITLE
827 case "content":
828 return notification_data_pb2.EventUpdateItem.EVENT_UPDATE_ITEM_CONTENT
829 case "location":
830 return notification_data_pb2.EventUpdateItem.EVENT_UPDATE_ITEM_LOCATION
831 case "start time":
832 return notification_data_pb2.EventUpdateItem.EVENT_UPDATE_ITEM_START_TIME
833 case "end time":
834 return notification_data_pb2.EventUpdateItem.EVENT_UPDATE_ITEM_END_TIME
835 case _:
836 return None
838 @staticmethod
839 def _updated_item_to_string_key(value: notification_data_pb2.EventUpdateItem.ValueType) -> str | None:
840 match value:
841 case notification_data_pb2.EventUpdateItem.EVENT_UPDATE_ITEM_TITLE:
842 return ".item_names.title"
843 case notification_data_pb2.EventUpdateItem.EVENT_UPDATE_ITEM_CONTENT:
844 return ".item_names.content"
845 case notification_data_pb2.EventUpdateItem.EVENT_UPDATE_ITEM_LOCATION:
846 return ".item_names.location"
847 case notification_data_pb2.EventUpdateItem.EVENT_UPDATE_ITEM_START_TIME:
848 return ".item_names.start_time"
849 case notification_data_pb2.EventUpdateItem.EVENT_UPDATE_ITEM_END_TIME:
850 return ".item_names.end_time"
851 case _:
852 return None
854 @classmethod
855 def test_instances(cls) -> list[Self]:
856 prototype = cls(
857 user_name="Alice",
858 updating_user=UserInfo.dummy_bob(),
859 event_info=EventInfo.dummy(),
860 updated_items=[],
861 )
862 return [
863 replace(prototype, updated_items=[]),
864 replace(prototype, updated_items=notification_data_pb2.EventUpdateItem.values()),
865 ]
868@dataclass(kw_only=True, slots=True)
869class EventOrganizerInvitedEmail(EmailBase):
870 """Sent when a user is invited to co-organize an event."""
872 inviting_user: UserInfo
873 event_info: EventInfo
875 @property
876 def string_key_base(self) -> str:
877 return "events.organizer_invited"
879 def get_subject_line(self, loc_context: LocalizationContext) -> str:
880 return self._localize(
881 loc_context,
882 ".subject",
883 {"user": self.inviting_user.name, "title": self.event_info.title},
884 )
886 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
887 builder = self._body_builder(loc_context)
888 builder.para(".purpose", {"user": self.inviting_user.name, "title": self.event_info.title})
889 builder.block(self.event_info.get_details_block(loc_context))
890 builder.user(self.inviting_user)
891 builder.block(self.event_info.get_description_block())
892 builder.block(self.event_info.get_view_action_block(loc_context))
893 builder.para(_do_not_reply_request_string_key, epilogue=True)
894 return builder.build()
896 @classmethod
897 def from_notification(cls, data: notification_data_pb2.EventInviteOrganizer, *, user_name: str) -> Self:
898 return cls(
899 user_name=user_name,
900 inviting_user=UserInfo.from_protobuf(data.inviting_user),
901 event_info=EventInfo.from_proto(data.event),
902 )
904 @classmethod
905 def test_instances(cls) -> list[Self]:
906 return [cls(user_name="Alice", inviting_user=UserInfo.dummy_bob(), event_info=EventInfo.dummy())]
909@dataclass(kw_only=True, slots=True)
910class EventCommentEmail(EmailBase):
911 """Sent to subscribers when someone comments on an event."""
913 author: UserInfo
914 event_info: EventInfo
915 comment_markdown: str
917 @property
918 def string_key_base(self) -> str:
919 return "events.comment"
921 def get_subject_line(self, loc_context: LocalizationContext) -> str:
922 return self._localize(loc_context, ".subject", {"author": self.author.name, "title": self.event_info.title})
924 def get_preview_line(self, loc_context: LocalizationContext) -> str | None:
925 return markdown_to_plaintext(self.comment_markdown)
927 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
928 builder = self._body_builder(loc_context)
929 builder.para(".purpose", {"author": self.author.name})
930 builder.block(self.event_info.get_details_block(loc_context))
931 builder.user(self.author)
932 builder.quote(self.comment_markdown, markdown=True)
933 builder.block(self.event_info.get_view_action_block(loc_context))
934 return builder.build()
936 @classmethod
937 def from_notification(cls, data: notification_data_pb2.EventComment, *, user_name: str) -> Self:
938 return cls(
939 user_name=user_name,
940 author=UserInfo.from_protobuf(data.author),
941 event_info=EventInfo.from_proto(data.event),
942 comment_markdown=data.reply.content,
943 )
945 @classmethod
946 def test_instances(cls) -> list[Self]:
947 return [
948 cls(
949 user_name="Alice",
950 author=UserInfo.dummy_bob(),
951 event_info=EventInfo.dummy(),
952 comment_markdown="Looking forward to it, see you all there!",
953 )
954 ]
957@dataclass(kw_only=True, slots=True)
958class EventReminderEmail(EmailBase):
959 """Sent to subscribers as a reminder that an event starts soon."""
961 event_info: EventInfo
963 @property
964 def string_key_base(self) -> str:
965 return "events.reminder"
967 def get_subject_line(self, loc_context: LocalizationContext) -> str:
968 return self._localize(loc_context, ".subject", {"title": self.event_info.title})
970 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
971 builder = self._body_builder(loc_context)
972 builder.para(".purpose")
973 builder.block(self.event_info.get_details_block(loc_context))
974 builder.block(self.event_info.get_description_block())
975 builder.block(self.event_info.get_view_action_block(loc_context))
976 return builder.build()
978 @classmethod
979 def from_notification(cls, data: notification_data_pb2.EventReminder, *, user_name: str) -> Self:
980 return cls(
981 user_name=user_name,
982 event_info=EventInfo.from_proto(data.event),
983 )
985 @classmethod
986 def test_instances(cls) -> list[Self]:
987 return [cls(user_name="Alice", event_info=EventInfo.dummy())]
990@dataclass(kw_only=True, slots=True)
991class EventCancelledEmail(EmailBase):
992 """Sent to subscribers when an event is cancelled."""
994 cancelling_user: UserInfo
995 event_info: EventInfo
997 @property
998 def string_key_base(self) -> str:
999 return "events.cancel"
1001 def get_subject_line(self, loc_context: LocalizationContext) -> str:
1002 return self._localize(
1003 loc_context,
1004 ".subject",
1005 {"user": self.cancelling_user.name, "title": self.event_info.title},
1006 )
1008 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
1009 builder = self._body_builder(loc_context)
1010 builder.para(".purpose", {"user": self.cancelling_user.name})
1011 builder.block(self.event_info.get_details_block(loc_context))
1012 builder.user(self.cancelling_user)
1013 builder.quote(self.event_info.description_markdown, markdown=True)
1014 builder.block(self.event_info.get_view_action_block(loc_context))
1015 return builder.build()
1017 @classmethod
1018 def from_notification(cls, data: notification_data_pb2.EventCancel, *, user_name: str) -> Self:
1019 return cls(
1020 user_name=user_name,
1021 cancelling_user=UserInfo.from_protobuf(data.cancelling_user),
1022 event_info=EventInfo.from_proto(data.event),
1023 )
1025 @classmethod
1026 def test_instances(cls) -> list[Self]:
1027 return [cls(user_name="Alice", cancelling_user=UserInfo.dummy_bob(), event_info=EventInfo.dummy())]
1030@dataclass(kw_only=True, slots=True)
1031class EventDeletedEmail(EmailBase):
1032 """Sent to subscribers when a moderator deletes an event."""
1034 event_info: EventInfo
1036 @property
1037 def string_key_base(self) -> str:
1038 return "events.deleted"
1040 def get_subject_line(self, loc_context: LocalizationContext) -> str:
1041 return self._localize(loc_context, ".subject", {"title": self.event_info.title})
1043 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
1044 builder = self._body_builder(loc_context)
1045 builder.para(".purpose")
1046 builder.block(self.event_info.get_details_block(loc_context))
1047 return builder.build()
1049 @classmethod
1050 def from_notification(cls, data: notification_data_pb2.EventDelete, *, user_name: str) -> Self:
1051 return cls(
1052 user_name=user_name,
1053 event_info=EventInfo.from_proto(data.event),
1054 )
1056 @classmethod
1057 def test_instances(cls) -> list[Self]:
1058 return [
1059 cls(
1060 user_name="Alice",
1061 event_info=EventInfo.dummy(),
1062 )
1063 ]
1066@dataclass(kw_only=True, slots=True)
1067class FriendReferenceReceivedEmail(EmailBase):
1068 """Sent to a user when they receive a friend reference."""
1070 from_user: UserInfo
1071 text: str
1073 @property
1074 def string_key_base(self) -> str:
1075 return "references.received.friend"
1077 def get_subject_line(self, loc_context: LocalizationContext) -> str:
1078 return self._localize(loc_context, ".subject", {"name": self.from_user.name})
1080 def get_preview_line(self, loc_context: LocalizationContext) -> str | None:
1081 return self.text
1083 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
1084 builder = self._body_builder(loc_context)
1085 builder.para(".purpose", {"name": self.from_user.name})
1086 builder.user(self.from_user)
1087 builder.quote(self.text, markdown=False)
1088 builder.action(urls.profile_references_link(), "references.received.view_action")
1089 return builder.build()
1091 @classmethod
1092 def from_notification(cls, data: notification_data_pb2.ReferenceReceiveFriend, *, user_name: str) -> Self:
1093 return cls(user_name=user_name, from_user=UserInfo.from_protobuf(data.from_user), text=data.text)
1095 @classmethod
1096 def test_instances(cls) -> list[Self]:
1097 return [
1098 cls(
1099 user_name="Alice",
1100 from_user=UserInfo.dummy_bob(),
1101 text="Alice is a wonderful person and a great travel companion!",
1102 )
1103 ]
1106@dataclass(kw_only=True, slots=True)
1107class FriendRequestReceivedEmail(EmailBase):
1108 """Sent to a user when they receive a friend request."""
1110 befriender: UserInfo
1112 @property
1113 def string_key_base(self) -> str:
1114 return "friend_requests.received"
1116 def get_subject_line(self, loc_context: LocalizationContext) -> str:
1117 return self._localize(loc_context, ".subject", {"name": self.befriender.name})
1119 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
1120 builder = self._body_builder(loc_context)
1121 builder.para(".purpose", {"name": self.befriender.name})
1122 builder.user(self.befriender)
1123 builder.action(urls.friend_requests_link(), ".view_action")
1124 builder.para(".closing")
1125 builder.para(_do_not_reply_request_string_key, epilogue=True)
1126 return builder.build()
1128 @classmethod
1129 def from_notification(cls, data: notification_data_pb2.FriendRequestCreate, *, user_name: str) -> Self:
1130 return cls(user_name=user_name, befriender=UserInfo.from_protobuf(data.other_user))
1132 @classmethod
1133 def test_instances(cls) -> list[Self]:
1134 return [
1135 cls(
1136 user_name="Alice",
1137 befriender=UserInfo.dummy_bob(),
1138 )
1139 ]
1142@dataclass(kw_only=True, slots=True)
1143class FriendRequestAcceptedEmail(EmailBase):
1144 """Sent to a user when their friend request is accepted."""
1146 new_friend: UserInfo
1148 @property
1149 def string_key_base(self) -> str:
1150 return "friend_requests.accepted"
1152 def get_subject_line(self, loc_context: LocalizationContext) -> str:
1153 return self._localize(loc_context, ".subject", {"name": self.new_friend.name})
1155 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
1156 builder = self._body_builder(loc_context)
1157 builder.para(".purpose", {"name": self.new_friend.name})
1158 builder.user(self.new_friend)
1159 builder.action(self.new_friend.profile_url, ".view_action")
1160 builder.para(".closing")
1161 return builder.build()
1163 @classmethod
1164 def from_notification(cls, data: notification_data_pb2.FriendRequestAccept, *, user_name: str) -> Self:
1165 return cls(user_name=user_name, new_friend=UserInfo.from_protobuf(data.other_user))
1167 @classmethod
1168 def test_instances(cls) -> list[Self]:
1169 return [
1170 cls(
1171 user_name="Alice",
1172 new_friend=UserInfo.dummy_bob(),
1173 )
1174 ]
1177@dataclass(kw_only=True, slots=True)
1178class GenderChangedEmail(EmailBase):
1179 """Sent to a user to notify them that their gender was changed."""
1181 new_gender: str
1183 @property
1184 def string_key_base(self) -> str:
1185 return "gender_changed"
1187 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
1188 builder = self._body_builder(loc_context, security_warning=True)
1189 builder.para(".purpose", {"gender": self.new_gender})
1190 return builder.build()
1192 @classmethod
1193 def from_notification(cls, data: notification_data_pb2.GenderChange, *, user_name: str) -> Self:
1194 return cls(user_name=user_name, new_gender=data.gender)
1196 @classmethod
1197 def test_instances(cls) -> list[Self]:
1198 return [
1199 cls(
1200 user_name="Alice",
1201 new_gender="Male",
1202 )
1203 ]
1206@dataclass(kw_only=True, slots=True)
1207class HostRequestCreatedEmail(EmailBase):
1208 """Sent to a host when a surfer sends them a new host request."""
1210 surfer: UserInfo
1211 from_date: date
1212 to_date: date
1213 text: str
1214 view_link: str
1215 quick_decline_link: str
1217 @property
1218 def string_key_base(self) -> str:
1219 return "host_requests.created"
1221 def get_subject_line(self, loc_context: LocalizationContext) -> str:
1222 return self._localize(loc_context, ".subject", {"surfer_name": self.surfer.name})
1224 def get_preview_line(self, loc_context: LocalizationContext) -> str | None:
1225 return self.text
1227 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
1228 builder = self._body_builder(loc_context)
1229 builder.para(".purpose", {"surfer_name": self.surfer.name})
1230 builder.user(
1231 self.surfer,
1232 "host_requests.generic.date_range",
1233 {
1234 "from_date": _localize_host_request_date(self.from_date, loc_context),
1235 "to_date": _localize_host_request_date(self.to_date, loc_context),
1236 },
1237 )
1238 builder.quote(self.text, markdown=False)
1239 builder.action(self.view_link, "host_requests.generic.view_action")
1240 builder.action(self.quick_decline_link, "host_requests.generic.quick_decline_action")
1241 builder.para(".respond_encouragement")
1242 builder.para(_do_not_reply_request_string_key, epilogue=True)
1243 return builder.build()
1245 @classmethod
1246 def from_notification(cls, data: notification_data_pb2.HostRequestCreate, *, user_name: str) -> Self:
1247 return cls(
1248 user_name,
1249 surfer=UserInfo.from_protobuf(data.surfer),
1250 from_date=date.fromisoformat(data.host_request.from_date),
1251 to_date=date.fromisoformat(data.host_request.to_date),
1252 text=data.text,
1253 view_link=urls.host_request(host_request_id=data.host_request.host_request_id),
1254 quick_decline_link=generate_quick_decline_link(data.host_request),
1255 )
1257 @classmethod
1258 def test_instances(cls) -> list[Self]:
1259 return [
1260 cls(
1261 user_name="Alice",
1262 surfer=UserInfo.dummy_bob(),
1263 from_date=date(2025, 6, 1),
1264 to_date=date(2025, 6, 7),
1265 text="Hey, I'd love to stay for a few nights!",
1266 view_link="https://couchers.org/requests/123",
1267 quick_decline_link="https://couchers.org/requests/123/decline?token=xxx",
1268 )
1269 ]
1272@dataclass(kw_only=True, slots=True)
1273class HostRequestReminderEmail(EmailBase):
1274 """Sent to a host as a reminder to respond to a pending host request."""
1276 surfer: UserInfo
1277 from_date: date
1278 to_date: date
1279 view_link: str
1280 quick_decline_link: str
1282 @property
1283 def string_key_base(self) -> str:
1284 return "host_requests.reminder"
1286 def get_subject_line(self, loc_context: LocalizationContext) -> str:
1287 return self._localize(loc_context, ".subject", {"surfer_name": self.surfer.name})
1289 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
1290 builder = self._body_builder(loc_context)
1291 builder.para(".purpose", {"surfer_name": self.surfer.name})
1292 builder.user(
1293 self.surfer,
1294 "host_requests.generic.date_range",
1295 {
1296 "from_date": _localize_host_request_date(self.from_date, loc_context),
1297 "to_date": _localize_host_request_date(self.to_date, loc_context),
1298 },
1299 )
1300 builder.action(self.view_link, "host_requests.generic.view_action")
1301 builder.action(self.quick_decline_link, "host_requests.generic.quick_decline_action")
1302 builder.para(_do_not_reply_request_string_key, epilogue=True)
1303 return builder.build()
1305 @classmethod
1306 def from_notification(cls, data: notification_data_pb2.HostRequestReminder, *, user_name: str) -> Self:
1307 return cls(
1308 user_name,
1309 surfer=UserInfo.from_protobuf(data.surfer),
1310 from_date=date.fromisoformat(data.host_request.from_date),
1311 to_date=date.fromisoformat(data.host_request.to_date),
1312 view_link=urls.host_request(host_request_id=data.host_request.host_request_id),
1313 quick_decline_link=generate_quick_decline_link(data.host_request),
1314 )
1316 @classmethod
1317 def test_instances(cls) -> list[Self]:
1318 return [
1319 cls(
1320 user_name="Alice",
1321 surfer=UserInfo.dummy_bob(),
1322 from_date=date(2025, 6, 1),
1323 to_date=date(2025, 6, 7),
1324 view_link="https://couchers.org/requests/123",
1325 quick_decline_link="https://couchers.org/requests/123/decline?token=xxx",
1326 )
1327 ]
1330@dataclass(kw_only=True, slots=True)
1331class HostRequestMessageEmail(EmailBase):
1332 """Sent when a user sends a message in an existing host request."""
1334 other_user: UserInfo
1335 from_date: date
1336 to_date: date
1337 text: str
1338 from_host: bool
1339 view_link: str
1341 @property
1342 def string_key_base(self) -> str:
1343 variant = "from_host" if self.from_host else "from_surfer"
1344 return f"host_requests.message.{variant}"
1346 def get_subject_line(self, loc_context: LocalizationContext) -> str:
1347 return self._localize(loc_context, ".subject", {"other_name": self.other_user.name})
1349 def get_preview_line(self, loc_context: LocalizationContext) -> str | None:
1350 return self.text
1352 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
1353 builder = self._body_builder(loc_context)
1354 builder.para(".purpose", {"other_name": self.other_user.name})
1355 builder.user(
1356 self.other_user,
1357 "host_requests.generic.date_range",
1358 {
1359 "from_date": _localize_host_request_date(self.from_date, loc_context),
1360 "to_date": _localize_host_request_date(self.to_date, loc_context),
1361 },
1362 )
1363 builder.quote(self.text, markdown=False)
1364 builder.action(self.view_link, "host_requests.generic.view_action")
1365 builder.para(_do_not_reply_request_string_key, epilogue=True)
1366 return builder.build()
1368 @classmethod
1369 def from_notification(cls, data: notification_data_pb2.HostRequestMessage, *, user_name: str) -> Self:
1370 return cls(
1371 user_name,
1372 other_user=UserInfo.from_protobuf(data.user),
1373 from_date=date.fromisoformat(data.host_request.from_date),
1374 to_date=date.fromisoformat(data.host_request.to_date),
1375 text=data.text,
1376 from_host=not data.am_host,
1377 view_link=urls.host_request(host_request_id=data.host_request.host_request_id),
1378 )
1380 @classmethod
1381 def test_instances(cls) -> list[Self]:
1382 prototype = cls(
1383 user_name="Alice",
1384 other_user=UserInfo.dummy_bob(),
1385 from_date=date(2025, 6, 1),
1386 to_date=date(2025, 6, 7),
1387 text="Looking forward to it, see you soon!",
1388 from_host=True,
1389 view_link="https://couchers.org/requests/123",
1390 )
1391 return [replace(prototype, from_host=True), replace(prototype, from_host=False)]
1394@dataclass(kw_only=True, slots=True)
1395class HostRequestMissedMessagesEmail(EmailBase):
1396 """Sent as a digest when a user has missed messages in a host request."""
1398 other_user: UserInfo
1399 from_date: date
1400 to_date: date
1401 from_host: bool
1402 view_link: str
1404 @property
1405 def string_key_base(self) -> str:
1406 variant = "from_host" if self.from_host else "from_surfer"
1407 return f"host_requests.missed_messages.{variant}"
1409 def get_subject_line(self, loc_context: LocalizationContext) -> str:
1410 return self._localize(loc_context, ".subject", {"other_name": self.other_user.name})
1412 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
1413 builder = self._body_builder(loc_context)
1414 builder.para(".purpose", {"other_name": self.other_user.name})
1415 builder.user(
1416 self.other_user,
1417 "host_requests.generic.date_range",
1418 {
1419 "from_date": _localize_host_request_date(self.from_date, loc_context),
1420 "to_date": _localize_host_request_date(self.to_date, loc_context),
1421 },
1422 )
1423 builder.action(self.view_link, "host_requests.generic.view_action")
1424 builder.para(_do_not_reply_request_string_key, epilogue=True)
1425 return builder.build()
1427 @classmethod
1428 def from_notification(cls, data: notification_data_pb2.HostRequestMissedMessages, *, user_name: str) -> Self:
1429 return cls(
1430 user_name,
1431 other_user=UserInfo.from_protobuf(data.user),
1432 from_date=date.fromisoformat(data.host_request.from_date),
1433 to_date=date.fromisoformat(data.host_request.to_date),
1434 from_host=not data.am_host,
1435 view_link=urls.host_request(host_request_id=data.host_request.host_request_id),
1436 )
1438 @classmethod
1439 def test_instances(cls) -> list[Self]:
1440 prototype = cls(
1441 user_name="Alice",
1442 other_user=UserInfo.dummy_bob(),
1443 from_date=date(2025, 6, 1),
1444 to_date=date(2025, 6, 7),
1445 from_host=True,
1446 view_link="https://couchers.org/requests/123",
1447 )
1448 return [replace(prototype, from_host=True), replace(prototype, from_host=False)]
1451@dataclass(kw_only=True, slots=True)
1452class HostRequestStatusChangedEmail(EmailBase):
1453 """Sent when a host request is accepted, declined, confirmed, or cancelled."""
1455 other_user: UserInfo
1456 from_date: date
1457 to_date: date
1458 new_status: conversations_pb2.HostRequestStatus.ValueType
1459 view_link: str
1461 @property
1462 def string_key_base(self) -> str:
1463 base_key = "host_requests.status_changed"
1464 match self.new_status:
1465 case conversations_pb2.HOST_REQUEST_STATUS_ACCEPTED:
1466 return f"{base_key}.accepted_by_host"
1467 case conversations_pb2.HOST_REQUEST_STATUS_REJECTED:
1468 return f"{base_key}.declined_by_host"
1469 case conversations_pb2.HOST_REQUEST_STATUS_CONFIRMED:
1470 return f"{base_key}.confirmed_by_surfer"
1471 case conversations_pb2.HOST_REQUEST_STATUS_CANCELLED: 1471 ↛ 1473line 1471 didn't jump to line 1473 because the pattern on line 1471 always matched
1472 return f"{base_key}.cancelled_by_surfer"
1473 case _:
1474 raise ValueError(f"Unexpected host request status: {self.new_status}")
1476 def get_subject_line(self, loc_context: LocalizationContext) -> str:
1477 return self._localize(loc_context, ".subject", {"other_name": self.other_user.name})
1479 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
1480 builder = self._body_builder(loc_context)
1481 builder.para(".purpose", {"other_name": self.other_user.name})
1482 builder.user(
1483 self.other_user,
1484 "host_requests.generic.date_range",
1485 {
1486 "from_date": _localize_host_request_date(self.from_date, loc_context),
1487 "to_date": _localize_host_request_date(self.to_date, loc_context),
1488 },
1489 )
1490 builder.action(self.view_link, "host_requests.generic.view_action")
1491 builder.para(_do_not_reply_request_string_key, epilogue=True)
1492 return builder.build()
1494 @classmethod
1495 def from_notification(
1496 cls,
1497 data: notification_data_pb2.HostRequestAccept
1498 | notification_data_pb2.HostRequestReject
1499 | notification_data_pb2.HostRequestConfirm
1500 | notification_data_pb2.HostRequestCancel,
1501 *,
1502 user_name: str,
1503 ) -> Self:
1504 other_user: UserInfo
1505 new_status: conversations_pb2.HostRequestStatus.ValueType
1506 match data:
1507 case notification_data_pb2.HostRequestAccept():
1508 other_user = UserInfo.from_protobuf(data.host)
1509 new_status = conversations_pb2.HostRequestStatus.HOST_REQUEST_STATUS_ACCEPTED
1510 case notification_data_pb2.HostRequestReject(): 1510 ↛ 1511line 1510 didn't jump to line 1511 because the pattern on line 1510 never matched
1511 other_user = UserInfo.from_protobuf(data.host)
1512 new_status = conversations_pb2.HostRequestStatus.HOST_REQUEST_STATUS_REJECTED
1513 case notification_data_pb2.HostRequestConfirm():
1514 other_user = UserInfo.from_protobuf(data.surfer)
1515 new_status = conversations_pb2.HostRequestStatus.HOST_REQUEST_STATUS_CONFIRMED
1516 case notification_data_pb2.HostRequestCancel(): 1516 ↛ 1519line 1516 didn't jump to line 1519 because the pattern on line 1516 always matched
1517 other_user = UserInfo.from_protobuf(data.surfer)
1518 new_status = conversations_pb2.HostRequestStatus.HOST_REQUEST_STATUS_CANCELLED
1519 case _:
1520 # Enable mypy's exhaustiveness checking
1521 assert_never("Unexpected host request status changed notification data type.")
1523 return cls(
1524 user_name,
1525 other_user=other_user,
1526 from_date=date.fromisoformat(data.host_request.from_date),
1527 to_date=date.fromisoformat(data.host_request.to_date),
1528 new_status=new_status,
1529 view_link=urls.host_request(host_request_id=data.host_request.host_request_id),
1530 )
1532 @classmethod
1533 def test_instances(cls) -> list[Self]:
1534 prototype = cls(
1535 user_name="Alice",
1536 other_user=UserInfo.dummy_bob(),
1537 from_date=date(2025, 6, 1),
1538 to_date=date(2025, 6, 7),
1539 new_status=conversations_pb2.HOST_REQUEST_STATUS_ACCEPTED,
1540 view_link="https://couchers.org/requests/123",
1541 )
1542 return [
1543 replace(prototype, new_status=conversations_pb2.HOST_REQUEST_STATUS_ACCEPTED),
1544 replace(prototype, new_status=conversations_pb2.HOST_REQUEST_STATUS_REJECTED),
1545 replace(prototype, new_status=conversations_pb2.HOST_REQUEST_STATUS_CONFIRMED),
1546 replace(prototype, new_status=conversations_pb2.HOST_REQUEST_STATUS_CANCELLED),
1547 ]
1550@dataclass(kw_only=True, slots=True)
1551class HostReferenceReceivedEmail(EmailBase):
1552 """Sent to a user when they receive a reference from a past host or surfer."""
1554 from_user: UserInfo
1555 text: str | None # None if hidden because receiver hasn't written their reference yet.
1556 surfed: bool # True if I was the surfer, False if I was the host
1557 leave_reference_url: str
1559 @property
1560 def string_key_base(self) -> str:
1561 return "references.received"
1563 @property
1564 def string_role_subkey(self) -> str:
1565 return "surfed" if self.surfed else "hosted"
1567 def get_subject_line(self, loc_context: LocalizationContext) -> str:
1568 return self._localize(loc_context, ".subject", {"name": self.from_user.name})
1570 def get_preview_line(self, loc_context: LocalizationContext) -> str | None:
1571 return self.text
1573 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
1574 builder = self._body_builder(loc_context)
1575 builder.para(f".{self.string_role_subkey}.purpose", {"name": self.from_user.name})
1576 builder.user(self.from_user)
1577 if self.text:
1578 builder.quote(self.text, markdown=False)
1579 builder.action(urls.profile_references_link(), ".view_action")
1580 else:
1581 builder.para(f".{self.string_role_subkey}.reciprocate_encouragement", {"name": self.from_user.name})
1582 builder.action(self.leave_reference_url, "references.write_action", {"name": self.from_user.name})
1583 return builder.build()
1585 @classmethod
1586 def from_notification(
1587 cls, data: notification_data_pb2.ReferenceReceiveHostRequest, *, user_name: str, surfed: bool
1588 ) -> Self:
1589 return cls(
1590 user_name=user_name,
1591 from_user=UserInfo.from_protobuf(data.from_user),
1592 text=data.text or None,
1593 surfed=surfed,
1594 leave_reference_url=urls.leave_reference_link(
1595 reference_type="surfed" if surfed else "hosted",
1596 to_user_id=str(data.from_user.user_id),
1597 host_request_id=str(data.host_request_id),
1598 ),
1599 )
1601 @classmethod
1602 def test_instances(cls) -> list[Self]:
1603 prototype = cls(
1604 user_name="Alice",
1605 from_user=UserInfo.dummy_bob(),
1606 text="Alice was a fantastic guest!",
1607 surfed=True,
1608 leave_reference_url="https://couchers.org/leave-reference/123",
1609 )
1610 return [
1611 replace(prototype, surfed=True, text="Alice was a fantastic guest!"),
1612 replace(prototype, surfed=True, text=None),
1613 replace(prototype, surfed=False, text="Bob was a wonderful host!"),
1614 replace(prototype, surfed=False, text=None),
1615 ]
1618@dataclass(kw_only=True, slots=True)
1619class HostReferenceReminderEmail(EmailBase):
1620 """Sent as a reminder to write a reference after a stay."""
1622 other_user: UserInfo
1623 days_left: int
1624 surfed: bool # True if I was the surfer, False if I was the host
1625 leave_reference_url: str
1627 @property
1628 def string_key_base(self) -> str:
1629 return "references.reminder"
1631 @property
1632 def string_role_subkey(self) -> str:
1633 return "surfed" if self.surfed else "hosted"
1635 def get_subject_line(self, loc_context: LocalizationContext) -> str:
1636 return self._localize(
1637 loc_context,
1638 ".subject_days",
1639 {"name": self.other_user.name, "count": self.days_left},
1640 )
1642 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
1643 builder = self._body_builder(loc_context)
1644 builder.para(
1645 f".{self.string_role_subkey}.purpose_days", {"name": self.other_user.name, "count": self.days_left}
1646 )
1647 builder.user(self.other_user)
1648 builder.action(
1649 self.leave_reference_url,
1650 "references.write_action",
1651 {"name": self.other_user.name},
1652 )
1653 builder.para(".no_meeting_note", {"name": self.other_user.name})
1654 builder.para(".visibility_note")
1655 return builder.build()
1657 @classmethod
1658 def from_notification(cls, data: notification_data_pb2.ReferenceReminder, *, user_name: str, surfed: bool) -> Self:
1659 return cls(
1660 user_name=user_name,
1661 other_user=UserInfo.from_protobuf(data.other_user),
1662 days_left=data.days_left,
1663 surfed=surfed,
1664 leave_reference_url=urls.leave_reference_link(
1665 reference_type="surfed" if surfed else "hosted",
1666 to_user_id=str(data.other_user.user_id),
1667 host_request_id=str(data.host_request_id),
1668 ),
1669 )
1671 @classmethod
1672 def test_instances(cls) -> list[Self]:
1673 prototype = cls(
1674 user_name="Alice",
1675 other_user=UserInfo.dummy_bob(),
1676 days_left=7,
1677 surfed=True,
1678 leave_reference_url="https://couchers.org/leave-reference/123",
1679 )
1680 return [replace(prototype, surfed=True), replace(prototype, surfed=False)]
1683@dataclass(kw_only=True, slots=True)
1684class ModeratorNoteEmail(EmailBase):
1685 """Sent to a user to notify them they have received a moderator note."""
1687 @property
1688 def string_key_base(self) -> str:
1689 return "moderator_note"
1691 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
1692 builder = self._body_builder(loc_context)
1693 builder.para(".purpose")
1694 # Users with moderator notes are "jailed": any URL will show the note before
1695 # letting them use the platform.
1696 builder.action(urls.dashboard_link(), ".view_action")
1697 return builder.build()
1699 @classmethod
1700 def test_instances(cls) -> list[Self]:
1701 return [cls(user_name="Alice")]
1704@dataclass(kw_only=True, slots=True)
1705class NewBlogPostEmail(EmailBase):
1706 """Sent to notify users of a new blog post."""
1708 title: str
1709 blurb: str
1710 url: str
1712 @property
1713 def string_key_base(self) -> str:
1714 return "new_blog_post"
1716 def get_subject_line(self, loc_context: LocalizationContext) -> str:
1717 return self._localize(loc_context, ".subject", {"title": self.title})
1719 def get_preview_line(self, loc_context: LocalizationContext) -> str | None:
1720 return self.blurb
1722 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
1723 builder = self._body_builder(loc_context)
1724 builder.para(".purpose")
1725 builder.block(ParaBlock(text=Markup(f"<b>{escape(self.title)}</b>")))
1726 builder.quote(self.blurb, markdown=False)
1727 builder.action(self.url, ".read_action")
1728 return builder.build()
1730 @classmethod
1731 def from_notification(cls, data: notification_data_pb2.GeneralNewBlogPost, *, user_name: str) -> Self:
1732 return cls(user_name=user_name, title=data.title, blurb=data.blurb, url=data.url)
1734 @classmethod
1735 def test_instances(cls) -> list[Self]:
1736 return [
1737 cls(
1738 user_name="Alice",
1739 title="Exciting new features on Couchers.org",
1740 blurb="We've launched some great new features including improved messaging and event discovery.",
1741 url="https://couchers.org/blog/2025/01/01/new-features",
1742 )
1743 ]
1746@dataclass(kw_only=True, slots=True)
1747class OnboardingReminderEmail(EmailBase):
1748 """Onboarding email sent to new users; initial=True for the first email, False for the second."""
1750 initial: bool
1752 @property
1753 def string_key_base(self) -> str:
1754 return f"onboarding_reminder.{'initial' if self.initial else 'follow_up'}"
1756 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
1757 builder = self._body_builder(loc_context, default_closing=False)
1758 edit_profile_url = urls.edit_profile_link()
1759 if self.initial:
1760 builder.para(".welcome")
1761 builder.para(".early_user_role")
1762 builder.para(".fill_in_profile")
1763 builder.para(".edit_profile_prompt")
1764 builder.action(edit_profile_url, "onboarding_reminder.edit_profile_action")
1765 builder.para(".share_with_friends")
1766 builder.para(".link", {"url": urls.app_link()})
1767 builder.para(".platform_under_development")
1768 builder.para(".thanks_for_joining")
1769 builder.para("generic.closing_lines.aapeli")
1770 else:
1771 builder.para(".intro")
1772 builder.para(".request")
1773 builder.action(edit_profile_url, "onboarding_reminder.edit_profile_action")
1774 builder.para("generic.thanks")
1775 builder.para("generic.closing_lines.emily")
1776 return builder.build()
1778 @classmethod
1779 def test_instances(cls) -> list[Self]:
1780 prototype = cls(user_name="Alice", initial=True)
1781 return [replace(prototype, initial=True), replace(prototype, initial=False)]
1784@dataclass(kw_only=True, slots=True)
1785class PasswordChangedEmail(EmailBase):
1786 """Sent to a user to notify them that their login password was changed."""
1788 @property
1789 def string_key_base(self) -> str:
1790 return "password_changed"
1792 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
1793 builder = self._body_builder(loc_context, security_warning=True)
1794 builder.para(".purpose")
1795 return builder.build()
1797 @classmethod
1798 def test_instances(cls) -> list[Self]:
1799 return [cls(user_name="Alice")]
1802@dataclass(kw_only=True, slots=True)
1803class PasswordResetCompletedEmail(EmailBase):
1804 """Sent to a user to confirm their password was successfully reset."""
1806 @property
1807 def string_key_base(self) -> str:
1808 return "password_reset.completed"
1810 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
1811 builder = self._body_builder(loc_context, security_warning=True)
1812 builder.para(".purpose")
1813 return builder.build()
1815 @classmethod
1816 def test_instances(cls) -> list[Self]:
1817 return [cls(user_name="Alice")]
1820@dataclass(kw_only=True, slots=True)
1821class PasswordResetStartedEmail(EmailBase):
1822 """Sent to a user with a link to complete their password reset."""
1824 password_reset_link: str
1826 @property
1827 def string_key_base(self) -> str:
1828 return "password_reset.started"
1830 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
1831 builder = self._body_builder(loc_context, security_warning=True)
1832 builder.para(".purpose")
1833 builder.action(self.password_reset_link, ".reset_action")
1834 return builder.build()
1836 @classmethod
1837 def from_notification(cls, data: notification_data_pb2.PasswordResetStart, *, user_name: str) -> Self:
1838 return cls(
1839 user_name=user_name,
1840 password_reset_link=urls.password_reset_link(password_reset_token=data.password_reset_token),
1841 )
1843 @classmethod
1844 def test_instances(cls) -> list[Self]:
1845 return [cls(user_name="Alice", password_reset_link="https://couchers.org/reset-password")]
1848@dataclass(kw_only=True, slots=True)
1849class PhoneNumberChangeEmail(EmailBase):
1850 """Sent to a user to notify them that their phone number verification status was changed."""
1852 new_phone_number: str
1853 completed: bool # False = started, True = completed
1855 @property
1856 def string_key_base(self) -> str:
1857 return "phone_number_verification.verified" if self.completed else "phone_number_verification.started"
1859 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
1860 builder = self._body_builder(loc_context, security_warning=True)
1861 builder.para(".purpose", {"phone_number": format_phone_number(self.new_phone_number)})
1862 return builder.build()
1864 @classmethod
1865 def from_change_notification(cls, data: notification_data_pb2.PhoneNumberChange, *, user_name: str) -> Self:
1866 return cls(user_name=user_name, new_phone_number=data.phone, completed=False)
1868 @classmethod
1869 def from_verify_notification(cls, data: notification_data_pb2.PhoneNumberVerify, *, user_name: str) -> Self:
1870 return cls(user_name=user_name, new_phone_number=data.phone, completed=True)
1872 @classmethod
1873 def test_instances(cls) -> list[Self]:
1874 prototype = cls(
1875 user_name="Alice",
1876 new_phone_number="+12223334444",
1877 completed=False,
1878 )
1879 return [replace(prototype, completed=False), replace(prototype, completed=True)]
1882@dataclass(kw_only=True, slots=True)
1883class PostalVerificationFailedEmail(EmailBase):
1884 """Sent to a user when their postal verification attempt has failed."""
1886 reason: notification_data_pb2.PostalVerificationFailReason.ValueType
1888 @property
1889 def string_key_base(self) -> str:
1890 return "postal_verification.failed"
1892 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
1893 builder = self._body_builder(loc_context, security_warning=True)
1894 match self.reason:
1895 case notification_data_pb2.POSTAL_VERIFICATION_FAIL_REASON_CODE_EXPIRED:
1896 purpose_string_key = ".purpose.code_expired"
1897 case notification_data_pb2.POSTAL_VERIFICATION_FAIL_REASON_TOO_MANY_ATTEMPTS:
1898 purpose_string_key = ".purpose.too_many_attempts"
1899 case _:
1900 purpose_string_key = ".purpose.default"
1901 builder.para(purpose_string_key)
1902 builder.action(urls.account_settings_link(), ".restart_action")
1903 return builder.build()
1905 @classmethod
1906 def from_notification(cls, data: notification_data_pb2.PostalVerificationFailed, *, user_name: str) -> Self:
1907 return cls(user_name=user_name, reason=data.reason)
1909 @classmethod
1910 def test_instances(cls) -> list[Self]:
1911 prototype = cls(
1912 user_name="Alice",
1913 reason=notification_data_pb2.POSTAL_VERIFICATION_FAIL_REASON_CODE_EXPIRED,
1914 )
1915 return [
1916 replace(prototype, reason=notification_data_pb2.POSTAL_VERIFICATION_FAIL_REASON_CODE_EXPIRED),
1917 replace(prototype, reason=notification_data_pb2.POSTAL_VERIFICATION_FAIL_REASON_TOO_MANY_ATTEMPTS),
1918 replace(prototype, reason=notification_data_pb2.POSTAL_VERIFICATION_FAIL_REASON_UNKNOWN),
1919 ]
1922@dataclass(kw_only=True, slots=True)
1923class PostalVerificationPostcardSentEmail(EmailBase):
1924 """Sent to a user to notify them that their verification postcard has been sent."""
1926 city: str
1927 country: str
1929 @property
1930 def string_key_base(self) -> str:
1931 return "postal_verification.postcard_sent"
1933 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
1934 builder = self._body_builder(loc_context, security_warning=True)
1935 builder.para(".purpose", {"city": self.city, "country": self.country})
1936 builder.action(urls.dashboard_link(), ".enter_code_action")
1937 return builder.build()
1939 @classmethod
1940 def from_notification(cls, data: notification_data_pb2.PostalVerificationPostcardSent, *, user_name: str) -> Self:
1941 return cls(user_name=user_name, city=data.city, country=data.country)
1943 @classmethod
1944 def test_instances(cls) -> list[Self]:
1945 return [cls(user_name="Alice", city="New York", country="United States")]
1948@dataclass(kw_only=True, slots=True)
1949class PostalVerificationSucceededEmail(EmailBase):
1950 """Sent to a user when their postal verification has succeeded."""
1952 @property
1953 def string_key_base(self) -> str:
1954 return "postal_verification.succeeded"
1956 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
1957 builder = self._body_builder(loc_context, security_warning=True)
1958 builder.para(".purpose")
1959 return builder.build()
1961 @classmethod
1962 def test_instances(cls) -> list[Self]:
1963 return [cls(user_name="Alice")]
1966@dataclass(kw_only=True, slots=True)
1967class SignupVerifyEmail(EmailBase):
1968 """Sent to a user to verify their email address."""
1970 verify_url: str
1972 @property
1973 def string_key_base(self) -> str:
1974 return "signup.verify"
1976 def get_subject_line(self, loc_context: LocalizationContext) -> str:
1977 return self._localize(loc_context, "signup.subject")
1979 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
1980 builder = self._body_builder(loc_context)
1981 builder.para(".thanks")
1982 builder.para(".instructions")
1983 builder.action(self.verify_url, ".confirm_action")
1984 builder.para("signup.closing")
1985 return builder.build()
1987 @classmethod
1988 def test_instances(cls) -> list[Self]:
1989 return [cls(user_name="Alice", verify_url="https://example.com")]
1992@dataclass(kw_only=True, slots=True)
1993class SignupContinueEmail(EmailBase):
1994 """Sent to a user to ask them to continue the signup process."""
1996 continue_url: str
1998 @property
1999 def string_key_base(self) -> str:
2000 return "signup.continue"
2002 def get_subject_line(self, loc_context: LocalizationContext) -> str:
2003 return self._localize(loc_context, "signup.subject")
2005 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
2006 builder = self._body_builder(loc_context)
2007 builder.para(".purpose")
2008 builder.action(self.continue_url, ".continue_action")
2009 builder.para("signup.closing")
2010 builder.para(".ignore_if_unexpected")
2011 return builder.build()
2013 @classmethod
2014 def test_instances(cls) -> list[Self]:
2015 return [cls(user_name="Alice", continue_url="https://example.com")]
2018@dataclass(kw_only=True, slots=True)
2019class StrongVerificationFailedEmail(EmailBase):
2020 """Sent to a user when their strong verification attempt has failed."""
2022 reason: notification_data_pb2.SVFailReason.ValueType
2024 @property
2025 def string_key_base(self) -> str:
2026 return "strong_verification.failed"
2028 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
2029 builder = self._body_builder(loc_context, security_warning=True)
2030 match self.reason:
2031 case notification_data_pb2.SV_FAIL_REASON_WRONG_BIRTHDATE_OR_GENDER:
2032 purpose_string_key = ".purpose.wrong_birthdate_or_gender"
2033 case notification_data_pb2.SV_FAIL_REASON_NOT_A_PASSPORT:
2034 purpose_string_key = ".purpose.not_a_passport"
2035 case notification_data_pb2.SV_FAIL_REASON_DUPLICATE: 2035 ↛ 2037line 2035 didn't jump to line 2037 because the pattern on line 2035 always matched
2036 purpose_string_key = ".purpose.duplicate"
2037 case _:
2038 raise Exception("Shouldn't get here")
2039 builder.para(purpose_string_key)
2040 builder.action(urls.strong_verification_url(), ".restart_action")
2041 return builder.build()
2043 @classmethod
2044 def from_notification(cls, data: notification_data_pb2.VerificationSVFail, *, user_name: str) -> Self:
2045 return cls(user_name=user_name, reason=data.reason)
2047 @classmethod
2048 def test_instances(cls) -> list[Self]:
2049 prototype = cls(
2050 user_name="Alice",
2051 reason=notification_data_pb2.SV_FAIL_REASON_NOT_A_PASSPORT,
2052 )
2053 return [
2054 replace(prototype, reason=notification_data_pb2.SV_FAIL_REASON_WRONG_BIRTHDATE_OR_GENDER),
2055 replace(prototype, reason=notification_data_pb2.SV_FAIL_REASON_NOT_A_PASSPORT),
2056 replace(prototype, reason=notification_data_pb2.SV_FAIL_REASON_DUPLICATE),
2057 ]
2060@dataclass(kw_only=True, slots=True)
2061class StrongVerificationSucceededEmail(EmailBase):
2062 """Sent to a user when their strong verification has succeeded."""
2064 @property
2065 def string_key_base(self) -> str:
2066 return "strong_verification.succeeded"
2068 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
2069 builder = self._body_builder(loc_context, security_warning=True)
2070 builder.para(".purpose")
2071 builder.para(".thanks_message")
2072 builder.para(".cost_explanation")
2073 builder.para(".donation_request")
2074 donate_link = urls.donation_url() + "?utm_source=strong-verification-email"
2075 builder.action(donate_link, ".donate_action")
2076 return builder.build()
2078 @classmethod
2079 def test_instances(cls) -> list[Self]:
2080 return [cls(user_name="Alice")]
2083@dataclass(kw_only=True, slots=True)
2084class ThreadReplyEmail(EmailBase):
2085 """Sent to a user when someone replies in a comment thread they participated in."""
2087 author: UserInfo
2088 parent_context: str # Title of the event or discussion being replied in
2089 markdown_text: str
2090 view_link: str
2092 @property
2093 def string_key_base(self) -> str:
2094 return "thread_reply"
2096 def get_subject_line(self, loc_context: LocalizationContext) -> str:
2097 return self._localize(
2098 loc_context, ".subject", {"author": self.author.name, "parent_context": self.parent_context}
2099 )
2101 def get_preview_line(self, loc_context: LocalizationContext) -> str | None:
2102 return markdown_to_plaintext(self.markdown_text)
2104 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
2105 builder = self._body_builder(loc_context)
2106 builder.para(".purpose", {"author": self.author.name, "parent_context": self.parent_context})
2107 builder.user(self.author)
2108 builder.quote(self.markdown_text, markdown=True)
2109 builder.action(self.view_link, ".view_action")
2110 return builder.build()
2112 @classmethod
2113 def from_notification(cls, data: notification_data_pb2.ThreadReply, *, user_name: str) -> Self:
2114 parent = data.WhichOneof("reply_parent")
2115 if parent == "event":
2116 parent_context = data.event.title
2117 view_link = urls.event_link(occurrence_id=data.event.event_id, slug=data.event.slug)
2118 elif parent == "discussion": 2118 ↛ 2122line 2118 didn't jump to line 2122 because the condition on line 2118 was always true
2119 parent_context = data.discussion.title
2120 view_link = urls.discussion_link(discussion_id=data.discussion.discussion_id, slug=data.discussion.slug)
2121 else:
2122 raise Exception("Can only do replies to events and discussions")
2123 return cls(
2124 user_name=user_name,
2125 author=UserInfo.from_protobuf(data.author),
2126 parent_context=parent_context,
2127 markdown_text=data.reply.content,
2128 view_link=view_link,
2129 )
2131 @classmethod
2132 def test_instances(cls) -> list[Self]:
2133 return [
2134 cls(
2135 user_name="Alice",
2136 author=UserInfo.dummy_bob(),
2137 parent_context="Best hiking trails near Berlin",
2138 markdown_text="I agree, the Grünewald is **amazing**!",
2139 view_link="https://couchers.org/discussions/123",
2140 )
2141 ]
2144def _localize_host_request_date(value: date, loc_context: LocalizationContext) -> str:
2145 return loc_context.localize_date(value, with_year=False, with_day_of_week=True)