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

1import logging 

2from dataclasses import dataclass 

3from email.headerregistry import Address 

4from typing import assert_never 

5 

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 

23 

24logger = logging.getLogger(__name__) 

25 

26 

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) 

33 

34 source_data_header = get_source_data_header(notification) 

35 list_unsubscribe_header = get_list_unsubscribe_header(notification) 

36 

37 if include_ics_attachments: 

38 attachment = get_ics_attachment(notification, loc_context) 

39 else: 

40 attachment = None 

41 

42 sender = get_email_sender(notification) 

43 

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 ) 

56 

57 

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) 

169 

170 

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) 

177 

178 

179def get_source_data_header(notification: Notification) -> str: 

180 return f"notification; topic-action={notification.topic_action}; version={config.VERSION}" 

181 

182 

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 

203 

204 

205def get_list_unsubscribe_header(notification: Notification) -> str | None: 

206 if notification.topic_action.is_critical: 

207 return None 

208 

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) 

216 

217 return f"<{list_unsubscribe_url}>" 

218 

219 

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.") 

223 

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}.") 

294 

295 

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.") 

299 

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}.") 

306 

307 

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 ) 

329 

330 

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 """ 

337 

338 name: str 

339 age: int 

340 city: str 

341 avatar_url: str 

342 profile_url: str 

343 

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 )