-
Notifications
You must be signed in to change notification settings - Fork 79
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
Changes from 12 commits
ca03f76
4a27d7e
99123c4
8d99ea4
5e70cd5
12505e4
d2092fb
df66e46
1a88d7a
2cdcd35
68d79ce
004e36c
88cfa5e
972e2f8
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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: | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
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) |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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__)) | ||
|
@@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. What's the reasoning for using There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yea, I was going back and forth about using I don't have a strong preference either way so let me know if you want me to change it There was a problem hiding this comment. Choose a reason for hiding this commentThe 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") | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This change.