From 4649fe5104b02ce8c5e81ac8f817d92e1572c516 Mon Sep 17 00:00:00 2001 From: sea-kelp <66500457+sea-kelp@users.noreply.github.com> Date: Mon, 4 Sep 2023 18:24:54 +0000 Subject: [PATCH] Re-add SMTP email provider (#1047) --- CONTRIB.md | 26 ++- Makefile | 2 +- OpenOversight/app/__init__.py | 12 +- OpenOversight/app/email_client.py | 173 ++++++++++++++---- OpenOversight/app/models/config.py | 20 +- OpenOversight/app/models/emails.py | 86 ++++++--- .../app/templates/auth/email/change_email.txt | 11 ++ .../templates/auth/email/change_password.txt | 13 ++ .../app/templates/auth/email/confirm.txt | 13 ++ .../templates/auth/email/new_confirmation.txt | 16 ++ .../templates/auth/email/new_registration.txt | 16 ++ .../templates/auth/email/reset_password.txt | 13 ++ OpenOversight/app/utils/constants.py | 36 ++++ OpenOversight/app/utils/general.py | 2 + OpenOversight/tests/routes/test_user_api.py | 3 - OpenOversight/tests/test_email_client.py | 149 +++++++++++++++ docker-compose.dev.yml | 2 +- justfile | 2 +- requirements.txt | 1 + 19 files changed, 521 insertions(+), 75 deletions(-) create mode 100644 OpenOversight/app/templates/auth/email/change_email.txt create mode 100644 OpenOversight/app/templates/auth/email/change_password.txt create mode 100644 OpenOversight/app/templates/auth/email/confirm.txt create mode 100644 OpenOversight/app/templates/auth/email/new_confirmation.txt create mode 100644 OpenOversight/app/templates/auth/email/new_registration.txt create mode 100644 OpenOversight/app/templates/auth/email/reset_password.txt create mode 100644 OpenOversight/tests/test_email_client.py diff --git a/CONTRIB.md b/CONTRIB.md index 864aa15ab..da21eebba 100644 --- a/CONTRIB.md +++ b/CONTRIB.md @@ -49,17 +49,33 @@ $ docker exec -it openoversight_web_1 /bin/bash Once you're done, `make stop` and `make clean` to stop and remove the containers respectively. -## Gmail Requirements -**NOTE:** If you are running on dev and do not currently have a `service_account_key.json` file, create one and leave it empty. The email client will then default to an empty object and simulate emails in the logs. - -For the application to work properly, you will need a [Google Cloud Platform service account](https://cloud.google.com/iam/docs/service-account-overview) that is attached to a GSuite email address. Here are some general tips for working with service accounts: [Link](https://support.google.com/a/answer/7378726?hl=en). +## Setting Up Email +OpenOversight tries to auto-detect which email implementation to use based on which of the following is configured (in this order): +* Google: `service_account_key.json` exists and is not empty +* SMTP: `MAIL_SERVER` and `MAIL_PORT` environment variables are set +* Simulated: If neither of the previous 2 implementations are configured, emails will only be logged + +### GSuite +To send email using a GSuite email account, you will need a [Google Cloud Platform service account](https://cloud.google.com/iam/docs/service-account-overview) that is attached to that email address. Here are some general tips for working with service accounts: [Link](https://support.google.com/a/answer/7378726?hl=en). We would suggest that you do not use a personal email address, but instead one that is used strictly for sending out OpenOversight emails. You will need to do these two things for the service account to work as a Gmail bot: 1. Enable domain-wide delegation for the service account: [Link](https://support.google.com/a/answer/162106?hl=en) 2. Enable the `https://www.googleapis.com/auth/gmail.send` scope in the Gmail API for your service account: [Link](https://developers.google.com/gmail/api/auth/scopes#scopes) 3. Save the service account key file in OpenOversight's base folder as `service_account_key.json`. The file is in the `.gitignore` file GitHub will not allow you to save it, provided you've named it correctly. -4. For production, save the email address associated with your service account to a variable named `OO_SERVICE_EMAIL` in a `.env` file in the base directory of this repository. For development and testing, update the `OO_SERVICE_EMAIL` variable in the `docker-compose.yml` file. + +### SMTP +To send email using SMTP, set the following environment variables in your docker-compose.yml file or .env file: +* `MAIL_SERVER` +* `MAIL_PORT` +* `MAIL_USE_TLS` +* `MAIL_USERNAME` +* `MAIL_PASSWORD` + +For more information about these settings, please see the [Flask-Mail](https://flask-mail.readthedocs.io/en/latest/) documentation. + +### Setting email aliases +Regardless of implementation, save the email address associated with your service account to a variable named `OO_SERVICE_EMAIL` in a `.env` file in the base directory of this repository. For development and testing, update the `OO_SERVICE_EMAIL` variable in the `docker-compose.yml` file. Example `.env` variable: ```bash diff --git a/Makefile b/Makefile index 7c59a0c19..7e12d2693 100644 --- a/Makefile +++ b/Makefile @@ -33,7 +33,7 @@ assets: docker-compose run --rm web yarn build .PHONY: dev -dev: build start create_db populate +dev: create_empty_secret build start create_db populate .PHONY: populate populate: create_db ## Build and run containers diff --git a/OpenOversight/app/__init__.py b/OpenOversight/app/__init__.py index eb500f6cb..82d7cf2f8 100644 --- a/OpenOversight/app/__init__.py +++ b/OpenOversight/app/__init__.py @@ -16,7 +16,7 @@ from OpenOversight.app.email_client import EmailClient from OpenOversight.app.models.config import config from OpenOversight.app.models.database import db -from OpenOversight.app.utils.constants import MEGABYTE, SERVICE_ACCOUNT_FILE +from OpenOversight.app.utils.constants import MEGABYTE bootstrap = Bootstrap() @@ -41,14 +41,8 @@ def create_app(config_name="default"): bootstrap.init_app(app) csrf.init_app(app) db.init_app(app) - # This allows the application to run without creating an email client if it is - # in testing or dev mode and the service account file is empty. - service_account_file_size = os.path.getsize(SERVICE_ACCOUNT_FILE) - EmailClient( - config=app.config, - dev=app.debug and service_account_file_size == 0, - testing=app.testing, - ) + with app.app_context(): + EmailClient() limiter.init_app(app) login_manager.init_app(app) sitemap.init_app(app) diff --git a/OpenOversight/app/email_client.py b/OpenOversight/app/email_client.py index 9a860135d..a295174de 100644 --- a/OpenOversight/app/email_client.py +++ b/OpenOversight/app/email_client.py @@ -1,36 +1,159 @@ -from apiclient import errors +import base64 +import os.path +from abc import ABC, abstractmethod +from threading import Thread +from typing import Optional, Self + from flask import current_app +from flask_mail import Mail, Message from google.oauth2 import service_account from googleapiclient.discovery import build from OpenOversight.app.models.emails import Email -from OpenOversight.app.utils.constants import SERVICE_ACCOUNT_FILE +from OpenOversight.app.utils.constants import ( + KEY_MAIL_PORT, + KEY_MAIL_SERVER, + KEY_OO_HELP_EMAIL, + KEY_OO_SERVICE_EMAIL, + SERVICE_ACCOUNT_FILE, +) -class EmailClient(object): - """ - EmailClient is a Singleton class that is used for the Gmail client. - This can be fairly easily switched out with another email service, but it is - currently defaulted to Gmail. - """ +class EmailProvider(ABC): + """Base class to define how emails are sent.""" + + @abstractmethod + def start(self): + """Set up the email provider.""" + + @abstractmethod + def is_configured(self) -> bool: + """Determine the required env variables for this provider are configured.""" + + @abstractmethod + def send_email(self, email: Email): + """Send an email with this email provider.""" + + +class GmailEmailProvider(EmailProvider): + """Sends email through Gmail using the Google API client.""" SCOPES = ["https://www.googleapis.com/auth/gmail.send"] - _instance = None + def start(self): + credentials = service_account.Credentials.from_service_account_file( + SERVICE_ACCOUNT_FILE, scopes=self.SCOPES + ) + delegated_credentials = credentials.with_subject( + current_app.config[KEY_OO_SERVICE_EMAIL] + ) + self.service = build("gmail", "v1", credentials=delegated_credentials) + + def is_configured(self) -> bool: + return ( + os.path.isfile(SERVICE_ACCOUNT_FILE) + and os.path.getsize(SERVICE_ACCOUNT_FILE) > 0 + ) + + def send_email(self, email: Email): + message = email.create_message() + resource = {"raw": base64.urlsafe_b64encode(message.as_bytes()).decode()} + + (self.service.users().messages().send(userId="me", body=resource).execute()) + + +class SMTPEmailProvider(EmailProvider): + """Sends email with SMTP using Flask-Mail.""" + + def start(self): + self.mail = Mail(current_app) + + def is_configured(self) -> bool: + return bool( + current_app.config.get(KEY_MAIL_SERVER) + and current_app.config.get(KEY_MAIL_PORT) + ) + + def send_email(self, email: Email): + app = current_app._get_current_object() + msg = Message( + email.subject, + sender=app.config[KEY_OO_SERVICE_EMAIL], + recipients=[email.receiver], + reply_to=app.config[KEY_OO_HELP_EMAIL], + ) + msg.body = email.body + msg.html = email.html + + thread = Thread( + target=SMTPEmailProvider.send_async_email, + args=[self.mail, app, msg], + ) + current_app.logger.info("Sent email.") + thread.start() + + @staticmethod + def send_async_email(mail: Mail, app, msg: Message): + with app.app_context(): + mail.send(msg) - def __new__(cls, config=None, dev=False, testing=False): - if (testing or dev) and cls._instance is None: - cls._instance = {} - if cls._instance is None and config: - credentials = service_account.Credentials.from_service_account_file( - SERVICE_ACCOUNT_FILE, scopes=cls.SCOPES +class SimulatedEmailProvider(EmailProvider): + """Writes messages sent with this provider to log for dev/test usage.""" + + def start(self): + if not current_app.debug and not current_app.testing: + current_app.logger.warning( + "Using simulated email provider in non-development environment. " + "Please see CONTRIB.md to set up a email provider." + ) + + def is_configured(self): + return True + + def send_email(self, email: Email): + current_app.logger.info("simulated email:\n%s\n%s", email.subject, email.body) + + +class EmailClient: + """ + EmailClient is a Singleton class used for sending email. It auto-detects + the email provider implementation based on whether the required + configuration is provided for each implementation. + """ + + DEFAULT_PROVIDER: EmailProvider = SimulatedEmailProvider() + PROVIDER_PRECEDENCE: list[EmailProvider] = [ + GmailEmailProvider(), + SMTPEmailProvider(), + DEFAULT_PROVIDER, + ] + + _provider: Optional[EmailProvider] = None + _instance: Optional[Self] = None + + def __new__(cls): + if cls._instance is None: + cls._provider = cls.auto_detect() + cls._provider.start() + current_app.logger.info( + f"Using email provider: {cls._provider.__class__.__name__}" ) - delegated_credentials = credentials.with_subject(config["OO_SERVICE_EMAIL"]) - cls.service = build("gmail", "v1", credentials=delegated_credentials) cls._instance = super(EmailClient, cls).__new__(cls) return cls._instance + @classmethod + def auto_detect(cls): + """Auto-detect the configured email provider to use for email sending.""" + if current_app.testing: + return cls.DEFAULT_PROVIDER + + for provider in cls.PROVIDER_PRECEDENCE: + if provider.is_configured(): + return provider + + raise ValueError("No configured email providers") + @classmethod def send_email(cls, email: Email): """ @@ -38,17 +161,5 @@ def send_email(cls, email: Email): :param email: the specific email to be delivered """ - if not cls._instance: - current_app.logger.info( - "simulated email:\n%s\n%s", email.subject, email.body - ) - else: - try: - ( - cls.service.users() - .messages() - .send(userId="me", body=email.create_message()) - .execute() - ) - except errors.HttpError as error: - print("An error occurred: %s" % error) + if cls._provider is not None: + cls._provider.send_email(email) diff --git a/OpenOversight/app/models/config.py b/OpenOversight/app/models/config.py index ef72662dd..da2b3a875 100644 --- a/OpenOversight/app/models/config.py +++ b/OpenOversight/app/models/config.py @@ -1,6 +1,15 @@ import os -from OpenOversight.app.utils.constants import MEGABYTE +from OpenOversight.app.utils.constants import ( + KEY_MAIL_PASSWORD, + KEY_MAIL_PORT, + KEY_MAIL_SERVER, + KEY_MAIL_USE_TLS, + KEY_MAIL_USERNAME, + KEY_OO_HELP_EMAIL, + MEGABYTE, +) +from OpenOversight.app.utils.general import str_is_true basedir = os.path.abspath(os.path.dirname(__file__)) @@ -39,7 +48,14 @@ def __init__(self): self.OO_SERVICE_EMAIL = os.environ.get("OO_SERVICE_EMAIL") # TODO: Remove the default once we are able to update the production .env file # TODO: Once that is done, we can re-alpha sort these variables. - self.OO_HELP_EMAIL = os.environ.get("OO_HELP_EMAIL", self.OO_SERVICE_EMAIL) + self.OO_HELP_EMAIL = os.environ.get(KEY_OO_HELP_EMAIL, self.OO_SERVICE_EMAIL) + + # Flask-Mail-related settings + setattr(self, KEY_MAIL_SERVER, os.environ.get(KEY_MAIL_SERVER)) + setattr(self, KEY_MAIL_PORT, os.environ.get(KEY_MAIL_PORT)) + setattr(self, KEY_MAIL_USE_TLS, str_is_true(os.environ.get(KEY_MAIL_USE_TLS))) + setattr(self, KEY_MAIL_USERNAME, os.environ.get(KEY_MAIL_USERNAME)) + setattr(self, KEY_MAIL_PASSWORD, os.environ.get(KEY_MAIL_PASSWORD)) # AWS Settings self.AWS_ACCESS_KEY_ID = os.environ.get("AWS_ACCESS_KEY_ID") diff --git a/OpenOversight/app/models/emails.py b/OpenOversight/app/models/emails.py index 6ddc711e8..552d1b7b0 100644 --- a/OpenOversight/app/models/emails.py +++ b/OpenOversight/app/models/emails.py @@ -1,32 +1,51 @@ -import base64 +from email.mime.multipart import MIMEMultipart from email.mime.text import MIMEText from flask import current_app, render_template +from OpenOversight.app.utils.constants import ( + FILE_TYPE_HTML, + FILE_TYPE_PLAIN, + KEY_OO_HELP_EMAIL, + KEY_OO_MAIL_SUBJECT_PREFIX, + KEY_OO_SERVICE_EMAIL, +) + class Email: """Base class for all emails.""" - def __init__(self, body: str, subject: str, receiver: str): + EMAIL_PATH = "auth/email/" + + def __init__(self, body: str, html: str, subject: str, receiver: str): self.body = body + self.html = html self.receiver = receiver self.subject = subject def create_message(self): - message = MIMEText(self.body, "html") - message["to"] = self.receiver - message["from"] = current_app.config["OO_SERVICE_EMAIL"] - message["subject"] = self.subject - return {"raw": base64.urlsafe_b64encode(message.as_bytes()).decode()} + message = MIMEMultipart("alternative") + message["To"] = self.receiver + message["From"] = current_app.config[KEY_OO_SERVICE_EMAIL] + message["Subject"] = self.subject + message["Reply-To"] = current_app.config[KEY_OO_HELP_EMAIL] + + message.attach(MIMEText(self.body, FILE_TYPE_PLAIN)) + message.attach(MIMEText(self.html, FILE_TYPE_HTML)) + + return message class AdministratorApprovalEmail(Email): def __init__(self, receiver: str, user, admin): subject = f"{current_app.config['OO_MAIL_SUBJECT_PREFIX']} New User Registered" body = render_template( - "auth/email/new_registration.html", user=user, admin=admin + f"{self.EMAIL_PATH}new_registration.txt", user=user, admin=admin + ) + html = render_template( + f"{self.EMAIL_PATH}new_registration.html", user=user, admin=admin ) - super().__init__(body, subject, receiver) + super().__init__(body, html, subject, receiver) class ChangeEmailAddressEmail(Email): @@ -35,8 +54,13 @@ def __init__(self, receiver: str, user, token: str): f"{current_app.config['OO_MAIL_SUBJECT_PREFIX']} Confirm Your Email " f"Address" ) - body = render_template("auth/email/change_email.html", user=user, token=token) - super().__init__(body, subject, receiver) + body = render_template( + f"{self.EMAIL_PATH}change_email.txt", user=user, token=token + ) + html = render_template( + f"{self.EMAIL_PATH}change_email.html", user=user, token=token + ) + super().__init__(body, html, subject, receiver) class ChangePasswordEmail(Email): @@ -45,31 +69,49 @@ def __init__(self, receiver: str, user): f"{current_app.config['OO_MAIL_SUBJECT_PREFIX']} Your Password Has Changed" ) body = render_template( - "auth/email/change_password.html", + f"{self.EMAIL_PATH}change_password.txt", + user=user, + help_email=current_app.config[KEY_OO_HELP_EMAIL], + ) + html = render_template( + f"{self.EMAIL_PATH}change_password.html", user=user, - help_email=current_app.config["OO_HELP_EMAIL"], + help_email=current_app.config[KEY_OO_HELP_EMAIL], ) - super().__init__(body, subject, receiver) + super().__init__(body, html, subject, receiver) class ConfirmAccountEmail(Email): def __init__(self, receiver: str, user, token: str): - subject = f"{current_app.config['OO_MAIL_SUBJECT_PREFIX']} Confirm Your Account" - body = render_template("auth/email/confirm.html", user=user, token=token) - super().__init__(body, subject, receiver) + subject = ( + f"{current_app.config[KEY_OO_MAIL_SUBJECT_PREFIX]} Confirm Your Account" + ) + body = render_template(f"{self.EMAIL_PATH}confirm.txt", user=user, token=token) + html = render_template(f"{self.EMAIL_PATH}confirm.html", user=user, token=token) + super().__init__(body, html, subject, receiver) class ConfirmedUserEmail(Email): def __init__(self, receiver: str, user, admin): subject = f"{current_app.config['OO_MAIL_SUBJECT_PREFIX']} New User Confirmed" body = render_template( - "auth/email/new_confirmation.html", user=user, admin=admin + f"{self.EMAIL_PATH}new_confirmation.txt", user=user, admin=admin ) - super().__init__(body, subject, receiver) + html = render_template( + f"{self.EMAIL_PATH}new_confirmation.html", user=user, admin=admin + ) + super().__init__(body, html, subject, receiver) class ResetPasswordEmail(Email): def __init__(self, receiver: str, user, token: str): - subject = f"{current_app.config['OO_MAIL_SUBJECT_PREFIX']} Reset Your Password" - body = render_template("auth/email/reset_password.html", user=user, token=token) - super().__init__(body, subject, receiver) + subject = ( + f"{current_app.config[KEY_OO_MAIL_SUBJECT_PREFIX]} Reset Your Password" + ) + body = render_template( + f"{self.EMAIL_PATH}reset_password.txt", user=user, token=token + ) + html = render_template( + f"{self.EMAIL_PATH}reset_password.html", user=user, token=token + ) + super().__init__(body, html, subject, receiver) diff --git a/OpenOversight/app/templates/auth/email/change_email.txt b/OpenOversight/app/templates/auth/email/change_email.txt new file mode 100644 index 000000000..0604e669a --- /dev/null +++ b/OpenOversight/app/templates/auth/email/change_email.txt @@ -0,0 +1,11 @@ +Dear {{ user.username }}, + +To confirm your new email address click on the following link: + +{{ url_for('auth.change_email', token=token, _external=True) }} + +Sincerely, + +The OpenOversight Team + +Note: replies to this email address are not monitored. diff --git a/OpenOversight/app/templates/auth/email/change_password.txt b/OpenOversight/app/templates/auth/email/change_password.txt new file mode 100644 index 000000000..d5617ae2e --- /dev/null +++ b/OpenOversight/app/templates/auth/email/change_password.txt @@ -0,0 +1,13 @@ +Dear {{ user.username }}, + +Your password has just been changed. + +If you initiated this change to your password, you can ignore this email. + +If you did not reset your password, please contact the OpenOversight help account {{ help_email }}; they will help you address this issue. + +Sincerely, + +The OpenOversight Team + +Note: replies to this email address are not monitored. diff --git a/OpenOversight/app/templates/auth/email/confirm.txt b/OpenOversight/app/templates/auth/email/confirm.txt new file mode 100644 index 000000000..41abe7c57 --- /dev/null +++ b/OpenOversight/app/templates/auth/email/confirm.txt @@ -0,0 +1,13 @@ +Dear {{ user.username }}, + +Welcome to OpenOversight! + +To confirm your account please click on the following link: + +{{ url_for('auth.confirm', token=token, _external=True) }} + +Sincerely, + +The OpenOversight Team + +Note: replies to this email address are not monitored. diff --git a/OpenOversight/app/templates/auth/email/new_confirmation.txt b/OpenOversight/app/templates/auth/email/new_confirmation.txt new file mode 100644 index 000000000..a6328ac0e --- /dev/null +++ b/OpenOversight/app/templates/auth/email/new_confirmation.txt @@ -0,0 +1,16 @@ +Dear {{ admin.username }}, + +A new user account with the following information has been confirmed: + +Username: {{ user.username }} +Email: {{ user.email }} + +To view or delete this user, click on the following link: + +{{ url_for('auth.edit_user', user_id=user.id, _external=True) }} + +Sincerely, + +The OpenOversight Team + +Note: replies to this email address are not monitored. diff --git a/OpenOversight/app/templates/auth/email/new_registration.txt b/OpenOversight/app/templates/auth/email/new_registration.txt new file mode 100644 index 000000000..5840085a9 --- /dev/null +++ b/OpenOversight/app/templates/auth/email/new_registration.txt @@ -0,0 +1,16 @@ +Dear {{ admin.username }}, + +A new user has registered with the following information: + +Username: {{ user.username }} +Email: {{ user.email }} + +To approve or delete this user, click on the following link: + +{{ url_for('auth.edit_user', user_id=user.id, _external=True) }} + +Sincerely, + +The OpenOversight Team + +Note: replies to this email address are not monitored. diff --git a/OpenOversight/app/templates/auth/email/reset_password.txt b/OpenOversight/app/templates/auth/email/reset_password.txt new file mode 100644 index 000000000..d14796c51 --- /dev/null +++ b/OpenOversight/app/templates/auth/email/reset_password.txt @@ -0,0 +1,13 @@ +Dear {{ user.username }}, + +To reset your password click on the following link: + +{{ url_for('auth.password_reset', token=token, _external=True) }} + +If you have not requested a password reset simply ignore this message. + +Sincerely, + +The OpenOversight Team + +Note: replies to this email address are not monitored. diff --git a/OpenOversight/app/utils/constants.py b/OpenOversight/app/utils/constants.py index b5ad7b6e6..db9f87535 100644 --- a/OpenOversight/app/utils/constants.py +++ b/OpenOversight/app/utils/constants.py @@ -1,8 +1,44 @@ import os +# Cache Key Constants +KEY_DEPT_ALL_ASSIGNMENTS = "all_department_assignments" +KEY_DEPT_ALL_INCIDENTS = "all_department_incidents" +KEY_DEPT_ALL_LINKS = "all_department_links" +KEY_DEPT_ALL_NOTES = "all_department_notes" +KEY_DEPT_ALL_OFFICERS = "all_department_officers" +KEY_DEPT_ALL_SALARIES = "all_department_salaries" +KEY_DEPT_TOTAL_ASSIGNMENTS = "total_department_assignments" +KEY_DEPT_TOTAL_INCIDENTS = "total_department_incidents" +KEY_DEPT_TOTAL_OFFICERS = "total_department_officers" + +# Database Key Constants +KEY_DB_CREATOR = "creator" + +# Config Key Constants +KEY_ALLOWED_EXTENSIONS = "ALLOWED_EXTENSIONS" +KEY_DATABASE_URI = "SQLALCHEMY_DATABASE_URI" +KEY_ENV = "ENV" +KEY_ENV_DEV = "development" +KEY_ENV_TESTING = "testing" +KEY_ENV_PROD = "production" +KEY_NUM_OFFICERS = "NUM_OFFICERS" +KEY_OFFICERS_PER_PAGE = "OFFICERS_PER_PAGE" +KEY_OO_MAIL_SUBJECT_PREFIX = "OO_MAIL_SUBJECT_PREFIX" +KEY_OO_HELP_EMAIL = "OO_HELP_EMAIL" +KEY_OO_SERVICE_EMAIL = "OO_SERVICE_EMAIL" +KEY_MAIL_SERVER = "MAIL_SERVER" +KEY_MAIL_PORT = "MAIL_PORT" +KEY_MAIL_USE_TLS = "MAIL_USE_TLS" +KEY_MAIL_USERNAME = "MAIL_USERNAME" +KEY_MAIL_PASSWORD = "MAIL_PASSWORD" +KEY_S3_BUCKET_NAME = "S3_BUCKET_NAME" +KEY_TIMEZONE = "TIMEZONE" + # File Handling Constants ENCODING_UTF_8 = "utf-8" +FILE_TYPE_HTML = "html" +FILE_TYPE_PLAIN = "plain" SAVED_UMASK = os.umask(0o077) # Ensure the file is read/write by the creator only # File Name Constants diff --git a/OpenOversight/app/utils/general.py b/OpenOversight/app/utils/general.py index 22dabb3fe..b716b1c69 100644 --- a/OpenOversight/app/utils/general.py +++ b/OpenOversight/app/utils/general.py @@ -146,6 +146,8 @@ def serve_image(filepath): def str_is_true(str_): + if str_ is None: + return False return strtobool(str_.lower()) diff --git a/OpenOversight/tests/routes/test_user_api.py b/OpenOversight/tests/routes/test_user_api.py index d64b6706f..e170720e7 100644 --- a/OpenOversight/tests/routes/test_user_api.py +++ b/OpenOversight/tests/routes/test_user_api.py @@ -5,7 +5,6 @@ from flask import current_app, url_for from OpenOversight.app.auth.forms import EditUserForm, LoginForm, RegistrationForm -from OpenOversight.app.email_client import EmailClient from OpenOversight.app.models.database import User, db from OpenOversight.app.utils.constants import ENCODING_UTF_8 from OpenOversight.tests.conftest import AC_DEPT @@ -268,7 +267,6 @@ def test_admin_can_resend_user_confirmation_email(mockdata, client, session): def test_register_user_approval_required(mockdata, client, session): current_app.config["APPROVE_REGISTRATIONS"] = True - EmailClient(testing=True) with current_app.test_request_context(): diceware_password = "operative hamster persevere verbalize curling" form = RegistrationForm( @@ -353,7 +351,6 @@ def test_admin_approval_sends_confirmation_email( session, ): current_app.config["APPROVE_REGISTRATIONS"] = approve_registration_config - EmailClient(testing=True) with current_app.test_request_context(): login_admin(client) diff --git a/OpenOversight/tests/test_email_client.py b/OpenOversight/tests/test_email_client.py new file mode 100644 index 000000000..7d04ec2ce --- /dev/null +++ b/OpenOversight/tests/test_email_client.py @@ -0,0 +1,149 @@ +from email.mime.multipart import MIMEMultipart +from email.mime.text import MIMEText +from unittest.mock import MagicMock, patch + +import pytest +from flask import current_app + +from OpenOversight.app.email_client import ( + EmailClient, + GmailEmailProvider, + SimulatedEmailProvider, + SMTPEmailProvider, +) +from OpenOversight.app.models.database import User +from OpenOversight.app.models.emails import ChangePasswordEmail, Email +from OpenOversight.app.utils.constants import ( + FILE_TYPE_HTML, + FILE_TYPE_PLAIN, + KEY_MAIL_PORT, + KEY_MAIL_SERVER, + KEY_OO_HELP_EMAIL, + KEY_OO_SERVICE_EMAIL, +) + + +def test_email_create_message(faker): + email_body = faker.paragraph(nb_sentences=5) + email_html = faker.paragraph(nb_sentences=5) + email_receiver = faker.ascii_email() + email_subject = faker.paragraph(nb_sentences=1) + + email = Email(email_body, email_html, email_subject, email_receiver) + + test_message = MIMEMultipart("alternative") + test_message["To"] = email_receiver + test_message["From"] = current_app.config[KEY_OO_SERVICE_EMAIL] + test_message["Subject"] = email_subject + test_message["Reply-To"] = current_app.config[KEY_OO_HELP_EMAIL] + test_message.attach(MIMEText(email_body, FILE_TYPE_PLAIN)) + test_message.attach(MIMEText(email_html, FILE_TYPE_HTML)) + + actual_message = email.create_message() + + # Make sure both messages use same boundary string + boundary = "BOUNDARY" + actual_message.set_boundary(boundary) + test_message.set_boundary(boundary) + + assert actual_message.as_bytes() == test_message.as_bytes() + + +def test_email_client_auto_detect_debug_mode(app): + with app.app_context(): + app.debug = True + assert isinstance(EmailClient.auto_detect(), SimulatedEmailProvider) + + +def test_email_client_auto_detect_testing_mode(app): + with app.app_context(): + app.testing = True + assert isinstance(EmailClient.auto_detect(), SimulatedEmailProvider) + + +def test_email_client_auto_detect_follows_precedence(app): + with app.app_context(): + app.debug = False + app.testing = False + + provider1 = MagicMock() + provider1.is_configured.return_value = False + provider2 = MagicMock() + provider2.is_configured.return_value = True + provider3 = MagicMock() + provider3.is_configured.side_effect = AssertionError( + "Should not have been called" + ) + + EmailClient.PROVIDER_PRECEDENCE = [provider1, provider2, provider3] + detected_provider = EmailClient.auto_detect() + + assert detected_provider is provider2 + provider1.is_configured.assert_called_once() + provider2.is_configured.assert_called_once() + provider3.is_configured.assert_not_called() + + +def test_email_client_auto_detect_no_configured_providers_raises_error(app): + with app.app_context(): + app.debug = False + app.testing = False + + EmailClient.PROVIDER_PRECEDENCE = [] + with pytest.raises(ValueError): + EmailClient.auto_detect() + + +@pytest.mark.parametrize( + ("is_file", "size", "result"), + [ + (False, None, False), + (True, 0, False), + (True, 100, True), + ], +) +@patch("os.path.getsize") +@patch("os.path.isfile") +def test_gmail_email_provider_is_configured( + mock_isfile, mock_getsize, is_file, size, result +): + mock_getsize.return_value = size + mock_isfile.return_value = is_file + + assert GmailEmailProvider().is_configured() is result + + mock_isfile.assert_called_once() + if is_file: + mock_getsize.assert_called_once() + + +@pytest.mark.parametrize( + ("server", "port", "result"), + [ + (None, None, False), + ("smtp.example.org", None, False), + (None, 587, False), + ("smtp.example.org", 587, True), + ], +) +def test_smtp_email_provider_is_configured(app, server, port, result): + with app.app_context(): + app.config[KEY_MAIL_SERVER] = server + app.config[KEY_MAIL_PORT] = port + + assert SMTPEmailProvider().is_configured() is result + + +def test_smtp_email_provider_send_email(app, mockdata): + with app.app_context(): + mail = MagicMock() + user = User.query.first() + msg = ChangePasswordEmail("test@example.org", user) + + app.extensions["mail"] = mail + + provider = SMTPEmailProvider() + provider.mail = mail + provider.send_email(msg) + + mail.send.assert_called_once() diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index dd0ea2b76..c9aaa98f4 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -47,7 +47,7 @@ services: - postgres - minio environment: - ENV: development + ENV: ${ENV:-development} FLASK_DEBUG: 1 SQLALCHEMY_WARN_20: 1 volumes: diff --git a/justfile b/justfile index 49556d89a..aa7f6855e 100644 --- a/justfile +++ b/justfile @@ -91,7 +91,7 @@ db +migrateargs: # Run unit tests in the web container test *pytestargs: - just run --no-deps web pytest -n auto {{ pytestargs }} + ENV=testing just run --no-deps web pytest -n auto {{ pytestargs }} # Back up the postgres data using loomchild/volume-backup backup location: diff --git a/requirements.txt b/requirements.txt index cb2fa3fd5..551842700 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,6 +7,7 @@ Flask==2.3.2 Flask-Bootstrap==3.3.7.1 Flask-Limiter==3.3.1 Flask-Login==0.6.2 +Flask-Mail==0.9.1 Flask-Migrate==4.0.4 Flask-Sitemap==0.4.0 Flask-SQLAlchemy==3.0.3