__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.contrib.sites.models import Site from django.core.mail import EmailMultiAlternatives from django.db import models from django.template.loader import get_template 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'] _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 # Add the 'domain' template variable to all templates using the Sites framework: self.template_variables['domain'] = Site.objects.get_current().domain 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, } 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() 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', '') 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 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@%s' % Site.objects.get_current().domain)), self.mail_data['recipient_list'], bcc=self.mail_data['bcc'], reply_to=[ self.mail_data.get('from_email', 'noreply@%s' % Site.objects.get_current().domain) ], 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: with open(json_location, 'r') as f: self.mail_data = json.loads(f.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, email prefix or database relation given in `entry`. """ 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, Site.objects.get_current().domain) 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.')