Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
329 changes: 321 additions & 8 deletions app/backend/src/couchers/email/emails.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
from abc import ABC, abstractmethod
from dataclasses import dataclass
from datetime import UTC, date, datetime
from typing import Self
from typing import Self, assert_never

from couchers import urls
from couchers.email.rendering import (
Expand All @@ -18,7 +18,8 @@
from couchers.i18n import LocalizationContext
from couchers.i18n.i18next import SubstitutionDict
from couchers.i18n.localize import format_phone_number
from couchers.proto import notification_data_pb2
from couchers.notifications.quick_links import generate_quick_decline_link
from couchers.proto import conversations_pb2, notification_data_pb2


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

@classmethod
def from_add_notification(cls, data: notification_data_pb2.BadgeAdd, *, user_name: str) -> Self:
return cls(user_name=user_name, badge_name=data.badge_name, added=True)

@classmethod
def from_remove_notification(cls, data: notification_data_pb2.BadgeRemove, *, user_name: str) -> Self:
return cls(user_name=user_name, badge_name=data.badge_name, added=False)
def from_notification(
cls, data: notification_data_pb2.BadgeAdd | notification_data_pb2.BadgeRemove, *, user_name: str
) -> Self:
return cls(
user_name=user_name, badge_name=data.badge_name, added=isinstance(data, notification_data_pb2.BadgeAdd)
)

@classmethod
def dummy_data(cls) -> BadgeChangedEmail:
Expand Down Expand Up @@ -495,6 +496,318 @@ def dummy_data(cls) -> GenderChangedEmail:
)


@dataclass(kw_only=True, slots=True)
class HostRequestCreatedEmail(EmailBase):
"""Sent to a host when a surfer sends them a new host request."""

surfer: UserInfo
from_date: date
to_date: date
text: str
quick_decline_link: str
view_link: str

@property
def string_key_prefix(self) -> str:
return "host_request_created"

def get_subject_line(self, loc_context: LocalizationContext) -> str:
return self._localize(loc_context, "subject", {"surfer_name": self.surfer.name})

def build_body(self, builder: EmailBlocksBuilder, loc_context: LocalizationContext) -> None:
builder.para("body", {"surfer_name": self.surfer.name})
builder.user(
self.surfer,
"date_range",
{
"from_date": loc_context.localize_date(self.from_date),
"to_date": loc_context.localize_date(self.to_date),
},
)
builder.quote(self.text, markdown=False)
builder.action(self.view_link, "view_action")
builder.action(self.quick_decline_link, "quick_decline_action")
builder.para("respond_encouragement")
builder.do_not_reply_request_para()

@classmethod
def from_notification(cls, data: notification_data_pb2.HostRequestCreate, *, user_name: str) -> Self:
return cls(
user_name,
surfer=UserInfo.from_protobuf(data.surfer),
from_date=date.fromisoformat(data.host_request.from_date),
to_date=date.fromisoformat(data.host_request.to_date),
text=data.text,
quick_decline_link=generate_quick_decline_link(data.host_request),
view_link=urls.host_request(host_request_id=data.host_request.host_request_id),
)

@classmethod
def dummy_data(cls) -> HostRequestCreatedEmail:
return HostRequestCreatedEmail(
user_name="Alice",
surfer=UserInfo.dummy_bob(),
from_date=date(2025, 6, 1),
to_date=date(2025, 6, 7),
text="Hey, I'd love to stay for a few nights!",
quick_decline_link="https://couchers.org/requests/123/decline?token=xxx",
view_link="https://couchers.org/requests/123",
)


@dataclass(kw_only=True, slots=True)
class HostRequestReminderEmail(EmailBase):
"""Sent to a host as a reminder to respond to a pending host request."""

surfer: UserInfo
from_date: date
to_date: date
view_link: str

@property
def string_key_prefix(self) -> str:
return "host_request_reminder"

def get_subject_line(self, loc_context: LocalizationContext) -> str:
return self._localize(loc_context, "subject", {"surfer_name": self.surfer.name})

def build_body(self, builder: EmailBlocksBuilder, loc_context: LocalizationContext) -> None:
builder.para("body")
builder.user(
self.surfer,
".host_request_generic.date_range",
{
"from_date": loc_context.localize_date(self.from_date),
"to_date": loc_context.localize_date(self.to_date),
},
)
builder.action(self.view_link, ".host_request_generic.view_action")
builder.do_not_reply_request_para()

@classmethod
def from_notification(cls, data: notification_data_pb2.HostRequestReminder, *, user_name: str) -> Self:
return cls(
user_name,
surfer=UserInfo.from_protobuf(data.surfer),
from_date=date.fromisoformat(data.host_request.from_date),
to_date=date.fromisoformat(data.host_request.to_date),
view_link=urls.host_request(host_request_id=data.host_request.host_request_id),
)

@classmethod
def dummy_data(cls) -> HostRequestReminderEmail:
return HostRequestReminderEmail(
user_name="Alice",
surfer=UserInfo.dummy_bob(),
from_date=date(2025, 6, 1),
to_date=date(2025, 6, 7),
view_link="https://couchers.org/requests/123",
)


@dataclass(kw_only=True, slots=True)
class HostRequestMessageEmail(EmailBase):
"""Sent when a user sends a message in an existing host request."""

other_user: UserInfo
from_date: date
to_date: date
text: str
from_host: bool
view_link: str

@property
def string_key_prefix(self) -> str:
variant = "from_host" if self.from_host else "from_surfer"
return f"host_request_message.{variant}"

def get_subject_line(self, loc_context: LocalizationContext) -> str:
return self._localize(loc_context, "subject", {"other_name": self.other_user.name})

def build_body(self, builder: EmailBlocksBuilder, loc_context: LocalizationContext) -> None:
builder.para("body", {"other_name": self.other_user.name})
builder.user(
self.other_user,
".host_request_generic.date_range",
{
"from_date": loc_context.localize_date(self.from_date),
"to_date": loc_context.localize_date(self.to_date),
},
)
builder.quote(self.text, markdown=False)
builder.action(self.view_link, ".host_request_generic.view_action")
builder.do_not_reply_request_para()

@classmethod
def from_notification(cls, data: notification_data_pb2.HostRequestMessage, *, user_name: str) -> Self:
return cls(
user_name,
other_user=UserInfo.from_protobuf(data.user),
from_date=date.fromisoformat(data.host_request.from_date),
to_date=date.fromisoformat(data.host_request.to_date),
text=data.text,
from_host=not data.am_host,
view_link=urls.host_request(host_request_id=data.host_request.host_request_id),
)

@classmethod
def dummy_data(cls) -> HostRequestMessageEmail:
return HostRequestMessageEmail(
user_name="Alice",
other_user=UserInfo.dummy_bob(),
from_date=date(2025, 6, 1),
to_date=date(2025, 6, 7),
text="Looking forward to it, see you soon!",
from_host=True,
view_link="https://couchers.org/requests/123",
)


@dataclass(kw_only=True, slots=True)
class HostRequestMissedMessagesEmail(EmailBase):
"""Sent as a digest when a user has missed messages in a host request."""

other_user: UserInfo
from_date: date
to_date: date
from_host: bool
view_link: str

@property
def string_key_prefix(self) -> str:
variant = "from_host" if self.from_host else "from_surfer"
return f"host_request_missed_messages.{variant}"

def get_subject_line(self, loc_context: LocalizationContext) -> str:
return self._localize(loc_context, "subject", {"other_name": self.other_user.name})

def build_body(self, builder: EmailBlocksBuilder, loc_context: LocalizationContext) -> None:
builder.para("body", {"other_name": self.other_user.name})
builder.user(
self.other_user,
".host_request_generic.date_range",
{
"from_date": loc_context.localize_date(self.from_date),
"to_date": loc_context.localize_date(self.to_date),
},
)
builder.action(self.view_link, ".host_request_generic.view_action")
builder.do_not_reply_request_para()

@classmethod
def from_notification(cls, data: notification_data_pb2.HostRequestMissedMessages, *, user_name: str) -> Self:
return cls(
user_name,
other_user=UserInfo.from_protobuf(data.user),
from_date=date.fromisoformat(data.host_request.from_date),
to_date=date.fromisoformat(data.host_request.to_date),
from_host=not data.am_host,
view_link=urls.host_request(host_request_id=data.host_request.host_request_id),
)

@classmethod
def dummy_data(cls) -> HostRequestMissedMessagesEmail:
return HostRequestMissedMessagesEmail(
user_name="Alice",
other_user=UserInfo.dummy_bob(),
from_date=date(2025, 6, 1),
to_date=date(2025, 6, 7),
from_host=True,
view_link="https://couchers.org/requests/123",
)


@dataclass(kw_only=True, slots=True)
class HostRequestStatusChangedEmail(EmailBase):
"""Sent when a host request is accepted, declined, confirmed, or cancelled."""

other_user: UserInfo
from_date: date
to_date: date
new_status: conversations_pb2.HostRequestStatus.ValueType
view_link: str

@property
def string_key_prefix(self) -> str:
base_key = "host_request_status_changed"
match self.new_status:
case conversations_pb2.HOST_REQUEST_STATUS_ACCEPTED:
return f"{base_key}.accepted_by_host"
case conversations_pb2.HOST_REQUEST_STATUS_REJECTED:
return f"{base_key}.rejected_by_host"
case conversations_pb2.HOST_REQUEST_STATUS_CONFIRMED:
return f"{base_key}.confirmed_by_surfer"
case conversations_pb2.HOST_REQUEST_STATUS_CANCELLED:
return f"{base_key}.cancelled_by_surfer"
case _:
raise ValueError(f"Unexpected host request status: {self.new_status}")

def get_subject_line(self, loc_context: LocalizationContext) -> str:
return self._localize(loc_context, "subject", {"other_name": self.other_user.name})

def build_body(self, builder: EmailBlocksBuilder, loc_context: LocalizationContext) -> None:
builder.para("body", {"other_name": self.other_user.name})
builder.user(
self.other_user,
".host_request_generic.date_range",
{
"from_date": loc_context.localize_date(self.from_date),
"to_date": loc_context.localize_date(self.to_date),
},
)
builder.action(self.view_link, ".host_request_generic.view_action")
builder.do_not_reply_request_para()

@classmethod
def from_notification(
cls,
data: notification_data_pb2.HostRequestAccept
| notification_data_pb2.HostRequestReject
| notification_data_pb2.HostRequestConfirm
| notification_data_pb2.HostRequestCancel,
*,
user_name: str,
) -> Self:
other_user: UserInfo
new_status: conversations_pb2.HostRequestStatus.ValueType
match data:
case notification_data_pb2.HostRequestAccept():
other_user = UserInfo.from_protobuf(data.host)
new_status = conversations_pb2.HostRequestStatus.HOST_REQUEST_STATUS_ACCEPTED
case notification_data_pb2.HostRequestReject():
other_user = UserInfo.from_protobuf(data.host)
new_status = conversations_pb2.HostRequestStatus.HOST_REQUEST_STATUS_REJECTED
case notification_data_pb2.HostRequestConfirm():
other_user = UserInfo.from_protobuf(data.surfer)
new_status = conversations_pb2.HostRequestStatus.HOST_REQUEST_STATUS_CONFIRMED
case notification_data_pb2.HostRequestCancel():
other_user = UserInfo.from_protobuf(data.surfer)
new_status = conversations_pb2.HostRequestStatus.HOST_REQUEST_STATUS_CANCELLED
case _:
# Enable mypy's exhaustiveness checking
assert_never("Unexpected host request status changed notification data type.")

return cls(
user_name,
other_user=other_user,
from_date=date.fromisoformat(data.host_request.from_date),
to_date=date.fromisoformat(data.host_request.to_date),
new_status=new_status,
view_link=urls.host_request(host_request_id=data.host_request.host_request_id),
)

@classmethod
def dummy_data(cls) -> HostRequestStatusChangedEmail:
return HostRequestStatusChangedEmail(
user_name="Alice",
other_user=UserInfo.dummy_bob(),
from_date=date(2025, 6, 1),
to_date=date(2025, 6, 7),
new_status=conversations_pb2.HOST_REQUEST_STATUS_ACCEPTED,
view_link="https://couchers.org/requests/123",
)


@dataclass(kw_only=True, slots=True)
class ModeratorNoteEmail(EmailBase):
"""Sent to a user to notify them they have received a moderator note."""
Expand Down
Loading
Loading