Coverage for app/backend/src/couchers/email/emails.py: 98%
1061 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-26 00:31 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-26 00:31 +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 EmailBlocksBuilder,
45 ParaBlock,
46 QuoteBlock,
47 UserInfo,
48)
49from couchers.email.locales import get_emails_i18next
50from couchers.i18n import LocalizationContext
51from couchers.i18n.localize import format_phone_number
52from couchers.markup import html_link, html_mailto_link, markdown_to_plaintext
53from couchers.notifications.quick_links import generate_quick_decline_link
54from couchers.proto import events_pb2, messages_pb2, notification_data_pb2
55from couchers.utils import now, to_aware_datetime
57# Common string keys
58_do_not_reply_request_string_key = "generic.do_not_reply_request"
60# Specific email definitions
63@dataclass(kw_only=True, slots=True)
64class AccountDeletionStartedEmail(EmailBase):
65 """Sent to a user to confirm their account deletion request."""
67 deletion_link: str
69 @property
70 def string_key_base(self) -> str:
71 return "account_deletion.started"
73 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
74 builder = self._body_builder(loc_context, security_warning=True)
75 builder.para(".purpose")
76 builder.action(self.deletion_link, ".confirm_action")
77 return builder.build()
79 @classmethod
80 def from_notification(cls, data: notification_data_pb2.AccountDeletionStart, *, user_name: str) -> Self:
81 return cls(
82 user_name=user_name,
83 deletion_link=urls.delete_account_link(account_deletion_token=data.deletion_token),
84 )
86 @classmethod
87 def test_instances(cls) -> list[Self]:
88 return [
89 cls(
90 user_name="Alice",
91 deletion_link="https://couchers.org/delete-account?token=xxx",
92 )
93 ]
96@dataclass(kw_only=True, slots=True)
97class AccountDeletionCompletedEmail(EmailBase):
98 """Sent to a user after their account has been deleted."""
100 undelete_link: str
101 days: int
103 @property
104 def string_key_base(self) -> str:
105 return "account_deletion.completed"
107 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
108 builder = self._body_builder(loc_context, security_warning=True)
109 builder.para(".purpose")
110 builder.para(".farewell")
111 builder.para(".recovery_instructions_days", {"count": self.days})
112 builder.action(self.undelete_link, ".recover_action")
113 return builder.build()
115 @classmethod
116 def from_notification(cls, data: notification_data_pb2.AccountDeletionComplete, *, user_name: str) -> Self:
117 return cls(
118 user_name=user_name,
119 undelete_link=urls.recover_account_link(account_undelete_token=data.undelete_token),
120 days=data.undelete_days,
121 )
123 @classmethod
124 def test_instances(cls) -> list[Self]:
125 return [
126 cls(
127 user_name="Alice",
128 undelete_link="https://couchers.org/recover-account?token=xxx",
129 days=30,
130 )
131 ]
134@dataclass(kw_only=True, slots=True)
135class AccountDeletionRecoveredEmail(EmailBase):
136 """Sent to a user after their account deletion has been cancelled."""
138 @property
139 def string_key_base(self) -> str:
140 return "account_deletion.recovered"
142 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
143 builder = self._body_builder(loc_context, security_warning=True)
144 builder.para(".confirmation")
145 builder.para(".login_instructions")
146 builder.action(urls.app_link(), ".login_action")
147 builder.para(".redelete_instructions")
148 return builder.build()
150 @classmethod
151 def test_instances(cls) -> list[Self]:
152 return [cls(user_name="Alice")]
155@dataclass(kw_only=True, slots=True)
156class ActivenessProbeEmail(EmailBase):
157 """Sent to a host to check if they are still open to hosting."""
159 days_left: int
161 @property
162 def string_key_base(self) -> str:
163 return "activeness_probe"
165 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
166 builder = self._body_builder(loc_context)
167 builder.para(".purpose")
168 builder.para(".instructions_days", {"count": self.days_left})
169 builder.action(urls.app_link(), ".login_action")
170 builder.para(".encouragement")
172 # Extract major.minor from the version string. "v1.3.18927" -> "1.3"
173 version = config.VERSION
174 if version_match := re.search(r"^v?(\d+\.\d+)\b", version): 174 ↛ 175line 174 didn't jump to line 175 because the condition on line 174 was never true
175 version = version_match[1]
177 builder.para(".latest_release", {"version": version, "blog_url": LATEST_RELEASE_BLOG_URL})
178 return builder.build()
180 @classmethod
181 def from_notification(cls, data: notification_data_pb2.ActivenessProbe, *, user_name: str) -> Self:
182 days_left = (to_aware_datetime(data.deadline) - now()).days
183 return cls(user_name=user_name, days_left=days_left)
185 @classmethod
186 def test_instances(cls) -> list[Self]:
187 return [cls(user_name="Alice", days_left=7)]
190@dataclass(kw_only=True, slots=True)
191class APIKeyIssuedEmail(EmailBase):
192 """Sent to a user to notify them that their API key was issued."""
194 api_key: str
195 expiry: datetime
197 @property
198 def string_key_base(self) -> str:
199 return "api_key_issued"
201 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
202 builder = self._body_builder(loc_context, security_warning=True)
203 builder.para(".header")
204 builder.quote(self.api_key, markdown=False)
205 builder.para(".expiry", {"datetime": loc_context.localize_datetime(self.expiry)})
206 builder.para(".usage_warning")
207 builder.para(".policy_warning", {"terms_url": urls.terms_of_service_url()})
208 return builder.build()
210 @classmethod
211 def from_notification(cls, data: notification_data_pb2.ApiKeyCreate, *, user_name: str) -> Self:
212 return cls(user_name=user_name, api_key=data.api_key, expiry=data.expiry.ToDatetime(tzinfo=UTC))
214 @classmethod
215 def test_instances(cls) -> list[Self]:
216 return [cls(user_name="Alice", api_key="my_api_key_123", expiry=datetime(2099, 12, 31, 23, 59, 59, tzinfo=UTC))]
219@dataclass(kw_only=True, slots=True)
220class BadgeChangedEmail(EmailBase):
221 """Sent to a user to notify them that a badge was added or removed from their profile."""
223 badge_name: str
224 added: bool
226 @property
227 def string_key_base(self) -> str:
228 return "badges.added" if self.added else "badges.removed"
230 def get_subject_line(self, loc_context: LocalizationContext) -> str:
231 return self._localize(loc_context, ".subject", {"name": self.badge_name})
233 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
234 builder = self._body_builder(loc_context)
235 builder.para(".purpose", {"name": self.badge_name})
236 return builder.build()
238 @classmethod
239 def from_notification(
240 cls, data: notification_data_pb2.BadgeAdd | notification_data_pb2.BadgeRemove, *, user_name: str
241 ) -> Self:
242 return cls(
243 user_name=user_name, badge_name=data.badge_name, added=isinstance(data, notification_data_pb2.BadgeAdd)
244 )
246 @classmethod
247 def test_instances(cls) -> list[Self]:
248 prototype = cls(user_name="Alice", badge_name="Founder", added=True)
249 return [replace(prototype, added=True), replace(prototype, added=False)]
252@dataclass(kw_only=True, slots=True)
253class BirthdateChangedEmail(EmailBase):
254 """Sent to a user to notify them that their birthdate was changed."""
256 new_birthdate: date
258 @property
259 def string_key_base(self) -> str:
260 return "birthdate_changed"
262 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
263 builder = self._body_builder(loc_context, security_warning=True)
264 builder.para(".purpose", {"date": loc_context.localize_date(self.new_birthdate)})
265 return builder.build()
267 @classmethod
268 def from_notification(cls, data: notification_data_pb2.BirthdateChange, *, user_name: str) -> Self:
269 return cls(user_name=user_name, new_birthdate=date.fromisoformat(data.birthdate))
271 @classmethod
272 def test_instances(cls) -> list[Self]:
273 return [
274 cls(
275 user_name="Alice",
276 new_birthdate=date(1990, 1, 1),
277 )
278 ]
281@dataclass(kw_only=True, slots=True)
282class ChatMessageReceivedEmail(EmailBase):
283 """Sent to a user when they receive a new chat message."""
285 group_chat_title: str | None # None if direct message
286 author: UserInfo
287 text: str
288 view_url: str
290 @property
291 def string_key_base(self) -> str:
292 return f"chat_messages.received.{'direct' if self.group_chat_title is None else 'group'}"
294 def get_subject_line(self, loc_context: LocalizationContext) -> str:
295 return self._localize(
296 loc_context, ".subject", {"author": self.author.name, "group": self.group_chat_title or ""}
297 )
299 def get_preview_line(self, loc_context: LocalizationContext) -> str | None:
300 return self.text
302 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
303 builder = self._body_builder(loc_context)
304 builder.para(".purpose", {"author": self.author.name, "group": self.group_chat_title or ""})
305 builder.user(self.author)
306 builder.quote(self.text, markdown=False)
307 builder.action(self.view_url, ".view_action")
308 return builder.build()
310 @classmethod
311 def from_notification(cls, data: notification_data_pb2.ChatMessage, *, user_name: str) -> Self:
312 return cls(
313 user_name,
314 author=UserInfo.from_protobuf(data.author),
315 text=data.text,
316 group_chat_title=data.group_chat_title or None,
317 view_url=urls.chat_link(chat_id=data.group_chat_id),
318 )
320 @classmethod
321 def test_instances(cls) -> list[Self]:
322 prototype = cls(
323 user_name="Alice",
324 group_chat_title=None,
325 author=UserInfo.dummy_bob(),
326 text="Hi Alice!",
327 view_url="https://couchers.org/messages/chats/123",
328 )
329 return [
330 replace(prototype, group_chat_title=None),
331 replace(prototype, group_chat_title="Best friends"),
332 ]
335@dataclass(kw_only=True, slots=True)
336class ChatMessagesMissedEmail(EmailBase):
337 """Sent to a user after they've missed new chat messages."""
339 @dataclass(kw_only=True, slots=True)
340 class Entry:
341 """Entry for each chat with missed messages."""
343 group_chat_title: str | None # None if direct message
344 missed_count: int
345 latest_message_author: UserInfo
346 latest_message_text: str
347 view_url: str
349 entries: list[Entry]
351 @property
352 def string_key_base(self) -> str:
353 return "chat_messages.missed"
355 def get_subject_line(self, loc_context: LocalizationContext) -> str:
356 return self._localize(loc_context, ".subject")
358 def get_preview_line(self, loc_context: LocalizationContext) -> str | None:
359 if len(self.entries) != 1:
360 return None
361 return self.entries[0].latest_message_text
363 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
364 builder = self._body_builder(loc_context)
365 builder.para(".purpose")
366 for entry in self.entries:
367 if entry.group_chat_title is None:
368 builder.para(".count_in_dm", {"count": entry.missed_count, "author": entry.latest_message_author.name})
369 else:
370 builder.para(".count_in_group", {"count": entry.missed_count, "group": entry.group_chat_title})
371 builder.user(entry.latest_message_author)
372 builder.quote(entry.latest_message_text, markdown=False)
373 builder.action(entry.view_url, ".view_action")
374 return builder.build()
376 @classmethod
377 def from_notification(cls, data: notification_data_pb2.ChatMissedMessages, *, user_name: str) -> Self:
378 missed_entries = [
379 cls.Entry(
380 group_chat_title=message.group_chat_title or None,
381 missed_count=message.unseen_count,
382 latest_message_author=UserInfo.from_protobuf(message.author),
383 latest_message_text=message.text,
384 view_url=urls.chat_link(chat_id=message.group_chat_id),
385 )
386 for message in data.messages
387 ]
389 return cls(user_name, entries=missed_entries)
391 @classmethod
392 def test_instances(cls) -> list[Self]:
393 entry_prototype = ChatMessagesMissedEmail.Entry(
394 group_chat_title=None,
395 missed_count=1,
396 latest_message_author=UserInfo.dummy_bob(),
397 latest_message_text="Hello!",
398 view_url="https://couchers.org/messages/chats/123",
399 )
400 return [
401 cls(
402 user_name="Alice",
403 entries=[
404 replace(entry_prototype, group_chat_title=None),
405 replace(entry_prototype, group_chat_title="Best friends"),
406 ],
407 )
408 ]
411@dataclass(kw_only=True, slots=True)
412class DiscussionCreatedEmail(EmailBase):
413 """Sent to a user when a new discussion is created in a community they follow."""
415 author: UserInfo
416 title: str
417 parent_context: str # Community or group name
418 markdown_text: str
419 view_link: str
421 @property
422 def string_key_base(self) -> str:
423 return "discussions.created"
425 def get_subject_line(self, loc_context: LocalizationContext) -> str:
426 return self._localize(loc_context, ".subject", {"author": self.author.name, "title": self.title})
428 def get_preview_line(self, loc_context: LocalizationContext) -> str | None:
429 return markdown_to_plaintext(self.markdown_text)
431 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
432 builder = self._body_builder(loc_context)
433 builder.para(
434 ".purpose",
435 {
436 "author": self.author.name,
437 "parent_context": self.parent_context,
438 },
439 )
440 builder.user(self.author)
441 builder.block(ParaBlock(text=Markup(f"<b>{escape(self.title)}</b>")))
442 builder.quote(self.markdown_text, markdown=True)
443 builder.action(self.view_link, ".view_action")
444 return builder.build()
446 @classmethod
447 def from_notification(cls, data: notification_data_pb2.DiscussionCreate, *, user_name: str) -> Self:
448 discussion = data.discussion
449 return cls(
450 user_name=user_name,
451 author=UserInfo.from_protobuf(data.author),
452 title=discussion.title,
453 parent_context=discussion.owner_title,
454 markdown_text=discussion.content,
455 view_link=urls.discussion_link(discussion_id=discussion.discussion_id, slug=discussion.slug),
456 )
458 @classmethod
459 def test_instances(cls) -> list[Self]:
460 return [
461 cls(
462 user_name="Alice",
463 author=UserInfo.dummy_bob(),
464 title="Best hiking trails near Berlin",
465 parent_context="Berlin",
466 markdown_text="I've been exploring the area and found some **great** spots...",
467 view_link="https://couchers.org/discussions/123",
468 )
469 ]
472@dataclass(kw_only=True, slots=True)
473class DiscussionCommentEmail(EmailBase):
474 """Sent to a user when someone comments on a discussion they follow."""
476 author: UserInfo
477 discussion_title: str
478 discussion_parent_context: str # Community or group name
479 markdown_text: str
480 view_link: str
482 @property
483 def string_key_base(self) -> str:
484 return "discussions.comment"
486 def get_subject_line(self, loc_context: LocalizationContext) -> str:
487 return self._localize(
488 loc_context, ".subject", {"author": self.author.name, "discussion_title": self.discussion_title}
489 )
491 def get_preview_line(self, loc_context: LocalizationContext) -> str | None:
492 return markdown_to_plaintext(self.markdown_text)
494 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
495 builder = self._body_builder(loc_context)
496 builder.para(
497 ".purpose",
498 {
499 "author": self.author.name,
500 "discussion_title": self.discussion_title,
501 "parent_context": self.discussion_parent_context,
502 },
503 )
504 builder.user(self.author)
505 builder.quote(self.markdown_text, markdown=True)
506 builder.action(self.view_link, ".view_action")
507 return builder.build()
509 @classmethod
510 def from_notification(cls, data: notification_data_pb2.DiscussionComment, *, user_name: str) -> Self:
511 discussion = data.discussion
512 return cls(
513 user_name=user_name,
514 author=UserInfo.from_protobuf(data.author),
515 discussion_title=discussion.title,
516 discussion_parent_context=discussion.owner_title,
517 markdown_text=data.reply.content,
518 view_link=urls.discussion_link(discussion_id=discussion.discussion_id, slug=discussion.slug),
519 )
521 @classmethod
522 def test_instances(cls) -> list[Self]:
523 return [
524 cls(
525 user_name="Alice",
526 author=UserInfo.dummy_bob(),
527 discussion_title="Best hiking trails near Berlin",
528 discussion_parent_context="Berlin",
529 markdown_text="Great recommendations, I also **love** the Grünewald forest!",
530 view_link="https://couchers.org/discussions/123",
531 )
532 ]
535@dataclass(kw_only=True, slots=True)
536class DonationReceivedEmail(EmailBase):
537 """Sent to a user to thank them for a donation."""
539 amount: int
540 receipt_url: str
542 @property
543 def string_key_base(self) -> str:
544 return "donation_received"
546 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
547 builder = self._body_builder(loc_context, default_closing=False)
548 builder.para(".purpose", {"amount_with_currency": f"${self.amount}"})
549 builder.para(".contribution_impact")
550 builder.para(".invoice_receipt_info")
551 builder.action(self.receipt_url, ".download_invoice")
552 builder.para(".tax_acknowledgment")
553 builder.para(".questions_contact", {"email_link": html_mailto_link("donations@couchers.org")})
554 builder.para("generic.thanks")
555 builder.para("generic.closing_lines.founders")
556 return builder.build()
558 @classmethod
559 def from_notification(cls, data: notification_data_pb2.DonationReceived, *, user_name: str) -> Self:
560 return cls(user_name=user_name, amount=data.amount, receipt_url=data.receipt_url)
562 @classmethod
563 def test_instances(cls) -> list[Self]:
564 return [cls(user_name="Alice", amount=25, receipt_url="https://couchers.org/receipts/123")]
567@dataclass(kw_only=True, slots=True)
568class EmailChangedEmail(EmailBase):
569 """Sent to a user to notify them that their email address was changed."""
571 new_email: str
573 @property
574 def string_key_base(self) -> str:
575 return "email_change.initiated"
577 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
578 builder = self._body_builder(loc_context, security_warning=True)
579 builder.para(".purpose", {"email_address": self.new_email})
580 return builder.build()
582 @classmethod
583 def from_notification(cls, data: notification_data_pb2.EmailAddressChange, *, user_name: str) -> Self:
584 return cls(user_name=user_name, new_email=data.new_email)
586 @classmethod
587 def test_instances(cls) -> list[Self]:
588 return [cls(user_name="Alice", new_email="alice@example.com")]
591@dataclass(kw_only=True, slots=True)
592class EmailChangeConfirmationEmail(EmailBase):
593 """Sent to a user to confirm their new email address."""
595 old_email: str
596 confirm_url: str
598 @property
599 def string_key_base(self) -> str:
600 return "email_change.confirmation"
602 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
603 builder = self._body_builder(loc_context, security_warning=True)
604 builder.para(".purpose", {"old_email": self.old_email})
605 builder.action(self.confirm_url, ".confirm_action")
606 return builder.build()
608 @classmethod
609 def test_instances(cls) -> list[Self]:
610 return [cls(user_name="Alice", old_email="alice@example.com", confirm_url="https://example.com")]
613@dataclass(kw_only=True, slots=True)
614class EmailVerifiedEmail(EmailBase):
615 """Sent to a user to notify them that their new email address has been verified."""
617 @property
618 def string_key_base(self) -> str:
619 return "email_change.verified"
621 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
622 builder = self._body_builder(loc_context, security_warning=True)
623 builder.para(".purpose")
624 return builder.build()
626 @classmethod
627 def test_instances(cls) -> list[Self]:
628 return [cls(user_name="Alice")]
631@dataclass(kw_only=True, slots=True)
632class EventInfo:
633 """Common display fields for an event, extracted from its proto representation."""
635 title: str
636 start_time: datetime
637 end_time: datetime
638 address: str | None # The None case handles legacy online events
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 html = f"<b>{escape(self.title)}</b>"
649 html += "<br>"
650 html += time_range_display
651 if self.address:
652 html += "<br>"
653 html += f"<i>{escape(self.address)}</i>"
655 return ParaBlock(text=Markup(html))
657 def get_description_block(self) -> EmailBlock:
658 return QuoteBlock(text=Markup(self.description_markdown), markdown=True)
660 def get_view_action_block(self, loc_context: LocalizationContext) -> EmailBlock:
661 view_action_text = loc_context.localize_string("events.generic.view_action", i18next=get_emails_i18next())
662 return ActionBlock(text=view_action_text, target_url=self.view_url)
664 @classmethod
665 def from_proto(cls, event: events_pb2.Event) -> EventInfo:
666 return cls(
667 title=event.title,
668 start_time=event.start_time.ToDatetime(tzinfo=UTC),
669 end_time=event.end_time.ToDatetime(tzinfo=UTC),
670 # Backcompat (2026-06): We might still have queued notifications referencing events with online_information.
671 address=(event.location.address or None) if event.HasField("location") else None,
672 view_url=urls.event_link(occurrence_id=event.event_id, slug=event.slug),
673 description_markdown=event.content or "",
674 )
676 @staticmethod
677 def dummy() -> EventInfo:
678 return EventInfo(
679 title="Berlin Meetup",
680 start_time=datetime(2025, 7, 15, 18, 0, 0, tzinfo=UTC),
681 end_time=datetime(2025, 7, 15, 21, 0, 0, tzinfo=UTC),
682 address="Alexanderplatz, Berlin",
683 view_url="https://couchers.org/events/123/berlin-community-meetup",
684 description_markdown="Come join us for our monthly meetup!",
685 )
688@dataclass(kw_only=True, slots=True)
689class EventCreatedEmail(EmailBase):
690 """Sent when a user is invited to an event (create_approved) or a new event is created (create_any)."""
692 inviting_user: UserInfo
693 event_info: EventInfo
694 community_name: str | None
695 community_url: str | None
696 is_invite: bool # True = create_approved (invitation), False = create_any
698 @property
699 def string_key_base(self) -> str:
700 return f"events.created.{'invitation' if self.is_invite else 'notification'}"
702 def get_subject_line(self, loc_context: LocalizationContext) -> str:
703 return self._localize(
704 loc_context, ".subject", {"user": self.inviting_user.name, "title": self.event_info.title}
705 )
707 def get_preview_line(self, loc_context: LocalizationContext) -> str | None:
708 return markdown_to_plaintext(self.event_info.description_markdown)
710 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
711 builder = self._body_builder(loc_context)
712 if self.community_name:
713 builder.para(".purpose_with_community", {"user": self.inviting_user.name, "community": self.community_name})
714 else:
715 builder.para(".purpose_no_community", {"user": self.inviting_user.name})
716 builder.block(self.event_info.get_details_block(loc_context))
717 builder.user(self.inviting_user)
718 builder.block(self.event_info.get_description_block())
719 builder.block(self.event_info.get_view_action_block(loc_context))
720 return builder.build()
722 @classmethod
723 def from_notification(cls, data: notification_data_pb2.EventCreate, *, user_name: str, is_invite: bool) -> Self:
724 has_community = bool(data.in_community.community_id)
725 community_url = (
726 urls.community_link(node_id=data.in_community.community_id, slug=data.in_community.slug)
727 if has_community
728 else None
729 )
730 return cls(
731 user_name=user_name,
732 inviting_user=UserInfo.from_protobuf(data.inviting_user),
733 event_info=EventInfo.from_proto(data.event),
734 community_name=data.in_community.name if has_community else None,
735 community_url=community_url,
736 is_invite=is_invite,
737 )
739 @classmethod
740 def test_instances(cls) -> list[Self]:
741 prototype = cls(
742 user_name="Alice",
743 inviting_user=UserInfo.dummy_bob(),
744 event_info=EventInfo.dummy(),
745 community_name="Berlin",
746 community_url="https://couchers.org/community/1/berlin-community",
747 is_invite=True,
748 )
749 return [
750 replace(prototype, is_invite=True),
751 replace(prototype, is_invite=True, community_name=None, community_url=None),
752 replace(prototype, is_invite=False),
753 replace(prototype, is_invite=False, community_name=None, community_url=None),
754 ]
757@dataclass(kw_only=True, slots=True)
758class EventUpdatedEmail(EmailBase):
759 """Sent to subscribers when an event is updated."""
761 updating_user: UserInfo
762 event_info: EventInfo
763 updated_items: list[notification_data_pb2.EventUpdateItem.ValueType]
765 @property
766 def string_key_base(self) -> str:
767 return "events.updated"
769 def get_subject_line(self, loc_context: LocalizationContext) -> str:
770 return self._localize(
771 loc_context, ".subject", {"user": self.updating_user.name, "title": self.event_info.title}
772 )
774 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
775 builder = self._body_builder(loc_context)
777 updated_items_string_keys = list(
778 filter(None, (type(self)._updated_item_to_string_key(i) for i in self.updated_items))
779 )
780 if updated_items_string_keys:
781 updated_items_text = loc_context.localize_list(
782 [self._localize(loc_context, key) for key in updated_items_string_keys]
783 )
784 builder.para(".purpose_with_items", {"user": self.updating_user.name, "items_list": updated_items_text})
785 else:
786 builder.para(".purpose_generic", {"user": self.updating_user.name})
788 builder.block(self.event_info.get_details_block(loc_context))
789 builder.user(self.updating_user)
790 builder.block(self.event_info.get_description_block())
791 builder.block(self.event_info.get_view_action_block(loc_context))
792 return builder.build()
794 @classmethod
795 def from_notification(cls, data: notification_data_pb2.EventUpdate, *, user_name: str) -> Self:
796 updated_items: list[notification_data_pb2.EventUpdateItem.ValueType] = []
797 if data.updated_enum_items: 797 ↛ 800line 797 didn't jump to line 800 because the condition on line 797 was always true
798 updated_items.extend(data.updated_enum_items)
800 return cls(
801 user_name=user_name,
802 updating_user=UserInfo.from_protobuf(data.updating_user),
803 event_info=EventInfo.from_proto(data.event),
804 updated_items=updated_items,
805 )
807 @staticmethod
808 def _updated_item_to_string_key(value: notification_data_pb2.EventUpdateItem.ValueType) -> str | None:
809 match value:
810 case notification_data_pb2.EventUpdateItem.EVENT_UPDATE_ITEM_TITLE:
811 return ".item_names.title"
812 case notification_data_pb2.EventUpdateItem.EVENT_UPDATE_ITEM_CONTENT:
813 return ".item_names.content"
814 case notification_data_pb2.EventUpdateItem.EVENT_UPDATE_ITEM_LOCATION:
815 return ".item_names.location"
816 case notification_data_pb2.EventUpdateItem.EVENT_UPDATE_ITEM_START_TIME:
817 return ".item_names.start_time"
818 case notification_data_pb2.EventUpdateItem.EVENT_UPDATE_ITEM_END_TIME:
819 return ".item_names.end_time"
820 case _:
821 return None
823 @classmethod
824 def test_instances(cls) -> list[Self]:
825 prototype = cls(
826 user_name="Alice",
827 updating_user=UserInfo.dummy_bob(),
828 event_info=EventInfo.dummy(),
829 updated_items=[],
830 )
831 return [
832 replace(prototype, updated_items=[]),
833 replace(prototype, updated_items=notification_data_pb2.EventUpdateItem.values()),
834 ]
837@dataclass(kw_only=True, slots=True)
838class EventOrganizerInvitedEmail(EmailBase):
839 """Sent when a user is invited to co-organize an event."""
841 inviting_user: UserInfo
842 event_info: EventInfo
844 @property
845 def string_key_base(self) -> str:
846 return "events.organizer_invited"
848 def get_subject_line(self, loc_context: LocalizationContext) -> str:
849 return self._localize(
850 loc_context,
851 ".subject",
852 {"user": self.inviting_user.name, "title": self.event_info.title},
853 )
855 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
856 builder = self._body_builder(loc_context)
857 builder.para(".purpose", {"user": self.inviting_user.name, "title": self.event_info.title})
858 builder.block(self.event_info.get_details_block(loc_context))
859 builder.user(self.inviting_user)
860 builder.block(self.event_info.get_description_block())
861 builder.block(self.event_info.get_view_action_block(loc_context))
862 builder.para(_do_not_reply_request_string_key, epilogue=True)
863 return builder.build()
865 @classmethod
866 def from_notification(cls, data: notification_data_pb2.EventInviteOrganizer, *, user_name: str) -> Self:
867 return cls(
868 user_name=user_name,
869 inviting_user=UserInfo.from_protobuf(data.inviting_user),
870 event_info=EventInfo.from_proto(data.event),
871 )
873 @classmethod
874 def test_instances(cls) -> list[Self]:
875 return [cls(user_name="Alice", inviting_user=UserInfo.dummy_bob(), event_info=EventInfo.dummy())]
878@dataclass(kw_only=True, slots=True)
879class EventCommentEmail(EmailBase):
880 """Sent to subscribers when someone comments on an event."""
882 author: UserInfo
883 event_info: EventInfo
884 comment_markdown: str
886 @property
887 def string_key_base(self) -> str:
888 return "events.comment"
890 def get_subject_line(self, loc_context: LocalizationContext) -> str:
891 return self._localize(loc_context, ".subject", {"author": self.author.name, "title": self.event_info.title})
893 def get_preview_line(self, loc_context: LocalizationContext) -> str | None:
894 return markdown_to_plaintext(self.comment_markdown)
896 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
897 builder = self._body_builder(loc_context)
898 builder.para(".purpose", {"author": self.author.name})
899 builder.block(self.event_info.get_details_block(loc_context))
900 builder.user(self.author)
901 builder.quote(self.comment_markdown, markdown=True)
902 builder.block(self.event_info.get_view_action_block(loc_context))
903 return builder.build()
905 @classmethod
906 def from_notification(cls, data: notification_data_pb2.EventComment, *, user_name: str) -> Self:
907 return cls(
908 user_name=user_name,
909 author=UserInfo.from_protobuf(data.author),
910 event_info=EventInfo.from_proto(data.event),
911 comment_markdown=data.reply.content,
912 )
914 @classmethod
915 def test_instances(cls) -> list[Self]:
916 return [
917 cls(
918 user_name="Alice",
919 author=UserInfo.dummy_bob(),
920 event_info=EventInfo.dummy(),
921 comment_markdown="Looking forward to it, see you all there!",
922 )
923 ]
926@dataclass(kw_only=True, slots=True)
927class EventReminderEmail(EmailBase):
928 """Sent to subscribers as a reminder that an event starts soon."""
930 event_info: EventInfo
932 @property
933 def string_key_base(self) -> str:
934 return "events.reminder"
936 def get_subject_line(self, loc_context: LocalizationContext) -> str:
937 return self._localize(loc_context, ".subject", {"title": self.event_info.title})
939 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
940 builder = self._body_builder(loc_context)
941 builder.para(".purpose")
942 builder.block(self.event_info.get_details_block(loc_context))
943 builder.block(self.event_info.get_description_block())
944 builder.block(self.event_info.get_view_action_block(loc_context))
945 return builder.build()
947 @classmethod
948 def from_notification(cls, data: notification_data_pb2.EventReminder, *, user_name: str) -> Self:
949 return cls(
950 user_name=user_name,
951 event_info=EventInfo.from_proto(data.event),
952 )
954 @classmethod
955 def test_instances(cls) -> list[Self]:
956 return [cls(user_name="Alice", event_info=EventInfo.dummy())]
959@dataclass(kw_only=True, slots=True)
960class EventCancelledEmail(EmailBase):
961 """Sent to subscribers when an event is cancelled."""
963 cancelling_user: UserInfo
964 event_info: EventInfo
966 @property
967 def string_key_base(self) -> str:
968 return "events.cancel"
970 def get_subject_line(self, loc_context: LocalizationContext) -> str:
971 return self._localize(
972 loc_context,
973 ".subject",
974 {"user": self.cancelling_user.name, "title": self.event_info.title},
975 )
977 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
978 builder = self._body_builder(loc_context)
979 builder.para(".purpose", {"user": self.cancelling_user.name})
980 builder.block(self.event_info.get_details_block(loc_context))
981 builder.user(self.cancelling_user)
982 builder.quote(self.event_info.description_markdown, markdown=True)
983 builder.block(self.event_info.get_view_action_block(loc_context))
984 return builder.build()
986 @classmethod
987 def from_notification(cls, data: notification_data_pb2.EventCancel, *, user_name: str) -> Self:
988 return cls(
989 user_name=user_name,
990 cancelling_user=UserInfo.from_protobuf(data.cancelling_user),
991 event_info=EventInfo.from_proto(data.event),
992 )
994 @classmethod
995 def test_instances(cls) -> list[Self]:
996 return [cls(user_name="Alice", cancelling_user=UserInfo.dummy_bob(), event_info=EventInfo.dummy())]
999@dataclass(kw_only=True, slots=True)
1000class EventDeletedEmail(EmailBase):
1001 """Sent to subscribers when a moderator deletes an event."""
1003 event_info: EventInfo
1005 @property
1006 def string_key_base(self) -> str:
1007 return "events.deleted"
1009 def get_subject_line(self, loc_context: LocalizationContext) -> str:
1010 return self._localize(loc_context, ".subject", {"title": self.event_info.title})
1012 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
1013 builder = self._body_builder(loc_context)
1014 builder.para(".purpose")
1015 builder.block(self.event_info.get_details_block(loc_context))
1016 return builder.build()
1018 @classmethod
1019 def from_notification(cls, data: notification_data_pb2.EventDelete, *, user_name: str) -> Self:
1020 return cls(
1021 user_name=user_name,
1022 event_info=EventInfo.from_proto(data.event),
1023 )
1025 @classmethod
1026 def test_instances(cls) -> list[Self]:
1027 return [
1028 cls(
1029 user_name="Alice",
1030 event_info=EventInfo.dummy(),
1031 )
1032 ]
1035@dataclass(kw_only=True, slots=True)
1036class FriendReferenceReceivedEmail(EmailBase):
1037 """Sent to a user when they receive a friend reference."""
1039 from_user: UserInfo
1040 text: str
1042 @property
1043 def string_key_base(self) -> str:
1044 return "references.received.friend"
1046 def get_subject_line(self, loc_context: LocalizationContext) -> str:
1047 return self._localize(loc_context, ".subject", {"name": self.from_user.name})
1049 def get_preview_line(self, loc_context: LocalizationContext) -> str | None:
1050 return self.text
1052 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
1053 builder = self._body_builder(loc_context)
1054 builder.para(".purpose", {"name": self.from_user.name})
1055 builder.user(self.from_user)
1056 builder.quote(self.text, markdown=False)
1057 builder.action(urls.profile_references_link(), "references.received.view_action")
1058 return builder.build()
1060 @classmethod
1061 def from_notification(cls, data: notification_data_pb2.ReferenceReceiveFriend, *, user_name: str) -> Self:
1062 return cls(user_name=user_name, from_user=UserInfo.from_protobuf(data.from_user), text=data.text)
1064 @classmethod
1065 def test_instances(cls) -> list[Self]:
1066 return [
1067 cls(
1068 user_name="Alice",
1069 from_user=UserInfo.dummy_bob(),
1070 text="Alice is a wonderful person and a great travel companion!",
1071 )
1072 ]
1075@dataclass(kw_only=True, slots=True)
1076class FriendRequestReceivedEmail(EmailBase):
1077 """Sent to a user when they receive a friend request."""
1079 befriender: UserInfo
1081 @property
1082 def string_key_base(self) -> str:
1083 return "friend_requests.received"
1085 def get_subject_line(self, loc_context: LocalizationContext) -> str:
1086 return self._localize(loc_context, ".subject", {"name": self.befriender.name})
1088 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
1089 builder = self._body_builder(loc_context)
1090 builder.para(".purpose", {"name": self.befriender.name})
1091 builder.user(self.befriender)
1092 builder.action(urls.friend_requests_link(), ".view_action")
1093 builder.para(".closing")
1094 builder.para(_do_not_reply_request_string_key, epilogue=True)
1095 return builder.build()
1097 @classmethod
1098 def from_notification(cls, data: notification_data_pb2.FriendRequestCreate, *, user_name: str) -> Self:
1099 return cls(user_name=user_name, befriender=UserInfo.from_protobuf(data.other_user))
1101 @classmethod
1102 def test_instances(cls) -> list[Self]:
1103 return [
1104 cls(
1105 user_name="Alice",
1106 befriender=UserInfo.dummy_bob(),
1107 )
1108 ]
1111@dataclass(kw_only=True, slots=True)
1112class FriendRequestAcceptedEmail(EmailBase):
1113 """Sent to a user when their friend request is accepted."""
1115 new_friend: UserInfo
1117 @property
1118 def string_key_base(self) -> str:
1119 return "friend_requests.accepted"
1121 def get_subject_line(self, loc_context: LocalizationContext) -> str:
1122 return self._localize(loc_context, ".subject", {"name": self.new_friend.name})
1124 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
1125 builder = self._body_builder(loc_context)
1126 builder.para(".purpose", {"name": self.new_friend.name})
1127 builder.user(self.new_friend)
1128 builder.action(self.new_friend.profile_url, ".view_action")
1129 builder.para(".closing")
1130 return builder.build()
1132 @classmethod
1133 def from_notification(cls, data: notification_data_pb2.FriendRequestAccept, *, user_name: str) -> Self:
1134 return cls(user_name=user_name, new_friend=UserInfo.from_protobuf(data.other_user))
1136 @classmethod
1137 def test_instances(cls) -> list[Self]:
1138 return [
1139 cls(
1140 user_name="Alice",
1141 new_friend=UserInfo.dummy_bob(),
1142 )
1143 ]
1146@dataclass(kw_only=True, slots=True)
1147class GenderChangedEmail(EmailBase):
1148 """Sent to a user to notify them that their gender was changed."""
1150 new_gender: str
1152 @property
1153 def string_key_base(self) -> str:
1154 return "gender_changed"
1156 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
1157 builder = self._body_builder(loc_context, security_warning=True)
1158 builder.para(".purpose", {"gender": self.new_gender})
1159 return builder.build()
1161 @classmethod
1162 def from_notification(cls, data: notification_data_pb2.GenderChange, *, user_name: str) -> Self:
1163 return cls(user_name=user_name, new_gender=data.gender)
1165 @classmethod
1166 def test_instances(cls) -> list[Self]:
1167 return [
1168 cls(
1169 user_name="Alice",
1170 new_gender="Male",
1171 )
1172 ]
1175@dataclass(kw_only=True, slots=True)
1176class HostRequestCreatedEmail(EmailBase):
1177 """Sent to a host when a surfer sends them a new host request."""
1179 surfer: UserInfo
1180 from_date: date
1181 to_date: date
1182 text: str
1183 view_link: str
1184 quick_decline_link: str
1186 @property
1187 def string_key_base(self) -> str:
1188 return "host_requests.created"
1190 def get_subject_line(self, loc_context: LocalizationContext) -> str:
1191 return self._localize(loc_context, ".subject", {"surfer_name": self.surfer.name})
1193 def get_preview_line(self, loc_context: LocalizationContext) -> str | None:
1194 return self.text
1196 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
1197 builder = self._body_builder(loc_context)
1198 builder.para(".purpose", {"surfer_name": self.surfer.name})
1199 builder.user(
1200 self.surfer,
1201 "host_requests.generic.date_range",
1202 {
1203 "from_date": _localize_host_request_date(self.from_date, loc_context),
1204 "to_date": _localize_host_request_date(self.to_date, loc_context),
1205 },
1206 )
1207 builder.quote(self.text, markdown=False)
1208 builder.action(self.view_link, "host_requests.generic.view_action")
1209 builder.action(self.quick_decline_link, "host_requests.generic.quick_decline_action")
1210 builder.para(".respond_encouragement")
1211 builder.para(_do_not_reply_request_string_key, epilogue=True)
1212 return builder.build()
1214 @classmethod
1215 def from_notification(cls, data: notification_data_pb2.HostRequestCreate, *, user_name: str) -> Self:
1216 return cls(
1217 user_name,
1218 surfer=UserInfo.from_protobuf(data.surfer),
1219 from_date=date.fromisoformat(data.host_request.from_date),
1220 to_date=date.fromisoformat(data.host_request.to_date),
1221 text=data.text,
1222 view_link=urls.host_request(host_request_id=data.host_request.host_request_id),
1223 quick_decline_link=generate_quick_decline_link(data.host_request),
1224 )
1226 @classmethod
1227 def test_instances(cls) -> list[Self]:
1228 return [
1229 cls(
1230 user_name="Alice",
1231 surfer=UserInfo.dummy_bob(),
1232 from_date=date(2025, 6, 1),
1233 to_date=date(2025, 6, 7),
1234 text="Hey, I'd love to stay for a few nights!",
1235 view_link="https://couchers.org/requests/123",
1236 quick_decline_link="https://couchers.org/requests/123/decline?token=xxx",
1237 )
1238 ]
1241@dataclass(kw_only=True, slots=True)
1242class HostRequestReminderEmail(EmailBase):
1243 """Sent to a host as a reminder to respond to a pending host request."""
1245 surfer: UserInfo
1246 from_date: date
1247 to_date: date
1248 view_link: str
1249 quick_decline_link: str
1251 @property
1252 def string_key_base(self) -> str:
1253 return "host_requests.reminder"
1255 def get_subject_line(self, loc_context: LocalizationContext) -> str:
1256 return self._localize(loc_context, ".subject", {"surfer_name": self.surfer.name})
1258 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
1259 builder = self._body_builder(loc_context)
1260 builder.para(".purpose", {"surfer_name": self.surfer.name})
1261 builder.user(
1262 self.surfer,
1263 "host_requests.generic.date_range",
1264 {
1265 "from_date": _localize_host_request_date(self.from_date, loc_context),
1266 "to_date": _localize_host_request_date(self.to_date, loc_context),
1267 },
1268 )
1269 builder.action(self.view_link, "host_requests.generic.view_action")
1270 builder.action(self.quick_decline_link, "host_requests.generic.quick_decline_action")
1271 builder.para(_do_not_reply_request_string_key, epilogue=True)
1272 return builder.build()
1274 @classmethod
1275 def from_notification(cls, data: notification_data_pb2.HostRequestReminder, *, user_name: str) -> Self:
1276 return cls(
1277 user_name,
1278 surfer=UserInfo.from_protobuf(data.surfer),
1279 from_date=date.fromisoformat(data.host_request.from_date),
1280 to_date=date.fromisoformat(data.host_request.to_date),
1281 view_link=urls.host_request(host_request_id=data.host_request.host_request_id),
1282 quick_decline_link=generate_quick_decline_link(data.host_request),
1283 )
1285 @classmethod
1286 def test_instances(cls) -> list[Self]:
1287 return [
1288 cls(
1289 user_name="Alice",
1290 surfer=UserInfo.dummy_bob(),
1291 from_date=date(2025, 6, 1),
1292 to_date=date(2025, 6, 7),
1293 view_link="https://couchers.org/requests/123",
1294 quick_decline_link="https://couchers.org/requests/123/decline?token=xxx",
1295 )
1296 ]
1299@dataclass(kw_only=True, slots=True)
1300class HostRequestMessageEmail(EmailBase):
1301 """Sent when a user sends a message in an existing host request."""
1303 other_user: UserInfo
1304 from_date: date
1305 to_date: date
1306 text: str
1307 from_host: bool
1308 view_link: str
1310 @property
1311 def string_key_base(self) -> str:
1312 variant = "from_host" if self.from_host else "from_surfer"
1313 return f"host_requests.message.{variant}"
1315 def get_subject_line(self, loc_context: LocalizationContext) -> str:
1316 return self._localize(loc_context, ".subject", {"other_name": self.other_user.name})
1318 def get_preview_line(self, loc_context: LocalizationContext) -> str | None:
1319 return self.text
1321 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
1322 builder = self._body_builder(loc_context)
1323 builder.para(".purpose", {"other_name": self.other_user.name})
1324 builder.user(
1325 self.other_user,
1326 "host_requests.generic.date_range",
1327 {
1328 "from_date": _localize_host_request_date(self.from_date, loc_context),
1329 "to_date": _localize_host_request_date(self.to_date, loc_context),
1330 },
1331 )
1332 builder.quote(self.text, markdown=False)
1333 builder.action(self.view_link, "host_requests.generic.view_action")
1334 builder.para(_do_not_reply_request_string_key, epilogue=True)
1335 return builder.build()
1337 @classmethod
1338 def from_notification(cls, data: notification_data_pb2.HostRequestMessage, *, user_name: str) -> Self:
1339 return cls(
1340 user_name,
1341 other_user=UserInfo.from_protobuf(data.user),
1342 from_date=date.fromisoformat(data.host_request.from_date),
1343 to_date=date.fromisoformat(data.host_request.to_date),
1344 text=data.text,
1345 from_host=not data.am_host,
1346 view_link=urls.host_request(host_request_id=data.host_request.host_request_id),
1347 )
1349 @classmethod
1350 def test_instances(cls) -> list[Self]:
1351 prototype = cls(
1352 user_name="Alice",
1353 other_user=UserInfo.dummy_bob(),
1354 from_date=date(2025, 6, 1),
1355 to_date=date(2025, 6, 7),
1356 text="Looking forward to it, see you soon!",
1357 from_host=True,
1358 view_link="https://couchers.org/requests/123",
1359 )
1360 return [replace(prototype, from_host=True), replace(prototype, from_host=False)]
1363@dataclass(kw_only=True, slots=True)
1364class HostRequestMissedMessagesEmail(EmailBase):
1365 """Sent as a digest when a user has missed messages in a host request."""
1367 other_user: UserInfo
1368 from_date: date
1369 to_date: date
1370 from_host: bool
1371 view_link: str
1373 @property
1374 def string_key_base(self) -> str:
1375 variant = "from_host" if self.from_host else "from_surfer"
1376 return f"host_requests.missed_messages.{variant}"
1378 def get_subject_line(self, loc_context: LocalizationContext) -> str:
1379 return self._localize(loc_context, ".subject", {"other_name": self.other_user.name})
1381 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
1382 builder = self._body_builder(loc_context)
1383 builder.para(".purpose", {"other_name": self.other_user.name})
1384 builder.user(
1385 self.other_user,
1386 "host_requests.generic.date_range",
1387 {
1388 "from_date": _localize_host_request_date(self.from_date, loc_context),
1389 "to_date": _localize_host_request_date(self.to_date, loc_context),
1390 },
1391 )
1392 builder.action(self.view_link, "host_requests.generic.view_action")
1393 builder.para(_do_not_reply_request_string_key, epilogue=True)
1394 return builder.build()
1396 @classmethod
1397 def from_notification(cls, data: notification_data_pb2.HostRequestMissedMessages, *, user_name: str) -> Self:
1398 return cls(
1399 user_name,
1400 other_user=UserInfo.from_protobuf(data.user),
1401 from_date=date.fromisoformat(data.host_request.from_date),
1402 to_date=date.fromisoformat(data.host_request.to_date),
1403 from_host=not data.am_host,
1404 view_link=urls.host_request(host_request_id=data.host_request.host_request_id),
1405 )
1407 @classmethod
1408 def test_instances(cls) -> list[Self]:
1409 prototype = cls(
1410 user_name="Alice",
1411 other_user=UserInfo.dummy_bob(),
1412 from_date=date(2025, 6, 1),
1413 to_date=date(2025, 6, 7),
1414 from_host=True,
1415 view_link="https://couchers.org/requests/123",
1416 )
1417 return [replace(prototype, from_host=True), replace(prototype, from_host=False)]
1420@dataclass(kw_only=True, slots=True)
1421class HostRequestStatusChangedEmail(EmailBase):
1422 """Sent when a host request is accepted, declined, confirmed, or cancelled."""
1424 other_user: UserInfo
1425 from_date: date
1426 to_date: date
1427 new_status: messages_pb2.HostRequestStatus.ValueType
1428 view_link: str
1430 @property
1431 def string_key_base(self) -> str:
1432 base_key = "host_requests.status_changed"
1433 match self.new_status:
1434 case messages_pb2.HOST_REQUEST_STATUS_ACCEPTED:
1435 return f"{base_key}.accepted_by_host"
1436 case messages_pb2.HOST_REQUEST_STATUS_REJECTED:
1437 return f"{base_key}.declined_by_host"
1438 case messages_pb2.HOST_REQUEST_STATUS_CONFIRMED:
1439 return f"{base_key}.confirmed_by_surfer"
1440 case messages_pb2.HOST_REQUEST_STATUS_CANCELLED: 1440 ↛ 1442line 1440 didn't jump to line 1442 because the pattern on line 1440 always matched
1441 return f"{base_key}.cancelled_by_surfer"
1442 case _:
1443 raise ValueError(f"Unexpected host request status: {self.new_status}")
1445 def get_subject_line(self, loc_context: LocalizationContext) -> str:
1446 return self._localize(loc_context, ".subject", {"other_name": self.other_user.name})
1448 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
1449 builder = self._body_builder(loc_context)
1450 builder.para(".purpose", {"other_name": self.other_user.name})
1451 builder.user(
1452 self.other_user,
1453 "host_requests.generic.date_range",
1454 {
1455 "from_date": _localize_host_request_date(self.from_date, loc_context),
1456 "to_date": _localize_host_request_date(self.to_date, loc_context),
1457 },
1458 )
1459 builder.action(self.view_link, "host_requests.generic.view_action")
1460 builder.para(_do_not_reply_request_string_key, epilogue=True)
1461 return builder.build()
1463 @classmethod
1464 def from_notification(
1465 cls,
1466 data: notification_data_pb2.HostRequestAccept
1467 | notification_data_pb2.HostRequestReject
1468 | notification_data_pb2.HostRequestConfirm
1469 | notification_data_pb2.HostRequestCancel,
1470 *,
1471 user_name: str,
1472 ) -> Self:
1473 other_user: UserInfo
1474 new_status: messages_pb2.HostRequestStatus.ValueType
1475 match data:
1476 case notification_data_pb2.HostRequestAccept():
1477 other_user = UserInfo.from_protobuf(data.host)
1478 new_status = messages_pb2.HostRequestStatus.HOST_REQUEST_STATUS_ACCEPTED
1479 case notification_data_pb2.HostRequestReject(): 1479 ↛ 1480line 1479 didn't jump to line 1480 because the pattern on line 1479 never matched
1480 other_user = UserInfo.from_protobuf(data.host)
1481 new_status = messages_pb2.HostRequestStatus.HOST_REQUEST_STATUS_REJECTED
1482 case notification_data_pb2.HostRequestConfirm():
1483 other_user = UserInfo.from_protobuf(data.surfer)
1484 new_status = messages_pb2.HostRequestStatus.HOST_REQUEST_STATUS_CONFIRMED
1485 case notification_data_pb2.HostRequestCancel(): 1485 ↛ 1488line 1485 didn't jump to line 1488 because the pattern on line 1485 always matched
1486 other_user = UserInfo.from_protobuf(data.surfer)
1487 new_status = messages_pb2.HostRequestStatus.HOST_REQUEST_STATUS_CANCELLED
1488 case _:
1489 # Enable mypy's exhaustiveness checking
1490 assert_never("Unexpected host request status changed notification data type.")
1492 return cls(
1493 user_name,
1494 other_user=other_user,
1495 from_date=date.fromisoformat(data.host_request.from_date),
1496 to_date=date.fromisoformat(data.host_request.to_date),
1497 new_status=new_status,
1498 view_link=urls.host_request(host_request_id=data.host_request.host_request_id),
1499 )
1501 @classmethod
1502 def test_instances(cls) -> list[Self]:
1503 prototype = cls(
1504 user_name="Alice",
1505 other_user=UserInfo.dummy_bob(),
1506 from_date=date(2025, 6, 1),
1507 to_date=date(2025, 6, 7),
1508 new_status=messages_pb2.HOST_REQUEST_STATUS_ACCEPTED,
1509 view_link="https://couchers.org/requests/123",
1510 )
1511 return [
1512 replace(prototype, new_status=messages_pb2.HOST_REQUEST_STATUS_ACCEPTED),
1513 replace(prototype, new_status=messages_pb2.HOST_REQUEST_STATUS_REJECTED),
1514 replace(prototype, new_status=messages_pb2.HOST_REQUEST_STATUS_CONFIRMED),
1515 replace(prototype, new_status=messages_pb2.HOST_REQUEST_STATUS_CANCELLED),
1516 ]
1519@dataclass(kw_only=True, slots=True)
1520class HostReferenceReceivedEmail(EmailBase):
1521 """Sent to a user when they receive a reference from a past host or surfer."""
1523 from_user: UserInfo
1524 text: str | None # None if hidden because receiver hasn't written their reference yet.
1525 surfed: bool # True if I was the surfer, False if I was the host
1526 leave_reference_url: str
1528 @property
1529 def string_key_base(self) -> str:
1530 return "references.received"
1532 @property
1533 def string_role_subkey(self) -> str:
1534 return "surfed" if self.surfed else "hosted"
1536 def get_subject_line(self, loc_context: LocalizationContext) -> str:
1537 return self._localize(loc_context, ".subject", {"name": self.from_user.name})
1539 def get_preview_line(self, loc_context: LocalizationContext) -> str | None:
1540 return self.text
1542 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
1543 builder = self._body_builder(loc_context)
1544 builder.para(f".{self.string_role_subkey}.purpose", {"name": self.from_user.name})
1545 builder.user(self.from_user)
1546 if self.text:
1547 builder.quote(self.text, markdown=False)
1548 builder.action(urls.profile_references_link(), ".view_action")
1549 else:
1550 builder.para(f".{self.string_role_subkey}.reciprocate_encouragement", {"name": self.from_user.name})
1551 builder.action(self.leave_reference_url, "references.write_action", {"name": self.from_user.name})
1552 return builder.build()
1554 @classmethod
1555 def from_notification(
1556 cls, data: notification_data_pb2.ReferenceReceiveHostRequest, *, user_name: str, surfed: bool
1557 ) -> Self:
1558 return cls(
1559 user_name=user_name,
1560 from_user=UserInfo.from_protobuf(data.from_user),
1561 text=data.text or None,
1562 surfed=surfed,
1563 leave_reference_url=urls.leave_reference_link(
1564 reference_type="surfed" if surfed else "hosted",
1565 to_user_id=str(data.from_user.user_id),
1566 host_request_id=str(data.host_request_id),
1567 ),
1568 )
1570 @classmethod
1571 def test_instances(cls) -> list[Self]:
1572 prototype = cls(
1573 user_name="Alice",
1574 from_user=UserInfo.dummy_bob(),
1575 text="Alice was a fantastic guest!",
1576 surfed=True,
1577 leave_reference_url="https://couchers.org/leave-reference/123",
1578 )
1579 return [
1580 replace(prototype, surfed=True, text="Alice was a fantastic guest!"),
1581 replace(prototype, surfed=True, text=None),
1582 replace(prototype, surfed=False, text="Bob was a wonderful host!"),
1583 replace(prototype, surfed=False, text=None),
1584 ]
1587@dataclass(kw_only=True, slots=True)
1588class HostReferenceReminderEmail(EmailBase):
1589 """Sent as a reminder to write a reference after a stay."""
1591 other_user: UserInfo
1592 days_left: int
1593 surfed: bool # True if I was the surfer, False if I was the host
1594 leave_reference_url: str
1596 @property
1597 def string_key_base(self) -> str:
1598 return "references.reminder"
1600 @property
1601 def string_role_subkey(self) -> str:
1602 return "surfed" if self.surfed else "hosted"
1604 def get_subject_line(self, loc_context: LocalizationContext) -> str:
1605 return self._localize(
1606 loc_context,
1607 ".subject_days",
1608 {"name": self.other_user.name, "count": self.days_left},
1609 )
1611 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
1612 builder = self._body_builder(loc_context)
1613 builder.para(
1614 f".{self.string_role_subkey}.purpose_days", {"name": self.other_user.name, "count": self.days_left}
1615 )
1616 builder.user(self.other_user)
1617 builder.action(
1618 self.leave_reference_url,
1619 "references.write_action",
1620 {"name": self.other_user.name},
1621 )
1622 builder.para(".no_meeting_note", {"name": self.other_user.name})
1623 builder.para(".visibility_note")
1624 return builder.build()
1626 @classmethod
1627 def from_notification(cls, data: notification_data_pb2.ReferenceReminder, *, user_name: str, surfed: bool) -> Self:
1628 return cls(
1629 user_name=user_name,
1630 other_user=UserInfo.from_protobuf(data.other_user),
1631 days_left=data.days_left,
1632 surfed=surfed,
1633 leave_reference_url=urls.leave_reference_link(
1634 reference_type="surfed" if surfed else "hosted",
1635 to_user_id=str(data.other_user.user_id),
1636 host_request_id=str(data.host_request_id),
1637 ),
1638 )
1640 @classmethod
1641 def test_instances(cls) -> list[Self]:
1642 prototype = cls(
1643 user_name="Alice",
1644 other_user=UserInfo.dummy_bob(),
1645 days_left=7,
1646 surfed=True,
1647 leave_reference_url="https://couchers.org/leave-reference/123",
1648 )
1649 return [replace(prototype, surfed=True), replace(prototype, surfed=False)]
1652@dataclass(kw_only=True, slots=True)
1653class ModeratorNoteEmail(EmailBase):
1654 """Sent to a user to notify them they have received a moderator note."""
1656 @property
1657 def string_key_base(self) -> str:
1658 return "moderator_note"
1660 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
1661 builder = self._body_builder(loc_context)
1662 builder.para(".purpose")
1663 # Users with moderator notes are "jailed": any URL will show the note before
1664 # letting them use the platform.
1665 builder.action(urls.dashboard_link(), ".view_action")
1666 return builder.build()
1668 @classmethod
1669 def test_instances(cls) -> list[Self]:
1670 return [cls(user_name="Alice")]
1673@dataclass(kw_only=True, slots=True)
1674class NewBlogPostEmail(EmailBase):
1675 """Sent to notify users of a new blog post."""
1677 title: str
1678 blurb: str
1679 url: str
1681 @property
1682 def string_key_base(self) -> str:
1683 return "new_blog_post"
1685 def get_subject_line(self, loc_context: LocalizationContext) -> str:
1686 return self._localize(loc_context, ".subject", {"title": self.title})
1688 def get_preview_line(self, loc_context: LocalizationContext) -> str | None:
1689 return self.blurb
1691 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
1692 builder = self._body_builder(loc_context)
1693 builder.para(".purpose")
1694 builder.block(ParaBlock(text=Markup(f"<b>{escape(self.title)}</b>")))
1695 builder.quote(self.blurb, markdown=False)
1696 builder.action(self.url, ".read_action")
1697 return builder.build()
1699 @classmethod
1700 def from_notification(cls, data: notification_data_pb2.GeneralNewBlogPost, *, user_name: str) -> Self:
1701 return cls(user_name=user_name, title=data.title, blurb=data.blurb, url=data.url)
1703 @classmethod
1704 def test_instances(cls) -> list[Self]:
1705 return [
1706 cls(
1707 user_name="Alice",
1708 title="Exciting new features on Couchers.org",
1709 blurb="We've launched some great new features including improved messaging and event discovery.",
1710 url="https://couchers.org/blog/2025/01/01/new-features",
1711 )
1712 ]
1715@dataclass(kw_only=True, slots=True)
1716class OnboardingReminderEmail(EmailBase):
1717 """Onboarding email sent to new users; initial=True for the first email, False for the second."""
1719 initial: bool
1721 @property
1722 def string_key_base(self) -> str:
1723 return f"onboarding_reminder.{'initial' if self.initial else 'follow_up'}"
1725 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
1726 builder = self._body_builder(loc_context, default_closing=False)
1727 if self.initial:
1728 self._build_body_initial(builder)
1729 else:
1730 self._build_body_followup(builder)
1731 return builder.build()
1733 def _build_body_initial(self, builder: EmailBlocksBuilder) -> None:
1734 builder.para(".welcome")
1735 builder.para(".early_user_role")
1736 builder.para(".complete_profile_request")
1737 builder.para(".profile_importance")
1738 builder.action(urls.edit_profile_link(), "onboarding_reminder.complete_profile_action")
1739 builder.para(".share_request")
1740 builder.para(
1741 "generic.closing_lines.aapeli",
1742 {
1743 "profile_link": html_link("https://couchers.org/user/aapeli"),
1744 },
1745 )
1747 def _build_body_followup(self, builder: EmailBlocksBuilder) -> None:
1748 builder.para(".intro")
1749 builder.para(".request")
1750 builder.action(urls.edit_profile_link(), "onboarding_reminder.complete_profile_action")
1751 builder.para("generic.thanks")
1752 builder.para(
1753 "generic.closing_lines.emily",
1754 {
1755 "email_link": html_mailto_link("community@couchers.org"),
1756 "profile_link": html_link("https://couchers.org/user/emily"),
1757 },
1758 )
1760 @classmethod
1761 def test_instances(cls) -> list[Self]:
1762 prototype = cls(user_name="Alice", initial=True)
1763 return [replace(prototype, initial=True), replace(prototype, initial=False)]
1766@dataclass(kw_only=True, slots=True)
1767class PasswordChangedEmail(EmailBase):
1768 """Sent to a user to notify them that their login password was changed."""
1770 @property
1771 def string_key_base(self) -> str:
1772 return "password_changed"
1774 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
1775 builder = self._body_builder(loc_context, security_warning=True)
1776 builder.para(".purpose")
1777 return builder.build()
1779 @classmethod
1780 def test_instances(cls) -> list[Self]:
1781 return [cls(user_name="Alice")]
1784@dataclass(kw_only=True, slots=True)
1785class PasswordResetCompletedEmail(EmailBase):
1786 """Sent to a user to confirm their password was successfully reset."""
1788 @property
1789 def string_key_base(self) -> str:
1790 return "password_reset.completed"
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 PasswordResetStartedEmail(EmailBase):
1804 """Sent to a user with a link to complete their password reset."""
1806 password_reset_link: str
1808 @property
1809 def string_key_base(self) -> str:
1810 return "password_reset.started"
1812 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
1813 builder = self._body_builder(loc_context, security_warning=True)
1814 builder.para(".purpose")
1815 builder.action(self.password_reset_link, ".reset_action")
1816 return builder.build()
1818 @classmethod
1819 def from_notification(cls, data: notification_data_pb2.PasswordResetStart, *, user_name: str) -> Self:
1820 return cls(
1821 user_name=user_name,
1822 password_reset_link=urls.password_reset_link(password_reset_token=data.password_reset_token),
1823 )
1825 @classmethod
1826 def test_instances(cls) -> list[Self]:
1827 return [cls(user_name="Alice", password_reset_link="https://couchers.org/reset-password")]
1830@dataclass(kw_only=True, slots=True)
1831class PhoneNumberChangeEmail(EmailBase):
1832 """Sent to a user to notify them that their phone number verification status was changed."""
1834 new_phone_number: str
1835 completed: bool # False = started, True = completed
1837 @property
1838 def string_key_base(self) -> str:
1839 return "phone_number_verification.verified" if self.completed else "phone_number_verification.started"
1841 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
1842 builder = self._body_builder(loc_context, security_warning=True)
1843 builder.para(".purpose", {"phone_number": format_phone_number(self.new_phone_number)})
1844 return builder.build()
1846 @classmethod
1847 def from_change_notification(cls, data: notification_data_pb2.PhoneNumberChange, *, user_name: str) -> Self:
1848 return cls(user_name=user_name, new_phone_number=data.phone, completed=False)
1850 @classmethod
1851 def from_verify_notification(cls, data: notification_data_pb2.PhoneNumberVerify, *, user_name: str) -> Self:
1852 return cls(user_name=user_name, new_phone_number=data.phone, completed=True)
1854 @classmethod
1855 def test_instances(cls) -> list[Self]:
1856 prototype = cls(
1857 user_name="Alice",
1858 new_phone_number="+12223334444",
1859 completed=False,
1860 )
1861 return [replace(prototype, completed=False), replace(prototype, completed=True)]
1864@dataclass(kw_only=True, slots=True)
1865class PostalVerificationFailedEmail(EmailBase):
1866 """Sent to a user when their postal verification attempt has failed."""
1868 reason: notification_data_pb2.PostalVerificationFailReason.ValueType
1870 @property
1871 def string_key_base(self) -> str:
1872 return "postal_verification.failed"
1874 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
1875 builder = self._body_builder(loc_context, security_warning=True)
1876 match self.reason:
1877 case notification_data_pb2.POSTAL_VERIFICATION_FAIL_REASON_CODE_EXPIRED:
1878 purpose_string_key = ".purpose.code_expired"
1879 case notification_data_pb2.POSTAL_VERIFICATION_FAIL_REASON_TOO_MANY_ATTEMPTS:
1880 purpose_string_key = ".purpose.too_many_attempts"
1881 case _:
1882 purpose_string_key = ".purpose.default"
1883 builder.para(purpose_string_key)
1884 builder.action(urls.account_settings_link(), ".restart_action")
1885 return builder.build()
1887 @classmethod
1888 def from_notification(cls, data: notification_data_pb2.PostalVerificationFailed, *, user_name: str) -> Self:
1889 return cls(user_name=user_name, reason=data.reason)
1891 @classmethod
1892 def test_instances(cls) -> list[Self]:
1893 prototype = cls(
1894 user_name="Alice",
1895 reason=notification_data_pb2.POSTAL_VERIFICATION_FAIL_REASON_CODE_EXPIRED,
1896 )
1897 return [
1898 replace(prototype, reason=notification_data_pb2.POSTAL_VERIFICATION_FAIL_REASON_CODE_EXPIRED),
1899 replace(prototype, reason=notification_data_pb2.POSTAL_VERIFICATION_FAIL_REASON_TOO_MANY_ATTEMPTS),
1900 replace(prototype, reason=notification_data_pb2.POSTAL_VERIFICATION_FAIL_REASON_UNKNOWN),
1901 ]
1904@dataclass(kw_only=True, slots=True)
1905class PostalVerificationPostcardSentEmail(EmailBase):
1906 """Sent to a user to notify them that their verification postcard has been sent."""
1908 city: str
1909 country: str
1911 @property
1912 def string_key_base(self) -> str:
1913 return "postal_verification.postcard_sent"
1915 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
1916 builder = self._body_builder(loc_context, security_warning=True)
1917 builder.para(".purpose", {"city": self.city, "country": self.country})
1918 builder.action(urls.dashboard_link(), ".enter_code_action")
1919 return builder.build()
1921 @classmethod
1922 def from_notification(cls, data: notification_data_pb2.PostalVerificationPostcardSent, *, user_name: str) -> Self:
1923 return cls(user_name=user_name, city=data.city, country=data.country)
1925 @classmethod
1926 def test_instances(cls) -> list[Self]:
1927 return [cls(user_name="Alice", city="New York", country="United States")]
1930@dataclass(kw_only=True, slots=True)
1931class PostalVerificationSucceededEmail(EmailBase):
1932 """Sent to a user when their postal verification has succeeded."""
1934 @property
1935 def string_key_base(self) -> str:
1936 return "postal_verification.succeeded"
1938 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
1939 builder = self._body_builder(loc_context, security_warning=True)
1940 builder.para(".purpose")
1941 return builder.build()
1943 @classmethod
1944 def test_instances(cls) -> list[Self]:
1945 return [cls(user_name="Alice")]
1948@dataclass(kw_only=True, slots=True)
1949class SignupVerifyEmail(EmailBase):
1950 """Sent to a user to verify their email address."""
1952 verify_url: str
1954 @property
1955 def string_key_base(self) -> str:
1956 return "signup.verify"
1958 def get_subject_line(self, loc_context: LocalizationContext) -> str:
1959 return self._localize(loc_context, "signup.subject")
1961 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
1962 builder = self._body_builder(loc_context)
1963 builder.para(".thanks")
1964 builder.para(".instructions")
1965 builder.action(self.verify_url, ".confirm_action")
1966 builder.para("signup.closing")
1967 return builder.build()
1969 @classmethod
1970 def test_instances(cls) -> list[Self]:
1971 return [cls(user_name="Alice", verify_url="https://example.com")]
1974@dataclass(kw_only=True, slots=True)
1975class SignupContinueEmail(EmailBase):
1976 """Sent to a user to ask them to continue the signup process."""
1978 continue_url: str
1980 @property
1981 def string_key_base(self) -> str:
1982 return "signup.continue"
1984 def get_subject_line(self, loc_context: LocalizationContext) -> str:
1985 return self._localize(loc_context, "signup.subject")
1987 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
1988 builder = self._body_builder(loc_context)
1989 builder.para(".purpose")
1990 builder.action(self.continue_url, ".continue_action")
1991 builder.para("signup.closing")
1992 builder.para(".ignore_if_unexpected")
1993 return builder.build()
1995 @classmethod
1996 def test_instances(cls) -> list[Self]:
1997 return [cls(user_name="Alice", continue_url="https://example.com")]
2000@dataclass(kw_only=True, slots=True)
2001class StrongVerificationFailedEmail(EmailBase):
2002 """Sent to a user when their strong verification attempt has failed."""
2004 reason: notification_data_pb2.SVFailReason.ValueType
2006 @property
2007 def string_key_base(self) -> str:
2008 return "strong_verification.failed"
2010 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
2011 builder = self._body_builder(loc_context, security_warning=True)
2012 match self.reason:
2013 case notification_data_pb2.SV_FAIL_REASON_WRONG_BIRTHDATE_OR_GENDER:
2014 purpose_string_key = ".purpose.wrong_birthdate_or_gender"
2015 case notification_data_pb2.SV_FAIL_REASON_NOT_A_PASSPORT:
2016 purpose_string_key = ".purpose.not_a_passport"
2017 case notification_data_pb2.SV_FAIL_REASON_DUPLICATE: 2017 ↛ 2019line 2017 didn't jump to line 2019 because the pattern on line 2017 always matched
2018 purpose_string_key = ".purpose.duplicate"
2019 case _:
2020 raise Exception("Shouldn't get here")
2021 builder.para(purpose_string_key)
2022 builder.action(urls.strong_verification_url(), ".restart_action")
2023 return builder.build()
2025 @classmethod
2026 def from_notification(cls, data: notification_data_pb2.VerificationSVFail, *, user_name: str) -> Self:
2027 return cls(user_name=user_name, reason=data.reason)
2029 @classmethod
2030 def test_instances(cls) -> list[Self]:
2031 prototype = cls(
2032 user_name="Alice",
2033 reason=notification_data_pb2.SV_FAIL_REASON_NOT_A_PASSPORT,
2034 )
2035 return [
2036 replace(prototype, reason=notification_data_pb2.SV_FAIL_REASON_WRONG_BIRTHDATE_OR_GENDER),
2037 replace(prototype, reason=notification_data_pb2.SV_FAIL_REASON_NOT_A_PASSPORT),
2038 replace(prototype, reason=notification_data_pb2.SV_FAIL_REASON_DUPLICATE),
2039 ]
2042@dataclass(kw_only=True, slots=True)
2043class StrongVerificationSucceededEmail(EmailBase):
2044 """Sent to a user when their strong verification has succeeded."""
2046 @property
2047 def string_key_base(self) -> str:
2048 return "strong_verification.succeeded"
2050 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
2051 builder = self._body_builder(loc_context, security_warning=True)
2052 builder.para(".purpose")
2053 builder.para(".thanks_message")
2054 builder.para(".cost_explanation")
2055 builder.para(".donation_request")
2056 donate_link = urls.donation_url() + "?utm_source=strong-verification-email"
2057 builder.action(donate_link, ".donate_action")
2058 return builder.build()
2060 @classmethod
2061 def test_instances(cls) -> list[Self]:
2062 return [cls(user_name="Alice")]
2065@dataclass(kw_only=True, slots=True)
2066class ThreadReplyEmail(EmailBase):
2067 """Sent to a user when someone replies in a comment thread they participated in."""
2069 author: UserInfo
2070 parent_context: str # Title of the event or discussion being replied in
2071 markdown_text: str
2072 view_link: str
2074 @property
2075 def string_key_base(self) -> str:
2076 return "thread_reply"
2078 def get_subject_line(self, loc_context: LocalizationContext) -> str:
2079 return self._localize(
2080 loc_context, ".subject", {"author": self.author.name, "parent_context": self.parent_context}
2081 )
2083 def get_preview_line(self, loc_context: LocalizationContext) -> str | None:
2084 return markdown_to_plaintext(self.markdown_text)
2086 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
2087 builder = self._body_builder(loc_context)
2088 builder.para(".purpose", {"author": self.author.name, "parent_context": self.parent_context})
2089 builder.user(self.author)
2090 builder.quote(self.markdown_text, markdown=True)
2091 builder.action(self.view_link, ".view_action")
2092 return builder.build()
2094 @classmethod
2095 def from_notification(cls, data: notification_data_pb2.ThreadReply, *, user_name: str) -> Self:
2096 parent = data.WhichOneof("reply_parent")
2097 if parent == "event":
2098 parent_context = data.event.title
2099 view_link = urls.event_link(occurrence_id=data.event.event_id, slug=data.event.slug)
2100 elif parent == "discussion": 2100 ↛ 2104line 2100 didn't jump to line 2104 because the condition on line 2100 was always true
2101 parent_context = data.discussion.title
2102 view_link = urls.discussion_link(discussion_id=data.discussion.discussion_id, slug=data.discussion.slug)
2103 else:
2104 raise Exception("Can only do replies to events and discussions")
2105 return cls(
2106 user_name=user_name,
2107 author=UserInfo.from_protobuf(data.author),
2108 parent_context=parent_context,
2109 markdown_text=data.reply.content,
2110 view_link=view_link,
2111 )
2113 @classmethod
2114 def test_instances(cls) -> list[Self]:
2115 return [
2116 cls(
2117 user_name="Alice",
2118 author=UserInfo.dummy_bob(),
2119 parent_context="Best hiking trails near Berlin",
2120 markdown_text="I agree, the Grünewald is **amazing**!",
2121 view_link="https://couchers.org/discussions/123",
2122 )
2123 ]
2126def _localize_host_request_date(value: date, loc_context: LocalizationContext) -> str:
2127 return loc_context.localize_date(value, with_year=False, with_day_of_week=True)