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

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 notification_id=notification.id, 

56 ) 

57 

58 

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) 

170 

171 

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) 

178 

179 

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

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

182 

183 

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 

204 

205 

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

207 if notification.topic_action.is_critical: 

208 return None 

209 

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) 

217 

218 return f"<{list_unsubscribe_url}>" 

219 

220 

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

224 

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

295 

296 

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

300 

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

307 

308 

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 ) 

330 

331 

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

338 

339 name: str 

340 age: int 

341 city: str 

342 avatar_url: str 

343 profile_url: str 

344 

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 )