__copyright__ = "Copyright © Stichting SciPost (SciPost Foundation)"
__license__ = "AGPL v3"
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 .exceptions import ConfigurationError
[docs]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']
_processed_template = False
_mail_sent = False
def __init__(self, mail_code, subject='', recipient_list=[], bcc=[], from_email='',
from_name='', **kwargs):
"""
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
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,
}
[docs] 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()
if render_template:
self.render_template()
[docs] def render_only(self):
"""Render template. To be used in mail backend only."""
if not hasattr(self, 'mail_data'):
self.mail_data = {}
self._check_template_exists()
self.render_template()
return self.mail_data['message'], self.mail_data.get('html_message', '')
[docs] 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
else:
self.mail_data['html_message'] = self._template.render(self.template_variables) # Damn slow.
# Transform to non-HTML version.
handler = HTML2Text()
self.mail_data['message'] = handler.handle(self.mail_data['html_message'])
self._processed_template = True
[docs] 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'):
raise ValueError(
"The mail: %s could not be sent because the data didn't validate." % self.mail_code)
email = EmailMultiAlternatives(
self.mail_data['subject'],
self.mail_data.get('message', ''),
'%s <%s>' % (
self.mail_data.get('from_name', 'SciPost'),
self.mail_data.get('from_email', 'noreply@scipost.org')),
self.mail_data['recipient_list'],
bcc=self.mail_data['bcc'],
reply_to=[
self.mail_data.get('from_email', 'noreply@scipost.org')
],
headers={
'delayed_processing': not self._processed_template,
'context': self.template_variables,
'mail_code': self.mail_code,
})
# Send html version if available
if 'html_message' in self.mail_data:
email.attach_alternative(self.mail_data['html_message'], 'text/html')
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()
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']
context_object_name = object._meta.model_name
elif 'instance' in self.template_variables:
object = self.template_variables['instance']
context_object_name = object._meta.model_name
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.')
else:
object = var
if object:
self.template_variables['object'] = object
if context_object_name and context_object_name not in self.template_variables:
self.template_variables[context_object_name] = object
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)
try:
self.mail_data = json.loads(open(json_location).read())
except OSError:
raise ImportError('No configuration file found. Mail code: %s' % self.mail_code)
# Check if configuration file is valid.
if 'subject' not in self.mail_data:
raise ConfigurationError('key "subject" is missing.')
if 'recipient_list' not in self.mail_data:
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)
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)
raise ConfigurationError(txt)
# 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)
raise ConfigurationError(txt)
# Check all configuration value types
for email_key in ['subject', 'from_email', 'from_name']:
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']:
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,
})
def _validate_email_fields(self):
"""Validate all email addresses in the mail config."""
for email_key in self._email_fields:
if email_key in self.mail_data:
if isinstance(self.mail_data[email_key], list):
for i, email in enumerate(self.mail_data[email_key]):
self.mail_data[email_key][i] = self._validate_email_addresses(email)
else:
self.mail_data[email_key] = self._validate_email_addresses(self.mail_data[email_key])
def _validate_email_addresses(self, entry):
"""Return email address given raw email or database relation given in `entry`."""
if re.match("[^@]+@[^@]+\.[^@]+", entry):
# Email string
return entry
elif self.template_variables['object']:
mail_to = self.template_variables['object']
for attr in entry.split('.'):
try:
mail_to = getattr(mail_to, attr)
if inspect.ismethod(mail_to):
mail_to = mail_to()
except AttributeError:
# Invalid property/mail
raise KeyError('The property (%s) does not exist.' % entry)
return mail_to
raise KeyError('Neither an email adress nor db instance is given.')