settings: Add email notifications for the followed topics.

This commit makes it possible for users to control
the email notifications for messages sent to followed topics
via a global notification setting.
Although there is no support for configuring this setting
through the UI yet.

Add five new fields to the UserBaseSettings class for
the "followed topic notifications" feature, similar to
stream notifications. But this commit consists only of
the implementation of email notifications.
This commit is contained in:
Prakhar Pratyush 2023-05-17 19:31:16 +05:30 committed by Tim Abbott
parent e549c416db
commit 5e5538886f
24 changed files with 324 additions and 10 deletions

View File

@ -20,6 +20,13 @@ format used by the Zulip server that they are interacting with.
## Changes in Zulip 8.0 ## Changes in Zulip 8.0
**Feature level 189**
* [`PATCH /realm/user_settings_defaults`](/api/update-realm-user-settings-defaults),
[`POST /register`](/api/register-queue), [`PATCH /settings`](/api/update-settings):
Added `enable_followed_topic_email_notifications` field to control
email notifications for messages sent to followed topics.
**Feature level 188** **Feature level 188**
* [`POST /users/me/muted_users/{muted_user_id}`](/api/mute-user), * [`POST /users/me/muted_users/{muted_user_id}`](/api/mute-user),

View File

@ -33,6 +33,8 @@
{% trans %}You are receiving this because everyone was mentioned in #{{ stream_name }}.{% endtrans %}<br /> {% trans %}You are receiving this because everyone was mentioned in #{{ stream_name }}.{% endtrans %}<br />
{% elif stream_email_notify %} {% elif stream_email_notify %}
{% trans %}You are receiving this because you have email notifications enabled for #{{ stream_name }}.{% endtrans %}<br /> {% trans %}You are receiving this because you have email notifications enabled for #{{ stream_name }}.{% endtrans %}<br />
{% elif followed_topic_email_notify %}
{% trans %}You are receiving this because you have email notifications enabled for topics you follow.{% endtrans %}<br />
{% endif %} {% endif %}
{% if reply_to_zulip %} {% if reply_to_zulip %}
{% trans notif_url=realm_uri + "/#settings/notifications" %}Reply to this email directly, <a href="{{ narrow_url }}">view it in {{ realm_name }} Zulip</a>, or <a href="{{ notif_url }}">manage email preferences</a>.{% endtrans %} {% trans notif_url=realm_uri + "/#settings/notifications" %}Reply to this email directly, <a href="{{ narrow_url }}">view it in {{ realm_name }} Zulip</a>, or <a href="{{ notif_url }}">manage email preferences</a>.{% endtrans %}

View File

@ -1,7 +1,7 @@
{% if show_message_content %} {% if show_message_content %}
{% if group_pm %} {% trans %}Group DMs with {{ huddle_display_name }}{% endtrans %} {% if group_pm %} {% trans %}Group DMs with {{ huddle_display_name }}{% endtrans %}
{% elif private_message %} {% trans %}DMs with {{ sender_str }}{% endtrans %} {% elif private_message %} {% trans %}DMs with {{ sender_str }}{% endtrans %}
{% elif stream_email_notify or mention %} {% elif stream_email_notify or mention or followed_topic_email_notify %}
{# {#
Some email clients, like Gmail Web (as of 2022), will auto-thread Some email clients, like Gmail Web (as of 2022), will auto-thread
emails that share a subject and recipients, but will disregard emails that share a subject and recipients, but will disregard

View File

@ -29,6 +29,8 @@ See {{ alert_notif_url }} for more details.
{% trans %}You are receiving this because everyone was mentioned in #{{ stream_name }}.{% endtrans %} {% trans %}You are receiving this because everyone was mentioned in #{{ stream_name }}.{% endtrans %}
{% elif stream_email_notify %} {% elif stream_email_notify %}
{% trans %}You are receiving this because you have email notifications enabled for #{{ stream_name }}.{% endtrans %} {% trans %}You are receiving this because you have email notifications enabled for #{{ stream_name }}.{% endtrans %}
{% elif followed_topic_email_notify %}
{% trans %}You are receiving this because you have email notifications enabled for topics you follow.{% endtrans %}
{% endif %} {% endif %}
{% if reply_to_zulip %} {% if reply_to_zulip %}

View File

@ -473,6 +473,7 @@ def do_update_message(
event["pm_mention_email_disabled_user_ids"] = list(info.pm_mention_email_disabled_user_ids) event["pm_mention_email_disabled_user_ids"] = list(info.pm_mention_email_disabled_user_ids)
event["stream_push_user_ids"] = list(info.stream_push_user_ids) event["stream_push_user_ids"] = list(info.stream_push_user_ids)
event["stream_email_user_ids"] = list(info.stream_email_user_ids) event["stream_email_user_ids"] = list(info.stream_email_user_ids)
event["followed_topic_email_user_ids"] = list(info.followed_topic_email_user_ids)
event["muted_sender_user_ids"] = list(info.muted_sender_user_ids) event["muted_sender_user_ids"] = list(info.muted_sender_user_ids)
event["prior_mention_user_ids"] = list(prior_mention_user_ids) event["prior_mention_user_ids"] = list(prior_mention_user_ids)
event["presence_idle_user_ids"] = filter_presence_idle_user_ids(info.active_user_ids) event["presence_idle_user_ids"] = filter_presence_idle_user_ids(info.active_user_ids)

View File

@ -166,6 +166,7 @@ class RecipientInfoResult:
stream_email_user_ids: Set[int] stream_email_user_ids: Set[int]
stream_push_user_ids: Set[int] stream_push_user_ids: Set[int]
wildcard_mention_user_ids: Set[int] wildcard_mention_user_ids: Set[int]
followed_topic_email_user_ids: Set[int]
muted_sender_user_ids: Set[int] muted_sender_user_ids: Set[int]
um_eligible_user_ids: Set[int] um_eligible_user_ids: Set[int]
long_term_idle_user_ids: Set[int] long_term_idle_user_ids: Set[int]
@ -196,6 +197,7 @@ def get_recipient_info(
stream_push_user_ids: Set[int] = set() stream_push_user_ids: Set[int] = set()
stream_email_user_ids: Set[int] = set() stream_email_user_ids: Set[int] = set()
wildcard_mention_user_ids: Set[int] = set() wildcard_mention_user_ids: Set[int] = set()
followed_topic_email_user_ids: Set[int] = set()
muted_sender_user_ids: Set[int] = get_muting_users(sender_id) muted_sender_user_ids: Set[int] = get_muting_users(sender_id)
if recipient.type == Recipient.PERSONAL: if recipient.type == Recipient.PERSONAL:
@ -214,6 +216,7 @@ def get_recipient_info(
get_subscriptions_for_send_message( get_subscriptions_for_send_message(
realm_id=realm_id, realm_id=realm_id,
stream_id=stream_topic.stream_id, stream_id=stream_topic.stream_id,
topic_name=stream_topic.topic_name,
possible_wildcard_mention=possible_wildcard_mention, possible_wildcard_mention=possible_wildcard_mention,
possibly_mentioned_user_ids=possibly_mentioned_user_ids, possibly_mentioned_user_ids=possibly_mentioned_user_ids,
) )
@ -223,12 +226,16 @@ def get_recipient_info(
), ),
user_profile_push_notifications=F("user_profile__enable_stream_push_notifications"), user_profile_push_notifications=F("user_profile__enable_stream_push_notifications"),
user_profile_wildcard_mentions_notify=F("user_profile__wildcard_mentions_notify"), user_profile_wildcard_mentions_notify=F("user_profile__wildcard_mentions_notify"),
followed_topic_email_notifications=F(
"user_profile__enable_followed_topic_email_notifications"
),
) )
.values( .values(
"user_profile_id", "user_profile_id",
"push_notifications", "push_notifications",
"email_notifications", "email_notifications",
"wildcard_mentions_notify", "wildcard_mentions_notify",
"followed_topic_email_notifications",
"user_profile_email_notifications", "user_profile_email_notifications",
"user_profile_push_notifications", "user_profile_push_notifications",
"user_profile_wildcard_mentions_notify", "user_profile_wildcard_mentions_notify",
@ -257,6 +264,21 @@ def get_recipient_info(
stream_push_user_ids = notification_recipients("push_notifications") stream_push_user_ids = notification_recipients("push_notifications")
stream_email_user_ids = notification_recipients("email_notifications") stream_email_user_ids = notification_recipients("email_notifications")
def followed_topic_notification_recipients(setting: str) -> Set[int]:
return {
row["user_profile_id"]
for row in subscription_rows
if user_id_to_visibility_policy.get(
row["user_profile_id"], UserTopic.VisibilityPolicy.INHERIT
)
== UserTopic.VisibilityPolicy.FOLLOWED
and row["followed_topic_" + setting]
}
followed_topic_email_user_ids = followed_topic_notification_recipients(
"email_notifications"
)
if possible_wildcard_mention: if possible_wildcard_mention:
# We calculate `wildcard_mention_user_ids` only if there's a possible # We calculate `wildcard_mention_user_ids` only if there's a possible
# wildcard mention in the message. This is important so as to avoid # wildcard mention in the message. This is important so as to avoid
@ -381,6 +403,7 @@ def get_recipient_info(
stream_push_user_ids=stream_push_user_ids, stream_push_user_ids=stream_push_user_ids,
stream_email_user_ids=stream_email_user_ids, stream_email_user_ids=stream_email_user_ids,
wildcard_mention_user_ids=wildcard_mention_user_ids, wildcard_mention_user_ids=wildcard_mention_user_ids,
followed_topic_email_user_ids=followed_topic_email_user_ids,
muted_sender_user_ids=muted_sender_user_ids, muted_sender_user_ids=muted_sender_user_ids,
um_eligible_user_ids=um_eligible_user_ids, um_eligible_user_ids=um_eligible_user_ids,
long_term_idle_user_ids=long_term_idle_user_ids, long_term_idle_user_ids=long_term_idle_user_ids,
@ -564,6 +587,7 @@ def build_message_send_dict(
pm_mention_push_disabled_user_ids=info.pm_mention_push_disabled_user_ids, pm_mention_push_disabled_user_ids=info.pm_mention_push_disabled_user_ids,
stream_push_user_ids=info.stream_push_user_ids, stream_push_user_ids=info.stream_push_user_ids,
stream_email_user_ids=info.stream_email_user_ids, stream_email_user_ids=info.stream_email_user_ids,
followed_topic_email_user_ids=info.followed_topic_email_user_ids,
muted_sender_user_ids=info.muted_sender_user_ids, muted_sender_user_ids=info.muted_sender_user_ids,
um_eligible_user_ids=info.um_eligible_user_ids, um_eligible_user_ids=info.um_eligible_user_ids,
long_term_idle_user_ids=info.long_term_idle_user_ids, long_term_idle_user_ids=info.long_term_idle_user_ids,
@ -588,6 +612,7 @@ def create_user_messages(
stream_push_user_ids: AbstractSet[int], stream_push_user_ids: AbstractSet[int],
stream_email_user_ids: AbstractSet[int], stream_email_user_ids: AbstractSet[int],
mentioned_user_ids: AbstractSet[int], mentioned_user_ids: AbstractSet[int],
followed_topic_email_user_ids: AbstractSet[int],
mark_as_read_user_ids: Set[int], mark_as_read_user_ids: Set[int],
limit_unread_user_ids: Optional[Set[int]], limit_unread_user_ids: Optional[Set[int]],
scheduled_message_to_self: bool, scheduled_message_to_self: bool,
@ -648,6 +673,7 @@ def create_user_messages(
user_profile_id in long_term_idle_user_ids user_profile_id in long_term_idle_user_ids
and user_profile_id not in stream_push_user_ids and user_profile_id not in stream_push_user_ids
and user_profile_id not in stream_email_user_ids and user_profile_id not in stream_email_user_ids
and user_profile_id not in followed_topic_email_user_ids
and is_stream_message and is_stream_message
and int(flags) == 0 and int(flags) == 0
): ):
@ -761,6 +787,7 @@ def do_send_messages(
stream_push_user_ids=send_request.stream_push_user_ids, stream_push_user_ids=send_request.stream_push_user_ids,
stream_email_user_ids=send_request.stream_email_user_ids, stream_email_user_ids=send_request.stream_email_user_ids,
mentioned_user_ids=mentioned_user_ids, mentioned_user_ids=mentioned_user_ids,
followed_topic_email_user_ids=send_request.followed_topic_email_user_ids,
mark_as_read_user_ids=mark_as_read_user_ids, mark_as_read_user_ids=mark_as_read_user_ids,
limit_unread_user_ids=send_request.limit_unread_user_ids, limit_unread_user_ids=send_request.limit_unread_user_ids,
scheduled_message_to_self=scheduled_message_to_self, scheduled_message_to_self=scheduled_message_to_self,
@ -861,6 +888,7 @@ def do_send_messages(
stream_push_user_ids=send_request.stream_push_user_ids, stream_push_user_ids=send_request.stream_push_user_ids,
stream_email_user_ids=send_request.stream_email_user_ids, stream_email_user_ids=send_request.stream_email_user_ids,
wildcard_mention_user_ids=send_request.wildcard_mention_user_ids, wildcard_mention_user_ids=send_request.wildcard_mention_user_ids,
followed_topic_email_user_ids=send_request.followed_topic_email_user_ids,
muted_sender_user_ids=send_request.muted_sender_user_ids, muted_sender_user_ids=send_request.muted_sender_user_ids,
all_bot_user_ids=send_request.all_bot_user_ids, all_bot_user_ids=send_request.all_bot_user_ids,
) )
@ -886,6 +914,7 @@ def do_send_messages(
stream_push_user_ids=list(send_request.stream_push_user_ids), stream_push_user_ids=list(send_request.stream_push_user_ids),
stream_email_user_ids=list(send_request.stream_email_user_ids), stream_email_user_ids=list(send_request.stream_email_user_ids),
wildcard_mention_user_ids=list(send_request.wildcard_mention_user_ids), wildcard_mention_user_ids=list(send_request.wildcard_mention_user_ids),
followed_topic_email_user_ids=list(send_request.followed_topic_email_user_ids),
muted_sender_user_ids=list(send_request.muted_sender_user_ids), muted_sender_user_ids=list(send_request.muted_sender_user_ids),
all_bot_user_ids=list(send_request.all_bot_user_ids), all_bot_user_ids=list(send_request.all_bot_user_ids),
disable_external_notifications=send_request.disable_external_notifications, disable_external_notifications=send_request.disable_external_notifications,

View File

@ -457,6 +457,7 @@ def do_send_missedmessage_events_reply_in_zulip(
personal_mentioned=personal_mentioned, personal_mentioned=personal_mentioned,
wildcard_mentioned="wildcard_mentioned" in unique_triggers, wildcard_mentioned="wildcard_mentioned" in unique_triggers,
stream_email_notify="stream_email_notify" in unique_triggers, stream_email_notify="stream_email_notify" in unique_triggers,
followed_topic_email_notify="followed_topic_email_notify" in unique_triggers,
mention_count=triggers.count("mentioned") + triggers.count("wildcard_mentioned"), mention_count=triggers.count("mentioned") + triggers.count("wildcard_mentioned"),
mentioned_user_group_name=mentioned_user_group_name, mentioned_user_group_name=mentioned_user_group_name,
) )
@ -508,7 +509,11 @@ def do_send_missedmessage_events_reply_in_zulip(
context.update(huddle_display_name=huddle_display_name) context.update(huddle_display_name=huddle_display_name)
elif missed_messages[0]["message"].recipient.type == Recipient.PERSONAL: elif missed_messages[0]["message"].recipient.type == Recipient.PERSONAL:
context.update(private_message=True) context.update(private_message=True)
elif context["mention"] or context["stream_email_notify"]: elif (
context["mention"]
or context["stream_email_notify"]
or context["followed_topic_email_notify"]
):
# Keep only the senders who actually mentioned the user # Keep only the senders who actually mentioned the user
if context["mention"]: if context["mention"]:
senders = list( senders = list(

View File

@ -156,6 +156,8 @@ class SendMessageRequest:
pm_mention_email_disabled_user_ids: Set[int] pm_mention_email_disabled_user_ids: Set[int]
stream_push_user_ids: Set[int] stream_push_user_ids: Set[int]
stream_email_user_ids: Set[int] stream_email_user_ids: Set[int]
# IDs of users who have followed the topic the message is being sent to, and have the followed topic email notifications setting ON.
followed_topic_email_user_ids: Set[int]
muted_sender_user_ids: Set[int] muted_sender_user_ids: Set[int]
um_eligible_user_ids: Set[int] um_eligible_user_ids: Set[int]
long_term_idle_user_ids: Set[int] long_term_idle_user_ids: Set[int]

View File

@ -19,15 +19,20 @@ class UserMessageNotificationsData:
wildcard_mention_push_notify: bool wildcard_mention_push_notify: bool
stream_push_notify: bool stream_push_notify: bool
stream_email_notify: bool stream_email_notify: bool
followed_topic_email_notify: bool
sender_is_muted: bool sender_is_muted: bool
disable_external_notifications: bool disable_external_notifications: bool
def __post_init__(self) -> None: def __post_init__(self) -> None:
# Check that there's no dubious data. # Check that there's no dubious data.
if self.pm_email_notify or self.pm_push_notify: if self.pm_email_notify or self.pm_push_notify:
assert not (self.stream_email_notify or self.stream_push_notify) assert not (
self.stream_email_notify
or self.stream_push_notify
or self.followed_topic_email_notify
)
if self.stream_email_notify or self.stream_push_notify: if self.stream_email_notify or self.stream_push_notify or self.followed_topic_email_notify:
assert not (self.pm_email_notify or self.pm_push_notify) assert not (self.pm_email_notify or self.pm_push_notify)
@classmethod @classmethod
@ -44,6 +49,7 @@ class UserMessageNotificationsData:
stream_push_user_ids: Set[int], stream_push_user_ids: Set[int],
stream_email_user_ids: Set[int], stream_email_user_ids: Set[int],
wildcard_mention_user_ids: Set[int], wildcard_mention_user_ids: Set[int],
followed_topic_email_user_ids: Set[int],
muted_sender_user_ids: Set[int], muted_sender_user_ids: Set[int],
all_bot_user_ids: Set[int], all_bot_user_ids: Set[int],
) -> "UserMessageNotificationsData": ) -> "UserMessageNotificationsData":
@ -60,6 +66,7 @@ class UserMessageNotificationsData:
online_push_enabled=False, online_push_enabled=False,
stream_push_notify=False, stream_push_notify=False,
stream_email_notify=False, stream_email_notify=False,
followed_topic_email_notify=False,
sender_is_muted=False, sender_is_muted=False,
disable_external_notifications=False, disable_external_notifications=False,
) )
@ -97,6 +104,7 @@ class UserMessageNotificationsData:
online_push_enabled=(user_id in online_push_user_ids), online_push_enabled=(user_id in online_push_user_ids),
stream_push_notify=(user_id in stream_push_user_ids), stream_push_notify=(user_id in stream_push_user_ids),
stream_email_notify=(user_id in stream_email_user_ids), stream_email_notify=(user_id in stream_email_user_ids),
followed_topic_email_notify=(user_id in followed_topic_email_user_ids),
sender_is_muted=(user_id in muted_sender_user_ids), sender_is_muted=(user_id in muted_sender_user_ids),
disable_external_notifications=disable_external_notifications, disable_external_notifications=disable_external_notifications,
) )
@ -168,6 +176,8 @@ class UserMessageNotificationsData:
return NotificationTriggers.MENTION return NotificationTriggers.MENTION
elif self.wildcard_mention_email_notify: elif self.wildcard_mention_email_notify:
return NotificationTriggers.WILDCARD_MENTION return NotificationTriggers.WILDCARD_MENTION
elif self.followed_topic_email_notify:
return NotificationTriggers.FOLLOWED_TOPIC_EMAIL
elif self.stream_email_notify: elif self.stream_email_notify:
return NotificationTriggers.STREAM_EMAIL return NotificationTriggers.STREAM_EMAIL
else: else:

View File

@ -7,7 +7,7 @@ from typing import AbstractSet, Any, Collection, Dict, List, Optional, Set
from django.db.models import Q, QuerySet from django.db.models import Q, QuerySet
from django_stubs_ext import ValuesQuerySet from django_stubs_ext import ValuesQuerySet
from zerver.models import AlertWord, Realm, Recipient, Stream, Subscription, UserProfile from zerver.models import AlertWord, Realm, Recipient, Stream, Subscription, UserProfile, UserTopic
@dataclass @dataclass
@ -305,6 +305,7 @@ def get_subscriptions_for_send_message(
*, *,
realm_id: int, realm_id: int,
stream_id: int, stream_id: int,
topic_name: str,
possible_wildcard_mention: bool, possible_wildcard_mention: bool,
possibly_mentioned_user_ids: AbstractSet[int], possibly_mentioned_user_ids: AbstractSet[int],
) -> QuerySet[Subscription]: ) -> QuerySet[Subscription]:
@ -356,5 +357,12 @@ def get_subscriptions_for_send_message(
"user_profile_id" "user_profile_id"
) )
) )
| Q(
user_profile_id__in=UserTopic.objects.filter(
stream_id=stream_id,
topic_name__iexact=topic_name,
visibility_policy=UserTopic.VisibilityPolicy.FOLLOWED,
).values_list("user_profile_id")
)
) )
return query return query

View File

@ -1729,6 +1729,7 @@ Output:
wildcard_mention_push_notify=kwargs.get("wildcard_mention_push_notify", False), wildcard_mention_push_notify=kwargs.get("wildcard_mention_push_notify", False),
stream_email_notify=kwargs.get("stream_email_notify", False), stream_email_notify=kwargs.get("stream_email_notify", False),
stream_push_notify=kwargs.get("stream_push_notify", False), stream_push_notify=kwargs.get("stream_push_notify", False),
followed_topic_email_notify=kwargs.get("followed_topic_email_notify", False),
sender_is_muted=kwargs.get("sender_is_muted", False), sender_is_muted=kwargs.get("sender_is_muted", False),
disable_external_notifications=kwargs.get("disable_external_notifications", False), disable_external_notifications=kwargs.get("disable_external_notifications", False),
) )

View File

@ -28,6 +28,7 @@ class Migration(migrations.Migration):
("mentioned", "Mention"), ("mentioned", "Mention"),
("wildcard_mentioned", "Wildcard mention"), ("wildcard_mentioned", "Wildcard mention"),
("stream_email_notify", "Stream notifications enabled"), ("stream_email_notify", "Stream notifications enabled"),
("followed_topic_email_notify", "Followed topic notifications enabled"),
] ]
), ),
), ),

View File

@ -0,0 +1,62 @@
# Generated by Django 4.2.1 on 2023-05-24 11:09
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("zerver", "0452_realmauditlog_extra_data_json"),
]
operations = [
migrations.AddField(
model_name="realmuserdefault",
name="enable_followed_topic_audible_notifications",
field=models.BooleanField(default=True),
),
migrations.AddField(
model_name="realmuserdefault",
name="enable_followed_topic_desktop_notifications",
field=models.BooleanField(default=True),
),
migrations.AddField(
model_name="realmuserdefault",
name="enable_followed_topic_email_notifications",
field=models.BooleanField(default=True),
),
migrations.AddField(
model_name="realmuserdefault",
name="enable_followed_topic_push_notifications",
field=models.BooleanField(default=True),
),
migrations.AddField(
model_name="realmuserdefault",
name="enable_followed_topic_wildcard_mentions_notify",
field=models.BooleanField(default=True),
),
migrations.AddField(
model_name="userprofile",
name="enable_followed_topic_audible_notifications",
field=models.BooleanField(default=True),
),
migrations.AddField(
model_name="userprofile",
name="enable_followed_topic_desktop_notifications",
field=models.BooleanField(default=True),
),
migrations.AddField(
model_name="userprofile",
name="enable_followed_topic_email_notifications",
field=models.BooleanField(default=True),
),
migrations.AddField(
model_name="userprofile",
name="enable_followed_topic_push_notifications",
field=models.BooleanField(default=True),
),
migrations.AddField(
model_name="userprofile",
name="enable_followed_topic_wildcard_mentions_notify",
field=models.BooleanField(default=True),
),
]

View File

@ -1589,6 +1589,13 @@ class UserBaseSettings(models.Model):
notification_sound = models.CharField(max_length=20, default="zulip") notification_sound = models.CharField(max_length=20, default="zulip")
wildcard_mentions_notify = models.BooleanField(default=True) wildcard_mentions_notify = models.BooleanField(default=True)
# Followed Topics notifications.
enable_followed_topic_desktop_notifications = models.BooleanField(default=True)
enable_followed_topic_email_notifications = models.BooleanField(default=True)
enable_followed_topic_push_notifications = models.BooleanField(default=True)
enable_followed_topic_audible_notifications = models.BooleanField(default=True)
enable_followed_topic_wildcard_mentions_notify = models.BooleanField(default=True)
# PM + @-mention notifications. # PM + @-mention notifications.
enable_desktop_notifications = models.BooleanField(default=True) enable_desktop_notifications = models.BooleanField(default=True)
pm_content_in_desktop_notifications = models.BooleanField(default=True) pm_content_in_desktop_notifications = models.BooleanField(default=True)
@ -1716,6 +1723,7 @@ class UserBaseSettings(models.Model):
modern_notification_settings: Dict[str, Any] = dict( modern_notification_settings: Dict[str, Any] = dict(
# Add new notification settings here. # Add new notification settings here.
enable_followed_topic_email_notifications=bool,
) )
notification_setting_types = { notification_setting_types = {
@ -4276,6 +4284,7 @@ class NotificationTriggers:
WILDCARD_MENTION = "wildcard_mentioned" WILDCARD_MENTION = "wildcard_mentioned"
STREAM_PUSH = "stream_push_notify" STREAM_PUSH = "stream_push_notify"
STREAM_EMAIL = "stream_email_notify" STREAM_EMAIL = "stream_email_notify"
FOLLOWED_TOPIC_EMAIL = "followed_topic_email_notify"
class ScheduledMessageNotificationEmail(models.Model): class ScheduledMessageNotificationEmail(models.Model):
@ -4293,6 +4302,7 @@ class ScheduledMessageNotificationEmail(models.Model):
(NotificationTriggers.MENTION, "Mention"), (NotificationTriggers.MENTION, "Mention"),
(NotificationTriggers.WILDCARD_MENTION, "Wildcard mention"), (NotificationTriggers.WILDCARD_MENTION, "Wildcard mention"),
(NotificationTriggers.STREAM_EMAIL, "Stream notifications enabled"), (NotificationTriggers.STREAM_EMAIL, "Stream notifications enabled"),
(NotificationTriggers.FOLLOWED_TOPIC_EMAIL, "Followed topic notifications enabled"),
] ]
trigger = models.TextField(choices=EMAIL_NOTIFICATION_TRIGGER_CHOICES) trigger = models.TextField(choices=EMAIL_NOTIFICATION_TRIGGER_CHOICES)

View File

@ -9748,6 +9748,15 @@ paths:
schema: schema:
type: boolean type: boolean
example: true example: true
- name: enable_followed_topic_email_notifications
in: query
description: |
Enable email notifications for messages sent to followed topics.
**Changes**: New in Zulip 8.0 (feature level 189).
schema:
type: boolean
example: true
- name: email_notifications_batching_period_seconds - name: email_notifications_batching_period_seconds
in: query in: query
description: | description: |
@ -11840,6 +11849,12 @@ paths:
description: | description: |
Enable audible desktop notifications for private messages and Enable audible desktop notifications for private messages and
@-mentions. @-mentions.
enable_followed_topic_email_notifications:
type: boolean
description: |
Enable email notifications for messages sent to followed topics.
**Changes**: New in Zulip 8.0 (feature level 189).
email_notifications_batching_period_seconds: email_notifications_batching_period_seconds:
type: integer type: integer
description: | description: |
@ -13900,6 +13915,12 @@ paths:
description: | description: |
Enable mobile notification for private messages and @-mentions received Enable mobile notification for private messages and @-mentions received
when the user is online. when the user is online.
enable_followed_topic_email_notifications:
type: boolean
description: |
Enable email notifications for messages sent to followed topics.
**Changes**: New in Zulip 8.0 (feature level 189).
enable_digest_emails: enable_digest_emails:
type: boolean type: boolean
description: | description: |
@ -15033,6 +15054,15 @@ paths:
schema: schema:
type: boolean type: boolean
example: true example: true
- name: enable_followed_topic_email_notifications
in: query
description: |
Enable email notifications for messages sent to followed topics.
**Changes**: New in Zulip 8.0 (feature level 189).
schema:
type: boolean
example: true
- name: enable_digest_emails - name: enable_digest_emails
in: query in: query
description: | description: |

View File

@ -1851,6 +1851,26 @@ class TestMissedMessages(ZulipTestCase):
], ],
) )
def test_followed_topic_missed_message(self) -> None:
hamlet = self.example_user("hamlet")
othello = self.example_user("othello")
msg_id = self.send_stream_message(othello, "Denmark")
handle_missedmessage_emails(
hamlet.id,
[
{"message_id": msg_id, "trigger": "followed_topic_email_notify"},
],
)
self.assert_length(mail.outbox, 1)
email_subject = mail.outbox[0].subject
email_body = mail.outbox[0].body
self.assertEqual("#Denmark > test", email_subject)
self.assertIn(
"You are receiving this because you have email notifications enabled for topics you follow.",
email_body,
)
class TestFollowupEmailDelay(ZulipTestCase): class TestFollowupEmailDelay(ZulipTestCase):
def test_get_onboarding_email_schedule(self) -> None: def test_get_onboarding_email_schedule(self) -> None:

View File

@ -760,6 +760,56 @@ class MissedMessageHookTest(ZulipTestCase):
already_notified={"email_notified": False, "push_notified": False}, already_notified={"email_notified": False, "push_notified": False},
) )
def test_followed_topic_email_notify(self) -> None:
# By default, messages sent in followed topics should send email notifications.
do_set_user_topic_visibility_policy(
self.user_profile,
get_stream("Denmark", self.user_profile.realm),
"followed_topic_test",
visibility_policy=UserTopic.VisibilityPolicy.FOLLOWED,
)
msg_id = self.send_stream_message(
self.iago, "Denmark", content="what's up everyone?", topic_name="followed_topic_test"
)
with mock.patch("zerver.tornado.event_queue.maybe_enqueue_notifications") as mock_enqueue:
missedmessage_hook(self.user_profile.id, self.client_descriptor, True)
mock_enqueue.assert_called_once()
args_dict = mock_enqueue.call_args_list[0][1]
self.assert_maybe_enqueue_notifications_call_args(
args_dict=args_dict,
message_id=msg_id,
user_id=self.user_profile.id,
followed_topic_email_notify=True,
already_notified={"email_notified": True, "push_notified": False},
)
def test_followed_topic_email_notify_global_setting(self) -> None:
do_change_user_setting(
self.user_profile, "enable_followed_topic_email_notifications", False, acting_user=None
)
do_set_user_topic_visibility_policy(
self.user_profile,
get_stream("Denmark", self.user_profile.realm),
"followed_topic_test",
visibility_policy=UserTopic.VisibilityPolicy.FOLLOWED,
)
msg_id = self.send_stream_message(
self.iago, "Denmark", content="what's up everyone?", topic_name="followed_topic_test"
)
with mock.patch("zerver.tornado.event_queue.maybe_enqueue_notifications") as mock_enqueue:
missedmessage_hook(self.user_profile.id, self.client_descriptor, True)
mock_enqueue.assert_called_once()
args_dict = mock_enqueue.call_args_list[0][1]
self.assert_maybe_enqueue_notifications_call_args(
args_dict=args_dict,
message_id=msg_id,
user_id=self.user_profile.id,
followed_topic_email_notify=False,
already_notified={"email_notified": False, "push_notified": False},
)
def test_muted_sender(self) -> None: def test_muted_sender(self) -> None:
do_mute_user(self.user_profile, self.iago) do_mute_user(self.user_profile, self.iago)
msg_id = self.send_personal_message(self.iago, self.user_profile) msg_id = self.send_personal_message(self.iago, self.user_profile)

View File

@ -1946,6 +1946,17 @@ class NormalActionsTest(BaseAction):
self.user_profile, notification_setting, False, acting_user=self.user_profile self.user_profile, notification_setting, False, acting_user=self.user_profile
) )
num_events = 2
is_modern_notification_setting = (
notification_setting in self.user_profile.modern_notification_settings
)
if is_modern_notification_setting:
# The legacy event format is not sent for modern_notification_settings
# as it exists only for backwards-compatibility with
# clients that don't support the new user_settings event type.
# We only send the legacy event for settings added before Feature level 89.
num_events = 1
for setting_value in [True, False]: for setting_value in [True, False]:
events = self.verify_action( events = self.verify_action(
lambda: do_change_user_setting( lambda: do_change_user_setting(
@ -1954,10 +1965,11 @@ class NormalActionsTest(BaseAction):
setting_value, setting_value,
acting_user=self.user_profile, acting_user=self.user_profile,
), ),
num_events=2, num_events=num_events,
) )
check_user_settings_update("events[0]", events[0]) check_user_settings_update("events[0]", events[0])
check_update_global_notifications("events[1]", events[1], setting_value) if not is_modern_notification_setting:
check_update_global_notifications("events[1]", events[1], setting_value)
# Also test with notification_settings_null=True # Also test with notification_settings_null=True
events = self.verify_action( events = self.verify_action(
@ -1969,10 +1981,11 @@ class NormalActionsTest(BaseAction):
), ),
notification_settings_null=True, notification_settings_null=True,
state_change_expected=False, state_change_expected=False,
num_events=2, num_events=num_events,
) )
check_user_settings_update("events[0]", events[0]) check_user_settings_update("events[0]", events[0])
check_update_global_notifications("events[1]", events[1], setting_value) if not is_modern_notification_setting:
check_update_global_notifications("events[1]", events[1], setting_value)
def test_change_presence_enabled(self) -> None: def test_change_presence_enabled(self) -> None:
presence_enabled_setting = "presence_enabled" presence_enabled_setting = "presence_enabled"

View File

@ -182,6 +182,16 @@ class TestNotificationData(ZulipTestCase):
) )
self.assertTrue(user_data.is_email_notifiable(acting_user_id=acting_user_id, idle=True)) self.assertTrue(user_data.is_email_notifiable(acting_user_id=acting_user_id, idle=True))
# Followed Topic notification
user_data = self.create_user_notifications_data_object(
user_id=user_id, followed_topic_email_notify=True
)
self.assertEqual(
user_data.get_email_notification_trigger(acting_user_id=acting_user_id, idle=True),
"followed_topic_email_notify",
)
self.assertTrue(user_data.is_email_notifiable(acting_user_id=acting_user_id, idle=True))
# Test no notifications when not idle # Test no notifications when not idle
user_data = self.create_user_notifications_data_object( user_data = self.create_user_notifications_data_object(
user_id=user_id, pm_email_notify=True user_id=user_id, pm_email_notify=True
@ -267,6 +277,7 @@ class TestNotificationData(ZulipTestCase):
stream_email_user_ids=set(), stream_email_user_ids=set(),
stream_push_user_ids=set(), stream_push_user_ids=set(),
wildcard_mention_user_ids=set(), wildcard_mention_user_ids=set(),
followed_topic_email_user_ids=set(),
) )
self.assertEqual(user_data.is_notifiable(acting_user_id=1000, idle=True), notifiable) self.assertEqual(user_data.is_notifiable(acting_user_id=1000, idle=True), notifiable)

View File

@ -631,6 +631,7 @@ class SoftDeactivationMessageTest(ZulipTestCase):
get_subscriptions_for_send_message( get_subscriptions_for_send_message(
realm_id=realm_id, realm_id=realm_id,
stream_id=stream_id, stream_id=stream_id,
topic_name=topic_name,
possible_wildcard_mention=possible_wildcard_mention, possible_wildcard_mention=possible_wildcard_mention,
possibly_mentioned_user_ids=possibly_mentioned_user_ids, possibly_mentioned_user_ids=possibly_mentioned_user_ids,
) )

View File

@ -1763,6 +1763,7 @@ class RecipientInfoTest(ZulipTestCase):
stream_push_user_ids=set(), stream_push_user_ids=set(),
stream_email_user_ids=set(), stream_email_user_ids=set(),
wildcard_mention_user_ids=set(), wildcard_mention_user_ids=set(),
followed_topic_email_user_ids=set(),
muted_sender_user_ids=set(), muted_sender_user_ids=set(),
um_eligible_user_ids=all_user_ids, um_eligible_user_ids=all_user_ids,
long_term_idle_user_ids=set(), long_term_idle_user_ids=set(),
@ -1979,6 +1980,39 @@ class RecipientInfoTest(ZulipTestCase):
self.assertEqual(info.default_bot_user_ids, {normal_bot.id}) self.assertEqual(info.default_bot_user_ids, {normal_bot.id})
self.assertEqual(info.all_bot_user_ids, {normal_bot.id, service_bot.id}) self.assertEqual(info.all_bot_user_ids, {normal_bot.id, service_bot.id})
# Now Hamlet follows the topic with the 'followed_topic_email_notifications'
# global setting enabled by default.
do_set_user_topic_visibility_policy(
hamlet,
stream,
topic_name,
visibility_policy=UserTopic.VisibilityPolicy.FOLLOWED,
)
info = get_recipient_info(
realm_id=realm.id,
recipient=recipient,
sender_id=hamlet.id,
stream_topic=stream_topic,
)
self.assertEqual(info.followed_topic_email_user_ids, {hamlet.id})
# Omit Hamlet from followed_topic_email_user_ids
do_change_user_setting(
hamlet,
"enable_followed_topic_email_notifications",
False,
acting_user=None,
)
info = get_recipient_info(
realm_id=realm.id,
recipient=recipient,
sender_id=hamlet.id,
stream_topic=stream_topic,
)
self.assertEqual(info.followed_topic_email_user_ids, set())
def test_get_recipient_info_invalid_recipient_type(self) -> None: def test_get_recipient_info_invalid_recipient_type(self) -> None:
hamlet = self.example_user("hamlet") hamlet = self.example_user("hamlet")
realm = hamlet.realm realm = hamlet.realm

View File

@ -780,6 +780,7 @@ def missedmessage_hook(
wildcard_mention_email_notify=internal_data.get("wildcard_mention_email_notify", False), wildcard_mention_email_notify=internal_data.get("wildcard_mention_email_notify", False),
stream_push_notify=internal_data.get("stream_push_notify", False), stream_push_notify=internal_data.get("stream_push_notify", False),
stream_email_notify=internal_data.get("stream_email_notify", False), stream_email_notify=internal_data.get("stream_email_notify", False),
followed_topic_email_notify=internal_data.get("followed_topic_email_notify", False),
# Since one is by definition idle, we don't need to check online_push_enabled # Since one is by definition idle, we don't need to check online_push_enabled
online_push_enabled=False, online_push_enabled=False,
disable_external_notifications=internal_data.get( disable_external_notifications=internal_data.get(
@ -933,6 +934,7 @@ def process_message_event(
stream_push_user_ids = set(event_template.get("stream_push_user_ids", [])) stream_push_user_ids = set(event_template.get("stream_push_user_ids", []))
stream_email_user_ids = set(event_template.get("stream_email_user_ids", [])) stream_email_user_ids = set(event_template.get("stream_email_user_ids", []))
wildcard_mention_user_ids = set(event_template.get("wildcard_mention_user_ids", [])) wildcard_mention_user_ids = set(event_template.get("wildcard_mention_user_ids", []))
followed_topic_email_user_ids = set(event_template.get("followed_topic_email_user_ids", []))
muted_sender_user_ids = set(event_template.get("muted_sender_user_ids", [])) muted_sender_user_ids = set(event_template.get("muted_sender_user_ids", []))
all_bot_user_ids = set(event_template.get("all_bot_user_ids", [])) all_bot_user_ids = set(event_template.get("all_bot_user_ids", []))
disable_external_notifications = event_template.get("disable_external_notifications", False) disable_external_notifications = event_template.get("disable_external_notifications", False)
@ -983,6 +985,7 @@ def process_message_event(
stream_push_user_ids=stream_push_user_ids, stream_push_user_ids=stream_push_user_ids,
stream_email_user_ids=stream_email_user_ids, stream_email_user_ids=stream_email_user_ids,
wildcard_mention_user_ids=wildcard_mention_user_ids, wildcard_mention_user_ids=wildcard_mention_user_ids,
followed_topic_email_user_ids=followed_topic_email_user_ids,
muted_sender_user_ids=muted_sender_user_ids, muted_sender_user_ids=muted_sender_user_ids,
all_bot_user_ids=all_bot_user_ids, all_bot_user_ids=all_bot_user_ids,
) )
@ -1134,6 +1137,7 @@ def process_message_update_event(
stream_push_user_ids = set(event_template.pop("stream_push_user_ids", [])) stream_push_user_ids = set(event_template.pop("stream_push_user_ids", []))
stream_email_user_ids = set(event_template.pop("stream_email_user_ids", [])) stream_email_user_ids = set(event_template.pop("stream_email_user_ids", []))
wildcard_mention_user_ids = set(event_template.pop("wildcard_mention_user_ids", [])) wildcard_mention_user_ids = set(event_template.pop("wildcard_mention_user_ids", []))
followed_topic_email_user_ids = set(event_template.pop("followed_topic_email_user_ids", []))
muted_sender_user_ids = set(event_template.pop("muted_sender_user_ids", [])) muted_sender_user_ids = set(event_template.pop("muted_sender_user_ids", []))
all_bot_user_ids = set(event_template.pop("all_bot_user_ids", [])) all_bot_user_ids = set(event_template.pop("all_bot_user_ids", []))
disable_external_notifications = event_template.pop("disable_external_notifications", False) disable_external_notifications = event_template.pop("disable_external_notifications", False)
@ -1194,6 +1198,7 @@ def process_message_update_event(
stream_push_user_ids=stream_push_user_ids, stream_push_user_ids=stream_push_user_ids,
stream_email_user_ids=stream_email_user_ids, stream_email_user_ids=stream_email_user_ids,
wildcard_mention_user_ids=wildcard_mention_user_ids, wildcard_mention_user_ids=wildcard_mention_user_ids,
followed_topic_email_user_ids=followed_topic_email_user_ids,
muted_sender_user_ids=muted_sender_user_ids, muted_sender_user_ids=muted_sender_user_ids,
all_bot_user_ids=all_bot_user_ids, all_bot_user_ids=all_bot_user_ids,
) )
@ -1266,7 +1271,11 @@ def maybe_enqueue_notifications_for_message_update(
# without extending the UserMessage data model. # without extending the UserMessage data model.
return return
if user_notifications_data.stream_push_notify or user_notifications_data.stream_email_notify: if (
user_notifications_data.stream_push_notify
or user_notifications_data.stream_email_notify
or user_notifications_data.followed_topic_email_notify
):
# Currently we assume that if this flag is set to True, then # Currently we assume that if this flag is set to True, then
# the user already was notified about the earlier message, # the user already was notified about the earlier message,
# so we short circuit. We may handle this more rigorously # so we short circuit. We may handle this more rigorously

View File

@ -426,6 +426,9 @@ def update_realm_user_settings_defaults(
json_validator=check_bool, default=None json_validator=check_bool, default=None
), ),
wildcard_mentions_notify: Optional[bool] = REQ(json_validator=check_bool, default=None), wildcard_mentions_notify: Optional[bool] = REQ(json_validator=check_bool, default=None),
enable_followed_topic_email_notifications: Optional[bool] = REQ(
json_validator=check_bool, default=None
),
notification_sound: Optional[str] = REQ(default=None), notification_sound: Optional[str] = REQ(default=None),
enable_desktop_notifications: Optional[bool] = REQ(json_validator=check_bool, default=None), enable_desktop_notifications: Optional[bool] = REQ(json_validator=check_bool, default=None),
enable_sounds: Optional[bool] = REQ(json_validator=check_bool, default=None), enable_sounds: Optional[bool] = REQ(json_validator=check_bool, default=None),

View File

@ -193,6 +193,9 @@ def json_change_settings(
json_validator=check_bool, default=None json_validator=check_bool, default=None
), ),
wildcard_mentions_notify: Optional[bool] = REQ(json_validator=check_bool, default=None), wildcard_mentions_notify: Optional[bool] = REQ(json_validator=check_bool, default=None),
enable_followed_topic_email_notifications: Optional[bool] = REQ(
json_validator=check_bool, default=None
),
notification_sound: Optional[str] = REQ(default=None), notification_sound: Optional[str] = REQ(default=None),
enable_desktop_notifications: Optional[bool] = REQ(json_validator=check_bool, default=None), enable_desktop_notifications: Optional[bool] = REQ(json_validator=check_bool, default=None),
enable_sounds: Optional[bool] = REQ(json_validator=check_bool, default=None), enable_sounds: Optional[bool] = REQ(json_validator=check_bool, default=None),