Coverage for app/backend/src/couchers/email/emails.py: 98%
1062 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-26 11:50 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-26 11:50 +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, tzinfo
33from typing import Self, assert_never
34from zoneinfo import ZoneInfo
36from markupsafe import Markup, escape
38from couchers import urls
39from couchers.config import config
40from couchers.constants import LATEST_RELEASE_BLOG_URL
41from couchers.email.blocks import (
42 ActionBlock,
43 EmailBase,
44 EmailBlock,
45 EmailBlocksBuilder,
46 ParaBlock,
47 QuoteBlock,
48 UserInfo,
49)
50from couchers.email.locales import get_emails_i18next
51from couchers.i18n import LocalizationContext
52from couchers.i18n.localize import format_phone_number
53from couchers.markup import html_link, html_mailto_link, markdown_to_plaintext
54from couchers.notifications.quick_links import generate_quick_decline_link
55from couchers.proto import events_pb2, messages_pb2, notification_data_pb2
56from couchers.utils import now, to_aware_datetime
58# Common string keys
59_do_not_reply_request_string_key = "generic.do_not_reply_request"
61# Specific email definitions
64@dataclass(kw_only=True, slots=True)
65class AccountDeletionStartedEmail(EmailBase):
66 """Sent to a user to confirm their account deletion request."""
68 deletion_link: str
70 @property
71 def string_key_base(self) -> str:
72 return "account_deletion.started"
74 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
75 builder = self._body_builder(loc_context, security_warning=True)
76 builder.para(".purpose")
77 builder.action(self.deletion_link, ".confirm_action")
78 return builder.build()
80 @classmethod
81 def from_notification(cls, data: notification_data_pb2.AccountDeletionStart, *, user_name: str) -> Self:
82 return cls(
83 user_name=user_name,
84 deletion_link=urls.delete_account_link(account_deletion_token=data.deletion_token),
85 )
87 @classmethod
88 def test_instances(cls) -> list[Self]:
89 return [
90 cls(
91 user_name="Alice",
92 deletion_link="https://couchers.org/delete-account?token=xxx",
93 )
94 ]
97@dataclass(kw_only=True, slots=True)
98class AccountDeletionCompletedEmail(EmailBase):
99 """Sent to a user after their account has been deleted."""
101 undelete_link: str
102 days: int
104 @property
105 def string_key_base(self) -> str:
106 return "account_deletion.completed"
108 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
109 builder = self._body_builder(loc_context, security_warning=True)
110 builder.para(".purpose")
111 builder.para(".farewell")
112 builder.para(".recovery_instructions_days", {"count": self.days})
113 builder.action(self.undelete_link, ".recover_action")
114 return builder.build()
116 @classmethod
117 def from_notification(cls, data: notification_data_pb2.AccountDeletionComplete, *, user_name: str) -> Self:
118 return cls(
119 user_name=user_name,
120 undelete_link=urls.recover_account_link(account_undelete_token=data.undelete_token),
121 days=data.undelete_days,
122 )
124 @classmethod
125 def test_instances(cls) -> list[Self]:
126 return [
127 cls(
128 user_name="Alice",
129 undelete_link="https://couchers.org/recover-account?token=xxx",
130 days=30,
131 )
132 ]
135@dataclass(kw_only=True, slots=True)
136class AccountDeletionRecoveredEmail(EmailBase):
137 """Sent to a user after their account deletion has been cancelled."""
139 @property
140 def string_key_base(self) -> str:
141 return "account_deletion.recovered"
143 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
144 builder = self._body_builder(loc_context, security_warning=True)
145 builder.para(".confirmation")
146 builder.para(".login_instructions")
147 builder.action(urls.app_link(), ".login_action")
148 builder.para(".redelete_instructions")
149 return builder.build()
151 @classmethod
152 def test_instances(cls) -> list[Self]:
153 return [cls(user_name="Alice")]
156@dataclass(kw_only=True, slots=True)
157class ActivenessProbeEmail(EmailBase):
158 """Sent to a host to check if they are still open to hosting."""
160 days_left: int
162 @property
163 def string_key_base(self) -> str:
164 return "activeness_probe"
166 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
167 builder = self._body_builder(loc_context)
168 builder.para(".purpose")
169 builder.para(".instructions_days", {"count": self.days_left})
170 builder.action(urls.app_link(), ".login_action")
171 builder.para(".encouragement")
173 # Extract major.minor from the version string. "v1.3.18927" -> "1.3"
174 version = config.VERSION
175 if version_match := re.search(r"^v?(\d+\.\d+)\b", version): 175 ↛ 176line 175 didn't jump to line 176 because the condition on line 175 was never true
176 version = version_match[1]
178 builder.para(".latest_release", {"version": version, "blog_url": LATEST_RELEASE_BLOG_URL})
179 return builder.build()
181 @classmethod
182 def from_notification(cls, data: notification_data_pb2.ActivenessProbe, *, user_name: str) -> Self:
183 days_left = (to_aware_datetime(data.deadline) - now()).days
184 return cls(user_name=user_name, days_left=days_left)
186 @classmethod
187 def test_instances(cls) -> list[Self]:
188 return [cls(user_name="Alice", days_left=7)]
191@dataclass(kw_only=True, slots=True)
192class APIKeyIssuedEmail(EmailBase):
193 """Sent to a user to notify them that their API key was issued."""
195 api_key: str
196 expiry: datetime
198 @property
199 def string_key_base(self) -> str:
200 return "api_key_issued"
202 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
203 builder = self._body_builder(loc_context, security_warning=True)
204 builder.para(".header")
205 builder.quote(self.api_key, markdown=False)
206 builder.para(".expiry", {"datetime": loc_context.localize_datetime(self.expiry)})
207 builder.para(".usage_warning")
208 builder.para(".policy_warning", {"terms_url": urls.terms_of_service_url()})
209 return builder.build()
211 @classmethod
212 def from_notification(cls, data: notification_data_pb2.ApiKeyCreate, *, user_name: str) -> Self:
213 return cls(user_name=user_name, api_key=data.api_key, expiry=data.expiry.ToDatetime(tzinfo=UTC))
215 @classmethod
216 def test_instances(cls) -> list[Self]:
217 return [cls(user_name="Alice", api_key="my_api_key_123", expiry=datetime(2099, 12, 31, 23, 59, 59, tzinfo=UTC))]
220@dataclass(kw_only=True, slots=True)
221class BadgeChangedEmail(EmailBase):
222 """Sent to a user to notify them that a badge was added or removed from their profile."""
224 badge_name: str
225 added: bool
227 @property
228 def string_key_base(self) -> str:
229 return "badges.added" if self.added else "badges.removed"
231 def get_subject_line(self, loc_context: LocalizationContext) -> str:
232 return self._localize(loc_context, ".subject", {"name": self.badge_name})
234 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
235 builder = self._body_builder(loc_context)
236 builder.para(".purpose", {"name": self.badge_name})
237 return builder.build()
239 @classmethod
240 def from_notification(
241 cls, data: notification_data_pb2.BadgeAdd | notification_data_pb2.BadgeRemove, *, user_name: str
242 ) -> Self:
243 return cls(
244 user_name=user_name, badge_name=data.badge_name, added=isinstance(data, notification_data_pb2.BadgeAdd)
245 )
247 @classmethod
248 def test_instances(cls) -> list[Self]:
249 prototype = cls(user_name="Alice", badge_name="Founder", added=True)
250 return [replace(prototype, added=True), replace(prototype, added=False)]
253@dataclass(kw_only=True, slots=True)
254class BirthdateChangedEmail(EmailBase):
255 """Sent to a user to notify them that their birthdate was changed."""
257 new_birthdate: date
259 @property
260 def string_key_base(self) -> str:
261 return "birthdate_changed"
263 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
264 builder = self._body_builder(loc_context, security_warning=True)
265 builder.para(".purpose", {"date": loc_context.localize_date(self.new_birthdate)})
266 return builder.build()
268 @classmethod
269 def from_notification(cls, data: notification_data_pb2.BirthdateChange, *, user_name: str) -> Self:
270 return cls(user_name=user_name, new_birthdate=date.fromisoformat(data.birthdate))
272 @classmethod
273 def test_instances(cls) -> list[Self]:
274 return [
275 cls(
276 user_name="Alice",
277 new_birthdate=date(1990, 1, 1),
278 )
279 ]
282@dataclass(kw_only=True, slots=True)
283class ChatMessageReceivedEmail(EmailBase):
284 """Sent to a user when they receive a new chat message."""
286 group_chat_title: str | None # None if direct message
287 author: UserInfo
288 text: str
289 view_url: str
291 @property
292 def string_key_base(self) -> str:
293 return f"chat_messages.received.{'direct' if self.group_chat_title is None else 'group'}"
295 def get_subject_line(self, loc_context: LocalizationContext) -> str:
296 return self._localize(
297 loc_context, ".subject", {"author": self.author.name, "group": self.group_chat_title or ""}
298 )
300 def get_preview_line(self, loc_context: LocalizationContext) -> str | None:
301 return self.text
303 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
304 builder = self._body_builder(loc_context)
305 builder.para(".purpose", {"author": self.author.name, "group": self.group_chat_title or ""})
306 builder.user(self.author)
307 builder.quote(self.text, markdown=False)
308 builder.action(self.view_url, ".view_action")
309 return builder.build()
311 @classmethod
312 def from_notification(cls, data: notification_data_pb2.ChatMessage, *, user_name: str) -> Self:
313 return cls(
314 user_name,
315 author=UserInfo.from_protobuf(data.author),
316 text=data.text,
317 group_chat_title=data.group_chat_title or None,
318 view_url=urls.chat_link(chat_id=data.group_chat_id),
319 )
321 @classmethod
322 def test_instances(cls) -> list[Self]:
323 prototype = cls(
324 user_name="Alice",
325 group_chat_title=None,
326 author=UserInfo.dummy_bob(),
327 text="Hi Alice!",
328 view_url="https://couchers.org/messages/chats/123",
329 )
330 return [
331 replace(prototype, group_chat_title=None),
332 replace(prototype, group_chat_title="Best friends"),
333 ]
336@dataclass(kw_only=True, slots=True)
337class ChatMessagesMissedEmail(EmailBase):
338 """Sent to a user after they've missed new chat messages."""
340 @dataclass(kw_only=True, slots=True)
341 class Entry:
342 """Entry for each chat with missed messages."""
344 group_chat_title: str | None # None if direct message
345 missed_count: int
346 latest_message_author: UserInfo
347 latest_message_text: str
348 view_url: str
350 entries: list[Entry]
352 @property
353 def string_key_base(self) -> str:
354 return "chat_messages.missed"
356 def get_subject_line(self, loc_context: LocalizationContext) -> str:
357 return self._localize(loc_context, ".subject")
359 def get_preview_line(self, loc_context: LocalizationContext) -> str | None:
360 if len(self.entries) != 1:
361 return None
362 return self.entries[0].latest_message_text
364 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
365 builder = self._body_builder(loc_context)
366 builder.para(".purpose")
367 for entry in self.entries:
368 if entry.group_chat_title is None:
369 builder.para(".count_in_dm", {"count": entry.missed_count, "author": entry.latest_message_author.name})
370 else:
371 builder.para(".count_in_group", {"count": entry.missed_count, "group": entry.group_chat_title})
372 builder.user(entry.latest_message_author)
373 builder.quote(entry.latest_message_text, markdown=False)
374 builder.action(entry.view_url, ".view_action")
375 return builder.build()
377 @classmethod
378 def from_notification(cls, data: notification_data_pb2.ChatMissedMessages, *, user_name: str) -> Self:
379 missed_entries = [
380 cls.Entry(
381 group_chat_title=message.group_chat_title or None,
382 missed_count=message.unseen_count,
383 latest_message_author=UserInfo.from_protobuf(message.author),
384 latest_message_text=message.text,
385 view_url=urls.chat_link(chat_id=message.group_chat_id),
386 )
387 for message in data.messages
388 ]
390 return cls(user_name, entries=missed_entries)
392 @classmethod
393 def test_instances(cls) -> list[Self]:
394 entry_prototype = ChatMessagesMissedEmail.Entry(
395 group_chat_title=None,
396 missed_count=1,
397 latest_message_author=UserInfo.dummy_bob(),
398 latest_message_text="Hello!",
399 view_url="https://couchers.org/messages/chats/123",
400 )
401 return [
402 cls(
403 user_name="Alice",
404 entries=[
405 replace(entry_prototype, group_chat_title=None),
406 replace(entry_prototype, group_chat_title="Best friends"),
407 ],
408 )
409 ]
412@dataclass(kw_only=True, slots=True)
413class DiscussionCreatedEmail(EmailBase):
414 """Sent to a user when a new discussion is created in a community they follow."""
416 author: UserInfo
417 title: str
418 parent_context: str # Community or group name
419 markdown_text: str
420 view_link: str
422 @property
423 def string_key_base(self) -> str:
424 return "discussions.created"
426 def get_subject_line(self, loc_context: LocalizationContext) -> str:
427 return self._localize(loc_context, ".subject", {"author": self.author.name, "title": self.title})
429 def get_preview_line(self, loc_context: LocalizationContext) -> str | None:
430 return markdown_to_plaintext(self.markdown_text)
432 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
433 builder = self._body_builder(loc_context)
434 builder.para(
435 ".purpose",
436 {
437 "author": self.author.name,
438 "parent_context": self.parent_context,
439 },
440 )
441 builder.user(self.author)
442 builder.block(ParaBlock(text=Markup(f"<b>{escape(self.title)}</b>")))
443 builder.quote(self.markdown_text, markdown=True)
444 builder.action(self.view_link, ".view_action")
445 return builder.build()
447 @classmethod
448 def from_notification(cls, data: notification_data_pb2.DiscussionCreate, *, user_name: str) -> Self:
449 discussion = data.discussion
450 return cls(
451 user_name=user_name,
452 author=UserInfo.from_protobuf(data.author),
453 title=discussion.title,
454 parent_context=discussion.owner_title,
455 markdown_text=discussion.content,
456 view_link=urls.discussion_link(discussion_id=discussion.discussion_id, slug=discussion.slug),
457 )
459 @classmethod
460 def test_instances(cls) -> list[Self]:
461 return [
462 cls(
463 user_name="Alice",
464 author=UserInfo.dummy_bob(),
465 title="Best hiking trails near Berlin",
466 parent_context="Berlin",
467 markdown_text="I've been exploring the area and found some **great** spots...",
468 view_link="https://couchers.org/discussions/123",
469 )
470 ]
473@dataclass(kw_only=True, slots=True)
474class DiscussionCommentEmail(EmailBase):
475 """Sent to a user when someone comments on a discussion they follow."""
477 author: UserInfo
478 discussion_title: str
479 discussion_parent_context: str # Community or group name
480 markdown_text: str
481 view_link: str
483 @property
484 def string_key_base(self) -> str:
485 return "discussions.comment"
487 def get_subject_line(self, loc_context: LocalizationContext) -> str:
488 return self._localize(
489 loc_context, ".subject", {"author": self.author.name, "discussion_title": self.discussion_title}
490 )
492 def get_preview_line(self, loc_context: LocalizationContext) -> str | None:
493 return markdown_to_plaintext(self.markdown_text)
495 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
496 builder = self._body_builder(loc_context)
497 builder.para(
498 ".purpose",
499 {
500 "author": self.author.name,
501 "discussion_title": self.discussion_title,
502 "parent_context": self.discussion_parent_context,
503 },
504 )
505 builder.user(self.author)
506 builder.quote(self.markdown_text, markdown=True)
507 builder.action(self.view_link, ".view_action")
508 return builder.build()
510 @classmethod
511 def from_notification(cls, data: notification_data_pb2.DiscussionComment, *, user_name: str) -> Self:
512 discussion = data.discussion
513 return cls(
514 user_name=user_name,
515 author=UserInfo.from_protobuf(data.author),
516 discussion_title=discussion.title,
517 discussion_parent_context=discussion.owner_title,
518 markdown_text=data.reply.content,
519 view_link=urls.discussion_link(discussion_id=discussion.discussion_id, slug=discussion.slug),
520 )
522 @classmethod
523 def test_instances(cls) -> list[Self]:
524 return [
525 cls(
526 user_name="Alice",
527 author=UserInfo.dummy_bob(),
528 discussion_title="Best hiking trails near Berlin",
529 discussion_parent_context="Berlin",
530 markdown_text="Great recommendations, I also **love** the Grünewald forest!",
531 view_link="https://couchers.org/discussions/123",
532 )
533 ]
536@dataclass(kw_only=True, slots=True)
537class DonationReceivedEmail(EmailBase):
538 """Sent to a user to thank them for a donation."""
540 amount: int
541 receipt_url: str
543 @property
544 def string_key_base(self) -> str:
545 return "donation_received"
547 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
548 builder = self._body_builder(loc_context, default_closing=False)
549 builder.para(".purpose", {"amount_with_currency": f"${self.amount}"})
550 builder.para(".contribution_impact")
551 builder.para(".invoice_receipt_info")
552 builder.action(self.receipt_url, ".download_invoice")
553 builder.para(".tax_acknowledgment")
554 builder.para(".questions_contact", {"email_link": html_mailto_link("donations@couchers.org")})
555 builder.para("generic.thanks")
556 builder.para("generic.closing_lines.founders")
557 return builder.build()
559 @classmethod
560 def from_notification(cls, data: notification_data_pb2.DonationReceived, *, user_name: str) -> Self:
561 return cls(user_name=user_name, amount=data.amount, receipt_url=data.receipt_url)
563 @classmethod
564 def test_instances(cls) -> list[Self]:
565 return [cls(user_name="Alice", amount=25, receipt_url="https://couchers.org/receipts/123")]
568@dataclass(kw_only=True, slots=True)
569class EmailChangedEmail(EmailBase):
570 """Sent to a user to notify them that their email address was changed."""
572 new_email: str
574 @property
575 def string_key_base(self) -> str:
576 return "email_change.initiated"
578 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
579 builder = self._body_builder(loc_context, security_warning=True)
580 builder.para(".purpose", {"email_address": self.new_email})
581 return builder.build()
583 @classmethod
584 def from_notification(cls, data: notification_data_pb2.EmailAddressChange, *, user_name: str) -> Self:
585 return cls(user_name=user_name, new_email=data.new_email)
587 @classmethod
588 def test_instances(cls) -> list[Self]:
589 return [cls(user_name="Alice", new_email="alice@example.com")]
592@dataclass(kw_only=True, slots=True)
593class EmailChangeConfirmationEmail(EmailBase):
594 """Sent to a user to confirm their new email address."""
596 old_email: str
597 confirm_url: str
599 @property
600 def string_key_base(self) -> str:
601 return "email_change.confirmation"
603 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
604 builder = self._body_builder(loc_context, security_warning=True)
605 builder.para(".purpose", {"old_email": self.old_email})
606 builder.action(self.confirm_url, ".confirm_action")
607 return builder.build()
609 @classmethod
610 def test_instances(cls) -> list[Self]:
611 return [cls(user_name="Alice", old_email="alice@example.com", confirm_url="https://example.com")]
614@dataclass(kw_only=True, slots=True)
615class EmailVerifiedEmail(EmailBase):
616 """Sent to a user to notify them that their new email address has been verified."""
618 @property
619 def string_key_base(self) -> str:
620 return "email_change.verified"
622 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
623 builder = self._body_builder(loc_context, security_warning=True)
624 builder.para(".purpose")
625 return builder.build()
627 @classmethod
628 def test_instances(cls) -> list[Self]:
629 return [cls(user_name="Alice")]
632@dataclass(kw_only=True, slots=True)
633class EventInfo:
634 """Common display fields for an event, extracted from its proto representation."""
636 title: str
637 start_time: datetime
638 end_time: datetime
639 timezone: tzinfo
640 address: str | None # The None case handles legacy online events
641 view_url: str
642 description_markdown: str
644 def get_details_block(self, loc_context: LocalizationContext) -> EmailBlock:
645 # TODO(#8695): Support localized time ranges
646 # Force using the start/end time timezone (that of the event), not the user's.
647 start_time_display = loc_context.localize_datetime(
648 self.start_time, display_timezone=self.timezone, with_year=False, with_day_of_week=True
649 )
650 end_time_display = loc_context.localize_datetime(
651 self.end_time, display_timezone=self.timezone, with_year=False, with_day_of_week=True
652 )
653 time_range_display = f"{start_time_display} - {end_time_display}"
655 html = f"<b>{escape(self.title)}</b>"
656 html += "<br>"
657 html += time_range_display
658 if self.address:
659 html += "<br>"
660 html += f"<i>{escape(self.address)}</i>"
662 return ParaBlock(text=Markup(html))
664 def get_description_block(self) -> EmailBlock:
665 return QuoteBlock(text=Markup(self.description_markdown), markdown=True)
667 def get_view_action_block(self, loc_context: LocalizationContext) -> EmailBlock:
668 view_action_text = loc_context.localize_string("events.generic.view_action", i18next=get_emails_i18next())
669 return ActionBlock(text=view_action_text, target_url=self.view_url)
671 @classmethod
672 def from_proto(cls, event: events_pb2.Event) -> EventInfo:
673 return cls(
674 title=event.title,
675 start_time=to_aware_datetime(event.start_time),
676 end_time=to_aware_datetime(event.end_time),
677 timezone=ZoneInfo(event.timezone),
678 # Backcompat (2026-06): We might still have queued notifications referencing events with online_information.
679 address=(event.location.address or None) if event.HasField("location") else None,
680 view_url=urls.event_link(occurrence_id=event.event_id, slug=event.slug),
681 description_markdown=event.content or "",
682 )
684 @staticmethod
685 def dummy() -> EventInfo:
686 return EventInfo(
687 title="Berlin Meetup",
688 start_time=datetime(2025, 7, 15, 18, 0, 0, tzinfo=UTC),
689 end_time=datetime(2025, 7, 15, 21, 0, 0, tzinfo=UTC),
690 timezone=UTC,
691 address="Alexanderplatz, Berlin",
692 view_url="https://couchers.org/events/123/berlin-community-meetup",
693 description_markdown="Come join us for our monthly meetup!",
694 )
697@dataclass(kw_only=True, slots=True)
698class EventCreatedEmail(EmailBase):
699 """Sent when a user is invited to an event (create_approved) or a new event is created (create_any)."""
701 inviting_user: UserInfo
702 event_info: EventInfo
703 community_name: str | None
704 community_url: str | None
705 is_invite: bool # True = create_approved (invitation), False = create_any
707 @property
708 def string_key_base(self) -> str:
709 return f"events.created.{'invitation' if self.is_invite else 'notification'}"
711 def get_subject_line(self, loc_context: LocalizationContext) -> str:
712 return self._localize(
713 loc_context, ".subject", {"user": self.inviting_user.name, "title": self.event_info.title}
714 )
716 def get_preview_line(self, loc_context: LocalizationContext) -> str | None:
717 return markdown_to_plaintext(self.event_info.description_markdown)
719 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
720 builder = self._body_builder(loc_context)
721 if self.community_name:
722 builder.para(".purpose_with_community", {"user": self.inviting_user.name, "community": self.community_name})
723 else:
724 builder.para(".purpose_no_community", {"user": self.inviting_user.name})
725 builder.block(self.event_info.get_details_block(loc_context))
726 builder.user(self.inviting_user)
727 builder.block(self.event_info.get_description_block())
728 builder.block(self.event_info.get_view_action_block(loc_context))
729 return builder.build()
731 @classmethod
732 def from_notification(cls, data: notification_data_pb2.EventCreate, *, user_name: str, is_invite: bool) -> Self:
733 has_community = bool(data.in_community.community_id)
734 community_url = (
735 urls.community_link(node_id=data.in_community.community_id, slug=data.in_community.slug)
736 if has_community
737 else None
738 )
739 return cls(
740 user_name=user_name,
741 inviting_user=UserInfo.from_protobuf(data.inviting_user),
742 event_info=EventInfo.from_proto(data.event),
743 community_name=data.in_community.name if has_community else None,
744 community_url=community_url,
745 is_invite=is_invite,
746 )
748 @classmethod
749 def test_instances(cls) -> list[Self]:
750 prototype = cls(
751 user_name="Alice",
752 inviting_user=UserInfo.dummy_bob(),
753 event_info=EventInfo.dummy(),
754 community_name="Berlin",
755 community_url="https://couchers.org/community/1/berlin-community",
756 is_invite=True,
757 )
758 return [
759 replace(prototype, is_invite=True),
760 replace(prototype, is_invite=True, community_name=None, community_url=None),
761 replace(prototype, is_invite=False),
762 replace(prototype, is_invite=False, community_name=None, community_url=None),
763 ]
766@dataclass(kw_only=True, slots=True)
767class EventUpdatedEmail(EmailBase):
768 """Sent to subscribers when an event is updated."""
770 updating_user: UserInfo
771 event_info: EventInfo
772 updated_items: list[notification_data_pb2.EventUpdateItem.ValueType]
774 @property
775 def string_key_base(self) -> str:
776 return "events.updated"
778 def get_subject_line(self, loc_context: LocalizationContext) -> str:
779 return self._localize(
780 loc_context, ".subject", {"user": self.updating_user.name, "title": self.event_info.title}
781 )
783 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
784 builder = self._body_builder(loc_context)
786 updated_items_string_keys = list(
787 filter(None, (type(self)._updated_item_to_string_key(i) for i in self.updated_items))
788 )
789 if updated_items_string_keys:
790 updated_items_text = loc_context.localize_list(
791 [self._localize(loc_context, key) for key in updated_items_string_keys]
792 )
793 builder.para(".purpose_with_items", {"user": self.updating_user.name, "items_list": updated_items_text})
794 else:
795 builder.para(".purpose_generic", {"user": self.updating_user.name})
797 builder.block(self.event_info.get_details_block(loc_context))
798 builder.user(self.updating_user)
799 builder.block(self.event_info.get_description_block())
800 builder.block(self.event_info.get_view_action_block(loc_context))
801 return builder.build()
803 @classmethod
804 def from_notification(cls, data: notification_data_pb2.EventUpdate, *, user_name: str) -> Self:
805 updated_items: list[notification_data_pb2.EventUpdateItem.ValueType] = []
806 if data.updated_enum_items: 806 ↛ 809line 806 didn't jump to line 809 because the condition on line 806 was always true
807 updated_items.extend(data.updated_enum_items)
809 return cls(
810 user_name=user_name,
811 updating_user=UserInfo.from_protobuf(data.updating_user),
812 event_info=EventInfo.from_proto(data.event),
813 updated_items=updated_items,
814 )
816 @staticmethod
817 def _updated_item_to_string_key(value: notification_data_pb2.EventUpdateItem.ValueType) -> str | None:
818 match value:
819 case notification_data_pb2.EventUpdateItem.EVENT_UPDATE_ITEM_TITLE:
820 return ".item_names.title"
821 case notification_data_pb2.EventUpdateItem.EVENT_UPDATE_ITEM_CONTENT:
822 return ".item_names.content"
823 case notification_data_pb2.EventUpdateItem.EVENT_UPDATE_ITEM_LOCATION:
824 return ".item_names.location"
825 case notification_data_pb2.EventUpdateItem.EVENT_UPDATE_ITEM_START_TIME:
826 return ".item_names.start_time"
827 case notification_data_pb2.EventUpdateItem.EVENT_UPDATE_ITEM_END_TIME:
828 return ".item_names.end_time"
829 case _:
830 return None
832 @classmethod
833 def test_instances(cls) -> list[Self]:
834 prototype = cls(
835 user_name="Alice",
836 updating_user=UserInfo.dummy_bob(),
837 event_info=EventInfo.dummy(),
838 updated_items=[],
839 )
840 return [
841 replace(prototype, updated_items=[]),
842 replace(prototype, updated_items=notification_data_pb2.EventUpdateItem.values()),
843 ]
846@dataclass(kw_only=True, slots=True)
847class EventOrganizerInvitedEmail(EmailBase):
848 """Sent when a user is invited to co-organize an event."""
850 inviting_user: UserInfo
851 event_info: EventInfo
853 @property
854 def string_key_base(self) -> str:
855 return "events.organizer_invited"
857 def get_subject_line(self, loc_context: LocalizationContext) -> str:
858 return self._localize(
859 loc_context,
860 ".subject",
861 {"user": self.inviting_user.name, "title": self.event_info.title},
862 )
864 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
865 builder = self._body_builder(loc_context)
866 builder.para(".purpose", {"user": self.inviting_user.name, "title": self.event_info.title})
867 builder.block(self.event_info.get_details_block(loc_context))
868 builder.user(self.inviting_user)
869 builder.block(self.event_info.get_description_block())
870 builder.block(self.event_info.get_view_action_block(loc_context))
871 builder.para(_do_not_reply_request_string_key, epilogue=True)
872 return builder.build()
874 @classmethod
875 def from_notification(cls, data: notification_data_pb2.EventInviteOrganizer, *, user_name: str) -> Self:
876 return cls(
877 user_name=user_name,
878 inviting_user=UserInfo.from_protobuf(data.inviting_user),
879 event_info=EventInfo.from_proto(data.event),
880 )
882 @classmethod
883 def test_instances(cls) -> list[Self]:
884 return [cls(user_name="Alice", inviting_user=UserInfo.dummy_bob(), event_info=EventInfo.dummy())]
887@dataclass(kw_only=True, slots=True)
888class EventCommentEmail(EmailBase):
889 """Sent to subscribers when someone comments on an event."""
891 author: UserInfo
892 event_info: EventInfo
893 comment_markdown: str
895 @property
896 def string_key_base(self) -> str:
897 return "events.comment"
899 def get_subject_line(self, loc_context: LocalizationContext) -> str:
900 return self._localize(loc_context, ".subject", {"author": self.author.name, "title": self.event_info.title})
902 def get_preview_line(self, loc_context: LocalizationContext) -> str | None:
903 return markdown_to_plaintext(self.comment_markdown)
905 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
906 builder = self._body_builder(loc_context)
907 builder.para(".purpose", {"author": self.author.name})
908 builder.block(self.event_info.get_details_block(loc_context))
909 builder.user(self.author)
910 builder.quote(self.comment_markdown, markdown=True)
911 builder.block(self.event_info.get_view_action_block(loc_context))
912 return builder.build()
914 @classmethod
915 def from_notification(cls, data: notification_data_pb2.EventComment, *, user_name: str) -> Self:
916 return cls(
917 user_name=user_name,
918 author=UserInfo.from_protobuf(data.author),
919 event_info=EventInfo.from_proto(data.event),
920 comment_markdown=data.reply.content,
921 )
923 @classmethod
924 def test_instances(cls) -> list[Self]:
925 return [
926 cls(
927 user_name="Alice",
928 author=UserInfo.dummy_bob(),
929 event_info=EventInfo.dummy(),
930 comment_markdown="Looking forward to it, see you all there!",
931 )
932 ]
935@dataclass(kw_only=True, slots=True)
936class EventReminderEmail(EmailBase):
937 """Sent to subscribers as a reminder that an event starts soon."""
939 event_info: EventInfo
941 @property
942 def string_key_base(self) -> str:
943 return "events.reminder"
945 def get_subject_line(self, loc_context: LocalizationContext) -> str:
946 return self._localize(loc_context, ".subject", {"title": self.event_info.title})
948 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
949 builder = self._body_builder(loc_context)
950 builder.para(".purpose")
951 builder.block(self.event_info.get_details_block(loc_context))
952 builder.block(self.event_info.get_description_block())
953 builder.block(self.event_info.get_view_action_block(loc_context))
954 return builder.build()
956 @classmethod
957 def from_notification(cls, data: notification_data_pb2.EventReminder, *, user_name: str) -> Self:
958 return cls(
959 user_name=user_name,
960 event_info=EventInfo.from_proto(data.event),
961 )
963 @classmethod
964 def test_instances(cls) -> list[Self]:
965 return [cls(user_name="Alice", event_info=EventInfo.dummy())]
968@dataclass(kw_only=True, slots=True)
969class EventCancelledEmail(EmailBase):
970 """Sent to subscribers when an event is cancelled."""
972 cancelling_user: UserInfo
973 event_info: EventInfo
975 @property
976 def string_key_base(self) -> str:
977 return "events.cancel"
979 def get_subject_line(self, loc_context: LocalizationContext) -> str:
980 return self._localize(
981 loc_context,
982 ".subject",
983 {"user": self.cancelling_user.name, "title": self.event_info.title},
984 )
986 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
987 builder = self._body_builder(loc_context)
988 builder.para(".purpose", {"user": self.cancelling_user.name})
989 builder.block(self.event_info.get_details_block(loc_context))
990 builder.user(self.cancelling_user)
991 builder.quote(self.event_info.description_markdown, markdown=True)
992 builder.block(self.event_info.get_view_action_block(loc_context))
993 return builder.build()
995 @classmethod
996 def from_notification(cls, data: notification_data_pb2.EventCancel, *, user_name: str) -> Self:
997 return cls(
998 user_name=user_name,
999 cancelling_user=UserInfo.from_protobuf(data.cancelling_user),
1000 event_info=EventInfo.from_proto(data.event),
1001 )
1003 @classmethod
1004 def test_instances(cls) -> list[Self]:
1005 return [cls(user_name="Alice", cancelling_user=UserInfo.dummy_bob(), event_info=EventInfo.dummy())]
1008@dataclass(kw_only=True, slots=True)
1009class EventDeletedEmail(EmailBase):
1010 """Sent to subscribers when a moderator deletes an event."""
1012 event_info: EventInfo
1014 @property
1015 def string_key_base(self) -> str:
1016 return "events.deleted"
1018 def get_subject_line(self, loc_context: LocalizationContext) -> str:
1019 return self._localize(loc_context, ".subject", {"title": self.event_info.title})
1021 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
1022 builder = self._body_builder(loc_context)
1023 builder.para(".purpose")
1024 builder.block(self.event_info.get_details_block(loc_context))
1025 return builder.build()
1027 @classmethod
1028 def from_notification(cls, data: notification_data_pb2.EventDelete, *, user_name: str) -> Self:
1029 return cls(
1030 user_name=user_name,
1031 event_info=EventInfo.from_proto(data.event),
1032 )
1034 @classmethod
1035 def test_instances(cls) -> list[Self]:
1036 return [
1037 cls(
1038 user_name="Alice",
1039 event_info=EventInfo.dummy(),
1040 )
1041 ]
1044@dataclass(kw_only=True, slots=True)
1045class FriendReferenceReceivedEmail(EmailBase):
1046 """Sent to a user when they receive a friend reference."""
1048 from_user: UserInfo
1049 text: str
1051 @property
1052 def string_key_base(self) -> str:
1053 return "references.received.friend"
1055 def get_subject_line(self, loc_context: LocalizationContext) -> str:
1056 return self._localize(loc_context, ".subject", {"name": self.from_user.name})
1058 def get_preview_line(self, loc_context: LocalizationContext) -> str | None:
1059 return self.text
1061 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
1062 builder = self._body_builder(loc_context)
1063 builder.para(".purpose", {"name": self.from_user.name})
1064 builder.user(self.from_user)
1065 builder.quote(self.text, markdown=False)
1066 builder.action(urls.profile_references_link(), "references.received.view_action")
1067 return builder.build()
1069 @classmethod
1070 def from_notification(cls, data: notification_data_pb2.ReferenceReceiveFriend, *, user_name: str) -> Self:
1071 return cls(user_name=user_name, from_user=UserInfo.from_protobuf(data.from_user), text=data.text)
1073 @classmethod
1074 def test_instances(cls) -> list[Self]:
1075 return [
1076 cls(
1077 user_name="Alice",
1078 from_user=UserInfo.dummy_bob(),
1079 text="Alice is a wonderful person and a great travel companion!",
1080 )
1081 ]
1084@dataclass(kw_only=True, slots=True)
1085class FriendRequestReceivedEmail(EmailBase):
1086 """Sent to a user when they receive a friend request."""
1088 befriender: UserInfo
1090 @property
1091 def string_key_base(self) -> str:
1092 return "friend_requests.received"
1094 def get_subject_line(self, loc_context: LocalizationContext) -> str:
1095 return self._localize(loc_context, ".subject", {"name": self.befriender.name})
1097 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
1098 builder = self._body_builder(loc_context)
1099 builder.para(".purpose", {"name": self.befriender.name})
1100 builder.user(self.befriender)
1101 builder.action(urls.friend_requests_link(), ".view_action")
1102 builder.para(".closing")
1103 builder.para(_do_not_reply_request_string_key, epilogue=True)
1104 return builder.build()
1106 @classmethod
1107 def from_notification(cls, data: notification_data_pb2.FriendRequestCreate, *, user_name: str) -> Self:
1108 return cls(user_name=user_name, befriender=UserInfo.from_protobuf(data.other_user))
1110 @classmethod
1111 def test_instances(cls) -> list[Self]:
1112 return [
1113 cls(
1114 user_name="Alice",
1115 befriender=UserInfo.dummy_bob(),
1116 )
1117 ]
1120@dataclass(kw_only=True, slots=True)
1121class FriendRequestAcceptedEmail(EmailBase):
1122 """Sent to a user when their friend request is accepted."""
1124 new_friend: UserInfo
1126 @property
1127 def string_key_base(self) -> str:
1128 return "friend_requests.accepted"
1130 def get_subject_line(self, loc_context: LocalizationContext) -> str:
1131 return self._localize(loc_context, ".subject", {"name": self.new_friend.name})
1133 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
1134 builder = self._body_builder(loc_context)
1135 builder.para(".purpose", {"name": self.new_friend.name})
1136 builder.user(self.new_friend)
1137 builder.action(self.new_friend.profile_url, ".view_action")
1138 builder.para(".closing")
1139 return builder.build()
1141 @classmethod
1142 def from_notification(cls, data: notification_data_pb2.FriendRequestAccept, *, user_name: str) -> Self:
1143 return cls(user_name=user_name, new_friend=UserInfo.from_protobuf(data.other_user))
1145 @classmethod
1146 def test_instances(cls) -> list[Self]:
1147 return [
1148 cls(
1149 user_name="Alice",
1150 new_friend=UserInfo.dummy_bob(),
1151 )
1152 ]
1155@dataclass(kw_only=True, slots=True)
1156class GenderChangedEmail(EmailBase):
1157 """Sent to a user to notify them that their gender was changed."""
1159 new_gender: str
1161 @property
1162 def string_key_base(self) -> str:
1163 return "gender_changed"
1165 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
1166 builder = self._body_builder(loc_context, security_warning=True)
1167 builder.para(".purpose", {"gender": self.new_gender})
1168 return builder.build()
1170 @classmethod
1171 def from_notification(cls, data: notification_data_pb2.GenderChange, *, user_name: str) -> Self:
1172 return cls(user_name=user_name, new_gender=data.gender)
1174 @classmethod
1175 def test_instances(cls) -> list[Self]:
1176 return [
1177 cls(
1178 user_name="Alice",
1179 new_gender="Male",
1180 )
1181 ]
1184@dataclass(kw_only=True, slots=True)
1185class HostRequestCreatedEmail(EmailBase):
1186 """Sent to a host when a surfer sends them a new host request."""
1188 surfer: UserInfo
1189 from_date: date
1190 to_date: date
1191 text: str
1192 view_link: str
1193 quick_decline_link: str
1195 @property
1196 def string_key_base(self) -> str:
1197 return "host_requests.created"
1199 def get_subject_line(self, loc_context: LocalizationContext) -> str:
1200 return self._localize(loc_context, ".subject", {"surfer_name": self.surfer.name})
1202 def get_preview_line(self, loc_context: LocalizationContext) -> str | None:
1203 return self.text
1205 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
1206 builder = self._body_builder(loc_context)
1207 builder.para(".purpose", {"surfer_name": self.surfer.name})
1208 builder.user(
1209 self.surfer,
1210 "host_requests.generic.date_range",
1211 {
1212 "from_date": _localize_host_request_date(self.from_date, loc_context),
1213 "to_date": _localize_host_request_date(self.to_date, loc_context),
1214 },
1215 )
1216 builder.quote(self.text, markdown=False)
1217 builder.action(self.view_link, "host_requests.generic.view_action")
1218 builder.action(self.quick_decline_link, "host_requests.generic.quick_decline_action")
1219 builder.para(".respond_encouragement")
1220 builder.para(_do_not_reply_request_string_key, epilogue=True)
1221 return builder.build()
1223 @classmethod
1224 def from_notification(cls, data: notification_data_pb2.HostRequestCreate, *, user_name: str) -> Self:
1225 return cls(
1226 user_name,
1227 surfer=UserInfo.from_protobuf(data.surfer),
1228 from_date=date.fromisoformat(data.host_request.from_date),
1229 to_date=date.fromisoformat(data.host_request.to_date),
1230 text=data.text,
1231 view_link=urls.host_request(host_request_id=data.host_request.host_request_id),
1232 quick_decline_link=generate_quick_decline_link(data.host_request),
1233 )
1235 @classmethod
1236 def test_instances(cls) -> list[Self]:
1237 return [
1238 cls(
1239 user_name="Alice",
1240 surfer=UserInfo.dummy_bob(),
1241 from_date=date(2025, 6, 1),
1242 to_date=date(2025, 6, 7),
1243 text="Hey, I'd love to stay for a few nights!",
1244 view_link="https://couchers.org/requests/123",
1245 quick_decline_link="https://couchers.org/requests/123/decline?token=xxx",
1246 )
1247 ]
1250@dataclass(kw_only=True, slots=True)
1251class HostRequestReminderEmail(EmailBase):
1252 """Sent to a host as a reminder to respond to a pending host request."""
1254 surfer: UserInfo
1255 from_date: date
1256 to_date: date
1257 view_link: str
1258 quick_decline_link: str
1260 @property
1261 def string_key_base(self) -> str:
1262 return "host_requests.reminder"
1264 def get_subject_line(self, loc_context: LocalizationContext) -> str:
1265 return self._localize(loc_context, ".subject", {"surfer_name": self.surfer.name})
1267 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
1268 builder = self._body_builder(loc_context)
1269 builder.para(".purpose", {"surfer_name": self.surfer.name})
1270 builder.user(
1271 self.surfer,
1272 "host_requests.generic.date_range",
1273 {
1274 "from_date": _localize_host_request_date(self.from_date, loc_context),
1275 "to_date": _localize_host_request_date(self.to_date, loc_context),
1276 },
1277 )
1278 builder.action(self.view_link, "host_requests.generic.view_action")
1279 builder.action(self.quick_decline_link, "host_requests.generic.quick_decline_action")
1280 builder.para(_do_not_reply_request_string_key, epilogue=True)
1281 return builder.build()
1283 @classmethod
1284 def from_notification(cls, data: notification_data_pb2.HostRequestReminder, *, user_name: str) -> Self:
1285 return cls(
1286 user_name,
1287 surfer=UserInfo.from_protobuf(data.surfer),
1288 from_date=date.fromisoformat(data.host_request.from_date),
1289 to_date=date.fromisoformat(data.host_request.to_date),
1290 view_link=urls.host_request(host_request_id=data.host_request.host_request_id),
1291 quick_decline_link=generate_quick_decline_link(data.host_request),
1292 )
1294 @classmethod
1295 def test_instances(cls) -> list[Self]:
1296 return [
1297 cls(
1298 user_name="Alice",
1299 surfer=UserInfo.dummy_bob(),
1300 from_date=date(2025, 6, 1),
1301 to_date=date(2025, 6, 7),
1302 view_link="https://couchers.org/requests/123",
1303 quick_decline_link="https://couchers.org/requests/123/decline?token=xxx",
1304 )
1305 ]
1308@dataclass(kw_only=True, slots=True)
1309class HostRequestMessageEmail(EmailBase):
1310 """Sent when a user sends a message in an existing host request."""
1312 other_user: UserInfo
1313 from_date: date
1314 to_date: date
1315 text: str
1316 from_host: bool
1317 view_link: str
1319 @property
1320 def string_key_base(self) -> str:
1321 variant = "from_host" if self.from_host else "from_surfer"
1322 return f"host_requests.message.{variant}"
1324 def get_subject_line(self, loc_context: LocalizationContext) -> str:
1325 return self._localize(loc_context, ".subject", {"other_name": self.other_user.name})
1327 def get_preview_line(self, loc_context: LocalizationContext) -> str | None:
1328 return self.text
1330 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
1331 builder = self._body_builder(loc_context)
1332 builder.para(".purpose", {"other_name": self.other_user.name})
1333 builder.user(
1334 self.other_user,
1335 "host_requests.generic.date_range",
1336 {
1337 "from_date": _localize_host_request_date(self.from_date, loc_context),
1338 "to_date": _localize_host_request_date(self.to_date, loc_context),
1339 },
1340 )
1341 builder.quote(self.text, markdown=False)
1342 builder.action(self.view_link, "host_requests.generic.view_action")
1343 builder.para(_do_not_reply_request_string_key, epilogue=True)
1344 return builder.build()
1346 @classmethod
1347 def from_notification(cls, data: notification_data_pb2.HostRequestMessage, *, user_name: str) -> Self:
1348 return cls(
1349 user_name,
1350 other_user=UserInfo.from_protobuf(data.user),
1351 from_date=date.fromisoformat(data.host_request.from_date),
1352 to_date=date.fromisoformat(data.host_request.to_date),
1353 text=data.text,
1354 from_host=not data.am_host,
1355 view_link=urls.host_request(host_request_id=data.host_request.host_request_id),
1356 )
1358 @classmethod
1359 def test_instances(cls) -> list[Self]:
1360 prototype = cls(
1361 user_name="Alice",
1362 other_user=UserInfo.dummy_bob(),
1363 from_date=date(2025, 6, 1),
1364 to_date=date(2025, 6, 7),
1365 text="Looking forward to it, see you soon!",
1366 from_host=True,
1367 view_link="https://couchers.org/requests/123",
1368 )
1369 return [replace(prototype, from_host=True), replace(prototype, from_host=False)]
1372@dataclass(kw_only=True, slots=True)
1373class HostRequestMissedMessagesEmail(EmailBase):
1374 """Sent as a digest when a user has missed messages in a host request."""
1376 other_user: UserInfo
1377 from_date: date
1378 to_date: date
1379 from_host: bool
1380 view_link: str
1382 @property
1383 def string_key_base(self) -> str:
1384 variant = "from_host" if self.from_host else "from_surfer"
1385 return f"host_requests.missed_messages.{variant}"
1387 def get_subject_line(self, loc_context: LocalizationContext) -> str:
1388 return self._localize(loc_context, ".subject", {"other_name": self.other_user.name})
1390 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
1391 builder = self._body_builder(loc_context)
1392 builder.para(".purpose", {"other_name": self.other_user.name})
1393 builder.user(
1394 self.other_user,
1395 "host_requests.generic.date_range",
1396 {
1397 "from_date": _localize_host_request_date(self.from_date, loc_context),
1398 "to_date": _localize_host_request_date(self.to_date, loc_context),
1399 },
1400 )
1401 builder.action(self.view_link, "host_requests.generic.view_action")
1402 builder.para(_do_not_reply_request_string_key, epilogue=True)
1403 return builder.build()
1405 @classmethod
1406 def from_notification(cls, data: notification_data_pb2.HostRequestMissedMessages, *, user_name: str) -> Self:
1407 return cls(
1408 user_name,
1409 other_user=UserInfo.from_protobuf(data.user),
1410 from_date=date.fromisoformat(data.host_request.from_date),
1411 to_date=date.fromisoformat(data.host_request.to_date),
1412 from_host=not data.am_host,
1413 view_link=urls.host_request(host_request_id=data.host_request.host_request_id),
1414 )
1416 @classmethod
1417 def test_instances(cls) -> list[Self]:
1418 prototype = cls(
1419 user_name="Alice",
1420 other_user=UserInfo.dummy_bob(),
1421 from_date=date(2025, 6, 1),
1422 to_date=date(2025, 6, 7),
1423 from_host=True,
1424 view_link="https://couchers.org/requests/123",
1425 )
1426 return [replace(prototype, from_host=True), replace(prototype, from_host=False)]
1429@dataclass(kw_only=True, slots=True)
1430class HostRequestStatusChangedEmail(EmailBase):
1431 """Sent when a host request is accepted, declined, confirmed, or cancelled."""
1433 other_user: UserInfo
1434 from_date: date
1435 to_date: date
1436 new_status: messages_pb2.HostRequestStatus.ValueType
1437 view_link: str
1439 @property
1440 def string_key_base(self) -> str:
1441 base_key = "host_requests.status_changed"
1442 match self.new_status:
1443 case messages_pb2.HOST_REQUEST_STATUS_ACCEPTED:
1444 return f"{base_key}.accepted_by_host"
1445 case messages_pb2.HOST_REQUEST_STATUS_REJECTED:
1446 return f"{base_key}.declined_by_host"
1447 case messages_pb2.HOST_REQUEST_STATUS_CONFIRMED:
1448 return f"{base_key}.confirmed_by_surfer"
1449 case messages_pb2.HOST_REQUEST_STATUS_CANCELLED: 1449 ↛ 1451line 1449 didn't jump to line 1451 because the pattern on line 1449 always matched
1450 return f"{base_key}.cancelled_by_surfer"
1451 case _:
1452 raise ValueError(f"Unexpected host request status: {self.new_status}")
1454 def get_subject_line(self, loc_context: LocalizationContext) -> str:
1455 return self._localize(loc_context, ".subject", {"other_name": self.other_user.name})
1457 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
1458 builder = self._body_builder(loc_context)
1459 builder.para(".purpose", {"other_name": self.other_user.name})
1460 builder.user(
1461 self.other_user,
1462 "host_requests.generic.date_range",
1463 {
1464 "from_date": _localize_host_request_date(self.from_date, loc_context),
1465 "to_date": _localize_host_request_date(self.to_date, loc_context),
1466 },
1467 )
1468 builder.action(self.view_link, "host_requests.generic.view_action")
1469 builder.para(_do_not_reply_request_string_key, epilogue=True)
1470 return builder.build()
1472 @classmethod
1473 def from_notification(
1474 cls,
1475 data: notification_data_pb2.HostRequestAccept
1476 | notification_data_pb2.HostRequestReject
1477 | notification_data_pb2.HostRequestConfirm
1478 | notification_data_pb2.HostRequestCancel,
1479 *,
1480 user_name: str,
1481 ) -> Self:
1482 other_user: UserInfo
1483 new_status: messages_pb2.HostRequestStatus.ValueType
1484 match data:
1485 case notification_data_pb2.HostRequestAccept():
1486 other_user = UserInfo.from_protobuf(data.host)
1487 new_status = messages_pb2.HostRequestStatus.HOST_REQUEST_STATUS_ACCEPTED
1488 case notification_data_pb2.HostRequestReject(): 1488 ↛ 1489line 1488 didn't jump to line 1489 because the pattern on line 1488 never matched
1489 other_user = UserInfo.from_protobuf(data.host)
1490 new_status = messages_pb2.HostRequestStatus.HOST_REQUEST_STATUS_REJECTED
1491 case notification_data_pb2.HostRequestConfirm():
1492 other_user = UserInfo.from_protobuf(data.surfer)
1493 new_status = messages_pb2.HostRequestStatus.HOST_REQUEST_STATUS_CONFIRMED
1494 case notification_data_pb2.HostRequestCancel(): 1494 ↛ 1497line 1494 didn't jump to line 1497 because the pattern on line 1494 always matched
1495 other_user = UserInfo.from_protobuf(data.surfer)
1496 new_status = messages_pb2.HostRequestStatus.HOST_REQUEST_STATUS_CANCELLED
1497 case _:
1498 # Enable mypy's exhaustiveness checking
1499 assert_never("Unexpected host request status changed notification data type.")
1501 return cls(
1502 user_name,
1503 other_user=other_user,
1504 from_date=date.fromisoformat(data.host_request.from_date),
1505 to_date=date.fromisoformat(data.host_request.to_date),
1506 new_status=new_status,
1507 view_link=urls.host_request(host_request_id=data.host_request.host_request_id),
1508 )
1510 @classmethod
1511 def test_instances(cls) -> list[Self]:
1512 prototype = cls(
1513 user_name="Alice",
1514 other_user=UserInfo.dummy_bob(),
1515 from_date=date(2025, 6, 1),
1516 to_date=date(2025, 6, 7),
1517 new_status=messages_pb2.HOST_REQUEST_STATUS_ACCEPTED,
1518 view_link="https://couchers.org/requests/123",
1519 )
1520 return [
1521 replace(prototype, new_status=messages_pb2.HOST_REQUEST_STATUS_ACCEPTED),
1522 replace(prototype, new_status=messages_pb2.HOST_REQUEST_STATUS_REJECTED),
1523 replace(prototype, new_status=messages_pb2.HOST_REQUEST_STATUS_CONFIRMED),
1524 replace(prototype, new_status=messages_pb2.HOST_REQUEST_STATUS_CANCELLED),
1525 ]
1528@dataclass(kw_only=True, slots=True)
1529class HostReferenceReceivedEmail(EmailBase):
1530 """Sent to a user when they receive a reference from a past host or surfer."""
1532 from_user: UserInfo
1533 text: str | None # None if hidden because receiver hasn't written their reference yet.
1534 surfed: bool # True if I was the surfer, False if I was the host
1535 leave_reference_url: str
1537 @property
1538 def string_key_base(self) -> str:
1539 return "references.received"
1541 @property
1542 def string_role_subkey(self) -> str:
1543 return "surfed" if self.surfed else "hosted"
1545 def get_subject_line(self, loc_context: LocalizationContext) -> str:
1546 return self._localize(loc_context, ".subject", {"name": self.from_user.name})
1548 def get_preview_line(self, loc_context: LocalizationContext) -> str | None:
1549 return self.text
1551 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
1552 builder = self._body_builder(loc_context)
1553 builder.para(f".{self.string_role_subkey}.purpose", {"name": self.from_user.name})
1554 builder.user(self.from_user)
1555 if self.text:
1556 builder.quote(self.text, markdown=False)
1557 builder.action(urls.profile_references_link(), ".view_action")
1558 else:
1559 builder.para(f".{self.string_role_subkey}.reciprocate_encouragement", {"name": self.from_user.name})
1560 builder.action(self.leave_reference_url, "references.write_action", {"name": self.from_user.name})
1561 return builder.build()
1563 @classmethod
1564 def from_notification(
1565 cls, data: notification_data_pb2.ReferenceReceiveHostRequest, *, user_name: str, surfed: bool
1566 ) -> Self:
1567 return cls(
1568 user_name=user_name,
1569 from_user=UserInfo.from_protobuf(data.from_user),
1570 text=data.text or None,
1571 surfed=surfed,
1572 leave_reference_url=urls.leave_reference_link(
1573 reference_type="surfed" if surfed else "hosted",
1574 to_user_id=str(data.from_user.user_id),
1575 host_request_id=str(data.host_request_id),
1576 ),
1577 )
1579 @classmethod
1580 def test_instances(cls) -> list[Self]:
1581 prototype = cls(
1582 user_name="Alice",
1583 from_user=UserInfo.dummy_bob(),
1584 text="Alice was a fantastic guest!",
1585 surfed=True,
1586 leave_reference_url="https://couchers.org/leave-reference/123",
1587 )
1588 return [
1589 replace(prototype, surfed=True, text="Alice was a fantastic guest!"),
1590 replace(prototype, surfed=True, text=None),
1591 replace(prototype, surfed=False, text="Bob was a wonderful host!"),
1592 replace(prototype, surfed=False, text=None),
1593 ]
1596@dataclass(kw_only=True, slots=True)
1597class HostReferenceReminderEmail(EmailBase):
1598 """Sent as a reminder to write a reference after a stay."""
1600 other_user: UserInfo
1601 days_left: int
1602 surfed: bool # True if I was the surfer, False if I was the host
1603 leave_reference_url: str
1605 @property
1606 def string_key_base(self) -> str:
1607 return "references.reminder"
1609 @property
1610 def string_role_subkey(self) -> str:
1611 return "surfed" if self.surfed else "hosted"
1613 def get_subject_line(self, loc_context: LocalizationContext) -> str:
1614 return self._localize(
1615 loc_context,
1616 ".subject_days",
1617 {"name": self.other_user.name, "count": self.days_left},
1618 )
1620 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
1621 builder = self._body_builder(loc_context)
1622 builder.para(
1623 f".{self.string_role_subkey}.purpose_days", {"name": self.other_user.name, "count": self.days_left}
1624 )
1625 builder.user(self.other_user)
1626 builder.action(
1627 self.leave_reference_url,
1628 "references.write_action",
1629 {"name": self.other_user.name},
1630 )
1631 builder.para(".no_meeting_note", {"name": self.other_user.name})
1632 builder.para(".visibility_note")
1633 return builder.build()
1635 @classmethod
1636 def from_notification(cls, data: notification_data_pb2.ReferenceReminder, *, user_name: str, surfed: bool) -> Self:
1637 return cls(
1638 user_name=user_name,
1639 other_user=UserInfo.from_protobuf(data.other_user),
1640 days_left=data.days_left,
1641 surfed=surfed,
1642 leave_reference_url=urls.leave_reference_link(
1643 reference_type="surfed" if surfed else "hosted",
1644 to_user_id=str(data.other_user.user_id),
1645 host_request_id=str(data.host_request_id),
1646 ),
1647 )
1649 @classmethod
1650 def test_instances(cls) -> list[Self]:
1651 prototype = cls(
1652 user_name="Alice",
1653 other_user=UserInfo.dummy_bob(),
1654 days_left=7,
1655 surfed=True,
1656 leave_reference_url="https://couchers.org/leave-reference/123",
1657 )
1658 return [replace(prototype, surfed=True), replace(prototype, surfed=False)]
1661@dataclass(kw_only=True, slots=True)
1662class ModeratorNoteEmail(EmailBase):
1663 """Sent to a user to notify them they have received a moderator note."""
1665 @property
1666 def string_key_base(self) -> str:
1667 return "moderator_note"
1669 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
1670 builder = self._body_builder(loc_context)
1671 builder.para(".purpose")
1672 # Users with moderator notes are "jailed": any URL will show the note before
1673 # letting them use the platform.
1674 builder.action(urls.dashboard_link(), ".view_action")
1675 return builder.build()
1677 @classmethod
1678 def test_instances(cls) -> list[Self]:
1679 return [cls(user_name="Alice")]
1682@dataclass(kw_only=True, slots=True)
1683class NewBlogPostEmail(EmailBase):
1684 """Sent to notify users of a new blog post."""
1686 title: str
1687 blurb: str
1688 url: str
1690 @property
1691 def string_key_base(self) -> str:
1692 return "new_blog_post"
1694 def get_subject_line(self, loc_context: LocalizationContext) -> str:
1695 return self._localize(loc_context, ".subject", {"title": self.title})
1697 def get_preview_line(self, loc_context: LocalizationContext) -> str | None:
1698 return self.blurb
1700 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
1701 builder = self._body_builder(loc_context)
1702 builder.para(".purpose")
1703 builder.block(ParaBlock(text=Markup(f"<b>{escape(self.title)}</b>")))
1704 builder.quote(self.blurb, markdown=False)
1705 builder.action(self.url, ".read_action")
1706 return builder.build()
1708 @classmethod
1709 def from_notification(cls, data: notification_data_pb2.GeneralNewBlogPost, *, user_name: str) -> Self:
1710 return cls(user_name=user_name, title=data.title, blurb=data.blurb, url=data.url)
1712 @classmethod
1713 def test_instances(cls) -> list[Self]:
1714 return [
1715 cls(
1716 user_name="Alice",
1717 title="Exciting new features on Couchers.org",
1718 blurb="We've launched some great new features including improved messaging and event discovery.",
1719 url="https://couchers.org/blog/2025/01/01/new-features",
1720 )
1721 ]
1724@dataclass(kw_only=True, slots=True)
1725class OnboardingReminderEmail(EmailBase):
1726 """Onboarding email sent to new users; initial=True for the first email, False for the second."""
1728 initial: bool
1730 @property
1731 def string_key_base(self) -> str:
1732 return f"onboarding_reminder.{'initial' if self.initial else 'follow_up'}"
1734 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
1735 builder = self._body_builder(loc_context, default_closing=False)
1736 if self.initial:
1737 self._build_body_initial(builder)
1738 else:
1739 self._build_body_followup(builder)
1740 return builder.build()
1742 def _build_body_initial(self, builder: EmailBlocksBuilder) -> None:
1743 builder.para(".welcome")
1744 builder.para(".early_user_role")
1745 builder.para(".complete_profile_request")
1746 builder.para(".profile_importance")
1747 builder.action(urls.edit_profile_link(), "onboarding_reminder.complete_profile_action")
1748 builder.para(".share_request")
1749 builder.para(
1750 "generic.closing_lines.aapeli",
1751 {
1752 "profile_link": html_link("https://couchers.org/user/aapeli"),
1753 },
1754 )
1756 def _build_body_followup(self, builder: EmailBlocksBuilder) -> None:
1757 builder.para(".intro")
1758 builder.para(".request")
1759 builder.action(urls.edit_profile_link(), "onboarding_reminder.complete_profile_action")
1760 builder.para("generic.thanks")
1761 builder.para(
1762 "generic.closing_lines.emily",
1763 {
1764 "email_link": html_mailto_link("community@couchers.org"),
1765 "profile_link": html_link("https://couchers.org/user/emily"),
1766 },
1767 )
1769 @classmethod
1770 def test_instances(cls) -> list[Self]:
1771 prototype = cls(user_name="Alice", initial=True)
1772 return [replace(prototype, initial=True), replace(prototype, initial=False)]
1775@dataclass(kw_only=True, slots=True)
1776class PasswordChangedEmail(EmailBase):
1777 """Sent to a user to notify them that their login password was changed."""
1779 @property
1780 def string_key_base(self) -> str:
1781 return "password_changed"
1783 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
1784 builder = self._body_builder(loc_context, security_warning=True)
1785 builder.para(".purpose")
1786 return builder.build()
1788 @classmethod
1789 def test_instances(cls) -> list[Self]:
1790 return [cls(user_name="Alice")]
1793@dataclass(kw_only=True, slots=True)
1794class PasswordResetCompletedEmail(EmailBase):
1795 """Sent to a user to confirm their password was successfully reset."""
1797 @property
1798 def string_key_base(self) -> str:
1799 return "password_reset.completed"
1801 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
1802 builder = self._body_builder(loc_context, security_warning=True)
1803 builder.para(".purpose")
1804 return builder.build()
1806 @classmethod
1807 def test_instances(cls) -> list[Self]:
1808 return [cls(user_name="Alice")]
1811@dataclass(kw_only=True, slots=True)
1812class PasswordResetStartedEmail(EmailBase):
1813 """Sent to a user with a link to complete their password reset."""
1815 password_reset_link: str
1817 @property
1818 def string_key_base(self) -> str:
1819 return "password_reset.started"
1821 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
1822 builder = self._body_builder(loc_context, security_warning=True)
1823 builder.para(".purpose")
1824 builder.action(self.password_reset_link, ".reset_action")
1825 return builder.build()
1827 @classmethod
1828 def from_notification(cls, data: notification_data_pb2.PasswordResetStart, *, user_name: str) -> Self:
1829 return cls(
1830 user_name=user_name,
1831 password_reset_link=urls.password_reset_link(password_reset_token=data.password_reset_token),
1832 )
1834 @classmethod
1835 def test_instances(cls) -> list[Self]:
1836 return [cls(user_name="Alice", password_reset_link="https://couchers.org/reset-password")]
1839@dataclass(kw_only=True, slots=True)
1840class PhoneNumberChangeEmail(EmailBase):
1841 """Sent to a user to notify them that their phone number verification status was changed."""
1843 new_phone_number: str
1844 completed: bool # False = started, True = completed
1846 @property
1847 def string_key_base(self) -> str:
1848 return "phone_number_verification.verified" if self.completed else "phone_number_verification.started"
1850 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
1851 builder = self._body_builder(loc_context, security_warning=True)
1852 builder.para(".purpose", {"phone_number": format_phone_number(self.new_phone_number)})
1853 return builder.build()
1855 @classmethod
1856 def from_change_notification(cls, data: notification_data_pb2.PhoneNumberChange, *, user_name: str) -> Self:
1857 return cls(user_name=user_name, new_phone_number=data.phone, completed=False)
1859 @classmethod
1860 def from_verify_notification(cls, data: notification_data_pb2.PhoneNumberVerify, *, user_name: str) -> Self:
1861 return cls(user_name=user_name, new_phone_number=data.phone, completed=True)
1863 @classmethod
1864 def test_instances(cls) -> list[Self]:
1865 prototype = cls(
1866 user_name="Alice",
1867 new_phone_number="+12223334444",
1868 completed=False,
1869 )
1870 return [replace(prototype, completed=False), replace(prototype, completed=True)]
1873@dataclass(kw_only=True, slots=True)
1874class PostalVerificationFailedEmail(EmailBase):
1875 """Sent to a user when their postal verification attempt has failed."""
1877 reason: notification_data_pb2.PostalVerificationFailReason.ValueType
1879 @property
1880 def string_key_base(self) -> str:
1881 return "postal_verification.failed"
1883 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
1884 builder = self._body_builder(loc_context, security_warning=True)
1885 match self.reason:
1886 case notification_data_pb2.POSTAL_VERIFICATION_FAIL_REASON_CODE_EXPIRED:
1887 purpose_string_key = ".purpose.code_expired"
1888 case notification_data_pb2.POSTAL_VERIFICATION_FAIL_REASON_TOO_MANY_ATTEMPTS:
1889 purpose_string_key = ".purpose.too_many_attempts"
1890 case _:
1891 purpose_string_key = ".purpose.default"
1892 builder.para(purpose_string_key)
1893 builder.action(urls.account_settings_link(), ".restart_action")
1894 return builder.build()
1896 @classmethod
1897 def from_notification(cls, data: notification_data_pb2.PostalVerificationFailed, *, user_name: str) -> Self:
1898 return cls(user_name=user_name, reason=data.reason)
1900 @classmethod
1901 def test_instances(cls) -> list[Self]:
1902 prototype = cls(
1903 user_name="Alice",
1904 reason=notification_data_pb2.POSTAL_VERIFICATION_FAIL_REASON_CODE_EXPIRED,
1905 )
1906 return [
1907 replace(prototype, reason=notification_data_pb2.POSTAL_VERIFICATION_FAIL_REASON_CODE_EXPIRED),
1908 replace(prototype, reason=notification_data_pb2.POSTAL_VERIFICATION_FAIL_REASON_TOO_MANY_ATTEMPTS),
1909 replace(prototype, reason=notification_data_pb2.POSTAL_VERIFICATION_FAIL_REASON_UNKNOWN),
1910 ]
1913@dataclass(kw_only=True, slots=True)
1914class PostalVerificationPostcardSentEmail(EmailBase):
1915 """Sent to a user to notify them that their verification postcard has been sent."""
1917 city: str
1918 country: str
1920 @property
1921 def string_key_base(self) -> str:
1922 return "postal_verification.postcard_sent"
1924 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
1925 builder = self._body_builder(loc_context, security_warning=True)
1926 builder.para(".purpose", {"city": self.city, "country": self.country})
1927 builder.action(urls.dashboard_link(), ".enter_code_action")
1928 return builder.build()
1930 @classmethod
1931 def from_notification(cls, data: notification_data_pb2.PostalVerificationPostcardSent, *, user_name: str) -> Self:
1932 return cls(user_name=user_name, city=data.city, country=data.country)
1934 @classmethod
1935 def test_instances(cls) -> list[Self]:
1936 return [cls(user_name="Alice", city="New York", country="United States")]
1939@dataclass(kw_only=True, slots=True)
1940class PostalVerificationSucceededEmail(EmailBase):
1941 """Sent to a user when their postal verification has succeeded."""
1943 @property
1944 def string_key_base(self) -> str:
1945 return "postal_verification.succeeded"
1947 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
1948 builder = self._body_builder(loc_context, security_warning=True)
1949 builder.para(".purpose")
1950 return builder.build()
1952 @classmethod
1953 def test_instances(cls) -> list[Self]:
1954 return [cls(user_name="Alice")]
1957@dataclass(kw_only=True, slots=True)
1958class SignupVerifyEmail(EmailBase):
1959 """Sent to a user to verify their email address."""
1961 verify_url: str
1963 @property
1964 def string_key_base(self) -> str:
1965 return "signup.verify"
1967 def get_subject_line(self, loc_context: LocalizationContext) -> str:
1968 return self._localize(loc_context, "signup.subject")
1970 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
1971 builder = self._body_builder(loc_context)
1972 builder.para(".thanks")
1973 builder.para(".instructions")
1974 builder.action(self.verify_url, ".confirm_action")
1975 builder.para("signup.closing")
1976 return builder.build()
1978 @classmethod
1979 def test_instances(cls) -> list[Self]:
1980 return [cls(user_name="Alice", verify_url="https://example.com")]
1983@dataclass(kw_only=True, slots=True)
1984class SignupContinueEmail(EmailBase):
1985 """Sent to a user to ask them to continue the signup process."""
1987 continue_url: str
1989 @property
1990 def string_key_base(self) -> str:
1991 return "signup.continue"
1993 def get_subject_line(self, loc_context: LocalizationContext) -> str:
1994 return self._localize(loc_context, "signup.subject")
1996 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
1997 builder = self._body_builder(loc_context)
1998 builder.para(".purpose")
1999 builder.action(self.continue_url, ".continue_action")
2000 builder.para("signup.closing")
2001 builder.para(".ignore_if_unexpected")
2002 return builder.build()
2004 @classmethod
2005 def test_instances(cls) -> list[Self]:
2006 return [cls(user_name="Alice", continue_url="https://example.com")]
2009@dataclass(kw_only=True, slots=True)
2010class StrongVerificationFailedEmail(EmailBase):
2011 """Sent to a user when their strong verification attempt has failed."""
2013 reason: notification_data_pb2.SVFailReason.ValueType
2015 @property
2016 def string_key_base(self) -> str:
2017 return "strong_verification.failed"
2019 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
2020 builder = self._body_builder(loc_context, security_warning=True)
2021 match self.reason:
2022 case notification_data_pb2.SV_FAIL_REASON_WRONG_BIRTHDATE_OR_GENDER:
2023 purpose_string_key = ".purpose.wrong_birthdate_or_gender"
2024 case notification_data_pb2.SV_FAIL_REASON_NOT_A_PASSPORT:
2025 purpose_string_key = ".purpose.not_a_passport"
2026 case notification_data_pb2.SV_FAIL_REASON_DUPLICATE: 2026 ↛ 2028line 2026 didn't jump to line 2028 because the pattern on line 2026 always matched
2027 purpose_string_key = ".purpose.duplicate"
2028 case _:
2029 raise Exception("Shouldn't get here")
2030 builder.para(purpose_string_key)
2031 builder.action(urls.strong_verification_url(), ".restart_action")
2032 return builder.build()
2034 @classmethod
2035 def from_notification(cls, data: notification_data_pb2.VerificationSVFail, *, user_name: str) -> Self:
2036 return cls(user_name=user_name, reason=data.reason)
2038 @classmethod
2039 def test_instances(cls) -> list[Self]:
2040 prototype = cls(
2041 user_name="Alice",
2042 reason=notification_data_pb2.SV_FAIL_REASON_NOT_A_PASSPORT,
2043 )
2044 return [
2045 replace(prototype, reason=notification_data_pb2.SV_FAIL_REASON_WRONG_BIRTHDATE_OR_GENDER),
2046 replace(prototype, reason=notification_data_pb2.SV_FAIL_REASON_NOT_A_PASSPORT),
2047 replace(prototype, reason=notification_data_pb2.SV_FAIL_REASON_DUPLICATE),
2048 ]
2051@dataclass(kw_only=True, slots=True)
2052class StrongVerificationSucceededEmail(EmailBase):
2053 """Sent to a user when their strong verification has succeeded."""
2055 @property
2056 def string_key_base(self) -> str:
2057 return "strong_verification.succeeded"
2059 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
2060 builder = self._body_builder(loc_context, security_warning=True)
2061 builder.para(".purpose")
2062 builder.para(".thanks_message")
2063 builder.para(".cost_explanation")
2064 builder.para(".donation_request")
2065 donate_link = urls.donation_url() + "?utm_source=strong-verification-email"
2066 builder.action(donate_link, ".donate_action")
2067 return builder.build()
2069 @classmethod
2070 def test_instances(cls) -> list[Self]:
2071 return [cls(user_name="Alice")]
2074@dataclass(kw_only=True, slots=True)
2075class ThreadReplyEmail(EmailBase):
2076 """Sent to a user when someone replies in a comment thread they participated in."""
2078 author: UserInfo
2079 parent_context: str # Title of the event or discussion being replied in
2080 markdown_text: str
2081 view_link: str
2083 @property
2084 def string_key_base(self) -> str:
2085 return "thread_reply"
2087 def get_subject_line(self, loc_context: LocalizationContext) -> str:
2088 return self._localize(
2089 loc_context, ".subject", {"author": self.author.name, "parent_context": self.parent_context}
2090 )
2092 def get_preview_line(self, loc_context: LocalizationContext) -> str | None:
2093 return markdown_to_plaintext(self.markdown_text)
2095 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
2096 builder = self._body_builder(loc_context)
2097 builder.para(".purpose", {"author": self.author.name, "parent_context": self.parent_context})
2098 builder.user(self.author)
2099 builder.quote(self.markdown_text, markdown=True)
2100 builder.action(self.view_link, ".view_action")
2101 return builder.build()
2103 @classmethod
2104 def from_notification(cls, data: notification_data_pb2.ThreadReply, *, user_name: str) -> Self:
2105 parent = data.WhichOneof("reply_parent")
2106 if parent == "event":
2107 parent_context = data.event.title
2108 view_link = urls.event_link(occurrence_id=data.event.event_id, slug=data.event.slug)
2109 elif parent == "discussion": 2109 ↛ 2113line 2109 didn't jump to line 2113 because the condition on line 2109 was always true
2110 parent_context = data.discussion.title
2111 view_link = urls.discussion_link(discussion_id=data.discussion.discussion_id, slug=data.discussion.slug)
2112 else:
2113 raise Exception("Can only do replies to events and discussions")
2114 return cls(
2115 user_name=user_name,
2116 author=UserInfo.from_protobuf(data.author),
2117 parent_context=parent_context,
2118 markdown_text=data.reply.content,
2119 view_link=view_link,
2120 )
2122 @classmethod
2123 def test_instances(cls) -> list[Self]:
2124 return [
2125 cls(
2126 user_name="Alice",
2127 author=UserInfo.dummy_bob(),
2128 parent_context="Best hiking trails near Berlin",
2129 markdown_text="I agree, the Grünewald is **amazing**!",
2130 view_link="https://couchers.org/discussions/123",
2131 )
2132 ]
2135def _localize_host_request_date(value: date, loc_context: LocalizationContext) -> str:
2136 return loc_context.localize_date(value, with_year=False, with_day_of_week=True)