diff --git a/requirements.txt b/requirements.txt index 4d6df5bf..d57ddd8d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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 \ No newline at end of file +rfc3987>=1.3.4 +email>=4.0.2 +Jinja2>=2.7.3 diff --git a/storyboard/plugin/email/__init__.py b/storyboard/plugin/email/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/storyboard/plugin/email/factory.py b/storyboard/plugin/email/factory.py new file mode 100644 index 00000000..5341c212 --- /dev/null +++ b/storyboard/plugin/email/factory.py @@ -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 diff --git a/storyboard/tests/plugin/email/__init__.py b/storyboard/tests/plugin/email/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/storyboard/tests/plugin/email/templates/test.html b/storyboard/tests/plugin/email/templates/test.html new file mode 100644 index 00000000..b4a9b879 --- /dev/null +++ b/storyboard/tests/plugin/email/templates/test.html @@ -0,0 +1 @@ +{{test_parameter}} \ No newline at end of file diff --git a/storyboard/tests/plugin/email/templates/test.txt b/storyboard/tests/plugin/email/templates/test.txt new file mode 100644 index 00000000..b4a9b879 --- /dev/null +++ b/storyboard/tests/plugin/email/templates/test.txt @@ -0,0 +1 @@ +{{test_parameter}} \ No newline at end of file diff --git a/storyboard/tests/plugin/email/test_factory.py b/storyboard/tests/plugin/email/test_factory.py new file mode 100644 index 00000000..40f68f86 --- /dev/null +++ b/storyboard/tests/plugin/email/test_factory.py @@ -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)