SciPost Code Repository

Skip to content
Snippets Groups Projects
core.py 11.2 KiB
Newer Older
__copyright__ = "Copyright © Stichting SciPost (SciPost Foundation)"
__license__ = "AGPL v3"

Jorran de Wit's avatar
Jorran de Wit committed
from html2text import HTML2Text
import json
import re
import inspect

from django.conf import settings
from django.core.mail import EmailMultiAlternatives
from django.db import models
from django.template.loader import get_template

from common.utils import get_current_domain

Jorran de Wit's avatar
Jorran de Wit committed
from .exceptions import ConfigurationError


class MailEngine:
    """
    This engine processes the configuration and template files to be saved into the database in
    the MailLog table.
    """

    _required_parameters = ["recipient_list", "subject"]
    _possible_parameters = [
        "recipient_list",
        "subject",
        "from_email",
        "from_name",
        "bcc",
    ]
    _email_fields = ["recipient_list", "from_email", "bcc"]
Jorran de Wit's avatar
Jorran de Wit committed
    _processed_template = False
    _mail_sent = False

    def __init__(
        self,
        mail_code,
        subject="",
        recipient_list=[],
        bcc=[],
        from_email="",
        from_name="",
        """
        Start engine with specific mail_code. Any other keyword argument that is passed will
        be used as a variable in the mail template.

        @Arguments
        -- mail_code (str)

        @Keyword arguments
        The following arguments overwrite the default values, set in the configuration files:
        -- subject (str, optional)
        -- recipient_list (str, optional): List of email addresses or db-relations.
        -- bcc (str, optional): List of email addresses or db-relations.
        -- from_email (str, optional): Plain email address.
        -- from_name (str, optional): Display name for from address.
        """
        self.mail_code = mail_code
        self.extra_config = {
            "bcc": bcc,
            "subject": subject,
            "from_name": from_name,
            "from_email": from_email,
            "recipient_list": recipient_list,
        }
        self.template_variables = kwargs
        # Add the 'domain' template variable to all templates using the Sites framework:
        self.template_variables["domain"] = get_current_domain()
Jorran de Wit's avatar
Jorran de Wit committed
    def __repr__(self):
        return '<%(cls)s code="%(code)s", validated=%(validated)s sent=%(sent)s>' % {
            "cls": self.__class__.__name__,
            "code": self.mail_code,
            "validated": hasattr(self, "mail_data"),
            "sent": self._mail_sent,
Jorran de Wit's avatar
Jorran de Wit committed
        }

    def validate(self, render_template=False):
        """Check if MailEngine is valid and ready for sending."""
        self._read_configuration_file()
        self._detect_and_save_object()
        self._check_template_exists()
        self._validate_configuration()
        self._validate_email_fields()
Jorran de Wit's avatar
Jorran de Wit committed
        if render_template:
            self.render_template()

Jorran de Wit's avatar
Jorran de Wit committed
    def render_only(self):
        """Render template. To be used in mail backend only."""
        if not hasattr(self, "mail_data"):
Jorran de Wit's avatar
Jorran de Wit committed
            self.mail_data = {}
        self._check_template_exists()
        self.render_template()
        return self.mail_data["message"], self.mail_data.get("html_message", "")
Jorran de Wit's avatar
Jorran de Wit committed

Jorran de Wit's avatar
Jorran de Wit committed
    def render_template(self, html_message=None):
        """
        Render the template associated with the mail_code. If html_message is given,
        use this as a template instead.
        """
        if html_message:
            self.mail_data["html_message"] = html_message
Jorran de Wit's avatar
Jorran de Wit committed
        else:
            self.mail_data["html_message"] = self._template.render(
                self.template_variables
            )  # Damn slow.
Jorran de Wit's avatar
Jorran de Wit committed

        # Transform to non-HTML version.
        handler = HTML2Text()
        self.mail_data["message"] = handler.handle(self.mail_data["html_message"])
Jorran de Wit's avatar
Jorran de Wit committed
        self._processed_template = True

    def send_mail(self):
        """Send the mail."""
        if self._mail_sent:
            # Prevent double sending when using a Django form.
            return
        elif not hasattr(self, "mail_data"):
Jorran de Wit's avatar
Jorran de Wit committed
            raise ValueError(
                "The mail: %s could not be sent because the data didn't validate."
                % self.mail_code
            )
Jorran de Wit's avatar
Jorran de Wit committed
        email = EmailMultiAlternatives(
            self.mail_data["subject"],
            self.mail_data.get("message", ""),
            "%s <%s>"
            % (
                self.mail_data.get("from_name", "SciPost"),
Jean-Sébastien Caux's avatar
Jean-Sébastien Caux committed
                self.mail_data.get("from_email", "noreply@%s" % get_current_domain()),
            ),
            self.mail_data["recipient_list"],
            bcc=self.mail_data["bcc"],
Jorran de Wit's avatar
Jorran de Wit committed
            reply_to=[
Jean-Sébastien Caux's avatar
Jean-Sébastien Caux committed
                self.mail_data.get("from_email", "noreply@%s" % get_current_domain())
Jorran de Wit's avatar
Jorran de Wit committed
            ],
            headers={
                "delayed_processing": not self._processed_template,
                "context": self.template_variables,
                "mail_code": self.mail_code,
            },
        )
Jorran de Wit's avatar
Jorran de Wit committed

        # Send html version if available
        if "html_message" in self.mail_data:
            email.attach_alternative(self.mail_data["html_message"], "text/html")
Jorran de Wit's avatar
Jorran de Wit committed

        email.send(fail_silently=False)
        self._mail_sent = True

        if "object" in self.template_variables and hasattr(
            self.template_variables["object"], "mail_sent"
        ):
            self.template_variables["object"].mail_sent()
Jorran de Wit's avatar
Jorran de Wit committed

    def _detect_and_save_object(self):
        """
        Detect if less than or equal to one object exists and save it, else raise exception.
        Stick to Django's convention of saving it as a central `object` variable.
        """
        object = None
        context_object_name = None

        if "object" in self.template_variables:
            object = self.template_variables["object"]
Jorran de Wit's avatar
Jorran de Wit committed
            context_object_name = object._meta.model_name
        elif "instance" in self.template_variables:
            object = self.template_variables["instance"]
Jorran de Wit's avatar
Jorran de Wit committed
            context_object_name = object._meta.model_name
Jorran de Wit's avatar
Jorran de Wit committed
        else:
            for key, var in self.template_variables.items():
                if isinstance(var, models.Model):
                    if object:
                        raise ValueError(
                            "Multiple db instances are given. Please specify which object to use."
                        )
Jorran de Wit's avatar
Jorran de Wit committed
                    else:
                        object = var
Jorran de Wit's avatar
Jorran de Wit committed
        if object:
            self.template_variables["object"] = object
Jorran de Wit's avatar
Jorran de Wit committed

            if (
                context_object_name
                and context_object_name not in self.template_variables
            ):
Jorran de Wit's avatar
Jorran de Wit committed
                self.template_variables[context_object_name] = object
Jorran de Wit's avatar
Jorran de Wit committed

    def _read_configuration_file(self):
        """Retrieve default configuration for specific mail_code."""
        json_location = "%s/templates/email/%s.json" % (
            settings.BASE_DIR,
            self.mail_code,
        )
Jorran de Wit's avatar
Jorran de Wit committed

        try:
            with open(json_location, "r") as f:
                self.mail_data = json.loads(f.read())
Jorran de Wit's avatar
Jorran de Wit committed
        except OSError:
            raise ImportError(
                "No configuration file found. Mail code: %s" % self.mail_code
            )
Jorran de Wit's avatar
Jorran de Wit committed

        # Check if configuration file is valid.
        if "subject" not in self.mail_data:
Jorran de Wit's avatar
Jorran de Wit committed
            raise ConfigurationError('key "subject" is missing.')
        if "recipient_list" not in self.mail_data:
Jorran de Wit's avatar
Jorran de Wit committed
            raise ConfigurationError('key "recipient_list" is missing.')

        # Overwrite mail data if parameters are given.
        for key, val in self.extra_config.items():
            if val or key not in self.mail_data:
                self.mail_data[key] = val

    def _check_template_exists(self):
        """Save template or raise TemplateDoesNotExist."""
        self._template = get_template("email/%s.html" % self.mail_code)
Jorran de Wit's avatar
Jorran de Wit committed

    def _validate_configuration(self):
        """Check if all required data is given via either configuration or extra parameters."""

        # Check data is complete
        if not all(key in self.mail_data for key in self._required_parameters):
            txt = "Not all required parameters are given in the configuration file or on instantiation."
            txt += " Check required parameters: {}".format(self._required_parameters)
Jorran de Wit's avatar
Jorran de Wit committed
            raise ConfigurationError(txt)

Jorran de Wit's avatar
Jorran de Wit committed
        # Check if data is overcomplete/
        if not all(key in self._possible_parameters for key in self.mail_data.keys()):
            txt = "Configuration file may only contain the following parameters: {}.".format(
                self._possible_parameters
            )
Jorran de Wit's avatar
Jorran de Wit committed
            raise ConfigurationError(txt)

Jorran de Wit's avatar
Jorran de Wit committed
        # Check all configuration value types
        for email_key in ["subject", "from_email", "from_name"]:
Jorran de Wit's avatar
Jorran de Wit committed
            if email_key in self.mail_data and self.mail_data[email_key]:
                if not isinstance(self.mail_data[email_key], str):
                    raise ConfigurationError(
                        '"%(key)s" argument must be a string'
                        % {
                            "key": email_key,
                        }
                    )
        for email_key in ["recipient_list", "bcc"]:
Jorran de Wit's avatar
Jorran de Wit committed
            if email_key in self.mail_data and self.mail_data[email_key]:
                if not isinstance(self.mail_data[email_key], list):
                    raise ConfigurationError(
                        '"%(key)s" argument must be a list'
                        % {
                            "key": email_key,
                        }
                    )
Jorran de Wit's avatar
Jorran de Wit committed

Jorran de Wit's avatar
Jorran de Wit committed
    def _validate_email_fields(self):
        """Validate all email addresses in the mail config."""
Jorran de Wit's avatar
Jorran de Wit committed
        for email_key in self._email_fields:
            if emails := self.mail_data.get(email_key, None):
                was_list = isinstance(emails, list)
                emails = emails if was_list else [emails]

                valid_emails = [
                    valid_entry
                    for entry in emails
                    if (valid_entry := self._validate_email_addresses(entry))
                ]

                if len(valid_emails) == 0:
                    raise ConfigurationError(
                        "No valid email addresses found for %s." % email_key
Jorran de Wit's avatar
Jorran de Wit committed

                self.mail_data[email_key] = (
                    valid_emails if was_list else valid_emails[0]
                )
Jorran de Wit's avatar
Jorran de Wit committed
    def _validate_email_addresses(self, entry):
        """
        Return email address given raw email, email prefix or database relation given in `entry`.
        """
Jorran de Wit's avatar
Jorran de Wit committed
        if re.match("[^@]+@[^@]+\.[^@]+", entry):
            # Email string
            return entry
        # if the email address is given as a prefix of the form `[recipient]@`, add domain name:
        elif re.match("[^@]+@$", entry):
            return "%s%s" % (entry, get_current_domain())
        elif self.template_variables["object"]:
            mail_to = self.template_variables["object"]
            for attr in entry.split("|")[0].split("."):
Jorran de Wit's avatar
Jorran de Wit committed
                try:
                    mail_to = getattr(mail_to, attr)
                    if inspect.ismethod(mail_to):
                        mail_to = mail_to()
                except AttributeError:
                    # Invalid property/mail
                    if entry.endswith("|None"):
                        # Allow None values
                        return None
                    raise KeyError("The property (%s) does not exist." % entry)
Jorran de Wit's avatar
Jorran de Wit committed
            return mail_to
        raise KeyError("Neither an email adress nor db instance is given.")