Coverage for app/backend/src/couchers/notifications/render_email.py: 94%
234 statements
« prev ^ index » next coverage.py v7.14.2, created at 2026-06-21 09:29 +0000
« prev ^ index » next coverage.py v7.14.2, created at 2026-06-21 09:29 +0000
1import logging
2from dataclasses import dataclass
3from typing import assert_never
5import couchers.email.emails as emails
6from couchers import urls
7from couchers.config import config
8from couchers.email.blocks import EmailBase, EmailFooter, UnsubscribeInfo, UnsubscribeLink
9from couchers.email.calendar_events import create_host_request_attachment, create_host_request_cancellation_attachment
10from couchers.email.rendering import render_email
11from couchers.i18n import LocalizationContext
12from couchers.models import Notification, NotificationTopicAction, User
13from couchers.notifications.quick_links import (
14 can_unsubscribe_topic_key,
15 generate_do_not_email,
16 generate_unsub_topic_action,
17 generate_unsub_topic_key,
18)
19from couchers.proto import api_pb2
20from couchers.proto.internal.jobs_pb2 import EmailPart, SendEmailPayload
21from couchers.utils import now
23logger = logging.getLogger(__name__)
26def get_send_email_payload(
27 user: User, notification: Notification, loc_context: LocalizationContext, *, include_ics_attachments: bool
28) -> SendEmailPayload:
29 email = get_notification_email(notification, user_name=user.name)
30 email_footer = get_email_footer(user, notification, loc_context)
31 rendered_email = render_email(email, email_footer, loc_context)
33 source_data_header = get_source_data_header(notification)
34 list_unsubscribe_header = get_list_unsubscribe_header(notification)
36 if include_ics_attachments:
37 attachment = get_ics_attachment(notification, loc_context)
38 else:
39 attachment = None
41 return SendEmailPayload(
42 sender_name=config.NOTIFICATION_EMAIL_SENDER,
43 sender_email=config.NOTIFICATION_EMAIL_ADDRESS,
44 recipient=user.email,
45 subject=config.NOTIFICATION_PREFIX + rendered_email.subject,
46 plain=rendered_email.body_plaintext,
47 html=rendered_email.body_html,
48 html_related_parts=rendered_email.html_image_parts,
49 source_data=source_data_header,
50 list_unsubscribe_header=list_unsubscribe_header,
51 attachments=[attachment] if attachment else [],
52 )
55def get_notification_email(notification: Notification, *, user_name: str) -> EmailBase:
56 data = notification.topic_action.data_type.FromString(notification.data) # type: ignore[attr-defined]
57 match notification.topic_action:
58 case NotificationTopicAction.account_deletion__start:
59 return emails.AccountDeletionStartedEmail.from_notification(data, user_name=user_name)
60 case NotificationTopicAction.account_deletion__complete:
61 return emails.AccountDeletionCompletedEmail.from_notification(data, user_name=user_name)
62 case NotificationTopicAction.account_deletion__recovered:
63 return emails.AccountDeletionRecoveredEmail(user_name=user_name)
64 case NotificationTopicAction.activeness__probe:
65 return emails.ActivenessProbeEmail.from_notification(data, user_name=user_name)
66 case NotificationTopicAction.api_key__create:
67 return emails.APIKeyIssuedEmail.from_notification(data, user_name=user_name)
68 case NotificationTopicAction.badge__add | NotificationTopicAction.badge__remove:
69 return emails.BadgeChangedEmail.from_notification(data, user_name=user_name)
70 case NotificationTopicAction.birthdate__change:
71 return emails.BirthdateChangedEmail.from_notification(data, user_name=user_name)
72 case NotificationTopicAction.chat__message:
73 return emails.ChatMessageReceivedEmail.from_notification(data, user_name=user_name)
74 case NotificationTopicAction.chat__missed_messages:
75 return emails.ChatMessagesMissedEmail.from_notification(data, user_name=user_name)
76 case NotificationTopicAction.discussion__create: 76 ↛ 77line 76 didn't jump to line 77 because the pattern on line 76 never matched
77 return emails.DiscussionCreatedEmail.from_notification(data, user_name=user_name)
78 case NotificationTopicAction.discussion__comment:
79 return emails.DiscussionCommentEmail.from_notification(data, user_name=user_name)
80 case NotificationTopicAction.donation__received:
81 return emails.DonationReceivedEmail.from_notification(data, user_name=user_name)
82 case NotificationTopicAction.email_address__change:
83 return emails.EmailChangedEmail.from_notification(data, user_name=user_name)
84 case NotificationTopicAction.email_address__verify:
85 return emails.EmailVerifiedEmail(user_name=user_name)
86 case NotificationTopicAction.event__create_approved:
87 return emails.EventCreatedEmail.from_notification(data, user_name=user_name, is_invite=True)
88 case NotificationTopicAction.event__create_any: 88 ↛ 89line 88 didn't jump to line 89 because the pattern on line 88 never matched
89 return emails.EventCreatedEmail.from_notification(data, user_name=user_name, is_invite=False)
90 case NotificationTopicAction.event__update:
91 return emails.EventUpdatedEmail.from_notification(data, user_name=user_name)
92 case NotificationTopicAction.event__invite_organizer: 92 ↛ 93line 92 didn't jump to line 93 because the pattern on line 92 never matched
93 return emails.EventOrganizerInvitedEmail.from_notification(data, user_name=user_name)
94 case NotificationTopicAction.event__comment:
95 return emails.EventCommentEmail.from_notification(data, user_name=user_name)
96 case NotificationTopicAction.event__reminder:
97 return emails.EventReminderEmail.from_notification(data, user_name=user_name)
98 case NotificationTopicAction.event__cancel:
99 return emails.EventCancelledEmail.from_notification(data, user_name=user_name)
100 case NotificationTopicAction.event__delete: 100 ↛ 101line 100 didn't jump to line 101 because the pattern on line 100 never matched
101 return emails.EventDeletedEmail.from_notification(data, user_name=user_name)
102 case NotificationTopicAction.host_request__create:
103 return emails.HostRequestCreatedEmail.from_notification(data, user_name=user_name)
104 case NotificationTopicAction.host_request__reminder:
105 return emails.HostRequestReminderEmail.from_notification(data, user_name=user_name)
106 case NotificationTopicAction.host_request__message: 106 ↛ 107line 106 didn't jump to line 107 because the pattern on line 106 never matched
107 return emails.HostRequestMessageEmail.from_notification(data, user_name=user_name)
108 case NotificationTopicAction.host_request__missed_messages:
109 return emails.HostRequestMissedMessagesEmail.from_notification(data, user_name=user_name)
110 case (
111 NotificationTopicAction.host_request__accept
112 | NotificationTopicAction.host_request__reject
113 | NotificationTopicAction.host_request__cancel
114 | NotificationTopicAction.host_request__confirm
115 ):
116 return emails.HostRequestStatusChangedEmail.from_notification(data, user_name=user_name)
117 case NotificationTopicAction.friend_request__create:
118 return emails.FriendRequestReceivedEmail.from_notification(data, user_name=user_name)
119 case NotificationTopicAction.friend_request__accept:
120 return emails.FriendRequestAcceptedEmail.from_notification(data, user_name=user_name)
121 case NotificationTopicAction.gender__change:
122 return emails.GenderChangedEmail.from_notification(data, user_name=user_name)
123 case NotificationTopicAction.general__new_blog_post:
124 return emails.NewBlogPostEmail.from_notification(data, user_name=user_name)
125 case NotificationTopicAction.modnote__create:
126 return emails.ModeratorNoteEmail(user_name=user_name)
127 case NotificationTopicAction.onboarding__reminder:
128 return emails.OnboardingReminderEmail(user_name=user_name, initial=notification.key == "1")
129 case NotificationTopicAction.password__change:
130 return emails.PasswordChangedEmail(user_name=user_name)
131 case NotificationTopicAction.password_reset__complete:
132 return emails.PasswordResetCompletedEmail(user_name=user_name)
133 case NotificationTopicAction.password_reset__start:
134 return emails.PasswordResetStartedEmail.from_notification(data, user_name=user_name)
135 case NotificationTopicAction.phone_number__change:
136 return emails.PhoneNumberChangeEmail.from_change_notification(data, user_name=user_name)
137 case NotificationTopicAction.phone_number__verify:
138 return emails.PhoneNumberChangeEmail.from_verify_notification(data, user_name=user_name)
139 case NotificationTopicAction.postal_verification__failed: 139 ↛ 140line 139 didn't jump to line 140 because the pattern on line 139 never matched
140 return emails.PostalVerificationFailedEmail.from_notification(data, user_name=user_name)
141 case NotificationTopicAction.postal_verification__postcard_sent:
142 return emails.PostalVerificationPostcardSentEmail.from_notification(data, user_name=user_name)
143 case NotificationTopicAction.postal_verification__success: 143 ↛ 144line 143 didn't jump to line 144 because the pattern on line 143 never matched
144 return emails.PostalVerificationSucceededEmail(user_name=user_name)
145 case NotificationTopicAction.reference__receive_friend:
146 return emails.FriendReferenceReceivedEmail.from_notification(data, user_name=user_name)
147 case NotificationTopicAction.reference__receive_hosted: 147 ↛ 149line 147 didn't jump to line 149 because the pattern on line 147 never matched
148 # Reference received from the host, so I'm the surfer
149 return emails.HostReferenceReceivedEmail.from_notification(data, user_name=user_name, surfed=True)
150 case NotificationTopicAction.reference__receive_surfed:
151 return emails.HostReferenceReceivedEmail.from_notification(data, user_name=user_name, surfed=False)
152 case NotificationTopicAction.reference__reminder_hosted:
153 # Reminder to send a "hosted" reference, so I'm the host
154 return emails.HostReferenceReminderEmail.from_notification(data, user_name=user_name, surfed=False)
155 case NotificationTopicAction.reference__reminder_surfed:
156 return emails.HostReferenceReminderEmail.from_notification(data, user_name=user_name, surfed=True)
157 case NotificationTopicAction.thread__reply:
158 return emails.ThreadReplyEmail.from_notification(data, user_name=user_name)
159 case NotificationTopicAction.verification__sv_fail:
160 return emails.StrongVerificationFailedEmail.from_notification(data, user_name=user_name)
161 case NotificationTopicAction.verification__sv_success: 161 ↛ 163line 161 didn't jump to line 163 because the pattern on line 161 always matched
162 return emails.StrongVerificationSucceededEmail(user_name=user_name)
163 case _:
164 # Enable mypy's exhaustiveness checking
165 assert_never(notification.topic_action)
168def get_source_data_header(notification: Notification) -> str:
169 return f"notification; topic-action={notification.topic_action}; version={config.VERSION}"
172def get_ics_attachment(notification: Notification, loc_context: LocalizationContext) -> EmailPart | None:
173 data = notification.topic_action.data_type.FromString(notification.data) # type: ignore[attr-defined]
174 if notification.topic_action == NotificationTopicAction.host_request__accept:
175 # Caveat: The surfer technically still hasn't confirmed, but when they do they don't receive an email,
176 # so the accept notification is our last opportunity to provide them with a calendar event.
177 return create_host_request_attachment(
178 data.host_request, other_name=data.host.name, hosting=False, loc_context=loc_context
179 )
180 elif notification.topic_action == NotificationTopicAction.host_request__confirm:
181 return create_host_request_attachment(
182 data.host_request, other_name=data.surfer.name, hosting=True, loc_context=loc_context
183 )
184 elif notification.topic_action == NotificationTopicAction.host_request__cancel:
185 # Caveat: only the party getting cancelled receives this notification,
186 # we have no opportunity to provide the cancelling party with a cancelled ics attachment.
187 return create_host_request_cancellation_attachment(
188 data.host_request, other_name=data.surfer.name, hosting=True, loc_context=loc_context
189 )
190 else:
191 return None
194def get_list_unsubscribe_header(notification: Notification) -> str | None:
195 if notification.topic_action.is_critical:
196 return None
198 # We can only have one List-Unsubscribe header.
199 # Prefer topic-key unsubscription as it is more specific than topic-action (e.g. current chat, not all chats).
200 list_unsubscribe_url: str
201 if can_unsubscribe_topic_key(notification.topic_action):
202 list_unsubscribe_url = generate_unsub_topic_key(notification)
203 else:
204 list_unsubscribe_url = generate_unsub_topic_action(notification)
206 return f"<{list_unsubscribe_url}>"
209def get_topic_action_unsubscribe_text(topic_action: NotificationTopicAction) -> str:
210 if topic_action.is_critical:
211 raise ValueError(f"Notification {topic_action} does not support unsubscription.")
213 # Not localized because the design will change so avoid useless work by translators.
214 match topic_action:
215 case NotificationTopicAction.host_request__missed_messages:
216 return "missed messages in host requests"
217 case NotificationTopicAction.host_request__create:
218 return "new host requests"
219 case NotificationTopicAction.host_request__message:
220 return "messages in host request"
221 case NotificationTopicAction.host_request__accept:
222 return "accepted host requests"
223 case NotificationTopicAction.host_request__reject:
224 return "declined host requests"
225 case NotificationTopicAction.host_request__confirm:
226 return "confirmed host requests"
227 case NotificationTopicAction.host_request__cancel:
228 return "cancelled host requests"
229 case NotificationTopicAction.host_request__reminder:
230 return "Pending host request reminders"
231 case NotificationTopicAction.reference__receive_friend:
232 return "new references from friends"
233 case NotificationTopicAction.reference__receive_hosted:
234 return "new references from hosts"
235 case NotificationTopicAction.reference__receive_surfed:
236 return "new references from surfers"
237 case NotificationTopicAction.reference__reminder_hosted:
238 return "hosted reference reminders"
239 case NotificationTopicAction.reference__reminder_surfed:
240 return "surfed reference reminders"
241 case NotificationTopicAction.badge__add:
242 return "badge additions"
243 case NotificationTopicAction.badge__remove:
244 return "badge removals"
245 case NotificationTopicAction.chat__message:
246 return "new chat messages"
247 case NotificationTopicAction.chat__missed_messages:
248 return "unseen chat messages"
249 case NotificationTopicAction.event__create_approved:
250 return "invitations to events (approved by moderators)"
251 case NotificationTopicAction.event__create_any:
252 return "new events by community members"
253 case NotificationTopicAction.event__update:
254 return "event updates"
255 case NotificationTopicAction.event__cancel:
256 return "event cancellations"
257 case NotificationTopicAction.event__delete:
258 return "event deletions"
259 case NotificationTopicAction.event__invite_organizer:
260 return "invitations to co-organize events"
261 case NotificationTopicAction.event__reminder:
262 return "event reminders"
263 case NotificationTopicAction.event__comment:
264 return "event comments"
265 case NotificationTopicAction.discussion__create:
266 return "new discussions"
267 case NotificationTopicAction.discussion__comment:
268 return "discussion comments"
269 case NotificationTopicAction.thread__reply:
270 return "comment replies"
271 case NotificationTopicAction.friend_request__create:
272 return "new friend requests"
273 case NotificationTopicAction.friend_request__accept:
274 return "accepted friend requests"
275 case NotificationTopicAction.onboarding__reminder:
276 return "onboarding emails"
277 case NotificationTopicAction.postal_verification__postcard_sent:
278 return "postal verification postcards"
279 case NotificationTopicAction.general__new_blog_post: 279 ↛ 281line 279 didn't jump to line 281 because the pattern on line 279 always matched
280 return "new blog post alerts"
281 case _:
282 raise NotImplementedError(f"No topic-action unsubscribe text for {topic_action}.")
285def get_topic_key_unsubscribe_text(topic_action: NotificationTopicAction) -> str:
286 if not can_unsubscribe_topic_key(topic_action):
287 raise ValueError(f"Notification {topic_action} does not support topic-key unsubscription.")
289 # Not localized because the design will change so avoid useless work by translators.
290 match topic_action:
291 case NotificationTopicAction.chat__message: 291 ↛ 293line 291 didn't jump to line 293 because the pattern on line 291 always matched
292 return "this chat (mute)"
293 case _:
294 raise NotImplementedError(f"No topic-key unsubscribe text for {topic_action}.")
297def get_email_footer(user: User, notification: Notification, loc_context: LocalizationContext) -> EmailFooter:
298 return EmailFooter(
299 timezone_name=loc_context.localized_timezone,
300 copyright_year=now().year,
301 unsubscribe_info=UnsubscribeInfo(
302 manage_notifications_url=urls.notification_settings_link(),
303 do_not_email_url=generate_do_not_email(user),
304 topic_action_link=UnsubscribeLink(
305 text=get_topic_action_unsubscribe_text(notification.topic_action),
306 url=generate_unsub_topic_action(notification),
307 ),
308 topic_key_link=UnsubscribeLink(
309 text=get_topic_key_unsubscribe_text(notification.topic_action),
310 url=generate_unsub_topic_key(notification),
311 )
312 if can_unsubscribe_topic_key(notification.topic_action)
313 else None,
314 )
315 if not notification.topic_action.is_critical
316 else None,
317 )
320@dataclass(frozen=True, slots=True, kw_only=True)
321class UserTemplateArgs:
322 """
323 A user's information for email template placeholders.
324 Allows decoupling from protocol buffer objects.
325 """
327 name: str
328 age: int
329 city: str
330 avatar_url: str
331 profile_url: str
333 @staticmethod
334 def from_protobuf_user(user: api_pb2.User) -> UserTemplateArgs:
335 return UserTemplateArgs(
336 name=user.name,
337 age=user.age,
338 city=user.city,
339 avatar_url=user.avatar_thumbnail_url or urls.icon_url(),
340 profile_url=urls.user_link(username=user.username),
341 )