Coverage for app/backend/src/couchers/notifications/quick_links.py: 89%
32 statements
« prev ^ index » next coverage.py v7.14.2, created at 2026-06-21 09:29 +0000
« prev ^ index » next coverage.py v7.14.2, created at 2026-06-21 09:29 +0000
1"""
2Quick email actions, based on signed URLs that don't require login to submit, such as unsubscribe, quick decline
4It is called "unsubscribe" in some places for historical reasons (it was the first use case).
5"""
7import logging
9import grpc
10from google.protobuf.message import Message
12from couchers import urls
13from couchers.context import CouchersContext
14from couchers.crypto import UNSUBSCRIBE_KEY_NAME, b64encode, generate_hash_signature, get_secret, verify_hash_signature
15from couchers.models import (
16 Notification,
17 NotificationTopicAction,
18 User,
19)
20from couchers.proto import requests_pb2
21from couchers.proto.internal import unsubscribe_pb2
22from couchers.utils import now
24logger = logging.getLogger(__name__)
27def _generate_quick_link(payload: Message) -> str:
28 payload.created.FromDatetime(now()) # type: ignore[attr-defined]
29 msg = payload.SerializeToString()
30 sig = generate_hash_signature(message=msg, key=get_secret(UNSUBSCRIBE_KEY_NAME))
31 return urls.quick_link(payload=b64encode(msg), sig=b64encode(sig))
34def decode_quick_link(payload: bytes, sig: bytes, context: CouchersContext) -> unsubscribe_pb2.UnsubscribePayload:
35 if not verify_hash_signature(message=payload, key=get_secret(UNSUBSCRIBE_KEY_NAME), sig=sig): 35 ↛ 36line 35 didn't jump to line 36 because the condition on line 35 was never true
36 context.abort_with_error_code(grpc.StatusCode.PERMISSION_DENIED, "wrong_signature")
38 return unsubscribe_pb2.UnsubscribePayload.FromString(payload)
41def generate_do_not_email(user: User) -> str:
42 return _generate_quick_link(
43 unsubscribe_pb2.UnsubscribePayload(
44 user_id=user.id,
45 do_not_email=unsubscribe_pb2.DoNotEmail(),
46 )
47 )
50def generate_unsub_topic_key(notification: Notification) -> str:
51 if not notification.key: 51 ↛ 52line 51 didn't jump to line 52 because the condition on line 51 was never true
52 raise ValueError(
53 f"Cannot generate topic_key unsubscribe link for notification with empty key "
54 f"(topic_action={notification.topic_action})"
55 )
56 return _generate_quick_link(
57 unsubscribe_pb2.UnsubscribePayload(
58 user_id=notification.user_id,
59 topic_key=unsubscribe_pb2.UnsubscribeTopicKey(
60 topic=notification.topic,
61 key=notification.key,
62 ),
63 )
64 )
67def generate_unsub_topic_action(notification: Notification) -> str:
68 return _generate_quick_link(
69 unsubscribe_pb2.UnsubscribePayload(
70 user_id=notification.user_id,
71 topic_action=unsubscribe_pb2.UnsubscribeTopicAction(
72 topic=notification.topic,
73 action=notification.action,
74 ),
75 )
76 )
79def generate_quick_decline_link(host_request: requests_pb2.HostRequest) -> str:
80 return _generate_quick_link(
81 unsubscribe_pb2.UnsubscribePayload(
82 user_id=host_request.host_user_id,
83 host_request_quick_decline=unsubscribe_pb2.HostRequestQuickDecline(
84 host_request_id=host_request.host_request_id,
85 ),
86 )
87 )
90def can_unsubscribe_topic_key(topic_action: NotificationTopicAction) -> bool:
91 """
92 Determines whether a user can unsubscribe from a specific topic key
93 (e.g. muting a specific chat).
94 """
95 # Only chat__message has a meaningful key (the chat ID); chat__missed_messages is a summary with no specific chat
96 return topic_action == NotificationTopicAction.chat__message