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