Repository: zulip/zulip

Issue:
Group email notifications for resolved/unresolved topics into the same thread
Resolving topics changes which thread messages are grouped into in email notifications, which is annoying for users. We should figure out how to fix this.

As [discussed on CZO](https://chat.zulip.org/#narrow/stream/137-feedback/topic/resolved.20topics.20in.20email.20digests/near/1407208), the first step is to investigate whether or not emails with the subject `[resolved] {original topic}` get grouped together with emails with the subject `{original topic}` by email clients. The results of this investigation should be posted on the [CZO discussion thread](https://chat.zulip.org/#narrow/stream/137-feedback/topic/resolved.20topics.20in.20email.20digests).


Discussion:
Hello @zulip/server-development members, this issue was labeled with the "area: emails" label, so you may want to check it out!

<!-- areaLabelAddition -->

Updated the CZO thread over the past couple evenings with some investigations; at this point I have a test case written so I think it's sane to

@zulipbot claim
Welcome to Zulip, @klardotsh! We just sent you an invite to collaborate on this repository at https://github.com/zulip/zulip/invitations. Please accept this invite in order to claim this issue and begin a fun, rewarding experience contributing to Zulip!

Here's some tips to get you off to a good start:

- Join me on the [Zulip developers' server](https://chat.zulip.org), to get help, chat about this issue, and meet the other developers.
- [Unwatch this repository](https://help.github.com/articles/unwatching-repositories/), so that you don't get 100 emails a day.

As you work on this issue, you'll also want to refer to the [Zulip code contribution guide](https://zulip.readthedocs.io/en/latest/contributing/index.html), as well as the rest of the developer documentation on that site.

See you on the other side (that is, the pull request side)!


Resolving patch:
diff --git a/zerver/lib/email_notifications.py b/zerver/lib/email_notifications.py
--- a/zerver/lib/email_notifications.py
+++ b/zerver/lib/email_notifications.py
@@ -27,6 +27,7 @@
 from zerver.lib.queue import queue_json_publish
 from zerver.lib.send_email import FromAddress, send_future_email
 from zerver.lib.soft_deactivation import soft_reactivate_if_personal_notification
+from zerver.lib.topic import get_topic_resolution_and_bare_name
 from zerver.lib.types import DisplayRecipientT
 from zerver.lib.url_encoding import (
     huddle_narrow_url,
@@ -481,10 +482,11 @@ def do_send_missedmessage_events_reply_in_zulip(
             )
         message = missed_messages[0]["message"]
         stream = Stream.objects.only("id", "name").get(id=message.recipient.type_id)
-        stream_header = f"{stream.name} > {message.topic_name()}"
+        topic_resolved, topic_name = get_topic_resolution_and_bare_name(message.topic_name())
         context.update(
             stream_name=stream.name,
-            stream_header=stream_header,
+            topic_name=topic_name,
+            topic_resolved=topic_resolved,
         )
     else:
         raise AssertionError("Invalid messages!")
diff --git a/zerver/lib/topic.py b/zerver/lib/topic.py
--- a/zerver/lib/topic.py
+++ b/zerver/lib/topic.py
@@ -270,3 +270,17 @@ def get_topic_history_for_stream(
     cursor.close()
 
     return generate_topic_history_from_db_rows(rows)
+
+
+def get_topic_resolution_and_bare_name(stored_name: str) -> Tuple[bool, str]:
+    """
+    Resolved topics are denoted only by a title change, not by a boolean toggle in a database column. This
+    method inspects the topic name and returns a tuple of:
+
+    - Whether the topic has been resolved
+    - The topic name with the resolution prefix, if present in stored_name, removed
+    """
+    if stored_name.startswith(RESOLVED_TOPIC_PREFIX):
+        return (True, stored_name[len(RESOLVED_TOPIC_PREFIX) :])
+
+    return (False, stored_name)


Test patch:
diff --git a/zerver/lib/test_classes.py b/zerver/lib/test_classes.py
--- a/zerver/lib/test_classes.py
+++ b/zerver/lib/test_classes.py
@@ -54,6 +54,7 @@
 from zerver.decorator import do_two_factor_login
 from zerver.lib.cache import bounce_key_prefix_for_testing
 from zerver.lib.initial_password import initial_password
+from zerver.lib.message import access_message
 from zerver.lib.notification_data import UserMessageNotificationsData
 from zerver.lib.rate_limiter import bounce_redis_key_prefix_for_testing
 from zerver.lib.sessions import get_session_dict_user
@@ -71,7 +72,7 @@
     tee_stdout_and_find_extra_console_output,
 )
 from zerver.lib.test_helpers import find_key_by_email, instrument_url, queries_captured
-from zerver.lib.topic import filter_by_topic_name_via_message
+from zerver.lib.topic import RESOLVED_TOPIC_PREFIX, filter_by_topic_name_via_message
 from zerver.lib.user_groups import get_system_user_group_for_user
 from zerver.lib.users import get_api_key
 from zerver.lib.validator import check_string
@@ -1315,6 +1316,26 @@ def check_user_subscribed_only_to_streams(self, user_name: str, streams: List[St
         for x, y in zip(subscribed_streams, streams):
             self.assertEqual(x["name"], y.name)
 
+    def resolve_topic_containing_message(
+        self,
+        acting_user: UserProfile,
+        target_message_id: int,
+        **extra: str,
+    ) -> "TestHttpResponse":
+        """
+        Mark all messages within the topic associated with message `target_message_id` as resolved.
+        """
+        message, _ = access_message(acting_user, target_message_id)
+        return self.api_patch(
+            acting_user,
+            f"/api/v1/messages/{target_message_id}",
+            {
+                "topic": RESOLVED_TOPIC_PREFIX + message.topic_name(),
+                "propagate_mode": "change_all",
+            },
+            **extra,
+        )
+
     def send_webhook_payload(
         self,
         user_profile: UserProfile,
diff --git a/zerver/tests/test_email_notifications.py b/zerver/tests/test_email_notifications.py
--- a/zerver/tests/test_email_notifications.py
+++ b/zerver/tests/test_email_notifications.py
@@ -626,6 +626,29 @@ def _extra_context_in_missed_stream_messages_mention_two_senders(
             msg_id, verify_body_include, email_subject, send_as_user, trigger="mentioned"
         )
 
+    def _resolved_topic_missed_stream_messages_thread_friendly(self, send_as_user: bool) -> None:
+        topic_name = "threading and so forth"
+        othello_user = self.example_user("othello")
+        msg_id = -1
+        for i in range(0, 3):
+            msg_id = self.send_stream_message(
+                othello_user,
+                "Denmark",
+                content=str(i),
+                topic_name=topic_name,
+            )
+
+        self.assert_json_success(self.resolve_topic_containing_message(othello_user, msg_id))
+
+        verify_body_include = [
+            "Othello, the Moor of Venice: > 0 > 1 > 2 -- ",
+            "You are receiving this because you have email notifications enabled for #Denmark.",
+        ]
+        email_subject = "[resolved] #Denmark > threading and so forth"
+        self._test_cases(
+            msg_id, verify_body_include, email_subject, send_as_user, trigger="stream_email_notify"
+        )
+
     def _extra_context_in_missed_personal_messages(
         self,
         send_as_user: bool,
@@ -1028,6 +1051,13 @@ def test_extra_context_in_missed_stream_messages_email_notify_as_user(self) -> N
     def test_extra_context_in_missed_stream_messages_email_notify(self) -> None:
         self._extra_context_in_missed_stream_messages_email_notify(False)
 
+    @override_settings(SEND_MISSED_MESSAGE_EMAILS_AS_USER=True)
+    def test_resolved_topic_missed_stream_messages_thread_friendly_as_user(self) -> None:
+        self._resolved_topic_missed_stream_messages_thread_friendly(True)
+
+    def test_resolved_topic_missed_stream_messages_thread_friendly(self) -> None:
+        self._resolved_topic_missed_stream_messages_thread_friendly(False)
+
     @override_settings(EMAIL_GATEWAY_PATTERN="")
     def test_reply_warning_in_missed_personal_messages(self) -> None:
         self._reply_warning_in_missed_personal_messages(False)
diff --git a/zerver/tests/test_message_edit.py b/zerver/tests/test_message_edit.py
--- a/zerver/tests/test_message_edit.py
+++ b/zerver/tests/test_message_edit.py
@@ -3006,12 +3006,9 @@ def test_mark_topic_as_resolved(self) -> None:
         self.assert_json_error(result, "Nothing to change")
 
         resolved_topic = RESOLVED_TOPIC_PREFIX + original_topic
-        result = self.client_patch(
-            "/json/messages/" + str(id1),
-            {
-                "topic": resolved_topic,
-                "propagate_mode": "change_all",
-            },
+        result = self.resolve_topic_containing_message(
+            admin_user,
+            id1,
             HTTP_ACCEPT_LANGUAGE="de",
         )