Repository: CTFd/CTFd (python)

Issue:
Send email from user management screen
<!--
If this is a bug report please fill out the template below.

If this is a feature request please describe the behavior that you'd like to see.
-->

**Environment**:

  - CTFd Version/Commit: 2.0.4
  - Operating System: Ubuntu
  - Web Browser and Version: Same in any (chrome, FF)

**What happened?**

In users management screen, I click on a user and click on the "Send message" button. It does nothing.

**What did you expect to happen?**

Send an email to that user.

**How to reproduce your issue**

**Any associated stack traces or error logs**



Pull request description:
Reimplement admin send mail to users
* Reimplement admin send mail to users as `/api/v1/users/<user_id>/email`
* Update form and related Javascript
* Write test for controller
* Closes #897 

Patch:
diff --git a/CTFd/api/v1/users.py b/CTFd/api/v1/users.py
index 37e6ff02..5e37b9a8 100644
--- a/CTFd/api/v1/users.py
+++ b/CTFd/api/v1/users.py
@@ -4,9 +4,12 @@ from CTFd.models import db, Users, Solves, Awards, Fails, Tracking, Unlocks, Sub
 from CTFd.utils.decorators import (
     authed_only,
     admins_only,
-    authed
+    authed,
+    ratelimit
 )
 from CTFd.cache import cache, clear_standings
+from CTFd.utils.config import get_mail_provider
+from CTFd.utils.email import sendmail
 from CTFd.utils.user import get_current_user, is_admin
 from CTFd.utils.decorators.visibility import check_account_visibility, check_score_visibility
 
@@ -280,3 +283,44 @@ class UserAwards(Resource):
             'success': True,
             'data': response.data
         }
+
+
+@users_namespace.route('/<int:user_id>/email')
+@users_namespace.param('user_id', "User ID")
+class UserEmails(Resource):
+    @admins_only
+    @ratelimit(method="POST", limit=10, interval=60)
+    def post(self, user_id):
+        req = request.get_json()
+        text = req.get('text', '').strip()
+        user = Users.query.filter_by(id=user_id).first_or_404()
+
+        if get_mail_provider() is None:
+            return {
+                'success': False,
+                'errors': {
+                    "": [
+                        "Email settings not configured"
+                    ]
+                }
+            }, 400
+
+        if not text:
+            return {
+                'success': False,
+                'errors': {
+                    "text": [
+                        "Email text cannot be empty"
+                    ]
+                }
+            }, 400
+
+        result, response = sendmail(
+            addr=user.email,
+            text=text
+        )
+
+        return {
+            'success': result,
+            'data': {}
+        }
diff --git a/CTFd/themes/admin/static/js/users/actions.js b/CTFd/themes/admin/static/js/users/actions.js
index 5b95b6e3..64bc612d 100644
--- a/CTFd/themes/admin/static/js/users/actions.js
+++ b/CTFd/themes/admin/static/js/users/actions.js
@@ -64,6 +64,46 @@ $(document).ready(function () {
         });
     });
 
+    $('#user-mail-form').submit(function(e){
+        e.preventDefault();
+        var params = $('#user-mail-form').serializeJSON(true);
+        CTFd.fetch('/api/v1/users/'+USER_ID+'/email', {
+            method: 'POST',
+            credentials: 'same-origin',
+            headers: {
+                'Accept': 'application/json',
+                'Content-Type': 'application/json'
+            },
+            body: JSON.stringify(params)
+        }).then(function (response) {
+            return response.json();
+        }).then(function (response) {
+            if (response.success) {
+                $('#user-mail-form > #results').append(
+                    ezbadge({
+                        type: 'success',
+                        body: 'E-Mail sent successfully!'
+                    })
+                );
+                $('#user-mail-form').find("input[type=text], textarea").val("")
+            } else {
+                $('#user-mail-form > #results').empty();
+                Object.keys(response.errors).forEach(function (key, index) {
+                    $('#user-mail-form > #results').append(
+                        ezbadge({
+                            type: 'error',
+                            body: response.errors[key]
+                        })
+                    );
+                    var i = $('#user-mail-form').find('input[name={0}], textarea[name={0}]'.format(key));
+                    var input = $(i);
+                    input.addClass('input-filled-invalid');
+                    input.removeClass('input-filled-valid');
+                });
+            }
+        });
+    });
+
     $('.delete-submission').click(function(e){
         e.preventDefault();
         var submission_id = $(this).attr('submission-id');
diff --git a/CTFd/themes/admin/templates/modals/mail/send.html b/CTFd/themes/admin/templates/modals/mail/send.html
index 3a1c3538..cf482937 100644
--- a/CTFd/themes/admin/templates/modals/mail/send.html
+++ b/CTFd/themes/admin/templates/modals/mail/send.html
@@ -1,16 +1,16 @@
-<form id="mail-form" method="POST">
+<form id="user-mail-form" method="POST">
 	<div class="form-group">
 		<label>
 			Message
 			<br>
 			<small></small>
 		</label>
-		<textarea class="form-control" name="msg" placeholder="" rows="15"></textarea>
+		<textarea class="form-control" name="text" placeholder="" rows="15"></textarea>
 	</div>
 	<div id="results">
 
 	</div>
-	<button type="button" class="btn btn-primary float-right">
+	<button type="submit" class="btn btn-primary float-right">
 		Send
 	</button>
 </form>
\ No newline at end of file


Test patch:
diff --git a/tests/api/v1/test_users.py b/tests/api/v1/test_users.py
index 7e5f27fb..4108bdac 100644
--- a/tests/api/v1/test_users.py
+++ b/tests/api/v1/test_users.py
@@ -569,3 +569,60 @@ def test_api_user_get_awards():
             r = client.get('/api/v1/users/2/awards')
             assert r.status_code == 200
     destroy_ctfd(app)
+
+
+def test_api_user_send_email():
+    """Can an admin post /api/v1/users/<user_id>/email"""
+    app = create_ctfd()
+    with app.app_context():
+
+        register_user(app)
+
+        with login_as_user(app) as client:
+            r = client.post('/api/v1/users/2/email', json={
+                'text': 'email should get rejected'
+            })
+            assert r.status_code == 403
+
+        with login_as_user(app, "admin") as admin:
+            r = admin.post('/api/v1/users/2/email', json={
+                'text': 'email should be accepted'
+            })
+            assert r.get_json() == {
+                'success': False,
+                'errors': {
+                    "": [
+                        "Email settings not configured"
+                    ]
+                }
+            }
+            assert r.status_code == 400
+
+        set_config('verify_emails', True)
+        set_config('mail_server', 'localhost')
+        set_config('mail_port', 25)
+        set_config('mail_useauth', True)
+        set_config('mail_username', 'username')
+        set_config('mail_password', 'password')
+
+        with login_as_user(app, "admin") as admin:
+            r = admin.post('/api/v1/users/2/email', json={
+                'text': ''
+            })
+            assert r.get_json() == {
+                'success': False,
+                'errors': {
+                    "text": [
+                        "Email text cannot be empty"
+                    ]
+                }
+            }
+            assert r.status_code == 400
+
+        with login_as_user(app, "admin") as admin:
+            r = admin.post('/api/v1/users/2/email', json={
+                'text': 'email should be accepted'
+            })
+            assert r.status_code == 200
+
+    destroy_ctfd(app)