Email Templating Engine

This commit adds an email templating engine which permits batch
generation of emails. By default, it requires a text template,
however other text template types may be added to the engine and will
be appended in order. It is contained inside the email
plugin directory, as it is one component of the emailing system.

Change-Id: I90ee44425807e96d2fb3ddc0adf122a54636a266
This commit is contained in:
Michael Krotscheck 2015-01-15 15:13:28 -08:00 committed by Thierry Carrez
parent eb0964362a
commit e984d5c5c1
7 changed files with 294 additions and 1 deletions

View File

@ -24,4 +24,6 @@ eventlet>=0.13.0
stevedore>=1.0.0
python-crontab>=1.8.1
tzlocal>=1.1.2
rfc3987>=1.3.4
rfc3987>=1.3.4
email>=4.0.2
Jinja2>=2.7.3

View File

View File

@ -0,0 +1,116 @@
# Copyright (c) 2015 Hewlett-Packard Development Company, L.P.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
# implied. See the License for the specific language governing permissions and
# limitations under the License.
import collections
import re
import six
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from email.utils import formatdate
from jinja2 import Environment
from jinja2 import PackageLoader
from jinja2 import Template
class EmailFactory(object):
"""An email rendering utility, which takes a default Jinja2 templates,
a data dict, and builds an RFC 2046 compliant email. We enforce several
constant headers here, in order to reduce the amount of replicated code.
"""
def __init__(self, sender, subject, text_template,
template_package='storyboard.plugin.email'):
"""Create a new instance of the email renderer.
:param sender: The sender of this email.
:param subject: The subject of this email, which may be templated.
:param text_template: A Jinja2 template for the raw text content.
:return:
"""
super(EmailFactory, self).__init__()
# Build our template classpath loader and template cache. Default
# text encoding is UTF-8. The use of OrderedDict is important,
# as the Email RFC specifies rendering priority based on order.
self.template_cache = collections.OrderedDict()
self.env = Environment(loader=PackageLoader(template_package,
'templates'))
# Store internal values.
self.sender = sender
self.subject = Template(subject)
self.headers = dict()
# Add the default text template.
self.add_text_template(text_template, mime_subtype='plain')
# Declare default headers.
self.headers['Auto-Submitted'] = 'auto-generated'
def add_text_template(self, template, mime_subtype='plain'):
"""Add a text/* template type to this email engine.
:param template: The name of the template.
:param mime_subtype: The mime subtype. ex: text/html -> html
"""
self.template_cache[mime_subtype] = self.env.get_template(template)
def add_header(self, name, value):
"""Add a custom header to the factory.
:param name: The name of the header.
:param value: The value of the header.
"""
self.headers[name] = value
def build(self, recipient, **kwargs):
"""Build this email and return the Email Instance.
:param recipient: The recipient of the email.
:param kwargs: Additional key/value arguments to be rendered.
:return: The email instance.
"""
# Create message container as multipart/alternative.
msg = MIMEMultipart('alternative')
msg['From'] = self.sender
msg['To'] = recipient
msg['Date'] = formatdate(localtime=True)
# Render the subject template. Add length and \r\n sanity check
# replacements. While we could fold the subject line, if our subject
# lines are longer than 78 characters nobody's going to read them all.
subject = self.subject.render(**kwargs)
subject = re.sub(r'\r?\n', ' ', subject)
if len(subject) > 78:
subject = subject[0:75] + '...'
msg['Subject'] = subject
# Iterate and render over all additional headers.
for key, value in six.iteritems(self.headers):
try:
msg.replace_header(key, value)
except KeyError:
msg.add_header(key, value)
# Render and attach our templates.
for type, template in six.iteritems(self.template_cache):
body_part = template.render(**kwargs)
msg.attach(MIMEText(body_part, type, "utf-8"))
return msg

View File

@ -0,0 +1 @@
{{test_parameter}}

View File

@ -0,0 +1 @@
{{test_parameter}}

View File

@ -0,0 +1,173 @@
# -*- encoding: utf-8 -*-
# Copyright (c) 2015 Hewlett-Packard Development Company, L.P.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
# implied. See the License for the specific language governing permissions and
# limitations under the License.
import six
from jinja2.exceptions import TemplateNotFound
from storyboard.plugin.email.factory import EmailFactory
from storyboard.tests import base
class TestEmailFactory(base.TestCase):
def test_simple_build(self):
"""Assert that a simple build provides an email.
"""
factory = EmailFactory('test@example.org',
'test_subject',
'test.txt',
'plugin.email')
msg = factory.build('test_recipient@example.org',
test_parameter='value')
# Assert that the message is multipart
self.assertTrue(msg.is_multipart())
self.assertEqual(1, len(msg.get_payload()))
# Test message headers
self.assertEqual('test@example.org', msg.get('From'))
self.assertEqual('test_recipient@example.org', msg.get('To'))
self.assertEqual('test_subject', msg.get('Subject'))
self.assertEqual('auto-generated', msg.get('Auto-Submitted'))
self.assertEqual('multipart/alternative', msg.get('Content-Type'))
self.assertIsNotNone(msg.get('Date')) # This will vary
payload_text = msg.get_payload(0)
self.assertEqual('text/plain; charset="utf-8"',
payload_text.get('Content-Type'))
self.assertEqual('value',
payload_text.get_payload(decode=True))
# Assert that there's only one payload.
self.assertEqual(1, len(msg.get_payload()))
def test_custom_headers(self):
"""Assert that we can set custom headers."""
factory = EmailFactory('test@example.org',
'test_subject',
'test.txt',
'plugin.email')
custom_headers = {
'X-Custom-Header': 'test-header-value'
}
for name, value in six.iteritems(custom_headers):
factory.add_header(name, value)
msg = factory.build('test_recipient@example.org',
test_parameter='value')
self.assertEqual('test-header-value',
msg.get('X-Custom-Header'))
# test that headers may be overridden, and that we don't end up with
# duplicate subjects.
factory.add_header('Subject', 'new_subject')
msg = factory.build('test_recipient@example.org',
test_parameter='value')
self.assertEqual('new_subject', msg.get('Subject'))
def test_subject_template(self):
"""Assert that the subject is templateable."""
factory = EmailFactory('test@example.org',
'{{test_parameter}}',
'test.txt',
'plugin.email')
msg = factory.build('test_recipient@example.org',
test_parameter='value')
self.assertEqual('value', msg.get('Subject'))
# Assert that the subject is trimmed. and appended with an ellipsis.
test_subject = ('a' * 100)
factory = EmailFactory('test@example.org',
test_subject,
'test.txt',
'plugin.email')
msg = factory.build('test_recipient@example.org',
test_parameter='value')
self.assertEqual(78, len(msg.get('Subject')))
self.assertEqual('...', msg.get('Subject')[-3:])
# Assert that the subject has unix newlines trimmed
factory = EmailFactory('test@example.org',
'with\nnewline',
'test.txt',
'plugin.email')
msg = factory.build('test_recipient@example.org',
test_parameter='value')
self.assertEqual('with newline', msg.get('Subject'))
# Assert that the subject has windows returns trimmed
factory = EmailFactory('test@example.org',
'with\r\nnewline',
'test.txt',
'plugin.email')
msg = factory.build('test_recipient@example.org',
test_parameter='value')
self.assertEqual('with newline', msg.get('Subject'))
def test_html_template(self):
'''Assert that we may add an additional text template to the email
engine.
'''
factory = EmailFactory('test@example.org',
'test_subject',
'test.txt',
'plugin.email')
factory.add_text_template('test.html', 'html')
msg = factory.build('test_recipient@example.org',
test_parameter='value')
# Assert that the message is multipart
self.assertTrue(msg.is_multipart())
self.assertEqual(2, len(msg.get_payload()))
payload_text = msg.get_payload(0)
self.assertEqual('text/plain; charset="utf-8"',
payload_text.get('Content-Type'))
self.assertEqual('value',
payload_text.get_payload(decode=True))
payload_html = msg.get_payload(1)
self.assertEqual('text/html; charset="utf-8"',
payload_html.get('Content-Type'))
self.assertEqual('value',
payload_html.get_payload(decode=True))
def test_no_template(self):
"""Assert that attempting to load an invalid template raises an
exception.
"""
try:
EmailFactory('test@example.org',
'test_subject',
'invalid.txt',
'plugin.email')
self.assertFalse(True)
except TemplateNotFound:
self.assertFalse(False)
try:
factory = EmailFactory('test@example.org',
'test_subject',
'test.txt',
'plugin.email')
factory.add_text_template('invalid.html', 'html')
self.assertFalse(True)
except TemplateNotFound:
self.assertFalse(False)