From 72a60b409072a6fd956985457a4f548de4e26ebd Mon Sep 17 00:00:00 2001 From: Samuel Hoffman Date: Wed, 29 Jan 2020 18:00:23 -0500 Subject: [PATCH 1/4] scaffolding for daily email --- CodeChallenge/api/eb.py | 35 ++++++++++++++++++++++++++++++++++- CodeChallenge/config.py | 1 + Pipfile.lock | 41 +++++++++-------------------------------- 3 files changed, 44 insertions(+), 33 deletions(-) diff --git a/CodeChallenge/api/eb.py b/CodeChallenge/api/eb.py index f2d3c14..054f706 100644 --- a/CodeChallenge/api/eb.py +++ b/CodeChallenge/api/eb.py @@ -1,4 +1,10 @@ -from flask import Blueprint +from hmac import compare_digest + +from flask import Blueprint, request, current_app, render_template +from flask_mail import Message + +from .. import core +from ..mail import mail bp = Blueprint("awsebapi", __name__, url_prefix="/api/v1/eb") @@ -7,3 +13,30 @@ @bp.route("/health", methods=["GET"]) def eb_health_check(): return "OK", 200 + + +# POST request from an AWS Lambda function once per day +# any daily tasks should be placed here +@bp.route("/worker", methods=["POST"]) +def worker(): + try: + password = request.json["password"] + except (TypeError, KeyError): + return "", 400 + + if not compare_digest(password, + current_app.config["WORKER_PASSWORD"]): + return "", 401 + + # send daily reminder emails only while challenge is active + + if core.day_number() >= 1 and not core.challenge_ended(): + msg = Message("CHANGEME", + sender=current_app.config["MAIL_DEFAULT_SENDER"], + recipients=[current_app.config["MG_LIST"]]) + + msg.html = render_template("CHANGEME") + + mail.send(msg) + + return "", 200 diff --git a/CodeChallenge/config.py b/CodeChallenge/config.py index 6bfca97..1434492 100644 --- a/CodeChallenge/config.py +++ b/CodeChallenge/config.py @@ -29,6 +29,7 @@ class DefaultConfig: MAIL_SUPPRESS_SEND = True MG_PRIVATE_KEY = os.getenv("MG_PRIVATE_KEY") MG_LIST = "codechallenge@school.codewizardshq.com" + WORKER_PASSWORD = os.getenv("WORKER_PASSWORD") # no trailing / EXTERNAL_URL = "https://challenge.codewizardshq.com" diff --git a/Pipfile.lock b/Pipfile.lock index 8f14295..f642506 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -167,10 +167,10 @@ }, "jinja2": { "hashes": [ - "sha256:74320bb91f31270f9551d46522e33af46a80c3d619f4a4bf42b3164d30b5911f", - "sha256:9fe95f19286cfefaa917656583d020be14e7859c6b0252588391e47db34527de" + "sha256:6e7a3c2934694d59ad334c93dd1b6c96699cf24c53fdb8ec848ac6b23e685734", + "sha256:d6609ae5ec3d56212ca7d802eda654eaf2310000816ce815361041465b108be4" ], - "version": "==2.10.3" + "version": "==2.11.0" }, "limits": { "hashes": [ @@ -310,14 +310,6 @@ ], "version": "==19.3.0" }, - "colorama": { - "hashes": [ - "sha256:7d73d2a99753107a36ac6b455ee49046802e59d9d076ef8e47b61499fa29afff", - "sha256:e96da0d330793e2cb9485e9ddfd918d456036c7149416295932478192f4436a1" - ], - "markers": "sys_platform == 'win32'", - "version": "==0.4.3" - }, "entrypoints": { "hashes": [ "sha256:589f874b313739ad35be6e0cd7efde2a4e9b6fea91edcc34e58ecbb8dbe56d19", @@ -333,14 +325,6 @@ "index": "pypi", "version": "==3.7.9" }, - "importlib-metadata": { - "hashes": [ - "sha256:bdd9b7c397c273bcc9a11d6629a38487cd07154fa255a467bf704cd2c258e359", - "sha256:f17c015735e1a88296994c0697ecea7e11db24290941983b08c9feb30921e6d8" - ], - "markers": "python_version < '3.8'", - "version": "==1.4.0" - }, "mccabe": { "hashes": [ "sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42", @@ -350,10 +334,10 @@ }, "more-itertools": { "hashes": [ - "sha256:1a2a32c72400d365000412fe08eb4a24ebee89997c18d3d147544f70f5403b39", - "sha256:c468adec578380b6281a114cb8a5db34eb1116277da92d7c46f904f0b52d3288" + "sha256:5dd8bcf33e5f9513ffa06d5ad33d78f31e1931ac9a18f33d37e77a180d393a7c", + "sha256:b1ddb932186d8a6ac451e1d95844b382f55e12686d51ca0c68b6f61f2ab7a507" ], - "version": "==8.1.0" + "version": "==8.2.0" }, "mypy": { "hashes": [ @@ -432,11 +416,11 @@ }, "pytest": { "hashes": [ - "sha256:1d122e8be54d1a709e56f82e2d85dcba3018313d64647f38a91aec88c239b600", - "sha256:c13d1943c63e599b98cf118fcb9703e4d7bde7caa9a432567bcdcae4bf512d20" + "sha256:0d5fe9189a148acc3c3eb2ac8e1ac0742cb7618c084f3d228baaec0c254b318d", + "sha256:ff615c761e25eb25df19edddc0b970302d2a9091fbce0e7213298d85fb61fef6" ], "index": "pypi", - "version": "==5.3.4" + "version": "==5.3.5" }, "python-dotenv": { "hashes": [ @@ -506,13 +490,6 @@ "sha256:b353856d37dec59d6511359f97f6a4b2468442e454bd1c98298ddce53cac1f04" ], "version": "==0.16.1" - }, - "zipp": { - "hashes": [ - "sha256:ccc94ed0909b58ffe34430ea5451f07bc0c76467d7081619a454bf5c98b89e28", - "sha256:feae2f18633c32fc71f2de629bfb3bd3c9325cd4419642b1f1da42ee488d9b98" - ], - "version": "==2.1.0" } } } From 8d81bb1a981f3b7195daa748c7d1bcc3f45536ce Mon Sep 17 00:00:00 2001 From: Samuel Hoffman Date: Thu, 30 Jan 2020 00:28:01 -0500 Subject: [PATCH 2/4] welcome email + daily email --- CodeChallenge/api/eb.py | 3 ++- CodeChallenge/api/users.py | 27 ++++++++++++++++++++------- 2 files changed, 22 insertions(+), 8 deletions(-) diff --git a/CodeChallenge/api/eb.py b/CodeChallenge/api/eb.py index 054f706..6b07b70 100644 --- a/CodeChallenge/api/eb.py +++ b/CodeChallenge/api/eb.py @@ -35,7 +35,8 @@ def worker(): sender=current_app.config["MAIL_DEFAULT_SENDER"], recipients=[current_app.config["MG_LIST"]]) - msg.html = render_template("CHANGEME") + msg.html = render_template("challenge_daily_email.html", + name="%recipient_fname%") mail.send(msg) diff --git a/CodeChallenge/api/users.py b/CodeChallenge/api/users.py index aa19746..604a1a8 100644 --- a/CodeChallenge/api/users.py +++ b/CodeChallenge/api/users.py @@ -133,14 +133,27 @@ def register(): f"{new_u.studentfirstname} {new_u.studentlastname}", data=mg_vars) - msg = Message("Welcome Pilgrim! You have accepted the Code Challenge", - sender=current_app.config["MAIL_DEFAULT_SENDER"], - recipients=[new_u.parent_email]) + rcpts = [new_u.parent_email] + if new_u.student_email: + rcpts.append(new_u.student_email) + + confirm_email = Message("Welcome Pilgrim! You have accepted the Code Challenge", + sender=current_app.config["MAIL_DEFAULT_SENDER"], + recipients=rcpts) name = new_u.studentfirstname or new_u.parentfirstname - msg.html = render_template("challenge_account_confirm.html", - name=name) - msg.extra_headers = {"List-Unsubscribe": "%unsubscribe_email%"} - mail.send(msg) + confirm_email.html = render_template("challenge_account_confirm.html", + name=name) + confirm_email.extra_headers = {"List-Unsubscribe": "%unsubscribe_email%"} + + welcome_email = Message("CHANGEME", + sender=current_app.config["MAIL_DEFAULT_SENDER"], + recipients=rcpts) + welcome_email.html = render_template("challenge_welcome.html", name=name) + welcome_email.extra_headers = {"List-Unsubscribe": "%unsubscribe_email%"} + + # send emails + mail.send(confirm_email) + mail.send(welcome_email) return jsonify({"status": "success"}) From bba1f877cab980eca816d9c2752554c3cb1a9308 Mon Sep 17 00:00:00 2001 From: Samuel Hoffman Date: Thu, 30 Jan 2020 15:29:09 -0500 Subject: [PATCH 3/4] add HTML email templates: - daily email - password reset - account confirmation - welcome email --- CodeChallenge/api/eb.py | 3 +- CodeChallenge/api/users.py | 48 ++- .../templates/challenge_account_confirm.html | 140 +++---- .../templates/challenge_daily_email.html | 345 ++++++++++++++++++ .../templates/challenge_password_reset.html | 333 +++++++++++++++++ .../templates/challenge_welcome.html | 337 +++++++++++++++++ 6 files changed, 1106 insertions(+), 100 deletions(-) create mode 100644 CodeChallenge/templates/challenge_daily_email.html create mode 100644 CodeChallenge/templates/challenge_password_reset.html create mode 100644 CodeChallenge/templates/challenge_welcome.html diff --git a/CodeChallenge/api/eb.py b/CodeChallenge/api/eb.py index 6b07b70..9ea0c3f 100644 --- a/CodeChallenge/api/eb.py +++ b/CodeChallenge/api/eb.py @@ -31,12 +31,13 @@ def worker(): # send daily reminder emails only while challenge is active if core.day_number() >= 1 and not core.challenge_ended(): - msg = Message("CHANGEME", + msg = Message("New code challenge question is unlocked!", sender=current_app.config["MAIL_DEFAULT_SENDER"], recipients=[current_app.config["MG_LIST"]]) msg.html = render_template("challenge_daily_email.html", name="%recipient_fname%") + msg.extra_headers = {"List-Unsubscribe": "%unsubscribe_email%"} mail.send(msg) diff --git a/CodeChallenge/api/users.py b/CodeChallenge/api/users.py index 604a1a8..478fa47 100644 --- a/CodeChallenge/api/users.py +++ b/CodeChallenge/api/users.py @@ -137,15 +137,21 @@ def register(): if new_u.student_email: rcpts.append(new_u.student_email) - confirm_email = Message("Welcome Pilgrim! You have accepted the Code Challenge", + # account confirmation email + # only contains login/password + confirm_email = Message("Your Code Challenge Account", sender=current_app.config["MAIL_DEFAULT_SENDER"], recipients=rcpts) name = new_u.studentfirstname or new_u.parentfirstname confirm_email.html = render_template("challenge_account_confirm.html", - name=name) + name=name, + username=new_u.username, + password=password) confirm_email.extra_headers = {"List-Unsubscribe": "%unsubscribe_email%"} - welcome_email = Message("CHANGEME", + # welcome email + # more in depth + welcome_email = Message("Welcome Pilgrim! You have accepted the Code Challenge", sender=current_app.config["MAIL_DEFAULT_SENDER"], recipients=rcpts) welcome_email.html = render_template("challenge_welcome.html", name=name) @@ -188,29 +194,33 @@ def forgot_password(): return jsonify(status="error", reason="no account with that email"), 400 - for user in users: + multiple_accounts = len(users) > 1 + + for user in users: # type: Users token = password_reset_token(user) - msg = Message(subject="Password Reset", - body="You are receiving this message because a password " - "reset request has been issued for your account. If you " - "did not make this request, you can ignore this email. " - "To reset your password, use this link within 24 hours. " - f"\n\n{current_app.config['EXTERNAL_URL']}/reset-password/{token}" - f"\n\nAccount Username: {user.username}", - recipients=[user.parent_email]) + + rcpts = [user.parent_email] + if user.student_email: + rcpts.append(user.student_email) + + if user.studentfirstname: + name = user.studentfirstname + else: + name = user.username + + msg = Message(subject="Reset your Code Challenge password", + html=render_template("challenge_password_reset.html", + name=name, + token=token, + multiple=multiple_accounts), + recipients=rcpts) if current_app.config.get("TESTING", False): msg.extra_headers = {"X-Password-Reset-Token": token} - if len(users) > 1: - msg.body += "\n\nNOTICE: Your email address matched multiple " \ - "student accounts. Double check to make sure you " \ - "are resetting the intended account, as an email " \ - "was sent for all matching accounts." - mail.send(msg) - return jsonify(status="success", reason="password reset email sent", multiple=len(users) > 1) + return jsonify(status="success", reason="password reset email sent", multiple=multiple_accounts) @bp.route("/reset-password", methods=["POST"]) diff --git a/CodeChallenge/templates/challenge_account_confirm.html b/CodeChallenge/templates/challenge_account_confirm.html index 2dcffd7..c7c516d 100644 --- a/CodeChallenge/templates/challenge_account_confirm.html +++ b/CodeChallenge/templates/challenge_account_confirm.html @@ -3,9 +3,9 @@ -CodeWizardsHQ Code Challenge Welcome Email +CodeWizardsHQ Code Challenge Daily Email + + + + + + + + + + +
+
+ + + + + + + + + + +
+ +
+ + + + + + + +
+

{{name}}, today's code challenge question is unlocked!

+
+

Continue your quest by logging in.

+

Good luck and safe travels!

+
+ ANSWER TODAY'S QUESTION +
+

Sign in to play
+ Challenge Details
+ Prizes
+ Frequently Asked Questions

+
+
+ Share With Friends +
+ + + +
+ +
+
+ + + diff --git a/CodeChallenge/templates/challenge_password_reset.html b/CodeChallenge/templates/challenge_password_reset.html new file mode 100644 index 0000000..9f79cad --- /dev/null +++ b/CodeChallenge/templates/challenge_password_reset.html @@ -0,0 +1,333 @@ + + + + + +CodeWizardsHQ Code Challenge Password Reset + + + + + + + + + + + +
+
+ + + + + + + +
+ +
+ + + + + + + +
+

{{name}}, reset your Code Challenge password.

+
+

Reset Your Password

+
+

We received a request to reset your password. If you didn't ask to change your passsword, your password is still safe and you can delete this message.

+ {% if multiple %} +
+

+ Your email address matched multiple student accounts. Double check to make sure you are resetting the intended account, as an email was sent for all matching accounts +

+ {% endif %} +
+

Sign in to play
+ Challenge Details
+ Prizes
+ Frequently Asked Questions

+
+
+
+
+ + + diff --git a/CodeChallenge/templates/challenge_welcome.html b/CodeChallenge/templates/challenge_welcome.html new file mode 100644 index 0000000..e3aa5ab --- /dev/null +++ b/CodeChallenge/templates/challenge_welcome.html @@ -0,0 +1,337 @@ + + + + + +CodeWizardsHQ Code Challenge Welcome Email + + + + + + + + + + + +
+
+ + + + + + + + + + +
+ +
+ + + + + + + +
+

Welcome to Defeat the Dragon, {{name}}!

+
+

Your mission is to defeat the evil dragon who has invaded CWHQ land. Only the bravest and brightest kid coders, like you, are prepared for this quest.

+

Good luck and safe travels!

+
+ START YOUR JOURNEY +
+

Sign in to play
+ Challenge Details
+ Prizes
+ Frequently Asked Questions

+
+
+ Share With Friends +
+ + + +
+
+
+ + + From 8b57de919313153f449469e52f0c2f497eac0aa6 Mon Sep 17 00:00:00 2001 From: Samuel Hoffman Date: Thu, 30 Jan 2020 15:30:33 -0500 Subject: [PATCH 4/4] registration email tests --- tests/test_auth.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/test_auth.py b/tests/test_auth.py index b498ef1..7924b4c 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -16,8 +16,9 @@ def test_registration_success(client): retval = register(client, "sam@codewizardshq.com", "cwhqsam", "supersecurepassword", "Sam", "Hoffman") assert retval.get_json()["status"] == "success" - assert len(outbox) == 1 - assert "Sheldon, you've accepted" in outbox[0].html + assert len(outbox) == 2 + assert "Sheldon, your account has been created" in outbox[0].html + assert "Welcome to Defeat the Dragon, Sheldon!" in outbox[1].html def test_registration_failure_invalid_password(client):