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