Coverage for app / backend / src / couchers / notifications / render_email.py: 88%
337 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-05-05 09:44 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-05-05 09:44 +0000
1import logging
2from dataclasses import dataclass
3from typing import Any
5from couchers import urls
6from couchers.config import config
7from couchers.email.rendering import EmailFooter, UnsubscribeInfo, UnsubscribeLink
8from couchers.i18n import LocalizationContext
9from couchers.i18n.localize import format_phone_number
10from couchers.models import Notification, NotificationTopicAction, User
11from couchers.notifications.quick_links import (
12 can_unsubscribe_topic_key,
13 generate_do_not_email,
14 generate_quick_decline_link,
15 generate_unsub_topic_action,
16 generate_unsub_topic_key,
17)
18from couchers.proto import api_pb2, notification_data_pb2
19from couchers.templating import Jinja2Template, template_folder
20from couchers.utils import now, to_aware_datetime
22logger = logging.getLogger(__name__)
25@dataclass(kw_only=True, slots=True)
26class RenderedEmailNotification:
27 subject: str
28 body_plaintext: str
29 body_html: str | None
30 source_data: str | None
31 list_unsubscribe_header: str | None
34def render_email_notification(
35 user: User, notification: Notification, loc_context: LocalizationContext
36) -> RenderedEmailNotification:
37 footer = get_email_footer(user, notification, loc_context)
39 # Currently only support custom templated emails,
40 # in the future will also support couchers.email.emails (single generic template).
41 custom_templated = _get_custom_templated_email(notification, loc_context)
43 template_args = {
44 **custom_templated.template_args,
45 "header_subject": custom_templated.subject,
46 "header_preview": custom_templated.preview,
47 "user": user,
48 "time": notification.created,
49 **footer.to_template_args(),
50 }
52 # Format plaintext template
53 plain_tmplt_body = (template_folder / f"{custom_templated.template_name}.txt").read_text()
54 plain_tmplt_footer = (template_folder / "_footer.txt").read_text()
55 plain_tmplt = Jinja2Template(source=plain_tmplt_body + plain_tmplt_footer, html=False)
56 plain = plain_tmplt.render(template_args, loc_context)
58 # Format html template
59 html_tmplt = Jinja2Template(
60 source=(template_folder / "generated_html" / f"{custom_templated.template_name}.html").read_text(), html=True
61 )
62 html = html_tmplt.render(template_args, loc_context)
64 list_unsubscribe_header = get_list_unsubscribe_header(notification)
65 source_data = config["VERSION"] + f"/{custom_templated.template_name}"
67 return RenderedEmailNotification(
68 subject=custom_templated.subject,
69 body_plaintext=plain,
70 body_html=html,
71 source_data=source_data,
72 list_unsubscribe_header=list_unsubscribe_header,
73 )
76@dataclass(kw_only=True)
77class CustomTemplatedEmail:
78 # email subject
79 subject: str
80 # shows up when listing emails in many clients
81 preview: str
82 # corresponds to .mjml + .txt file in templates/v2
83 template_name: str
84 # other template args
85 template_args: dict[str, Any]
88# Gets the data necessary to template an email for which we have a custom template,
89# e.g. not yet using couchers.email.emails.
90def _get_custom_templated_email(notification: Notification, loc_context: LocalizationContext) -> CustomTemplatedEmail:
91 data = notification.topic_action.data_type.FromString(notification.data) # type: ignore[attr-defined]
92 if notification.topic == "host_request":
93 view_link = urls.host_request(host_request_id=data.host_request.host_request_id)
94 if notification.action == "missed_messages":
95 their_your = "their" if data.am_host else "your"
96 other = data.user
97 # "declined your host request", or similar
98 message = f"{other.name} sent you message(s) in {their_your} host request"
99 return CustomTemplatedEmail(
100 subject=message,
101 preview=message,
102 template_name="host_request__plain",
103 template_args={
104 "view_link": view_link,
105 "host_request": data.host_request,
106 "message": message,
107 "other": UserTemplateArgs.from_protobuf_user(other),
108 },
109 )
110 elif notification.action == "create":
111 other = data.surfer
112 message = f"{other.name} sent you a host request"
113 return CustomTemplatedEmail(
114 subject=message,
115 preview=message,
116 template_name="host_request__new",
117 template_args={
118 "view_link": view_link,
119 "quick_decline_link": generate_quick_decline_link(data.host_request),
120 "host_request": data.host_request,
121 "message": message,
122 "other": UserTemplateArgs.from_protobuf_user(other),
123 "text": data.text,
124 },
125 )
126 elif notification.action == "message": 126 ↛ 127line 126 didn't jump to line 127 because the condition on line 126 was never true
127 other = data.user
128 if data.am_host:
129 message = f"{other.name} sent you a message in their host request"
130 else:
131 message = f"{other.name} sent you a message in your host request"
132 return CustomTemplatedEmail(
133 subject=message,
134 preview=message,
135 template_name="host_request__message",
136 template_args={
137 "view_link": view_link,
138 "host_request": data.host_request,
139 "message": message,
140 "other": UserTemplateArgs.from_protobuf_user(other),
141 "text": data.text,
142 },
143 )
144 elif notification.action in ["accept", "reject", "confirm", "cancel"]:
145 if notification.action in ["accept", "reject"]:
146 other = data.host
147 their_your = "your"
148 else:
149 other = data.surfer
150 their_your = "their"
151 actioned = {
152 "accept": "accepted",
153 "reject": "declined",
154 "confirm": "confirmed",
155 "cancel": "cancelled",
156 }[notification.action]
157 # "declined your host request", or similar
158 message = f"{other.name} {actioned} {their_your} host request"
159 return CustomTemplatedEmail(
160 subject=message,
161 preview=message,
162 template_name="host_request__plain",
163 template_args={
164 "view_link": view_link,
165 "host_request": data.host_request,
166 "message": message,
167 "other": UserTemplateArgs.from_protobuf_user(other),
168 },
169 )
170 elif notification.action == "reminder": 170 ↛ 761line 170 didn't jump to line 761 because the condition on line 170 was always true
171 message = f"You have a pending host request from {data.surfer.name}!"
172 description = "Please respond to the request!"
173 return CustomTemplatedEmail(
174 subject=message,
175 preview=description,
176 template_name="host_request__plain",
177 template_args={
178 "view_link": view_link,
179 "host_request": data.host_request,
180 "message": description,
181 "other": UserTemplateArgs.from_protobuf_user(data.surfer),
182 },
183 )
184 elif notification.topic_action == NotificationTopicAction.password__change:
185 title = "Your password was changed"
186 message = "Your login password for Couchers.org was changed."
187 return CustomTemplatedEmail(
188 subject=title,
189 preview=message,
190 template_name="security",
191 template_args={
192 "title": title,
193 "message": message,
194 },
195 )
196 elif notification.topic_action == NotificationTopicAction.password_reset__start:
197 message = "Someone initiated a password change on your account."
198 return CustomTemplatedEmail(
199 subject="Reset your Couchers.org password",
200 preview=message,
201 template_name="password_reset",
202 template_args={
203 "password_reset_link": urls.password_reset_link(password_reset_token=data.password_reset_token)
204 },
205 )
206 elif notification.topic_action == NotificationTopicAction.password_reset__complete:
207 title = "Your password was successfully reset"
208 message = "Your password on Couchers.org was changed. If that was you, then no further action is needed."
209 return CustomTemplatedEmail(
210 subject=title,
211 preview=title,
212 template_name="security",
213 template_args={
214 "title": title,
215 "message": message,
216 },
217 )
218 elif notification.topic_action == NotificationTopicAction.email_address__change:
219 title = "An email change was initiated on your account"
220 message = f"An email change to the email <b>{data.new_email}</b> was initiated on your account."
221 return CustomTemplatedEmail(
222 subject=title,
223 preview=title,
224 template_name="security",
225 template_args={
226 "title": title,
227 "message": message,
228 },
229 )
230 elif notification.topic_action == NotificationTopicAction.email_address__verify:
231 title = "Email change completed"
232 message = "Your new email address has been verified."
233 return CustomTemplatedEmail(
234 subject=title,
235 preview=message,
236 template_name="security",
237 template_args={
238 "title": title,
239 "message": message,
240 },
241 )
242 elif notification.topic_action == NotificationTopicAction.phone_number__change:
243 title = "Phone verification started"
244 message = f"You started phone number verification with the number <b>{format_phone_number(data.phone)}</b>."
245 return CustomTemplatedEmail(
246 subject=title,
247 preview=message,
248 template_name="security",
249 template_args={
250 "title": title,
251 "message": message,
252 },
253 )
254 elif notification.topic_action == NotificationTopicAction.phone_number__verify:
255 title = "Phone successfully verified"
256 message = f"Your phone was successfully verified as <b>{format_phone_number(data.phone)}</b> on Couchers.org."
257 message_plain = f"Your phone was successfully verified as {format_phone_number(data.phone)} on Couchers.org."
258 return CustomTemplatedEmail(
259 subject=title,
260 preview=message_plain,
261 template_name="security",
262 template_args={
263 "title": title,
264 "message": message,
265 },
266 )
267 elif notification.topic_action == NotificationTopicAction.gender__change:
268 title = "Your gender was changed"
269 message = f"Your gender on Couchers.org was changed to <b>{data.gender}</b> by an admin."
270 message_plain = f"Your gender on Couchers.org was changed to {data.gender} by an admin."
271 return CustomTemplatedEmail(
272 subject=title,
273 preview=message_plain,
274 template_name="security",
275 template_args={
276 "title": title,
277 "message": message,
278 },
279 )
280 elif notification.topic_action == NotificationTopicAction.birthdate__change:
281 title = "Your date of birth was changed"
282 birthdate = loc_context.localize_date_from_iso(data.birthdate)
283 message = f"Your date of birth on Couchers.org was changed to <b>{birthdate}</b> by an admin."
284 message_plain = f"Your date of birth on Couchers.org was changed to {birthdate} by an admin."
285 return CustomTemplatedEmail(
286 subject=title,
287 preview=message_plain,
288 template_name="security",
289 template_args={
290 "title": title,
291 "message": message,
292 },
293 )
294 elif notification.topic_action == NotificationTopicAction.api_key__create:
295 return CustomTemplatedEmail(
296 subject="Your API key for Couchers.org",
297 preview="We have issued you an API key as per your request.",
298 template_name="api_key",
299 template_args={
300 "api_key": data.api_key,
301 "expiry": data.expiry,
302 },
303 )
304 elif notification.topic_action.display in ["badge:add", "badge:remove"]:
305 actioned = "added to" if notification.action == "add" else "removed from"
306 title = f"The {data.badge_name} badge was {actioned} your profile"
307 return CustomTemplatedEmail(
308 subject=title,
309 preview=title,
310 template_name="badge",
311 template_args={
312 "badge_name": data.badge_name,
313 "actioned": actioned,
314 "unsub_type": "badge additions" if notification.action == "add" else "badge removals",
315 },
316 )
317 elif notification.topic_action == NotificationTopicAction.donation__received:
318 title = loc_context.localize_string("notifications.donation_received.title")
319 message = loc_context.localize_string(
320 "notifications.donation_received.thanks_amount",
321 substitutions={
322 "amount": data.amount,
323 },
324 )
325 return CustomTemplatedEmail(
326 subject=title,
327 preview=message,
328 template_name="donation_received",
329 template_args={
330 "amount": data.amount,
331 "receipt_url": data.receipt_url,
332 },
333 )
334 elif notification.topic_action == NotificationTopicAction.friend_request__create:
335 other = data.other_user
336 preview = f"You've received a friend request from {other.name}"
337 return CustomTemplatedEmail(
338 subject=f"{other.name} wants to be your friend on Couchers.org!",
339 preview=preview,
340 template_name="friend_request",
341 template_args={
342 "friend_requests_link": urls.friend_requests_link(),
343 "other": UserTemplateArgs.from_protobuf_user(other),
344 },
345 )
346 elif notification.topic_action == NotificationTopicAction.friend_request__accept:
347 other = data.other_user
348 title = f"{other.name} accepted your friend request!"
349 preview = f"{other.name} has accepted your friend request"
350 return CustomTemplatedEmail(
351 subject=title,
352 preview=preview,
353 template_name="friend_request_accepted",
354 template_args={
355 "other": UserTemplateArgs.from_protobuf_user(other),
356 },
357 )
358 elif notification.topic_action == NotificationTopicAction.account_deletion__start:
359 return CustomTemplatedEmail(
360 subject="Confirm your Couchers.org account deletion",
361 preview="Please confirm that you want to delete your Couchers.org account.",
362 template_name="account_deletion_start",
363 template_args={
364 "deletion_link": urls.delete_account_link(account_deletion_token=data.deletion_token),
365 },
366 )
367 elif notification.topic_action == NotificationTopicAction.account_deletion__complete:
368 title = "Your Couchers.org account has been deleted"
369 return CustomTemplatedEmail(
370 subject=title,
371 preview="We have deleted your Couchers.org account, to undo, follow the link in this email.",
372 template_name="account_deletion_complete",
373 template_args={
374 "undelete_link": urls.recover_account_link(account_undelete_token=data.undelete_token),
375 "days": data.undelete_days,
376 },
377 )
378 elif notification.topic_action == NotificationTopicAction.account_deletion__recovered:
379 title = "Your Couchers.org account has been recovered!"
380 subtitle = "We have recovered your Couchers.org account as per your request! Welcome back!"
381 return CustomTemplatedEmail(
382 subject=title,
383 preview=subtitle,
384 template_name="account_deletion_recovered",
385 template_args={
386 "app_link": urls.app_link(),
387 },
388 )
389 elif notification.topic_action == NotificationTopicAction.chat__message:
390 return CustomTemplatedEmail(
391 subject=data.message,
392 preview="You received a message on Couchers.org!",
393 template_name="chat_message",
394 template_args={
395 "author": UserTemplateArgs.from_protobuf_user(data.author),
396 "message": data.message,
397 "text": data.text,
398 "view_link": urls.chat_link(chat_id=data.group_chat_id),
399 },
400 )
401 elif notification.topic_action == NotificationTopicAction.chat__missed_messages:
402 return CustomTemplatedEmail(
403 subject="You have unseen messages on Couchers.org!",
404 preview="You missed some messages on the platform.",
405 template_name="chat_unseen_messages",
406 template_args={
407 "items": [
408 {
409 "author": UserTemplateArgs.from_protobuf_user(item.author),
410 "message": item.message,
411 "text": item.text,
412 "view_link": urls.chat_link(chat_id=item.group_chat_id),
413 }
414 for item in data.messages
415 ]
416 },
417 )
418 elif notification.topic == "event":
419 event = data.event
420 start_time = loc_context.localize_datetime(event.start_time)
421 end_time = loc_context.localize_datetime(event.end_time)
422 time_display = f"{start_time} - {end_time}"
423 event_link = urls.event_link(occurrence_id=event.event_id, slug=event.slug)
424 if notification.action in ["create_approved", "create_any"]:
425 # create_approved = invitation, approved by mods
426 # create_any = new event created by anyone (no need for approval) -- off by default
427 if notification.action == "create_approved": 427 ↛ 430line 427 didn't jump to line 430 because the condition on line 427 was always true
428 subject = f'{data.inviting_user.name} invited you to "{event.title}"'
429 start_text = "You've been invited to a new event"
430 elif notification.action == "create_any":
431 subject = f'{data.inviting_user.name} created an event called "{event.title}"'
432 start_text = "A new event was created"
433 community_link = (
434 urls.community_link(node_id=data.in_community.community_id, slug=data.in_community.slug)
435 if data.in_community
436 else None
437 )
438 return CustomTemplatedEmail(
439 subject=subject,
440 preview=f"{start_text} on Couchers.org!",
441 template_name="event_create",
442 template_args={
443 "inviting_user": UserTemplateArgs.from_protobuf_user(data.inviting_user),
444 "time_display": time_display,
445 "start_text": start_text,
446 "nearby": "nearby" if data.nearby else None,
447 "community": data.in_community if data.in_community else None,
448 "community_link": community_link,
449 "event": event,
450 "view_link": event_link,
451 },
452 )
453 elif notification.action == "update":
454 updated_text = ", ".join(data.updated_items)
455 return CustomTemplatedEmail(
456 subject=f'{data.updating_user.name} updated "{event.title}"',
457 preview="An event you are subscribed to was updated.",
458 template_name="event_update",
459 template_args={
460 "updating_user": UserTemplateArgs.from_protobuf_user(data.updating_user),
461 "time_display": time_display,
462 "event": event,
463 "updated_text": updated_text,
464 "view_link": event_link,
465 },
466 )
467 elif notification.action == "cancel":
468 return CustomTemplatedEmail(
469 subject=f'{data.cancelling_user.name} cancelled "{event.title}"',
470 preview="An event you are subscribed to has been cancelled.",
471 template_name="event_cancel",
472 template_args={
473 "cancelling_user": UserTemplateArgs.from_protobuf_user(data.cancelling_user),
474 "time_display": time_display,
475 "event": event,
476 "view_link": event_link,
477 },
478 )
479 elif notification.action == "delete": 479 ↛ 480line 479 didn't jump to line 480 because the condition on line 479 was never true
480 return CustomTemplatedEmail(
481 subject=f'A moderator deleted "{event.title}"',
482 preview="An event you are subscribed to has been deleted.",
483 template_name="event_delete",
484 template_args={
485 "time_display": time_display,
486 "event": event,
487 },
488 )
489 elif notification.action == "invite_organizer": 489 ↛ 490line 489 didn't jump to line 490 because the condition on line 489 was never true
490 return CustomTemplatedEmail(
491 subject=f'{data.inviting_user.name} invited you to co-organize "{event.title}"',
492 preview="You were invited to co-organize an event on Couchers.org.",
493 template_name="event_invite_organizer",
494 template_args={
495 "inviting_user": UserTemplateArgs.from_protobuf_user(data.inviting_user),
496 "time_display": time_display,
497 "event": event,
498 "view_link": event_link,
499 },
500 )
501 elif notification.action == "comment":
502 return CustomTemplatedEmail(
503 subject=f'{data.author.name} commented on "{event.title}"',
504 preview="Someone commented on an event you are attending.",
505 template_name="event_comment",
506 template_args={
507 "author": UserTemplateArgs.from_protobuf_user(data.author),
508 "time_display": time_display,
509 "event": event,
510 "content": data.reply.content,
511 "view_link": event_link,
512 },
513 )
514 elif notification.action == "reminder": 514 ↛ 761line 514 didn't jump to line 761 because the condition on line 514 was always true
515 return CustomTemplatedEmail(
516 subject=f'Reminder: "{data.event.title}" starts soon',
517 preview="Don't forget your upcoming event on Couchers.org",
518 template_name="event_reminder",
519 template_args={
520 "time_display": time_display,
521 "event": event,
522 "view_link": event_link,
523 },
524 )
525 elif notification.topic == "discussion":
526 discussion = data.discussion
527 discussion_link = urls.discussion_link(discussion_id=discussion.discussion_id, slug=discussion.slug)
528 if notification.action == "create": 528 ↛ 529line 528 didn't jump to line 529 because the condition on line 528 was never true
529 return CustomTemplatedEmail(
530 subject=f'{data.author.name} created a discussion: "{discussion.title}"',
531 preview="Someone created a discussion in a community or group you are subscribed to.",
532 template_name="discussion_create",
533 template_args={
534 "author": UserTemplateArgs.from_protobuf_user(data.author),
535 "discussion": discussion,
536 "view_link": discussion_link,
537 },
538 )
539 elif notification.action == "comment": 539 ↛ 761line 539 didn't jump to line 761 because the condition on line 539 was always true
540 return CustomTemplatedEmail(
541 subject=f'{data.author.name} commented on "{discussion.title}"',
542 preview="Someone commented on your discussion.",
543 template_name="discussion_comment",
544 template_args={
545 "author": UserTemplateArgs.from_protobuf_user(data.author),
546 "discussion": discussion,
547 "reply": data.reply,
548 "view_link": discussion_link,
549 },
550 )
551 elif notification.topic_action == NotificationTopicAction.thread__reply:
552 parent = data.WhichOneof("reply_parent")
553 if parent == "event":
554 title = data.event.title
555 view_link = urls.event_link(occurrence_id=data.event.event_id, slug=data.event.slug)
556 elif parent == "discussion": 556 ↛ 560line 556 didn't jump to line 560 because the condition on line 556 was always true
557 title = data.discussion.title
558 view_link = urls.discussion_link(discussion_id=data.discussion.discussion_id, slug=data.discussion.slug)
559 else:
560 raise Exception("Can only do replies to events and discussions")
562 return CustomTemplatedEmail(
563 subject=f'{data.author.name} replied in "{title}"',
564 preview="Someone replied in a comment thread you have participated in.",
565 template_name="comment_reply",
566 template_args={
567 "author": UserTemplateArgs.from_protobuf_user(data.author),
568 "title": title,
569 "reply": data.reply,
570 "view_link": view_link,
571 },
572 )
573 elif notification.topic == "reference":
574 if notification.action == "receive_friend":
575 title = f"You've received a friend reference from {data.from_user.name}!"
576 return CustomTemplatedEmail(
577 subject=title,
578 preview=data.text,
579 template_name="friend_reference",
580 template_args={
581 "from_user": UserTemplateArgs.from_protobuf_user(data.from_user),
582 "profile_references_link": urls.profile_references_link(),
583 "text": data.text,
584 },
585 )
586 elif notification.action in ["receive_hosted", "receive_surfed"]:
587 title = f"You've received a reference from {data.from_user.name}!"
588 # what was my type? i surfed with them if i received a "hosted" request
589 surfed = notification.action == "receive_hosted"
590 leave_reference_link = urls.leave_reference_link(
591 reference_type="surfed" if surfed else "hosted",
592 to_user_id=data.from_user.user_id,
593 host_request_id=data.host_request_id,
594 )
595 profile_references_link = urls.profile_references_link()
596 if data.text: 596 ↛ 597line 596 didn't jump to line 597 because the condition on line 596 was never true
597 preview = data.text
598 else:
599 preview = "Please go and write a reference for them too. It's a nice gesture and helps us build a community together!"
600 return CustomTemplatedEmail(
601 subject=title,
602 preview=preview,
603 template_name="host_reference",
604 template_args={
605 "from_user": UserTemplateArgs.from_protobuf_user(data.from_user),
606 "leave_reference_link": leave_reference_link,
607 "profile_references_link": profile_references_link,
608 "text": data.text,
609 "both_written": True if data.text else False,
610 "surfed": surfed,
611 },
612 )
613 elif notification.action in ["reminder_hosted", "reminder_surfed"]: 613 ↛ 761line 613 didn't jump to line 761 because the condition on line 613 was always true
614 # what was my type? i surfed with them if i get a surfed reminder
615 surfed = notification.action == "reminder_surfed"
616 leave_reference_link = urls.leave_reference_link(
617 reference_type="surfed" if surfed else "hosted",
618 to_user_id=data.other_user.user_id,
619 host_request_id=data.host_request_id,
620 )
621 title = f"You have {data.days_left} days to write a reference for {data.other_user.name}!"
622 preview = "It's a nice gesture to write references and helps us build a community together! References will become visible 2 weeks after the stay, or when you've both written a reference for each other, whichever happens first."
623 return CustomTemplatedEmail(
624 subject=title,
625 preview=preview,
626 template_name="reference_reminder",
627 template_args={
628 "other_user": UserTemplateArgs.from_protobuf_user(data.other_user),
629 "leave_reference_link": leave_reference_link,
630 "days_left": str(data.days_left),
631 "surfed": surfed,
632 },
633 )
634 elif notification.topic_action == NotificationTopicAction.onboarding__reminder:
635 if notification.key == "1":
636 return CustomTemplatedEmail(
637 subject="Welcome to Couchers.org and the future of couch surfing",
638 preview="We are so excited to have you join our community!",
639 template_name="onboarding1",
640 template_args={
641 "app_link": urls.app_link(),
642 "edit_profile_link": urls.edit_profile_link(),
643 },
644 )
645 elif notification.key == "2": 645 ↛ 761line 645 didn't jump to line 761 because the condition on line 645 was always true
646 return CustomTemplatedEmail(
647 subject="Complete your profile on Couchers.org",
648 preview="We would ask one big favour of you: please fill out your profile by adding a photo and some text.",
649 template_name="onboarding2",
650 template_args={
651 "edit_profile_link": urls.edit_profile_link(),
652 },
653 )
654 elif notification.topic_action == NotificationTopicAction.modnote__create:
655 title = "You have received a mod note"
656 message = "You have received an important note from the moderators. You must read and acknowledge it before continuing to use the platform."
657 return CustomTemplatedEmail(
658 subject=title,
659 preview=message,
660 template_name="mod_note",
661 template_args={"title": title},
662 )
663 elif notification.topic_action == NotificationTopicAction.verification__sv_success:
664 title = "Strong Verification succeeded"
665 message = "You have been verified with Strong Verification! You will now see a tick next to your name on the platform."
666 return CustomTemplatedEmail(
667 subject=title,
668 preview=message,
669 template_name="strong_verification_success",
670 template_args={
671 "message": message,
672 },
673 )
674 elif notification.topic_action == NotificationTopicAction.verification__sv_fail:
675 title = "Strong Verification failed"
676 if data.reason == notification_data_pb2.SV_FAIL_REASON_WRONG_BIRTHDATE_OR_GENDER: 676 ↛ 677line 676 didn't jump to line 677 because the condition on line 676 was never true
677 reason_message = "The date of birth or gender on your profile does not match the date of birth or sex on your passport. Please contact the support team to update your date of birth or gender, or if your passport sex does not match your gender identity."
678 elif data.reason == notification_data_pb2.SV_FAIL_REASON_NOT_A_PASSPORT:
679 reason_message = "You tried to verify with a document that is not a passport. You can only use a passport for Strong Verification."
680 elif data.reason == notification_data_pb2.SV_FAIL_REASON_DUPLICATE: 680 ↛ 683line 680 didn't jump to line 683 because the condition on line 680 was always true
681 reason_message = "You tried to verify with a passport that has already been used for verification. Please use another passport."
682 else:
683 raise Exception("Shouldn't get here")
684 return CustomTemplatedEmail(
685 subject=title,
686 preview=title,
687 template_name="security",
688 template_args={
689 "title": title,
690 "message": reason_message,
691 },
692 )
693 elif notification.topic == "postal_verification":
694 if notification.action == "postcard_sent": 694 ↛ 706line 694 didn't jump to line 706 because the condition on line 694 was always true
695 title = "Your verification postcard is on its way"
696 message = f"We've sent a postcard with your verification code to {data.city}, {data.country}. It should arrive within 1-3 weeks depending on your location. Once it arrives, enter the code on the platform to complete verification."
697 return CustomTemplatedEmail(
698 subject=title,
699 preview=message,
700 template_name="security",
701 template_args={
702 "title": title,
703 "message": message,
704 },
705 )
706 elif notification.action == "success":
707 title = "Postal Verification succeeded"
708 message = "You have been verified with Postal Verification! Your address has been confirmed."
709 return CustomTemplatedEmail(
710 subject=title,
711 preview=message,
712 template_name="security",
713 template_args={
714 "title": title,
715 "message": message,
716 },
717 )
718 elif notification.action == "failed":
719 title = "Postal Verification failed"
720 if data.reason == notification_data_pb2.POSTAL_VERIFICATION_FAIL_REASON_CODE_EXPIRED:
721 reason_message = "Your verification code has expired. Codes are valid for 90 days after the postcard is sent. You can start a new verification attempt."
722 elif data.reason == notification_data_pb2.POSTAL_VERIFICATION_FAIL_REASON_TOO_MANY_ATTEMPTS:
723 reason_message = "Too many incorrect code attempts. You can start a new verification attempt."
724 else:
725 reason_message = (
726 "Your postal verification attempt has failed. You can start a new verification attempt."
727 )
728 return CustomTemplatedEmail(
729 subject=title,
730 preview=title,
731 template_name="security",
732 template_args={
733 "title": title,
734 "message": reason_message,
735 },
736 )
737 elif notification.topic_action == NotificationTopicAction.activeness__probe:
738 title = "Are you still open to hosting on Couchers.org?"
739 return CustomTemplatedEmail(
740 subject=title,
741 preview=title,
742 template_name="activeness_probe",
743 template_args={
744 "app_link": urls.app_link(),
745 "days_left": (to_aware_datetime(data.deadline) - now()).days,
746 },
747 )
748 elif notification.topic_action == NotificationTopicAction.general__new_blog_post: 748 ↛ 761line 748 didn't jump to line 761 because the condition on line 748 was always true
749 title = f"New blog post: {data.title}"
750 return CustomTemplatedEmail(
751 subject=title,
752 preview=data.blurb,
753 template_name="new_blog_post",
754 template_args={
755 "title": data.title,
756 "blurb": data.blurb,
757 "url": data.url,
758 },
759 )
761 raise NotImplementedError(f"Unknown topic-action: {notification.topic}:{notification.action}")
764def get_list_unsubscribe_header(notification: Notification) -> str | None:
765 if notification.topic_action.is_critical:
766 return None
768 # We can only have one List-Unsubscribe header.
769 # Prefer topic-key unsubscription as it is more specific than topic-action (e.g. current chat, not all chats).
770 list_unsubscribe_url: str
771 if can_unsubscribe_topic_key(notification.topic_action):
772 list_unsubscribe_url = generate_unsub_topic_key(notification)
773 else:
774 list_unsubscribe_url = generate_unsub_topic_action(notification)
776 return f"<{list_unsubscribe_url}>"
779def get_topic_action_unsubscribe_text(topic_action: NotificationTopicAction) -> str:
780 assert not topic_action.is_critical
781 # Not localized because the design will change so avoid useless work by translators.
782 match topic_action:
783 case NotificationTopicAction.host_request__missed_messages:
784 return "missed messages in host requests"
785 case NotificationTopicAction.host_request__create:
786 return "new host requests"
787 case NotificationTopicAction.host_request__message:
788 return "messages in host request"
789 case NotificationTopicAction.host_request__accept:
790 return "accepted host requests"
791 case NotificationTopicAction.host_request__reject:
792 return "declined host requests"
793 case NotificationTopicAction.host_request__confirm:
794 return "confirmed host requests"
795 case NotificationTopicAction.host_request__cancel:
796 return "cancelled host requests"
797 case NotificationTopicAction.host_request__reminder:
798 return "Pending host request reminders"
799 case NotificationTopicAction.reference__receive_friend:
800 return "new references from friends"
801 case NotificationTopicAction.reference__receive_hosted:
802 return "new references from hosts"
803 case NotificationTopicAction.reference__receive_surfed:
804 return "new references from surfers"
805 case NotificationTopicAction.reference__reminder_hosted:
806 return "hosted reference reminders"
807 case NotificationTopicAction.reference__reminder_surfed:
808 return "surfed reference reminders"
809 case NotificationTopicAction.badge__add:
810 return "badge additions"
811 case NotificationTopicAction.badge__remove:
812 return "badge removals"
813 case NotificationTopicAction.chat__message:
814 return "new chat messages"
815 case NotificationTopicAction.chat__missed_messages:
816 return "unseen chat messages"
817 case NotificationTopicAction.event__create_approved:
818 return "invitations to events (approved by moderators)"
819 case NotificationTopicAction.event__create_any:
820 return "new events by community members"
821 case NotificationTopicAction.event__update:
822 return "event updates"
823 case NotificationTopicAction.event__cancel:
824 return "event cancellations"
825 case NotificationTopicAction.event__delete:
826 return "event deletions"
827 case NotificationTopicAction.event__invite_organizer:
828 return "invitations to co-organize events"
829 case NotificationTopicAction.event__reminder:
830 return "event reminders"
831 case NotificationTopicAction.event__comment:
832 return "event comments"
833 case NotificationTopicAction.discussion__create:
834 return "new discussions"
835 case NotificationTopicAction.discussion__comment:
836 return "discussion comments"
837 case NotificationTopicAction.thread__reply:
838 return "comment replies"
839 case NotificationTopicAction.friend_request__create:
840 return "new friend requests"
841 case NotificationTopicAction.friend_request__accept:
842 return "accepted friend requests"
843 case NotificationTopicAction.onboarding__reminder:
844 return "onboarding emails"
845 case NotificationTopicAction.postal_verification__postcard_sent:
846 return "postal verification postcards"
847 case NotificationTopicAction.general__new_blog_post: 847 ↛ 849line 847 didn't jump to line 849 because the pattern on line 847 always matched
848 return "new blog post alerts"
849 case _:
850 raise ValueError(f"No topic-action unsubscribe text for {topic_action}")
853def get_topic_key_unsubscribe_text(topic_action: NotificationTopicAction) -> str:
854 assert can_unsubscribe_topic_key(topic_action)
855 # Not localized because the design will change so avoid useless work by translators.
856 match topic_action:
857 case NotificationTopicAction.chat__message: 857 ↛ 859line 857 didn't jump to line 859 because the pattern on line 857 always matched
858 return "this chat (mute)"
859 case _:
860 raise AssertionError(f"No topic-key description for {topic_action}")
863def get_email_footer(user: User, notification: Notification, loc_context: LocalizationContext) -> EmailFooter:
864 return EmailFooter(
865 timezone_name=loc_context.localized_timezone,
866 copyright_year=now().year,
867 unsubscribe_info=UnsubscribeInfo(
868 manage_notifications_url=urls.notification_settings_link(),
869 do_not_email_url=generate_do_not_email(user),
870 topic_action_link=UnsubscribeLink(
871 text=get_topic_action_unsubscribe_text(notification.topic_action),
872 url=generate_unsub_topic_action(notification),
873 ),
874 topic_key_link=UnsubscribeLink(
875 text=get_topic_key_unsubscribe_text(notification.topic_action),
876 url=generate_unsub_topic_key(notification),
877 )
878 if can_unsubscribe_topic_key(notification.topic_action)
879 else None,
880 )
881 if not notification.topic_action.is_critical
882 else None,
883 )
886@dataclass(frozen=True, slots=True, kw_only=True)
887class UserTemplateArgs:
888 """
889 A user's information for email template placeholders.
890 Allows decoupling from protocol buffer objects.
891 """
893 name: str
894 age: int
895 city: str
896 avatar_url: str
897 profile_url: str
899 @staticmethod
900 def from_protobuf_user(user: api_pb2.User) -> UserTemplateArgs:
901 return UserTemplateArgs(
902 name=user.name,
903 age=user.age,
904 city=user.city,
905 avatar_url=user.avatar_thumbnail_url or urls.icon_url(),
906 profile_url=urls.user_link(username=user.username),
907 )