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

1import logging 

2from dataclasses import dataclass 

3from typing import assert_never 

4 

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 

22 

23logger = logging.getLogger(__name__) 

24 

25 

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) 

32 

33 source_data_header = get_source_data_header(notification) 

34 list_unsubscribe_header = get_list_unsubscribe_header(notification) 

35 

36 if include_ics_attachments: 

37 attachment = get_ics_attachment(notification, loc_context) 

38 else: 

39 attachment = None 

40 

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 ) 

53 

54 

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) 

166 

167 

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

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

170 

171 

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 

192 

193 

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

195 if notification.topic_action.is_critical: 

196 return None 

197 

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) 

205 

206 return f"<{list_unsubscribe_url}>" 

207 

208 

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

212 

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

283 

284 

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

288 

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

295 

296 

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 ) 

318 

319 

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

326 

327 name: str 

328 age: int 

329 city: str 

330 avatar_url: str 

331 profile_url: str 

332 

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 )