Dallinger/Dallinger

View on GitHub
dallinger/notifications.py

Summary

Maintainability
A
0 mins
Test Coverage
import logging
import smtplib
from email.mime.text import MIMEText

import six
from cached_property import cached_property

logger = logging.getLogger(__file__)
CONFIG_PLACEHOLDER = "???"


class InvalidEmailConfig(ValueError):
    """The configuration contained missing or invalid email-related values."""


class SMTPMailer(object):
    def __init__(self, host, username, password):
        self.host = host
        self.username = username
        self.password = password
        self._sent = []

    @cached_property
    def server(self):
        return get_email_server(self.host)

    def send(self, subject, sender, recipients, body):
        msg = self._make_email(subject, sender, recipients, body)
        try:
            self.server.starttls()
            self.server.login(self.username, self.password)
            self.server.sendmail(sender, recipients, msg.as_string())
            self.server.quit()
        except smtplib.SMTPException as ex:
            six.raise_from(MessengerError("SMTP error sending HIT error email."), ex)
        except Exception as ex:
            six.raise_from(MessengerError("Unknown error sending HIT error email."), ex)

        self._sent.append(msg)

    def _make_email(self, subject, sender, recipients, body):
        msg = MIMEText(body)
        msg["Subject"] = subject
        msg["To"] = ",".join(recipients)
        msg["From"] = sender

        return msg


class LoggingMailer(object):
    def __init__(self):
        self._sent = []

    def send(self, subject, sender, recipients, body):
        msg = "{}:\nSubject: {}\nSender: {}\nRecipients: {}\nBody:\n{}".format(
            self.__class__.__name__, subject, sender, ", ".join(recipients), body
        )

        logger.info(msg)
        self._sent.append(msg)


def get_mailer(config, strict=False):
    """Return an appropriate Messenger.

    If we're in debug mode, or email settings aren't set, return a debug
    version which logs the message instead of attempting to send a real
    email.
    """
    settings = EmailConfig(config)
    if config.get("mode") == "debug":
        return LoggingMailer()
    problems = settings.validate()
    if problems:
        if strict:
            raise InvalidEmailConfig(problems)

        logger.info(problems + " Will log errors instead of emailing them.")
        return LoggingMailer()

    return SMTPMailer(
        settings.smtp_host, settings.smtp_username, settings.smtp_password
    )


def get_email_server(host):
    """Return an SMTP server using the specified host.

    Abandon attempts to connect after 8 seconds.
    """
    return smtplib.SMTP(host, timeout=8)


class MessengerError(Exception):
    """A message could not be relayed."""


class EmailConfig(object):
    """Extracts and validates email-related values from a Configuration"""

    mail_config_keys = {
        "smtp_host",
        "smtp_username",
        "smtp_password",
        "contact_email_on_error",
        "dallinger_email_address",
    }

    def __init__(self, config):
        self.smtp_host = config.get("smtp_host")
        self.smtp_username = config.get("smtp_username", None)
        self.contact_email_on_error = config.get("contact_email_on_error")
        self.smtp_password = config.get("smtp_password", None)
        self.dallinger_email_address = config.get("dallinger_email_address")

    def as_dict(self):
        cleaned = self.__dict__.copy()
        password = self.smtp_password
        if password and password != CONFIG_PLACEHOLDER:
            cleaned["smtp_password"] = password[:3] + "......" + password[-1]

        return cleaned

    def validate(self):
        """Could this config be used to send a real email?"""
        missing = []
        for k in self.mail_config_keys:
            attr = getattr(self, k, False)
            if not attr or attr == CONFIG_PLACEHOLDER:
                missing.append(k)
        if missing:
            return "Missing or invalid config values: {}".format(
                ", ".join(sorted(missing))
            )


class NotifiesAdmin(object):
    """Quickly email the experiment admin/author with to/from addresses
    taken from configuration.
    """

    def __init__(self, email_settings, mailer):
        self.fromaddr = email_settings.dallinger_email_address
        self.toaddr = email_settings.contact_email_on_error
        self.mailer = mailer

    def send(self, subject, body):
        self.mailer.send(subject, self.fromaddr, [self.toaddr], body)


def admin_notifier(config):
    """Return an appropriate NotifiesAdmin implementation.

    If we're in debug mode, or email settings aren't set, return a debug
    version which logs the message instead of attempting to send a real
    email.
    """
    settings = EmailConfig(config)
    if config.get("mode") == "debug":
        return NotifiesAdmin(settings, LoggingMailer())
    problems = settings.validate()
    if problems:
        logger.info(problems + " Will log errors instead of emailing them.")
        return NotifiesAdmin(settings, LoggingMailer())
    return NotifiesAdmin(
        settings,
        SMTPMailer(settings.smtp_host, settings.smtp_username, settings.smtp_password),
    )