Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Re-add SMTP email provider #1047

Merged
merged 14 commits into from
Sep 4, 2023
26 changes: 21 additions & 5 deletions CONTRIB.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,17 +49,33 @@ $ docker exec -it openoversight-web-1 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:
```shell
Expand Down
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This change.


.PHONY: populate
populate: create_db ## Build and run containers
Expand Down
12 changes: 3 additions & 9 deletions OpenOversight/app/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
from OpenOversight.app.filters import instantiate_filters
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()
Expand All @@ -43,14 +43,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)
Expand Down
173 changes: 141 additions & 32 deletions OpenOversight/app/email_client.py
Original file line number Diff line number Diff line change
@@ -1,56 +1,165 @@
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 KEY_OO_SERVICE_EMAIL, 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."
)
delegated_credentials = credentials.with_subject(
config[KEY_OO_SERVICE_EMAIL]

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__}"
)
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:
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

DEBUG is on in dev mode, so keeping DEBUG would prevent local Gmail 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):
"""
Deliver the email from the parameter list using the Singleton client.

: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(f"An error occurred: {error}")
if cls._provider is not None:
cls._provider.send_email(email)
16 changes: 15 additions & 1 deletion OpenOversight/app/models/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,20 @@
KEY_ENV_DEV,
KEY_ENV_PROD,
KEY_ENV_TESTING,
KEY_MAIL_PASSWORD,
KEY_MAIL_PORT,
KEY_MAIL_SERVER,
KEY_MAIL_USE_TLS,
KEY_MAIL_USERNAME,
KEY_OFFICERS_PER_PAGE,
KEY_OO_HELP_EMAIL,
KEY_OO_MAIL_SUBJECT_PREFIX,
KEY_OO_SERVICE_EMAIL,
KEY_S3_BUCKET_NAME,
KEY_TIMEZONE,
MEGABYTE,
)
from OpenOversight.app.utils.general import str_is_true


basedir = os.path.abspath(os.path.dirname(__file__))
Expand Down Expand Up @@ -52,7 +59,14 @@ def __init__(self):
self.OO_SERVICE_EMAIL = os.environ.get(KEY_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))
Comment on lines +65 to +69
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's the reasoning for using setattr instead of self.____?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yea, I was going back and forth about using setattr here but this lets us use the value of the constant as the config variable name, rather than hardcoding that name (for example self.MAIL_SERVER = ...) because otherwise we would need to update config.py along with constants.py if we wanted to change the config variable name.

I don't have a strong preference either way so let me know if you want me to change it

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The more I think about it, the more I like it. Keep it, imo. I may change the syntax for the rest of them in another PR.


# AWS Settings
self.AWS_ACCESS_KEY_ID = os.environ.get("AWS_ACCESS_KEY_ID")
Expand Down
Loading