Skip to content

Commit

Permalink
Merge pull request #1906 from cvisionai/dev/smtp-email
Browse files Browse the repository at this point in the history
Dev/smtp email
  • Loading branch information
jrtcppv authored Dec 11, 2024
2 parents 5f3390b + 7f91acd commit 1251344
Show file tree
Hide file tree
Showing 2 changed files with 29 additions and 94 deletions.
111 changes: 25 additions & 86 deletions api/main/mail.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,3 @@
# pylint: disable=import-error
from abc import ABC, abstractmethod
from enum import Enum, unique
import os
import io
import logging
Expand All @@ -15,21 +12,19 @@

from django.conf import settings
from django.db import models
import boto3

from .store import get_tator_store
import main.models

logger = logging.getLogger(__name__)


class TatorMail(ABC):
"""Abstract base class for sending emails from Tator"""
class TatorMail:
"""Class for sending emails from Tator using a standard SMTP server (e.g., AWS SES)"""

@abstractmethod
def _email(self, message, sender, recipients):
"""
Service-specific implementation of sending an email
Service-specific implementation of sending an email via SMTP.
:param sender: The email address of the sender
:type sender: str
Expand All @@ -38,6 +33,23 @@ def _email(self, message, sender, recipients):
:param message: The message to send
:type message: MIMEMultipart
"""
context = ssl.create_default_context()

# Ensure all required SMTP settings are defined
smtp_host = getattr(settings, "TATOR_EMAIL_HOST", None)
smtp_port = getattr(settings, "TATOR_EMAIL_PORT", None)
smtp_username = getattr(settings, "TATOR_EMAIL_USER", None)
smtp_password = getattr(settings, "TATOR_EMAIL_PASSWORD", None)

if not smtp_host or not smtp_port or not smtp_username or not smtp_password:
logger.error("SMTP settings are not correctly configured.")
return {"ResponseMetadata": {"HTTPStatusCode": 500}}

with smtplib.SMTP_SSL(smtp_host, smtp_port, context=context) as server:
server.login(smtp_username, smtp_password)
server.send_message(message)

return {"ResponseMetadata": {"HTTPStatusCode": 200}}

def email_staff(
self,
Expand All @@ -50,8 +62,7 @@ def email_staff(
add_footer: Optional[bool] = True,
) -> bool:
"""
Sends an email to all deployment staff members, see :meth:`main.mail.TatorMail.email`
for details
Sends an email to all deployment staff members.
"""
if settings.TATOR_EMAIL_NOTIFY_STAFF:
if add_footer and text:
Expand Down Expand Up @@ -99,7 +110,7 @@ def email(
:param text: The text body of the email
:type text: Optional[str]
:param html: The html body of the email
:type text: Optional[str]
:type html: Optional[str]
:param attachments: The list of storage object keys to attach as files to the email
:type attachments: Optional[list]
:param raise_on_failure: The text of the error to raise if the email fails.
Expand All @@ -112,9 +123,6 @@ def email(
msg["From"] = sender
msg["To"] = ", ".join(recipients)

# Record the MIME types of both parts - text/plain and text/html.
# According to RFC 2046, the last part of a multipart message, in this case the HTML
# message, is best and preferred.
if text:
part = MIMEText(text, "plain")
msg.attach(part)
Expand All @@ -123,10 +131,8 @@ def email(
msg.attach(part)

# Add attachments if there are any
# #TODO Potentially limit the attachment size(s)
if attachments:
for attachment in attachments:
# Download the S3 object into a byte stream and attach it
key = attachment["key"]
upload = key.startswith("_uploads")
bucket = None
Expand All @@ -145,90 +151,23 @@ def email(

email_response = self._email(msg, settings.TATOR_EMAIL_SENDER, recipients)

# If the email was successful, return True
# Check response for success
if email_response["ResponseMetadata"]["HTTPStatusCode"] == 200:
return True

# If the email was unsuccessful, log the response
# If the email was unsuccessful
logger.error(email_response)

# And if raise_on_failure is set, raise
if raise_on_failure is not None:
raise ValueError(raise_on_failure)

return False


class TatorSES(TatorMail):
"""Interface for AWS Simple Email Service."""

def __init__(self):
"""Creates the SES interface."""
super().__init__()
self.ses = boto3.client(
"ses",
region_name=settings.TATOR_EMAIL_AWS_REGION,
aws_access_key_id=settings.TATOR_EMAIL_AWS_ACCESS_KEY_ID,
aws_secret_access_key=settings.TATOR_EMAIL_AWS_SECRET_ACCESS_KEY,
)

def _email(self, message, sender, recipients):
"""Sends an email via AWS SES. See :class:`main.mail.TatorMail` for details"""
return self.ses.send_raw_email(
Source=sender,
Destinations=recipients,
RawMessage={"Data": message.as_string()},
)


class TatorEmailDelivery(TatorMail):
"""Interface for OCI Email Delivery Service."""

def __init__(self):
"""Creates the SMTP interface."""
# TODO Remove exception when implementation is complete
raise RuntimeError("OCI Email Delivery integration is incomplete, do not use!")
super().__init__() # pylint: disable=unreachable
self._host = settings.TATOR_EMAIL_OCI_HOST
self._port = settings.TATOR_EMAIL_OCI_PORT
self._user = settings.TATOR_EMAIL_OCI_USERNAME
self._pass = settings.TATOR_EMAIL_OCI_PASSWORD

def _email(self, message, sender, recipients):
"""
Sends an email via OCI Email Delivery. See :class:`main.mail.TatorMail`
for details
"""

# Set up mail server and test access
with smtplib.SMTP(self._host, self._port) as smtp:
smtp.ehlo()

# Start tls with trusted CA; may need to manually provide path if the default path does
# not contain any (or contains outdated) CAs
smtp.starttls(
context=ssl.create_default_context(
purpose=ssl.Purpose.SERVER_AUTH, cafile=None, capath=None
)
)
smtp.ehlo()

# Log in and send message
smtp.login(self._user, self._pass)
return smtp.send_message(message)


@unique
class EmailService(Enum):
AWS = TatorSES
OCI = TatorEmailDelivery


def get_email_service():
"""Instantiates the correct subclass of :class:`main.mail.TatorMail`"""

if settings.TATOR_EMAIL_ENABLED:
# TODO Hard-code AWS SES until OCI integration is complete
return TatorSES()
return TatorMail()

return None
12 changes: 4 additions & 8 deletions api/tator_online/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -294,14 +294,10 @@ def format(self, record):

if TATOR_EMAIL_ENABLED:
TATOR_EMAIL_SENDER = os.getenv("TATOR_EMAIL_SENDER")
TATOR_EMAIL_SERVICE = os.getenv("TATOR_EMAIL_SERVICE")

if TATOR_EMAIL_SERVICE == "AWS":
TATOR_EMAIL_AWS_REGION = os.getenv("TATOR_EMAIL_AWS_REGION")
TATOR_EMAIL_AWS_ACCESS_KEY_ID = os.getenv("TATOR_EMAIL_AWS_ACCESS_KEY_ID")
TATOR_EMAIL_AWS_SECRET_ACCESS_KEY = os.getenv("TATOR_EMAIL_AWS_SECRET_ACCESS_KEY")
# TODO Add `elif TATOR_EMAIL_SERVICE == "OCI":` case when OCI integration is complete

TATOR_EMAIL_HOST = os.getenv("TATOR_EMAIL_HOST")
TATOR_EMAIL_PORT = os.getenv("TATOR_EMAIL_PORT")
TATOR_EMAIL_USER = os.getenv("TATOR_EMAIL_USER")
TATOR_EMAIL_PASSWORD = os.getenv("TATOR_EMAIL_PASSWORD")
TATOR_EMAIL_NOTIFY_STAFF = os.getenv("TATOR_EMAIL_NOTIFY_STAFF")
if TATOR_EMAIL_NOTIFY_STAFF:
TATOR_EMAIL_NOTIFY_STAFF = TATOR_EMAIL_NOTIFY_STAFF.lower() == "true"
Expand Down

0 comments on commit 1251344

Please sign in to comment.