diff --git a/setup.py b/setup.py index 3eebcb6..6b01f91 100644 --- a/setup.py +++ b/setup.py @@ -18,6 +18,7 @@ }, install_requires=[ "bcrypt==4.0.1", + "email-validator==2.0.0", "Flask==2.3.2", "Flask-session2==1.3.1", "Flask-SQLAlchemy==3.0.3", diff --git a/yoda_eus/app.py b/yoda_eus/app.py index 296b08f..9e0dae5 100644 --- a/yoda_eus/app.py +++ b/yoda_eus/app.py @@ -15,7 +15,7 @@ from flask_sqlalchemy import SQLAlchemy from flask_wtf.csrf import CSRFProtect from jinja2 import ChoiceLoader, FileSystemLoader -from yoda_eus.mail import send_email_template +from yoda_eus.mail import is_email_valid, send_email_template_if_needed from yoda_eus.password_complexity import check_password_complexity @@ -278,27 +278,26 @@ def add_user() -> Response: db.session.commit() - if app.config.get("MAIL_ENABLED").lower() != "false": - # Send invitation - hash_url = "https://{}/user/activate/{}".format(app.config.get("YODA_EUS_FQDN"), - secret_hash) - invitation_data = {'USERNAME': content['username'], - 'CREATOR': content["creator_user"], - 'HASH_URL': hash_url} - send_email_template(app, - content['username'], - 'Welcome to Yoda!', - "invitation", - invitation_data) - - # Send invitation confirmation - confirmation_data = {"USERNAME": content['username'], - "CREATOR": content['creator_user']} - send_email_template(app, - content["creator_user"], - 'You have invited an external user to Yoda', - 'invitation-sent', - confirmation_data) + # Send invitation + hash_url = "https://{}/user/activate/{}".format(app.config.get("YODA_EUS_FQDN"), + secret_hash) + invitation_data = {'USERNAME': content['username'], + 'CREATOR': content["creator_user"], + 'HASH_URL': hash_url} + send_email_template_if_needed(app, + content['username'], + 'Welcome to Yoda!', + "invitation", + invitation_data) + + # Send invitation confirmation + confirmation_data = {"USERNAME": content['username'], + "CREATOR": content['creator_user']} + send_email_template_if_needed(app, + content["creator_user"], + 'You have invited an external user to Yoda', + 'invitation-sent', + confirmation_data) # Send response response = {"status": "ok", "message": "User created."} @@ -349,6 +348,12 @@ def process_forgot_password() -> Response: response.status_code = 404 return response + if (not is_email_valid(username) and app.config.get("MAIL_ONLY_TO_VALID_ADDRESS").lower() == "true"): + errors = {"errors": ["Unable to send password reset email, because your user name ('{}') is not a valid email address.".format(username)]} + response = make_response(render_template('forgot-password.html', **errors)) + response.status_code = 404 + return response + # Generate and update user hash secret_hash = get_random_hash() user.hash = secret_hash @@ -356,16 +361,15 @@ def process_forgot_password() -> Response: db.session.commit() # Send password reset email - if app.config.get("MAIL_ENABLED").lower() != "false": - hash_url = "https://{}/user/reset-password/{}".format(app.config.get("YODA_EUS_FQDN"), - secret_hash) - reset_data = {'USERNAME': user.username, - 'HASH_URL': hash_url} - send_email_template(app, - user.username, - 'Yoda password reset', - "reset-password", - reset_data) + hash_url = "https://{}/user/reset-password/{}".format(app.config.get("YODA_EUS_FQDN"), + secret_hash) + reset_data = {'USERNAME': user.username, + 'HASH_URL': hash_url} + send_email_template_if_needed(app, + user.username, + 'Yoda password reset', + "reset-password", + reset_data) return render_template("forgot-password-successful.html"), 200 @@ -439,19 +443,18 @@ def process_activate_account_form(hash: str) -> Response: db.session.commit() # Send confirmation emails - if app.config.get("MAIL_ENABLED").lower() != "false": - activation_data = {'USERNAME': user.username} - send_email_template(app, - user.username, - 'You have successfully activated your Yoda account', - "activation-successful", - activation_data) - activation_data["CREATOR"] = user.creator_user - send_email_template(app, - user.creator_user, - 'An external user has activated their Yoda account', - "invitation-accepted", - activation_data) + activation_data = {'USERNAME': user.username} + send_email_template_if_needed(app, + user.username, + 'You have successfully activated your Yoda account', + "activation-successful", + activation_data) + activation_data["CREATOR"] = user.creator_user + send_email_template_if_needed(app, + user.creator_user, + 'An external user has activated their Yoda account', + "invitation-accepted", + activation_data) # Confirm activation to user return render_template("activation-successful.html", **params), 200 diff --git a/yoda_eus/mail.py b/yoda_eus/mail.py index fcccb53..991ef8f 100644 --- a/yoda_eus/mail.py +++ b/yoda_eus/mail.py @@ -9,9 +9,56 @@ from email.mime.text import MIMEText from pathlib import Path +from email_validator import EmailNotValidError, validate_email from jinja2 import BaseLoader, Environment +def is_email_valid(address): + """Determines whether an email address is valid + + :param address: the email address + + :returns: boolean value that indicates whether the email address is valid + """ + try: + validate_email(address, check_deliverability=False) + return True + except EmailNotValidError: + return False + + +def send_email_template_if_needed(app, to, subject, template_name, template_data): + """Send an e-mail with specified recipient, subject and body using templates if + application is configured to deliver it. + + An email is sent if and only if: + - Email delivery has been enabled in the application configuration + - The email address is valid (or email address validation has been disabled) + + The originating address and mail server credentials are taken from the + app configuration. The templates are retrieved from the template directory + specified in the app configuration. + + :param app: Flask application, used for logging and retrieving configuration + :param to: Recipient of the mail + :param subject: Subject of mail + :param template_name: Name of the template in the template directory to use, excluding extensions + :param template_data: Variables to interpolate, as a dictionary + + """ + if app.config.get("MAIL_ENABLED").lower() == "false": + app.logger.warning("Not sending email to '{}' with subject '{}', because email delivery is disabled.".format( + to, subject)) + return + + if (not is_email_valid(to) and app.config.get("MAIL_ONLY_TO_VALID_ADDRESS").lower() == "true"): + app.logger.warning("Not sending email to '{}' with subject '{}', because recipient address is invalid.".format( + to, subject)) + return + + send_email_template(app, to, subject, template_name, template_data) + + def send_email_template(app, to, subject, template_name, template_data): """Send an e-mail with specified recipient, subject and body using templates diff --git a/yoda_eus/tests/flask.test.cfg b/yoda_eus/tests/flask.test.cfg index 0dd0e53..43fd7ae 100644 --- a/yoda_eus/tests/flask.test.cfg +++ b/yoda_eus/tests/flask.test.cfg @@ -20,6 +20,7 @@ SMTP_FROM_EMAIL = 'yoda@yoda.test' SMTP_REPLYTO_NAME = 'PLACEHOLDER' SMTP_REPLYTO_EMAIL = 'yoda@yoda.test' MAIL_ENABLED = 'false' +MAIL_ONLY_TO_VALID_ADDRESS = 'false' MAIL_TEMPLATE = 'uu' MAIL_TEMPLATE_DIR = "/var/www/extuser/yoda-external-user-service/yoda_eus/templates/mail" diff --git a/yoda_eus/tests/test_unit.py b/yoda_eus/tests/test_unit.py index df437b9..d8c85b9 100644 --- a/yoda_eus/tests/test_unit.py +++ b/yoda_eus/tests/test_unit.py @@ -3,6 +3,7 @@ import string +from yoda_eus.mail import is_email_valid from yoda_eus.password_complexity import check_password_complexity @@ -46,3 +47,9 @@ def test_password_validation_multiple(self): assert "Password is too short: it needs to be at least 10 characters." in result assert "Password needs to contain at least one digit." in result assert "Password needs to contain at least one punctuation character ({})".format(string.punctuation) in result + + def is_email_valid_yes(self): + assert is_email_valid("yoda@uu.nl") + + def is_email_valid_no(self): + assert not is_email_valid("this is not a valid email address")