Repository: zulip/zulip

Issue:
Option to send email to user when they are deactivated
We should add an option to send an email to a user when they are deactivated by an admin. We can add the following to the "Deactivate user" popover:

`E-mail {user name} ({user email})?` [ ]

If checkbox is checked, expand to show a text box where the admin can type in a message to the user.

This should be done a follow-on to #18941. In the case of deactivating a spammer, it partially solves #16473, but it is useful more generally.






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

<!-- areaLabelAddition -->
@zulipbot claim
Hello @juliaBichler01, it looks like you've currently claimed 1 issue in this repository. We encourage new contributors to focus their efforts on at most 1 issue at a time, so please complete your work on your other claimed issues before trying to claim this issue again.

We look forward to your valuable contributions!

@alya could i work on this? I am currently waiting for review on my other two open pull requests and would like to look into another issue in the meantime :)

Resolving patch:
diff --git a/tools/linter_lib/custom_check.py b/tools/linter_lib/custom_check.py
--- a/tools/linter_lib/custom_check.py
+++ b/tools/linter_lib/custom_check.py
@@ -230,7 +230,7 @@
     rules=[
         {
             "pattern": "subject|SUBJECT",
-            "exclude_pattern": "subject to the|email|outbox",
+            "exclude_pattern": "subject to the|email|outbox|account deactivation",
             "description": "avoid subject as a var",
             "good_lines": ["topic_name"],
             "bad_lines": ['subject="foo"', " MAX_SUBJECT_LEN"],
diff --git a/version.py b/version.py
--- a/version.py
+++ b/version.py
@@ -33,7 +33,7 @@
 # Changes should be accompanied by documentation explaining what the
 # new level means in templates/zerver/api/changelog.md, as well as
 # "**Changes**" entries in the endpoint's documentation in `zulip.yaml`.
-API_FEATURE_LEVEL = 134
+API_FEATURE_LEVEL = 135
 
 # Bump the minor PROVISION_VERSION to indicate that folks should provision
 # only when going from an old version of the code to a newer version. Bump
diff --git a/zerver/views/users.py b/zerver/views/users.py
--- a/zerver/views/users.py
+++ b/zerver/views/users.py
@@ -48,6 +48,7 @@
 from zerver.lib.rate_limiter import rate_limit_spectator_attachment_access_by_file
 from zerver.lib.request import REQ, has_request_variables
 from zerver.lib.response import json_success
+from zerver.lib.send_email import FromAddress, send_email
 from zerver.lib.streams import access_stream_by_id, access_stream_by_name, subscribed_to_stream
 from zerver.lib.types import ProfileDataElementUpdateDict, ProfileDataElementValue, Validator
 from zerver.lib.upload import upload_avatar_image
@@ -71,6 +72,7 @@
 from zerver.lib.utils import generate_api_key
 from zerver.lib.validator import (
     check_bool,
+    check_capped_string,
     check_dict,
     check_dict_only,
     check_int,
@@ -104,15 +106,28 @@ def check_last_owner(user_profile: UserProfile) -> bool:
     return user_profile.is_realm_owner and not user_profile.is_bot and len(owners) == 1
 
 
+@has_request_variables
 def deactivate_user_backend(
-    request: HttpRequest, user_profile: UserProfile, user_id: int
+    request: HttpRequest,
+    user_profile: UserProfile,
+    user_id: int,
+    deactivation_notification_comment: Optional[str] = REQ(
+        str_validator=check_capped_string(max_length=2000), default=None
+    ),
 ) -> HttpResponse:
     target = access_user_by_id(user_profile, user_id, for_admin=True)
     if target.is_realm_owner and not user_profile.is_realm_owner:
         raise OrganizationOwnerRequired()
     if check_last_owner(target):
         raise JsonableError(_("Cannot deactivate the only organization owner"))
-    return _deactivate_user_profile_backend(request, user_profile, target)
+    if deactivation_notification_comment is not None:
+        deactivation_notification_comment = deactivation_notification_comment.strip()
+    return _deactivate_user_profile_backend(
+        request,
+        user_profile,
+        target,
+        deactivation_notification_comment=deactivation_notification_comment,
+    )
 
 
 def deactivate_user_own_backend(request: HttpRequest, user_profile: UserProfile) -> HttpResponse:
@@ -129,13 +144,33 @@ def deactivate_bot_backend(
     request: HttpRequest, user_profile: UserProfile, bot_id: int
 ) -> HttpResponse:
     target = access_bot_by_id(user_profile, bot_id)
-    return _deactivate_user_profile_backend(request, user_profile, target)
+    return _deactivate_user_profile_backend(
+        request, user_profile, target, deactivation_notification_comment=None
+    )
 
 
 def _deactivate_user_profile_backend(
-    request: HttpRequest, user_profile: UserProfile, target: UserProfile
+    request: HttpRequest,
+    user_profile: UserProfile,
+    target: UserProfile,
+    *,
+    deactivation_notification_comment: Optional[str],
 ) -> HttpResponse:
     do_deactivate_user(target, acting_user=user_profile)
+
+    # It's important that we check for None explicitly here, since ""
+    # encodes sending an email without a custom administrator comment.
+    if deactivation_notification_comment is not None:
+        send_email(
+            "zerver/emails/deactivate",
+            to_user_ids=[target.id],
+            from_address=FromAddress.NOREPLY,
+            context={
+                "deactivation_notification_comment": deactivation_notification_comment,
+                "realm_uri": target.realm.uri,
+                "realm_name": target.realm.name,
+            },
+        )
     return json_success(request)
 
 


Test patch:
diff --git a/zerver/tests/test_users.py b/zerver/tests/test_users.py
--- a/zerver/tests/test_users.py
+++ b/zerver/tests/test_users.py
@@ -1399,6 +1399,42 @@ def test_api(self) -> None:
         user = self.example_user("hamlet")
         self.assertTrue(user.is_active)
 
+    def test_email_sent(self) -> None:
+        self.login("iago")
+        user = self.example_user("hamlet")
+
+        # Verify no email sent by default.
+        result = self.client_delete(f"/json/users/{user.id}", dict())
+        self.assert_json_success(result)
+        from django.core.mail import outbox
+
+        self.assert_length(outbox, 0)
+        user.refresh_from_db()
+        self.assertFalse(user.is_active)
+
+        # Reactivate user
+        do_reactivate_user(user, acting_user=None)
+        user.refresh_from_db()
+        self.assertTrue(user.is_active)
+
+        # Verify no email sent by default.
+        result = self.client_delete(
+            f"/json/users/{user.id}",
+            dict(
+                deactivation_notification_comment="Dear Hamlet,\nyou just got deactivated.",
+            ),
+        )
+        self.assert_json_success(result)
+        user.refresh_from_db()
+        self.assertFalse(user.is_active)
+
+        self.assert_length(outbox, 1)
+        msg = outbox[0]
+        self.assertEqual(msg.subject, "Notification of account deactivation on Zulip Dev")
+        self.assert_length(msg.reply_to, 1)
+        self.assertEqual(msg.reply_to[0], "noreply@testserver")
+        self.assertIn("Dear Hamlet,", msg.body)
+
     def test_api_with_nonexistent_user(self) -> None:
         self.login("iago")