Skip to content

Commit 3aab94d

Browse files
Backend/emails: Port to host request emails to new system (#8714)
1 parent 6337622 commit 3aab94d

13 files changed

Lines changed: 428 additions & 1540 deletions

app/backend/src/couchers/email/emails.py

Lines changed: 321 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
from abc import ABC, abstractmethod
66
from dataclasses import dataclass
77
from datetime import UTC, date, datetime
8-
from typing import Self
8+
from typing import Self, assert_never
99

1010
from couchers import urls
1111
from couchers.email.rendering import (
@@ -18,7 +18,8 @@
1818
from couchers.i18n import LocalizationContext
1919
from couchers.i18n.i18next import SubstitutionDict
2020
from couchers.i18n.localize import format_phone_number
21-
from couchers.proto import notification_data_pb2
21+
from couchers.notifications.quick_links import generate_quick_decline_link
22+
from couchers.proto import conversations_pb2, notification_data_pb2
2223

2324

2425
@dataclass
@@ -212,12 +213,12 @@ def build_body(self, builder: EmailBlocksBuilder, loc_context: LocalizationConte
212213
builder.para("body", {"badge_name": self.badge_name})
213214

214215
@classmethod
215-
def from_add_notification(cls, data: notification_data_pb2.BadgeAdd, *, user_name: str) -> Self:
216-
return cls(user_name=user_name, badge_name=data.badge_name, added=True)
217-
218-
@classmethod
219-
def from_remove_notification(cls, data: notification_data_pb2.BadgeRemove, *, user_name: str) -> Self:
220-
return cls(user_name=user_name, badge_name=data.badge_name, added=False)
216+
def from_notification(
217+
cls, data: notification_data_pb2.BadgeAdd | notification_data_pb2.BadgeRemove, *, user_name: str
218+
) -> Self:
219+
return cls(
220+
user_name=user_name, badge_name=data.badge_name, added=isinstance(data, notification_data_pb2.BadgeAdd)
221+
)
221222

222223
@classmethod
223224
def dummy_data(cls) -> BadgeChangedEmail:
@@ -495,6 +496,318 @@ def dummy_data(cls) -> GenderChangedEmail:
495496
)
496497

497498

499+
@dataclass(kw_only=True, slots=True)
500+
class HostRequestCreatedEmail(EmailBase):
501+
"""Sent to a host when a surfer sends them a new host request."""
502+
503+
surfer: UserInfo
504+
from_date: date
505+
to_date: date
506+
text: str
507+
quick_decline_link: str
508+
view_link: str
509+
510+
@property
511+
def string_key_prefix(self) -> str:
512+
return "host_request_created"
513+
514+
def get_subject_line(self, loc_context: LocalizationContext) -> str:
515+
return self._localize(loc_context, "subject", {"surfer_name": self.surfer.name})
516+
517+
def build_body(self, builder: EmailBlocksBuilder, loc_context: LocalizationContext) -> None:
518+
builder.para("body", {"surfer_name": self.surfer.name})
519+
builder.user(
520+
self.surfer,
521+
"date_range",
522+
{
523+
"from_date": loc_context.localize_date(self.from_date),
524+
"to_date": loc_context.localize_date(self.to_date),
525+
},
526+
)
527+
builder.quote(self.text, markdown=False)
528+
builder.action(self.view_link, "view_action")
529+
builder.action(self.quick_decline_link, "quick_decline_action")
530+
builder.para("respond_encouragement")
531+
builder.do_not_reply_request_para()
532+
533+
@classmethod
534+
def from_notification(cls, data: notification_data_pb2.HostRequestCreate, *, user_name: str) -> Self:
535+
return cls(
536+
user_name,
537+
surfer=UserInfo.from_protobuf(data.surfer),
538+
from_date=date.fromisoformat(data.host_request.from_date),
539+
to_date=date.fromisoformat(data.host_request.to_date),
540+
text=data.text,
541+
quick_decline_link=generate_quick_decline_link(data.host_request),
542+
view_link=urls.host_request(host_request_id=data.host_request.host_request_id),
543+
)
544+
545+
@classmethod
546+
def dummy_data(cls) -> HostRequestCreatedEmail:
547+
return HostRequestCreatedEmail(
548+
user_name="Alice",
549+
surfer=UserInfo.dummy_bob(),
550+
from_date=date(2025, 6, 1),
551+
to_date=date(2025, 6, 7),
552+
text="Hey, I'd love to stay for a few nights!",
553+
quick_decline_link="https://couchers.org/requests/123/decline?token=xxx",
554+
view_link="https://couchers.org/requests/123",
555+
)
556+
557+
558+
@dataclass(kw_only=True, slots=True)
559+
class HostRequestReminderEmail(EmailBase):
560+
"""Sent to a host as a reminder to respond to a pending host request."""
561+
562+
surfer: UserInfo
563+
from_date: date
564+
to_date: date
565+
view_link: str
566+
567+
@property
568+
def string_key_prefix(self) -> str:
569+
return "host_request_reminder"
570+
571+
def get_subject_line(self, loc_context: LocalizationContext) -> str:
572+
return self._localize(loc_context, "subject", {"surfer_name": self.surfer.name})
573+
574+
def build_body(self, builder: EmailBlocksBuilder, loc_context: LocalizationContext) -> None:
575+
builder.para("body")
576+
builder.user(
577+
self.surfer,
578+
".host_request_generic.date_range",
579+
{
580+
"from_date": loc_context.localize_date(self.from_date),
581+
"to_date": loc_context.localize_date(self.to_date),
582+
},
583+
)
584+
builder.action(self.view_link, ".host_request_generic.view_action")
585+
builder.do_not_reply_request_para()
586+
587+
@classmethod
588+
def from_notification(cls, data: notification_data_pb2.HostRequestReminder, *, user_name: str) -> Self:
589+
return cls(
590+
user_name,
591+
surfer=UserInfo.from_protobuf(data.surfer),
592+
from_date=date.fromisoformat(data.host_request.from_date),
593+
to_date=date.fromisoformat(data.host_request.to_date),
594+
view_link=urls.host_request(host_request_id=data.host_request.host_request_id),
595+
)
596+
597+
@classmethod
598+
def dummy_data(cls) -> HostRequestReminderEmail:
599+
return HostRequestReminderEmail(
600+
user_name="Alice",
601+
surfer=UserInfo.dummy_bob(),
602+
from_date=date(2025, 6, 1),
603+
to_date=date(2025, 6, 7),
604+
view_link="https://couchers.org/requests/123",
605+
)
606+
607+
608+
@dataclass(kw_only=True, slots=True)
609+
class HostRequestMessageEmail(EmailBase):
610+
"""Sent when a user sends a message in an existing host request."""
611+
612+
other_user: UserInfo
613+
from_date: date
614+
to_date: date
615+
text: str
616+
from_host: bool
617+
view_link: str
618+
619+
@property
620+
def string_key_prefix(self) -> str:
621+
variant = "from_host" if self.from_host else "from_surfer"
622+
return f"host_request_message.{variant}"
623+
624+
def get_subject_line(self, loc_context: LocalizationContext) -> str:
625+
return self._localize(loc_context, "subject", {"other_name": self.other_user.name})
626+
627+
def build_body(self, builder: EmailBlocksBuilder, loc_context: LocalizationContext) -> None:
628+
builder.para("body", {"other_name": self.other_user.name})
629+
builder.user(
630+
self.other_user,
631+
".host_request_generic.date_range",
632+
{
633+
"from_date": loc_context.localize_date(self.from_date),
634+
"to_date": loc_context.localize_date(self.to_date),
635+
},
636+
)
637+
builder.quote(self.text, markdown=False)
638+
builder.action(self.view_link, ".host_request_generic.view_action")
639+
builder.do_not_reply_request_para()
640+
641+
@classmethod
642+
def from_notification(cls, data: notification_data_pb2.HostRequestMessage, *, user_name: str) -> Self:
643+
return cls(
644+
user_name,
645+
other_user=UserInfo.from_protobuf(data.user),
646+
from_date=date.fromisoformat(data.host_request.from_date),
647+
to_date=date.fromisoformat(data.host_request.to_date),
648+
text=data.text,
649+
from_host=not data.am_host,
650+
view_link=urls.host_request(host_request_id=data.host_request.host_request_id),
651+
)
652+
653+
@classmethod
654+
def dummy_data(cls) -> HostRequestMessageEmail:
655+
return HostRequestMessageEmail(
656+
user_name="Alice",
657+
other_user=UserInfo.dummy_bob(),
658+
from_date=date(2025, 6, 1),
659+
to_date=date(2025, 6, 7),
660+
text="Looking forward to it, see you soon!",
661+
from_host=True,
662+
view_link="https://couchers.org/requests/123",
663+
)
664+
665+
666+
@dataclass(kw_only=True, slots=True)
667+
class HostRequestMissedMessagesEmail(EmailBase):
668+
"""Sent as a digest when a user has missed messages in a host request."""
669+
670+
other_user: UserInfo
671+
from_date: date
672+
to_date: date
673+
from_host: bool
674+
view_link: str
675+
676+
@property
677+
def string_key_prefix(self) -> str:
678+
variant = "from_host" if self.from_host else "from_surfer"
679+
return f"host_request_missed_messages.{variant}"
680+
681+
def get_subject_line(self, loc_context: LocalizationContext) -> str:
682+
return self._localize(loc_context, "subject", {"other_name": self.other_user.name})
683+
684+
def build_body(self, builder: EmailBlocksBuilder, loc_context: LocalizationContext) -> None:
685+
builder.para("body", {"other_name": self.other_user.name})
686+
builder.user(
687+
self.other_user,
688+
".host_request_generic.date_range",
689+
{
690+
"from_date": loc_context.localize_date(self.from_date),
691+
"to_date": loc_context.localize_date(self.to_date),
692+
},
693+
)
694+
builder.action(self.view_link, ".host_request_generic.view_action")
695+
builder.do_not_reply_request_para()
696+
697+
@classmethod
698+
def from_notification(cls, data: notification_data_pb2.HostRequestMissedMessages, *, user_name: str) -> Self:
699+
return cls(
700+
user_name,
701+
other_user=UserInfo.from_protobuf(data.user),
702+
from_date=date.fromisoformat(data.host_request.from_date),
703+
to_date=date.fromisoformat(data.host_request.to_date),
704+
from_host=not data.am_host,
705+
view_link=urls.host_request(host_request_id=data.host_request.host_request_id),
706+
)
707+
708+
@classmethod
709+
def dummy_data(cls) -> HostRequestMissedMessagesEmail:
710+
return HostRequestMissedMessagesEmail(
711+
user_name="Alice",
712+
other_user=UserInfo.dummy_bob(),
713+
from_date=date(2025, 6, 1),
714+
to_date=date(2025, 6, 7),
715+
from_host=True,
716+
view_link="https://couchers.org/requests/123",
717+
)
718+
719+
720+
@dataclass(kw_only=True, slots=True)
721+
class HostRequestStatusChangedEmail(EmailBase):
722+
"""Sent when a host request is accepted, declined, confirmed, or cancelled."""
723+
724+
other_user: UserInfo
725+
from_date: date
726+
to_date: date
727+
new_status: conversations_pb2.HostRequestStatus.ValueType
728+
view_link: str
729+
730+
@property
731+
def string_key_prefix(self) -> str:
732+
base_key = "host_request_status_changed"
733+
match self.new_status:
734+
case conversations_pb2.HOST_REQUEST_STATUS_ACCEPTED:
735+
return f"{base_key}.accepted_by_host"
736+
case conversations_pb2.HOST_REQUEST_STATUS_REJECTED:
737+
return f"{base_key}.rejected_by_host"
738+
case conversations_pb2.HOST_REQUEST_STATUS_CONFIRMED:
739+
return f"{base_key}.confirmed_by_surfer"
740+
case conversations_pb2.HOST_REQUEST_STATUS_CANCELLED:
741+
return f"{base_key}.cancelled_by_surfer"
742+
case _:
743+
raise ValueError(f"Unexpected host request status: {self.new_status}")
744+
745+
def get_subject_line(self, loc_context: LocalizationContext) -> str:
746+
return self._localize(loc_context, "subject", {"other_name": self.other_user.name})
747+
748+
def build_body(self, builder: EmailBlocksBuilder, loc_context: LocalizationContext) -> None:
749+
builder.para("body", {"other_name": self.other_user.name})
750+
builder.user(
751+
self.other_user,
752+
".host_request_generic.date_range",
753+
{
754+
"from_date": loc_context.localize_date(self.from_date),
755+
"to_date": loc_context.localize_date(self.to_date),
756+
},
757+
)
758+
builder.action(self.view_link, ".host_request_generic.view_action")
759+
builder.do_not_reply_request_para()
760+
761+
@classmethod
762+
def from_notification(
763+
cls,
764+
data: notification_data_pb2.HostRequestAccept
765+
| notification_data_pb2.HostRequestReject
766+
| notification_data_pb2.HostRequestConfirm
767+
| notification_data_pb2.HostRequestCancel,
768+
*,
769+
user_name: str,
770+
) -> Self:
771+
other_user: UserInfo
772+
new_status: conversations_pb2.HostRequestStatus.ValueType
773+
match data:
774+
case notification_data_pb2.HostRequestAccept():
775+
other_user = UserInfo.from_protobuf(data.host)
776+
new_status = conversations_pb2.HostRequestStatus.HOST_REQUEST_STATUS_ACCEPTED
777+
case notification_data_pb2.HostRequestReject():
778+
other_user = UserInfo.from_protobuf(data.host)
779+
new_status = conversations_pb2.HostRequestStatus.HOST_REQUEST_STATUS_REJECTED
780+
case notification_data_pb2.HostRequestConfirm():
781+
other_user = UserInfo.from_protobuf(data.surfer)
782+
new_status = conversations_pb2.HostRequestStatus.HOST_REQUEST_STATUS_CONFIRMED
783+
case notification_data_pb2.HostRequestCancel():
784+
other_user = UserInfo.from_protobuf(data.surfer)
785+
new_status = conversations_pb2.HostRequestStatus.HOST_REQUEST_STATUS_CANCELLED
786+
case _:
787+
# Enable mypy's exhaustiveness checking
788+
assert_never("Unexpected host request status changed notification data type.")
789+
790+
return cls(
791+
user_name,
792+
other_user=other_user,
793+
from_date=date.fromisoformat(data.host_request.from_date),
794+
to_date=date.fromisoformat(data.host_request.to_date),
795+
new_status=new_status,
796+
view_link=urls.host_request(host_request_id=data.host_request.host_request_id),
797+
)
798+
799+
@classmethod
800+
def dummy_data(cls) -> HostRequestStatusChangedEmail:
801+
return HostRequestStatusChangedEmail(
802+
user_name="Alice",
803+
other_user=UserInfo.dummy_bob(),
804+
from_date=date(2025, 6, 1),
805+
to_date=date(2025, 6, 7),
806+
new_status=conversations_pb2.HOST_REQUEST_STATUS_ACCEPTED,
807+
view_link="https://couchers.org/requests/123",
808+
)
809+
810+
498811
@dataclass(kw_only=True, slots=True)
499812
class ModeratorNoteEmail(EmailBase):
500813
"""Sent to a user to notify them they have received a moderator note."""

0 commit comments

Comments
 (0)