diff --git a/docs/api/wuttjamaican/email.handler.rst b/docs/api/wuttjamaican/email.handler.rst new file mode 100644 index 0000000..4e4900f --- /dev/null +++ b/docs/api/wuttjamaican/email.handler.rst @@ -0,0 +1,6 @@ + +``wuttjamaican.email.handler`` +============================== + +.. automodule:: wuttjamaican.email.handler + :members: diff --git a/docs/api/wuttjamaican/email.message.rst b/docs/api/wuttjamaican/email.message.rst new file mode 100644 index 0000000..1656196 --- /dev/null +++ b/docs/api/wuttjamaican/email.message.rst @@ -0,0 +1,6 @@ + +``wuttjamaican.email.message`` +============================== + +.. automodule:: wuttjamaican.email.message + :members: diff --git a/docs/api/wuttjamaican/email.rst b/docs/api/wuttjamaican/email.rst new file mode 100644 index 0000000..d187d98 --- /dev/null +++ b/docs/api/wuttjamaican/email.rst @@ -0,0 +1,6 @@ + +``wuttjamaican.email`` +====================== + +.. automodule:: wuttjamaican.email + :members: diff --git a/docs/api/wuttjamaican/index.rst b/docs/api/wuttjamaican/index.rst index 91a1cf8..69a754e 100644 --- a/docs/api/wuttjamaican/index.rst +++ b/docs/api/wuttjamaican/index.rst @@ -17,6 +17,9 @@ db.model.base db.model.upgrades db.sess + email + email.handler + email.message enum exc people diff --git a/docs/glossary.rst b/docs/glossary.rst index 2c70691..3b87762 100644 --- a/docs/glossary.rst +++ b/docs/glossary.rst @@ -124,6 +124,12 @@ Glossary In practice this generally refers to a :class:`~wuttjamaican.db.sess.Session` instance. + email handler + The :term:`handler` responsible for sending email on behalf of + the :term:`app`. + + Default is :class:`~wuttjamaican.email.handler.EmailHandler`. + entry point This refers to a "setuptools-style" entry point specifically, which is a mechanism used to register "plugins" and the like. diff --git a/pyproject.toml b/pyproject.toml index 4e985e5..8b7419e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,6 +35,7 @@ dependencies = [ [project.optional-dependencies] db = ["SQLAlchemy<2", "alembic", "alembic-postgresql-enum", "passlib"] +email = ["Mako"] docs = ["Sphinx", "sphinxcontrib-programoutput", "enum-tools[sphinx]", "furo"] tests = ["pytest-cov", "tox"] diff --git a/src/wuttjamaican/app.py b/src/wuttjamaican/app.py index 1a98b75..5d67df2 100644 --- a/src/wuttjamaican/app.py +++ b/src/wuttjamaican/app.py @@ -81,6 +81,7 @@ class AppHandler: default_model_spec = 'wuttjamaican.db.model' default_enum_spec = 'wuttjamaican.enum' default_auth_handler_spec = 'wuttjamaican.auth:AuthHandler' + default_email_handler_spec = 'wuttjamaican.email:EmailHandler' default_people_handler_spec = 'wuttjamaican.people:PeopleHandler' def __init__(self, config): @@ -606,6 +607,21 @@ class AppHandler: self.handlers['auth'] = factory(self.config, **kwargs) return self.handlers['auth'] + def get_email_handler(self, **kwargs): + """ + Get the configured :term:`email handler`. + + See also :meth:`send_email()`. + + :rtype: :class:`~wuttjamaican.email.handler.EmailHandler` + """ + if 'email' not in self.handlers: + spec = self.config.get(f'{self.appname}.email.handler', + default=self.default_email_handler_spec) + factory = self.load_object(spec) + self.handlers['email'] = factory(self.config, **kwargs) + return self.handlers['email'] + def get_people_handler(self, **kwargs): """ Get the configured "people" :term:`handler`. @@ -634,6 +650,15 @@ class AppHandler: """ return self.get_people_handler().get_person(obj, **kwargs) + def send_email(self, *args, **kwargs): + """ + Send an email message. + + This is a convenience wrapper around + :meth:`~wuttjamaican.email.handler.EmailHandler.send_email()`. + """ + self.get_email_handler().send_email(*args, **kwargs) + class AppProvider: """ diff --git a/src/wuttjamaican/conf.py b/src/wuttjamaican/conf.py index 6c3adf5..c04b603 100644 --- a/src/wuttjamaican/conf.py +++ b/src/wuttjamaican/conf.py @@ -440,8 +440,8 @@ class WuttaConfig: # raise error if required value not found if require: - message = message or "missing or invalid config" - raise ConfigurationError(f"{message}; please set config value for: {key}") + message = message or "missing config" + raise ConfigurationError(f"{message}; set value for: {key}") # give the default value if specified if default is not UNSPECIFIED: diff --git a/src/wuttjamaican/email/__init__.py b/src/wuttjamaican/email/__init__.py new file mode 100644 index 0000000..8702f9d --- /dev/null +++ b/src/wuttjamaican/email/__init__.py @@ -0,0 +1,33 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# WuttJamaican -- Base package for Wutta Framework +# Copyright © 2023-2024 Lance Edgar +# +# This file is part of Wutta Framework. +# +# Wutta Framework is free software: you can redistribute it and/or modify it +# under the terms of the GNU General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) any +# later version. +# +# Wutta Framework is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along with +# Wutta Framework. If not, see . +# +################################################################################ +""" +Email Utilities + +The following are available in this ``wuttjamaican.email`` namespace: + +* :class:`~wuttjamaican.email.handler.EmailHandler` +* :class:`~wuttjamaican.email.message.Message` +""" + +from .handler import EmailHandler +from .message import Message diff --git a/src/wuttjamaican/email/handler.py b/src/wuttjamaican/email/handler.py new file mode 100644 index 0000000..59f328c --- /dev/null +++ b/src/wuttjamaican/email/handler.py @@ -0,0 +1,423 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# WuttJamaican -- Base package for Wutta Framework +# Copyright © 2023-2024 Lance Edgar +# +# This file is part of Wutta Framework. +# +# Wutta Framework is free software: you can redistribute it and/or modify it +# under the terms of the GNU General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) any +# later version. +# +# Wutta Framework is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along with +# Wutta Framework. If not, see . +# +################################################################################ +""" +Email Handler +""" + +import logging +import smtplib + +from wuttjamaican.app import GenericHandler +from wuttjamaican.util import resource_path +from wuttjamaican.email.message import Message + + +log = logging.getLogger(__name__) + + +class EmailHandler(GenericHandler): + """ + Base class and default implementation for the :term:`email + handler`. + + Responsible for sending email messages on behalf of the + :term:`app`. + + You normally would not create this directly, but instead call + :meth:`~wuttjamaican.app.AppHandler.get_email_handler()` on your + :term:`app handler`. + """ + + # nb. this is fallback/default subject for auto-message + universal_subject = "Automated message" + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + from mako.lookup import TemplateLookup + + # prefer configured list of template lookup paths, if set + templates = self.config.get_list(f'{self.config.appname}.email.templates') + if not templates: + + # otherwise use all available paths, from app providers + available = [] + for provider in self.app.providers.values(): + if hasattr(provider, 'email_templates'): + templates = provider.email_templates + if isinstance(templates, str): + templates = [templates] + if templates: + available.extend(templates) + templates = available + + # convert all to true file paths + if templates: + templates = [resource_path(p) for p in templates] + + # will use these lookups from now on + self.txt_templates = TemplateLookup(directories=templates) + self.html_templates = TemplateLookup(directories=templates, + # nb. escape HTML special chars + # TODO: sounds great but i forget why? + default_filters=['h']) + + def make_message(self, **kwargs): + """ + Make and return a new email message. + + This is the "raw" factory which is simply a wrapper around the + class constructor. See also :meth:`make_auto_message()`. + + :returns: :class:`~wuttjamaican.email.message.Message` object. + """ + return Message(**kwargs) + + def make_auto_message(self, key, context={}, **kwargs): + """ + Make a new email message using config to determine its + properties, and auto-generating body from a template. + + Once everything has been collected/prepared, + :meth:`make_message()` is called to create the final message, + and that is returned. + + :param key: Unique key for this particular "type" of message. + This key is used as a prefix for all config settings and + template names pertinent to the message. + + :param context: Context dict used to render template(s) for + the message. + + :param \**kwargs: Any remaining kwargs are passed as-is to + :meth:`make_message()`. More on this below. + + :returns: :class:`~wuttjamaican.email.message.Message` object. + + This method may invoke some others, to gather the message + attributes. Each will check config, or render a template, or + both. However if a particular attribute is provided by the + caller, the corresponding "auto" method is skipped. + + * :meth:`get_auto_sender()` + * :meth:`get_auto_subject()` + * :meth:`get_auto_to()` + * :meth:`get_auto_cc()` + * :meth:`get_auto_bcc()` + * :meth:`get_auto_txt_body()` + * :meth:`get_auto_html_body()` + """ + kwargs['key'] = key + if 'sender' not in kwargs: + kwargs['sender'] = self.get_auto_sender(key) + if 'subject' not in kwargs: + kwargs['subject'] = self.get_auto_subject(key, context) + if 'to' not in kwargs: + kwargs['to'] = self.get_auto_to(key) + if 'cc' not in kwargs: + kwargs['cc'] = self.get_auto_cc(key) + if 'bcc' not in kwargs: + kwargs['bcc'] = self.get_auto_bcc(key) + if 'txt_body' not in kwargs: + kwargs['txt_body'] = self.get_auto_txt_body(key, context) + if 'html_body' not in kwargs: + kwargs['html_body'] = self.get_auto_html_body(key, context) + return self.make_message(**kwargs) + + def get_auto_sender(self, key): + """ + Returns automatic + :attr:`~wuttjamaican.email.message.Message.sender` address for + a message, as determined by config. + """ + # prefer configured sender specific to key + sender = self.config.get(f'{self.config.appname}.email.{key}.sender') + if sender: + return sender + + # fall back to global default (required!) + return self.config.require(f'{self.config.appname}.email.default.sender') + + def get_auto_subject(self, key, context={}, rendered=True): + """ + Returns automatic + :attr:`~wuttjamaican.email.message.Message.subject` line for a + message, as determined by config. + + This calls :meth:`get_auto_subject_template()` and then + renders the result using the given context. + + :param rendered: If this is ``False``, the "raw" subject + template will be returned, instead of the final/rendered + subject text. + """ + from mako.template import Template + + template = self.get_auto_subject_template(key) + if not rendered: + return template + return Template(template).render(**context) + + def get_auto_subject_template(self, key): + """ + Returns the template string to use for automatic subject line + of a message, as determined by config. + + In many cases this will be a simple string and not a + "template" per se; however it is still treated as a template. + + The template returned from this method is used to render the + final subject line in :meth:`get_auto_subject()`. + """ + # prefer configured subject specific to key + template = self.config.get(f'{self.config.appname}.email.{key}.subject') + if template: + return template + + # fall back to global default + return self.config.get(f'{self.config.appname}.email.default.subject', + default=self.universal_subject) + + def get_auto_to(self, key): + """ + Returns automatic + :attr:`~wuttjamaican.email.message.Message.to` recipient + address(es) for a message, as determined by config. + """ + return self.get_auto_recips(key, 'to') + + def get_auto_cc(self, key): + """ + Returns automatic + :attr:`~wuttjamaican.email.message.Message.cc` recipient + address(es) for a message, as determined by config. + """ + return self.get_auto_recips(key, 'cc') + + def get_auto_bcc(self, key): + """ + Returns automatic + :attr:`~wuttjamaican.email.message.Message.bcc` recipient + address(es) for a message, as determined by config. + """ + return self.get_auto_recips(key, 'bcc') + + def get_auto_recips(self, key, typ): + """ """ + typ = typ.lower() + if typ not in ('to', 'cc', 'bcc'): + raise ValueError("requested type not supported") + + # prefer configured recips specific to key + recips = self.config.get_list(f'{self.config.appname}.email.{key}.{typ}') + if recips: + return recips + + # fall back to global default + return self.config.get_list(f'{self.config.appname}.email.default.{typ}', + default=[]) + + def get_auto_txt_body(self, key, context={}): + """ + Returns automatic + :attr:`~wuttjamaican.email.message.Message.txt_body` content + for a message, as determined by config. This renders a + template with the given context. + """ + template = self.get_auto_body_template(key, 'txt') + if template: + return template.render(**context) + + def get_auto_html_body(self, key, context={}): + """ + Returns automatic + :attr:`~wuttjamaican.email.message.Message.html_body` content + for a message, as determined by config. This renders a + template with the given context. + """ + template = self.get_auto_body_template(key, 'html') + if template: + return template.render(**context) + + def get_auto_body_template(self, key, typ): + """ """ + from mako.exceptions import TopLevelLookupException + + typ = typ.lower() + if typ not in ('txt', 'html'): + raise ValueError("requested type not supported") + + if typ == 'txt': + templates = self.txt_templates + elif typ == 'html': + templates = self.html_templates + + try: + return templates.get_template(f'{key}.{typ}.mako') + except TopLevelLookupException: + pass + + def deliver_message(self, message, sender=None, recips=None): + """ + Deliver a message via SMTP smarthost. + + :param message: Either a + :class:`~wuttjamaican.email.message.Message` object or + similar, or a string representing the complete message to + be sent as-is. + + :param sender: Optional sender address to use for delivery. + If not specified, will be read from ``message``. + + :param recips: Optional recipient address(es) for delivery. + If not specified, will be read from ``message``. + + A general rule here is that you can either provide a proper + :class:`~wuttjamaican.email.message.Message` object, **or** + you *must* provide ``sender`` and ``recips``. The logic is + not smart enough (yet?) to parse sender/recips from a simple + string message. + + Note also, this method does not (yet?) have robust error + handling, so if an error occurs with the SMTP session, it will + simply raise to caller. + + :returns: ``None`` + """ + if not sender: + sender = message.sender + if not sender: + raise ValueError("no sender identified for message delivery") + + if not recips: + recips = set() + if message.to: + recips.update(message.to) + if message.cc: + recips.update(message.cc) + if message.bcc: + recips.update(message.bcc) + elif isinstance(recips, str): + recips = [recips] + + recips = set(recips) + if not recips: + raise ValueError("no recipients identified for message delivery") + + if not isinstance(message, str): + message = message.as_string() + + # get smtp info + server = self.config.get(f'{self.config.appname}.mail.smtp.server', default='localhost') + username = self.config.get(f'{self.config.appname}.mail.smtp.username') + password = self.config.get(f'{self.config.appname}.mail.smtp.password') + + # make sure sending is enabled + log.debug("sending email from %s; to %s", sender, recips) + if not self.sending_is_enabled(): + log.debug("nevermind, config says no emails") + return + + # smtp connect + session = smtplib.SMTP(server) + if username and password: + session.login(username, password) + + # smtp send + session.sendmail(sender, recips, message) + session.quit() + log.debug("email was sent") + + def sending_is_enabled(self): + """ + Returns boolean indicating if email sending is enabled. + + Set this flag in config like this: + + .. code-block:: ini + + [wutta.mail] + send_emails = true + + Note that it is OFF by default. + """ + return self.config.get_bool(f'{self.config.appname}.mail.send_emails', + default=False) + + def send_email(self, key=None, context={}, message=None, sender=None, recips=None, **kwargs): + """ + Send an email message. + + This method can send a ``message`` you provide, or it can + construct one automatically from key/config/templates. + + :param key: Indicates which "type" of automatic email to send. + Used to lookup config settings and template files. + + :param context: Context dict for rendering automatic email + template(s). + + :param message: Optional pre-built message instance, to send + as-is. + + :param sender: Optional sender address for the + message/delivery. + + If ``message`` is not provided, then the ``sender`` (if + provided) will also be used when constructing the + auto-message (i.e. to set the ``From:`` header). + + In any case if ``sender`` is provided, it will be used for + the actual SMTP delivery. + + :param recips: Optional list of recipient addresses for + delivery. If not specified, will be read from the message + itself (after auto-generating it, if applicable). + + .. note:: + + This param does not affect an auto-generated message; it + is used for delivery only. As such it must contain + *all* true recipients. + + If you provide the ``message`` but not the ``recips``, + the latter will be read from message headers: ``To:``, + ``Cc:`` and ``Bcc:`` + + If you want an auto-generated message but also want to + override various recipient headers, then you must + provide those explicitly:: + + context = {'data': [1, 2, 3]} + app.send_email('foo', context, to='me@example.com', cc='bobby@example.com') + + :param \**kwargs: Any remaining kwargs are passed along to + :meth:`make_auto_message()`. So, not used if you provide + the ``message``. + """ + if message is None: + if sender: + kwargs['sender'] = sender + message = self.make_auto_message(key, context, **kwargs) + + self.deliver_message(message, recips=recips) diff --git a/src/wuttjamaican/email/message.py b/src/wuttjamaican/email/message.py new file mode 100644 index 0000000..0e9f25e --- /dev/null +++ b/src/wuttjamaican/email/message.py @@ -0,0 +1,158 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# WuttJamaican -- Base package for Wutta Framework +# Copyright © 2023-2024 Lance Edgar +# +# This file is part of Wutta Framework. +# +# Wutta Framework is free software: you can redistribute it and/or modify it +# under the terms of the GNU General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) any +# later version. +# +# Wutta Framework is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along with +# Wutta Framework. If not, see . +# +################################################################################ +""" +Email Message +""" + +from email.mime.multipart import MIMEMultipart +from email.mime.text import MIMEText + + +class Message: + """ + Represents an email message to be sent. + + :param to: Recipient(s) for the message. This may be either a + string, or list of strings. If a string, it will be converted + to a list since that is how the :attr:`to` attribute tracks it. + Similar logic is used for :attr:`cc` and :attr:`bcc`. + + All attributes shown below may also be specified via constructor. + + .. attribute:: key + + Unique key indicating the "type" of message. An "ad-hoc" + message created arbitrarily may not have/need a key; however + one created via + :meth:`~wuttjamaican.email.handler.EmailHandler.make_auto_message()` + will always have a key. + + This key is not used for anything within the ``Message`` class + logic. It is used by + :meth:`~wuttjamaican.email.handler.EmailHandler.make_auto_message()` + when constructing the message, and the key is set on the final + message only as a reference. + + .. attribute:: sender + + Sender (``From:``) address for the message. + + .. attribute:: subject + + Subject text for the message. + + .. attribute:: to + + List of ``To:`` recipients for the message. + + .. attribute:: cc + + List of ``Cc:`` recipients for the message. + + .. attribute:: bcc + + List of ``Bcc:`` recipients for the message. + + .. attribute:: replyto + + Optional reply-to (``Reply-To:``) address for the message. + + .. attribute:: txt_body + + String with the ``text/plain`` body content. + + .. attribute:: html_body + + String with the ``text/html`` body content. + """ + + def __init__( + self, + key=None, + sender=None, + subject=None, + to=None, + cc=None, + bcc=None, + replyto=None, + txt_body=None, + html_body=None, + ): + self.key = key + self.sender = sender + self.subject = subject + self.set_recips('to', to) + self.set_recips('cc', cc) + self.set_recips('bcc', bcc) + self.replyto = replyto + self.txt_body = txt_body + self.html_body = html_body + + def set_recips(self, name, value): + """ """ + if value: + if isinstance(value, str): + value = [value] + if not isinstance(value, (list, tuple)): + raise ValueError("must specify a string, tuple or list value") + else: + value = [] + setattr(self, name, list(value)) + + def as_string(self): + """ + Returns the complete message as string. This is called from + within + :meth:`~wuttjamaican.email.handler.EmailHandler.deliver_message()` + to obtain the SMTP payload. + """ + msg = None + + if self.txt_body and self.html_body: + txt = MIMEText(self.txt_body, _charset='utf_8') + html = MIMEText(self.html_body, _subtype='html', _charset='utf_8') + msg = MIMEMultipart(_subtype='alternative', _subparts=[txt, html]) + + elif self.txt_body: + msg = MIMEText(self.txt_body, _charset='utf_8') + + elif self.html_body: + msg = MIMEText(self.html_body, 'html', _charset='utf_8') + + if not msg: + raise ValueError("message has no body parts") + + msg['Subject'] = self.subject + msg['From'] = self.sender + + for addr in self.to: + msg['To'] = addr + for addr in self.cc: + msg['Cc'] = addr + for addr in self.bcc: + msg['Bcc'] = addr + + if self.replyto: + msg.add_header('Reply-To', self.replyto) + + return msg.as_string() diff --git a/tests/email/__init__.py b/tests/email/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/email/templates/test_foo.html.mako b/tests/email/templates/test_foo.html.mako new file mode 100644 index 0000000..babdeaf --- /dev/null +++ b/tests/email/templates/test_foo.html.mako @@ -0,0 +1 @@ +

hello from foo html template

diff --git a/tests/email/templates/test_foo.txt.mako b/tests/email/templates/test_foo.txt.mako new file mode 100644 index 0000000..dcbc4c6 --- /dev/null +++ b/tests/email/templates/test_foo.txt.mako @@ -0,0 +1 @@ +hello from foo txt template diff --git a/tests/email/test_handler.py b/tests/email/test_handler.py new file mode 100644 index 0000000..63c4874 --- /dev/null +++ b/tests/email/test_handler.py @@ -0,0 +1,403 @@ +# -*- coding: utf-8; -*- + +from unittest import TestCase +from unittest.mock import patch, MagicMock + +from wuttjamaican.email import handler as mod +from wuttjamaican.email import Message +from wuttjamaican.conf import WuttaConfig +from wuttjamaican.util import resource_path +from wuttjamaican.exc import ConfigurationError + + +class TestEmailHandler(TestCase): + + def setUp(self): + self.config = WuttaConfig() + self.app = self.config.get_app() + + def make_handler(self, **kwargs): + return mod.EmailHandler(self.config, **kwargs) + + def test_constructor_lookups(self): + + # empty lookup paths by default, if no providers + with patch.object(self.app, 'providers', new={}): + handler = self.make_handler() + self.assertEqual(handler.txt_templates.directories, []) + self.assertEqual(handler.html_templates.directories, []) + + # provider may specify paths as list + providers = { + 'wuttatest': MagicMock(email_templates=['wuttjamaican.email:templates']), + } + with patch.object(self.app, 'providers', new=providers): + handler = self.make_handler() + path = resource_path('wuttjamaican.email:templates') + self.assertEqual(handler.txt_templates.directories, [path]) + self.assertEqual(handler.html_templates.directories, [path]) + + # provider may specify paths as string + providers = { + 'wuttatest': MagicMock(email_templates='wuttjamaican.email:templates'), + } + with patch.object(self.app, 'providers', new=providers): + handler = self.make_handler() + path = resource_path('wuttjamaican.email:templates') + self.assertEqual(handler.txt_templates.directories, [path]) + self.assertEqual(handler.html_templates.directories, [path]) + + def test_make_message(self): + handler = self.make_handler() + msg = handler.make_message() + self.assertIsInstance(msg, Message) + + def test_make_auto_message(self): + handler = self.make_handler() + + # error if default sender not defined + self.assertRaises(ConfigurationError, handler.make_auto_message, 'foo') + + # so let's define that + self.config.setdefault('wutta.email.default.sender', 'bob@example.com') + + # message is empty by default + msg = handler.make_auto_message('foo') + self.assertIsInstance(msg, Message) + self.assertEqual(msg.key, 'foo') + self.assertEqual(msg.sender, 'bob@example.com') + self.assertEqual(msg.subject, "Automated message") + self.assertEqual(msg.to, []) + self.assertEqual(msg.cc, []) + self.assertEqual(msg.bcc, []) + self.assertIsNone(msg.replyto) + self.assertIsNone(msg.txt_body) + self.assertIsNone(msg.html_body) + + # but if there is a proper email profile configured for key, + # then we should get back a more complete message + self.config.setdefault('wutta.email.test_foo.subject', "hello foo") + self.config.setdefault('wutta.email.test_foo.to', 'sally@example.com') + self.config.setdefault('wutta.email.templates', 'tests.email:templates') + handler = self.make_handler() + msg = handler.make_auto_message('test_foo') + self.assertEqual(msg.key, 'test_foo') + self.assertEqual(msg.sender, 'bob@example.com') + self.assertEqual(msg.subject, "hello foo") + self.assertEqual(msg.to, ['sally@example.com']) + self.assertEqual(msg.cc, []) + self.assertEqual(msg.bcc, []) + self.assertIsNone(msg.replyto) + self.assertEqual(msg.txt_body, "hello from foo txt template\n") + self.assertEqual(msg.html_body, "

hello from foo html template

\n") + + # *some* auto methods get skipped if caller specifies the + # kwarg at all; others get skipped if kwarg is empty + + # sender + with patch.object(handler, 'get_auto_sender') as get_auto_sender: + msg = handler.make_auto_message('foo', sender=None) + get_auto_sender.assert_not_called() + msg = handler.make_auto_message('foo') + get_auto_sender.assert_called_once_with('foo') + + # subject + with patch.object(handler, 'get_auto_subject') as get_auto_subject: + msg = handler.make_auto_message('foo', subject=None) + get_auto_subject.assert_not_called() + msg = handler.make_auto_message('foo') + get_auto_subject.assert_called_once_with('foo', {}) + + # to + with patch.object(handler, 'get_auto_to') as get_auto_to: + msg = handler.make_auto_message('foo', to=None) + get_auto_to.assert_not_called() + get_auto_to.return_value = None + msg = handler.make_auto_message('foo') + get_auto_to.assert_called_once_with('foo') + + # cc + with patch.object(handler, 'get_auto_cc') as get_auto_cc: + msg = handler.make_auto_message('foo', cc=None) + get_auto_cc.assert_not_called() + get_auto_cc.return_value = None + msg = handler.make_auto_message('foo') + get_auto_cc.assert_called_once_with('foo') + + # bcc + with patch.object(handler, 'get_auto_bcc') as get_auto_bcc: + msg = handler.make_auto_message('foo', bcc=None) + get_auto_bcc.assert_not_called() + get_auto_bcc.return_value = None + msg = handler.make_auto_message('foo') + get_auto_bcc.assert_called_once_with('foo') + + # txt_body + with patch.object(handler, 'get_auto_txt_body') as get_auto_txt_body: + msg = handler.make_auto_message('foo', txt_body=None) + get_auto_txt_body.assert_not_called() + msg = handler.make_auto_message('foo') + get_auto_txt_body.assert_called_once_with('foo', {}) + + # html_body + with patch.object(handler, 'get_auto_html_body') as get_auto_html_body: + msg = handler.make_auto_message('foo', html_body=None) + get_auto_html_body.assert_not_called() + msg = handler.make_auto_message('foo') + get_auto_html_body.assert_called_once_with('foo', {}) + + def test_get_auto_sender(self): + handler = self.make_handler() + + # error if none configured + self.assertRaises(ConfigurationError, handler.get_auto_sender, 'foo') + + # can set global default + self.config.setdefault('wutta.email.default.sender', 'bob@example.com') + self.assertEqual(handler.get_auto_sender('foo'), 'bob@example.com') + + # can set for key + self.config.setdefault('wutta.email.foo.sender', 'sally@example.com') + self.assertEqual(handler.get_auto_sender('foo'), 'sally@example.com') + + def test_get_auto_subject_template(self): + handler = self.make_handler() + + # global default + template = handler.get_auto_subject_template('foo') + self.assertEqual(template, "Automated message") + + # can configure alternate global default + self.config.setdefault('wutta.email.default.subject', "Wutta Message") + template = handler.get_auto_subject_template('foo') + self.assertEqual(template, "Wutta Message") + + # can configure just for key + self.config.setdefault('wutta.email.foo.subject', "Foo Message") + template = handler.get_auto_subject_template('foo') + self.assertEqual(template, "Foo Message") + + def test_get_auto_subject(self): + handler = self.make_handler() + + # global default + subject = handler.get_auto_subject('foo') + self.assertEqual(subject, "Automated message") + + # can configure alternate global default + self.config.setdefault('wutta.email.default.subject', "Wutta Message") + subject = handler.get_auto_subject('foo') + self.assertEqual(subject, "Wutta Message") + + # can configure just for key + self.config.setdefault('wutta.email.foo.subject', "Foo Message") + subject = handler.get_auto_subject('foo') + self.assertEqual(subject, "Foo Message") + + # proper template is rendered + self.config.setdefault('wutta.email.bar.subject', "${foo} Message") + subject = handler.get_auto_subject('bar', {'foo': "FOO"}) + self.assertEqual(subject, "FOO Message") + + # unless we ask it not to + subject = handler.get_auto_subject('bar', {'foo': "FOO"}, rendered=False) + self.assertEqual(subject, "${foo} Message") + + def test_get_auto_recips(self): + handler = self.make_handler() + + # error if bad type requested + self.assertRaises(ValueError, handler.get_auto_recips, 'foo', 'doesnotexist') + + # can configure global default + self.config.setdefault('wutta.email.default.to', 'admin@example.com') + recips = handler.get_auto_recips('foo', 'to') + self.assertEqual(recips, ['admin@example.com']) + + # can configure just for key + self.config.setdefault('wutta.email.foo.to', 'bob@example.com') + recips = handler.get_auto_recips('foo', 'to') + self.assertEqual(recips, ['bob@example.com']) + + def test_get_auto_body_template(self): + from mako.template import Template + + handler = self.make_handler() + + # error if bad request + self.assertRaises(ValueError, handler.get_auto_body_template, 'foo', 'BADTYPE') + + # empty by default + template = handler.get_auto_body_template('foo', 'txt') + self.assertIsNone(template) + + # but returns a template if it exists + providers = { + 'wuttatest': MagicMock(email_templates=['tests.email:templates']), + } + with patch.object(self.app, 'providers', new=providers): + handler = self.make_handler() + template = handler.get_auto_body_template('test_foo', 'txt') + self.assertIsInstance(template, Template) + self.assertEqual(template.uri, 'test_foo.txt.mako') + + def test_get_auto_txt_body(self): + handler = self.make_handler() + + # empty by default + body = handler.get_auto_txt_body('some-random-email') + self.assertIsNone(body) + + # but returns body if template exists + providers = { + 'wuttatest': MagicMock(email_templates=['tests.email:templates']), + } + with patch.object(self.app, 'providers', new=providers): + handler = self.make_handler() + body = handler.get_auto_txt_body('test_foo') + self.assertEqual(body, 'hello from foo txt template\n') + + def test_get_auto_html_body(self): + handler = self.make_handler() + + # empty by default + body = handler.get_auto_html_body('some-random-email') + self.assertIsNone(body) + + # but returns body if template exists + providers = { + 'wuttatest': MagicMock(email_templates=['tests.email:templates']), + } + with patch.object(self.app, 'providers', new=providers): + handler = self.make_handler() + body = handler.get_auto_html_body('test_foo') + self.assertEqual(body, '

hello from foo html template

\n') + + def test_deliver_message(self): + handler = self.make_handler() + + msg = handler.make_message(sender='bob@example.com', to='sally@example.com') + with patch.object(msg, 'as_string', return_value='msg-str'): + + # no smtp session since sending email is disabled by default + with patch.object(mod, 'smtplib') as smtplib: + session = MagicMock() + smtplib.SMTP.return_value = session + handler.deliver_message(msg) + smtplib.SMTP.assert_not_called() + session.login.assert_not_called() + session.sendmail.assert_not_called() + + # now let's enable sending + self.config.setdefault('wutta.mail.send_emails', 'true') + + # smtp login not attempted by default + with patch.object(mod, 'smtplib') as smtplib: + session = MagicMock() + smtplib.SMTP.return_value = session + handler.deliver_message(msg) + smtplib.SMTP.assert_called_once_with('localhost') + session.login.assert_not_called() + session.sendmail.assert_called_once_with('bob@example.com', {'sally@example.com'}, 'msg-str') + + # but login attempted if config has credentials + self.config.setdefault('wutta.mail.smtp.username', 'bob') + self.config.setdefault('wutta.mail.smtp.password', 'seekrit') + with patch.object(mod, 'smtplib') as smtplib: + session = MagicMock() + smtplib.SMTP.return_value = session + handler.deliver_message(msg) + smtplib.SMTP.assert_called_once_with('localhost') + session.login.assert_called_once_with('bob', 'seekrit') + session.sendmail.assert_called_once_with('bob@example.com', {'sally@example.com'}, 'msg-str') + + # error if no sender + msg = handler.make_message(to='sally@example.com') + self.assertRaises(ValueError, handler.deliver_message, msg) + + # error if no recips + msg = handler.make_message(sender='bob@example.com') + self.assertRaises(ValueError, handler.deliver_message, msg) + + # can set recips as list + msg = handler.make_message(sender='bob@example.com') + with patch.object(msg, 'as_string', return_value='msg-str'): + with patch.object(mod, 'smtplib') as smtplib: + session = MagicMock() + smtplib.SMTP.return_value = session + handler.deliver_message(msg, recips=['sally@example.com']) + smtplib.SMTP.assert_called_once_with('localhost') + session.sendmail.assert_called_once_with('bob@example.com', {'sally@example.com'}, 'msg-str') + + # can set recips as string + msg = handler.make_message(sender='bob@example.com') + with patch.object(msg, 'as_string', return_value='msg-str'): + with patch.object(mod, 'smtplib') as smtplib: + session = MagicMock() + smtplib.SMTP.return_value = session + handler.deliver_message(msg, recips='sally@example.com') + smtplib.SMTP.assert_called_once_with('localhost') + session.sendmail.assert_called_once_with('bob@example.com', {'sally@example.com'}, 'msg-str') + + # can set recips via to + msg = handler.make_message(sender='bob@example.com', to='sally@example.com') + with patch.object(msg, 'as_string', return_value='msg-str'): + with patch.object(mod, 'smtplib') as smtplib: + session = MagicMock() + smtplib.SMTP.return_value = session + handler.deliver_message(msg) + smtplib.SMTP.assert_called_once_with('localhost') + session.sendmail.assert_called_once_with('bob@example.com', {'sally@example.com'}, 'msg-str') + + # can set recips via cc + msg = handler.make_message(sender='bob@example.com', cc='sally@example.com') + with patch.object(msg, 'as_string', return_value='msg-str'): + with patch.object(mod, 'smtplib') as smtplib: + session = MagicMock() + smtplib.SMTP.return_value = session + handler.deliver_message(msg) + smtplib.SMTP.assert_called_once_with('localhost') + session.sendmail.assert_called_once_with('bob@example.com', {'sally@example.com'}, 'msg-str') + + # can set recips via bcc + msg = handler.make_message(sender='bob@example.com', bcc='sally@example.com') + with patch.object(msg, 'as_string', return_value='msg-str'): + with patch.object(mod, 'smtplib') as smtplib: + session = MagicMock() + smtplib.SMTP.return_value = session + handler.deliver_message(msg) + smtplib.SMTP.assert_called_once_with('localhost') + session.sendmail.assert_called_once_with('bob@example.com', {'sally@example.com'}, 'msg-str') + + def test_sending_is_enabled(self): + handler = self.make_handler() + + # off by default + self.assertFalse(handler.sending_is_enabled()) + + # but can be turned on + self.config.setdefault('wutta.mail.send_emails', 'true') + self.assertTrue(handler.sending_is_enabled()) + + def test_send_email(self): + with patch.object(mod.EmailHandler, 'deliver_message') as deliver_message: + handler = self.make_handler() + + # deliver_message() is called + handler.send_email('foo', sender='bob@example.com', to='sally@example.com', + txt_body='hello world') + deliver_message.assert_called_once() + + # make_auto_message() called only if needed + with patch.object(handler, 'make_auto_message') as make_auto_message: + + msg = handler.make_message() + handler.send_email(message=msg) + make_auto_message.assert_not_called() + + handler.send_email('foo', sender='bob@example.com', to='sally@example.com', + txt_body='hello world') + make_auto_message.assert_called_once_with('foo', {}, + sender='bob@example.com', + to='sally@example.com', + txt_body='hello world') diff --git a/tests/email/test_message.py b/tests/email/test_message.py new file mode 100644 index 0000000..f8ff67a --- /dev/null +++ b/tests/email/test_message.py @@ -0,0 +1,76 @@ +# -*- coding: utf-8; -*- + +from unittest import TestCase + +from wuttjamaican.email import message as mod + + +class TestMessage(TestCase): + + def make_message(self, **kwargs): + return mod.Message(**kwargs) + + def test_set_recips(self): + msg = self.make_message() + self.assertEqual(msg.to, []) + + # set as list + msg.set_recips('to', ['sally@example.com']) + self.assertEqual(msg.to, ['sally@example.com']) + + # set as tuple + msg.set_recips('to', ('barney@example.com',)) + self.assertEqual(msg.to, ['barney@example.com']) + + # set as string + msg.set_recips('to', 'wilma@example.com') + self.assertEqual(msg.to, ['wilma@example.com']) + + # set as null + msg.set_recips('to', None) + self.assertEqual(msg.to, []) + + # otherwise error + self.assertRaises(ValueError, msg.set_recips, 'to', {'foo': 'foo@example.com'}) + + def test_as_string(self): + + # error if no body + msg = self.make_message() + self.assertRaises(ValueError, msg.as_string) + + # txt body + msg = self.make_message(sender='bob@example.com', + txt_body="hello world") + complete = msg.as_string() + self.assertIn('From: bob@example.com', complete) + + # html body + msg = self.make_message(sender='bob@example.com', + html_body="

hello world

") + complete = msg.as_string() + self.assertIn('From: bob@example.com', complete) + + # txt + html body + msg = self.make_message(sender='bob@example.com', + txt_body="hello world", + html_body="

hello world

") + complete = msg.as_string() + self.assertIn('From: bob@example.com', complete) + + # everything + msg = self.make_message(sender='bob@example.com', + subject='meeting follow-up', + to='sally@example.com', + cc='marketing@example.com', + bcc='bob@example.com', + replyto='sales@example.com', + txt_body="hello world", + html_body="

hello world

") + complete = msg.as_string() + self.assertIn('From: bob@example.com', complete) + self.assertIn('Subject: meeting follow-up', complete) + self.assertIn('To: sally@example.com', complete) + self.assertIn('Cc: marketing@example.com', complete) + self.assertIn('Bcc: bob@example.com', complete) + self.assertIn('Reply-To: sales@example.com', complete) diff --git a/tests/test_app.py b/tests/test_app.py index bbf0bf9..ef4f254 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -397,12 +397,25 @@ class TestAppHandler(FileConfigTestCase): auth = self.app.get_auth_handler() self.assertIsInstance(auth, AuthHandler) + def test_get_email_handler(self): + from wuttjamaican.email import EmailHandler + + mail = self.app.get_email_handler() + self.assertIsInstance(mail, EmailHandler) + def test_get_people_handler(self): from wuttjamaican.people import PeopleHandler people = self.app.get_people_handler() self.assertIsInstance(people, PeopleHandler) + def test_get_send_email(self): + from wuttjamaican.email import EmailHandler + + with patch.object(EmailHandler, 'send_email') as send_email: + self.app.send_email('foo') + send_email.assert_called_once_with('foo') + class TestAppProvider(TestCase):