From 902412322e6cd6866681864049c4cbc85d5881d8 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 19 Dec 2024 18:20:57 -0600 Subject: [PATCH 1/3] fix: add `is_enabled()` method for email handler, to check per type also add some more descriptive errors when email template not found, body empty --- docs/glossary.rst | 11 +++++ src/wuttjamaican/email/handler.py | 80 ++++++++++++++++++++++++++++-- tests/email/test_handler.py | 82 ++++++++++++++++++++++++------- 3 files changed, 152 insertions(+), 21 deletions(-) diff --git a/docs/glossary.rst b/docs/glossary.rst index dd3768f..c3cd7a7 100644 --- a/docs/glossary.rst +++ b/docs/glossary.rst @@ -191,6 +191,17 @@ Glossary Default is :class:`~wuttjamaican.email.handler.EmailHandler`. + email key + String idenfier for a certain :term:`email type`. Each email key + must be unique across the app, so the correct template files and + other settings are used when sending etc. + + email type + The :term:`app` is capable of sending many types of emails, + e.g. daily reports, alerts of various kinds etc. Each "type" of + email then will have its own template(s) and sender/recipient + settings etc. See also :term:`email key`. + 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/src/wuttjamaican/email/handler.py b/src/wuttjamaican/email/handler.py index 59f328c..519bac0 100644 --- a/src/wuttjamaican/email/handler.py +++ b/src/wuttjamaican/email/handler.py @@ -277,6 +277,52 @@ class EmailHandler(GenericHandler): except TopLevelLookupException: pass + def is_enabled(self, key): + """ + Returns flag indicating whether the given email type is + "enabled" - i.e. whether it should ever be sent out (enabled) + or always suppressed (disabled). + + All email types are enabled by default, unless config says + otherwise; e.g. to disable ``foo`` emails: + + .. code-block:: ini + + [wutta.email] + + # nb. this is fallback if specific type is not configured + default.enabled = true + + # this disables 'foo' but e.g 'bar' is still enabled per default above + foo.enabled = false + + In a development setup you may want a reverse example, where + all emails are disabled by default but you can turn on just + one type for testing: + + .. code-block:: ini + + [wutta.email] + + # do not send any emails unless explicitly enabled + default.enabled = false + + # turn on 'bar' for testing + bar.enabled = true + + See also :meth:`sending_is_enabled()` which is more of a + master shutoff switch. + + :param key: Unique identifier for the email type. + + :returns: True if this email type is enabled, otherwise false. + """ + for key in set([key, 'default']): + enabled = self.config.get_bool(f'{self.config.appname}.email.{key}.enabled') + if enabled is not None: + return enabled + return True + def deliver_message(self, message, sender=None, recips=None): """ Deliver a message via SMTP smarthost. @@ -368,17 +414,25 @@ class EmailHandler(GenericHandler): """ Send an email message. - This method can send a ``message`` you provide, or it can - construct one automatically from key/config/templates. + 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. + The most common use case is assumed to be the latter, where + caller does not provide the message proper, but specifies key + and context so the message is auto-created. In that case this + method will also check :meth:`is_enabled()` and skip the + sending if that returns false. + + :param key: When auto-creating a message, this is the + :term:`email key` identifying the type of 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. + as-is. If specified, nothing about the message will be + auto-assigned from config. :param sender: Optional sender address for the message/delivery. @@ -415,9 +469,27 @@ class EmailHandler(GenericHandler): :meth:`make_auto_message()`. So, not used if you provide the ``message``. """ + if key and not self.is_enabled(key): + log.debug("skipping disabled email: %s", key) + return + if message is None: + if not key: + raise ValueError("must specify email key (and/or message object)") + + # auto-create message from key + context if sender: kwargs['sender'] = sender message = self.make_auto_message(key, context, **kwargs) + if not (message.txt_body or message.html_body): + raise RuntimeError(f"message (type: {key}) has no body - " + "perhaps template file not found?") + + if not (message.txt_body or message.html_body): + if key: + msg = f"message (type: {key}) has no body content" + else: + msg = "message has no body content" + raise ValueError(msg) self.deliver_message(message, recips=recips) diff --git a/tests/email/test_handler.py b/tests/email/test_handler.py index 3735509..8432144 100644 --- a/tests/email/test_handler.py +++ b/tests/email/test_handler.py @@ -280,6 +280,22 @@ class TestEmailHandler(TestCase): body = handler.get_auto_html_body('test_foo') self.assertEqual(body, '

hello from foo html template

\n') + def test_is_enabled(self): + handler = self.make_handler() + + # enabled by default + self.assertTrue(handler.is_enabled('default')) + self.assertTrue(handler.is_enabled('foo')) + + # specific type disabled + self.config.setdefault('wutta.email.foo.enabled', 'false') + self.assertFalse(handler.is_enabled('foo')) + + # default is disabled + self.assertTrue(handler.is_enabled('bar')) + self.config.setdefault('wutta.email.default.enabled', 'false') + self.assertFalse(handler.is_enabled('bar')) + def test_deliver_message(self): handler = self.make_handler() @@ -387,24 +403,56 @@ class TestEmailHandler(TestCase): 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() + handler = self.make_handler() + with patch.object(handler, 'deliver_message') as deliver_message: - # 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() + # specify message w/ no body + msg = handler.make_message() + self.assertRaises(ValueError, handler.send_email, message=msg) + self.assertFalse(deliver_message.called) - # make_auto_message() called only if needed - with patch.object(handler, 'make_auto_message') as make_auto_message: + # again, but also specify key + msg = handler.make_message() + self.assertRaises(ValueError, handler.send_email, 'foo', message=msg) + self.assertFalse(deliver_message.called) - msg = handler.make_message() - handler.send_email(message=msg) - make_auto_message.assert_not_called() + # specify complete message + deliver_message.reset_mock() + msg = handler.make_message(txt_body="hello world") + handler.send_email(message=msg) + deliver_message.assert_called_once_with(msg, recips=None) - 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') + # again, but also specify key + deliver_message.reset_mock() + msg = handler.make_message(txt_body="hello world") + handler.send_email('foo', message=msg) + deliver_message.assert_called_once_with(msg, recips=None) + + # no key, no message + deliver_message.reset_mock() + self.assertRaises(ValueError, handler.send_email) + + # auto-create message w/ no template + deliver_message.reset_mock() + self.assertRaises(RuntimeError, handler.send_email, 'foo', sender='foo@example.com') + self.assertFalse(deliver_message.called) + + # auto create w/ body + deliver_message.reset_mock() + handler.send_email('foo', sender='foo@example.com', txt_body="hello world") + self.assertTrue(deliver_message.called) + + # type is disabled + deliver_message.reset_mock() + self.config.setdefault('wutta.email.foo.enabled', False) + handler.send_email('foo', sender='foo@example.com', txt_body="hello world") + self.assertFalse(deliver_message.called) + + # default is disabled + deliver_message.reset_mock() + handler.send_email('bar', sender='bar@example.com', txt_body="hello world") + self.assertTrue(deliver_message.called) + deliver_message.reset_mock() + self.config.setdefault('wutta.email.default.enabled', False) + handler.send_email('bar', sender='bar@example.com', txt_body="hello world") + self.assertFalse(deliver_message.called) From 6c8f1c973d2728c498014e28bcc6e9fab40d0552 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 19 Dec 2024 18:34:03 -0600 Subject: [PATCH 2/3] fix: move `email` stuff from subpackage to module --- docs/api/wuttjamaican.email.handler.rst | 6 - docs/api/wuttjamaican.email.message.rst | 6 - docs/glossary.rst | 2 +- docs/index.rst | 2 - docs/narr/email/custom.rst | 5 +- src/wuttjamaican/app.py | 4 +- .../{email/handler.py => email.py} | 189 +++++++++++++++--- src/wuttjamaican/email/__init__.py | 33 --- src/wuttjamaican/email/message.py | 158 --------------- .../test_foo.html.mako | 0 .../test_foo.txt.mako | 0 tests/email/__init__.py | 0 tests/email/test_message.py | 76 ------- .../{email/test_handler.py => test_email.py} | 94 +++++++-- 14 files changed, 244 insertions(+), 331 deletions(-) delete mode 100644 docs/api/wuttjamaican.email.handler.rst delete mode 100644 docs/api/wuttjamaican.email.message.rst rename src/wuttjamaican/{email/handler.py => email.py} (75%) delete mode 100644 src/wuttjamaican/email/__init__.py delete mode 100644 src/wuttjamaican/email/message.py rename tests/{email/templates => email-templates}/test_foo.html.mako (100%) rename tests/{email/templates => email-templates}/test_foo.txt.mako (100%) delete mode 100644 tests/email/__init__.py delete mode 100644 tests/email/test_message.py rename tests/{email/test_handler.py => test_email.py} (85%) diff --git a/docs/api/wuttjamaican.email.handler.rst b/docs/api/wuttjamaican.email.handler.rst deleted file mode 100644 index 4e4900f..0000000 --- a/docs/api/wuttjamaican.email.handler.rst +++ /dev/null @@ -1,6 +0,0 @@ - -``wuttjamaican.email.handler`` -============================== - -.. automodule:: wuttjamaican.email.handler - :members: diff --git a/docs/api/wuttjamaican.email.message.rst b/docs/api/wuttjamaican.email.message.rst deleted file mode 100644 index 1656196..0000000 --- a/docs/api/wuttjamaican.email.message.rst +++ /dev/null @@ -1,6 +0,0 @@ - -``wuttjamaican.email.message`` -============================== - -.. automodule:: wuttjamaican.email.message - :members: diff --git a/docs/glossary.rst b/docs/glossary.rst index c3cd7a7..f696c66 100644 --- a/docs/glossary.rst +++ b/docs/glossary.rst @@ -189,7 +189,7 @@ Glossary The :term:`handler` responsible for sending email on behalf of the :term:`app`. - Default is :class:`~wuttjamaican.email.handler.EmailHandler`. + Default is :class:`~wuttjamaican.email.EmailHandler`. email key String idenfier for a certain :term:`email type`. Each email key diff --git a/docs/index.rst b/docs/index.rst index cb99327..ef8acce 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -82,8 +82,6 @@ Contents api/wuttjamaican.db.sess api/wuttjamaican.db.util api/wuttjamaican.email - api/wuttjamaican.email.handler - api/wuttjamaican.email.message api/wuttjamaican.enum api/wuttjamaican.exc api/wuttjamaican.install diff --git a/docs/narr/email/custom.rst b/docs/narr/email/custom.rst index f91d844..dac70fc 100644 --- a/docs/narr/email/custom.rst +++ b/docs/narr/email/custom.rst @@ -50,9 +50,8 @@ for one or both of the ``text/plain`` and ``text/html`` content-types (using ``txt`` and ``html`` as shorthand name, respectively). Template files must use the :doc:`Mako template language ` -and be named based on the -:attr:`~wuttjamaican.email.message.Message.key` for the email type, as -well as content-type. +and be named based on the :attr:`~wuttjamaican.email.Message.key` for +the email type, as well as content-type. Therefore a new email of type ``poser_alert_foo`` would need one or both of these defined: diff --git a/src/wuttjamaican/app.py b/src/wuttjamaican/app.py index 102d5db..becbf70 100644 --- a/src/wuttjamaican/app.py +++ b/src/wuttjamaican/app.py @@ -768,7 +768,7 @@ class AppHandler: See also :meth:`send_email()`. - :rtype: :class:`~wuttjamaican.email.handler.EmailHandler` + :rtype: :class:`~wuttjamaican.email.EmailHandler` """ if 'email' not in self.handlers: spec = self.config.get(f'{self.appname}.email.handler', @@ -823,7 +823,7 @@ class AppHandler: Send an email message. This is a convenience wrapper around - :meth:`~wuttjamaican.email.handler.EmailHandler.send_email()`. + :meth:`~wuttjamaican.email.EmailHandler.send_email()`. """ self.get_email_handler().send_email(*args, **kwargs) diff --git a/src/wuttjamaican/email/handler.py b/src/wuttjamaican/email.py similarity index 75% rename from src/wuttjamaican/email/handler.py rename to src/wuttjamaican/email.py index 519bac0..df4961e 100644 --- a/src/wuttjamaican/email/handler.py +++ b/src/wuttjamaican/email.py @@ -26,15 +26,146 @@ Email Handler import logging import smtplib +from email.mime.multipart import MIMEMultipart +from email.mime.text import MIMEText from wuttjamaican.app import GenericHandler from wuttjamaican.util import resource_path -from wuttjamaican.email.message import Message log = logging.getLogger(__name__) +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.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.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.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() + + class EmailHandler(GenericHandler): """ Base class and default implementation for the :term:`email @@ -89,7 +220,7 @@ class EmailHandler(GenericHandler): 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. + :returns: :class:`~wuttjamaican.email.Message` object. """ return Message(**kwargs) @@ -112,7 +243,7 @@ class EmailHandler(GenericHandler): :param \**kwargs: Any remaining kwargs are passed as-is to :meth:`make_message()`. More on this below. - :returns: :class:`~wuttjamaican.email.message.Message` object. + :returns: :class:`~wuttjamaican.email.Message` object. This method may invoke some others, to gather the message attributes. Each will check config, or render a template, or @@ -147,8 +278,8 @@ class EmailHandler(GenericHandler): def get_auto_sender(self, key): """ Returns automatic - :attr:`~wuttjamaican.email.message.Message.sender` address for - a message, as determined by config. + :attr:`~wuttjamaican.email.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') @@ -160,9 +291,8 @@ class EmailHandler(GenericHandler): 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. + Returns automatic :attr:`~wuttjamaican.email.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. @@ -200,25 +330,22 @@ class EmailHandler(GenericHandler): def get_auto_to(self, key): """ - Returns automatic - :attr:`~wuttjamaican.email.message.Message.to` recipient - address(es) for a message, as determined by config. + Returns automatic :attr:`~wuttjamaican.email.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. + Returns automatic :attr:`~wuttjamaican.email.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. + Returns automatic :attr:`~wuttjamaican.email.Message.bcc` + recipient address(es) for a message, as determined by config. """ return self.get_auto_recips(key, 'bcc') @@ -239,10 +366,9 @@ class EmailHandler(GenericHandler): 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. + Returns automatic :attr:`~wuttjamaican.email.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: @@ -251,9 +377,9 @@ class EmailHandler(GenericHandler): 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. + :attr:`~wuttjamaican.email.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: @@ -327,10 +453,9 @@ class EmailHandler(GenericHandler): """ 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 message: Either a :class:`~wuttjamaican.email.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``. @@ -339,10 +464,10 @@ class EmailHandler(GenericHandler): 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. + :class:`~wuttjamaican.email.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 diff --git a/src/wuttjamaican/email/__init__.py b/src/wuttjamaican/email/__init__.py deleted file mode 100644 index 8702f9d..0000000 --- a/src/wuttjamaican/email/__init__.py +++ /dev/null @@ -1,33 +0,0 @@ -# -*- 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/message.py b/src/wuttjamaican/email/message.py deleted file mode 100644 index 0e9f25e..0000000 --- a/src/wuttjamaican/email/message.py +++ /dev/null @@ -1,158 +0,0 @@ -# -*- 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/templates/test_foo.html.mako b/tests/email-templates/test_foo.html.mako similarity index 100% rename from tests/email/templates/test_foo.html.mako rename to tests/email-templates/test_foo.html.mako diff --git a/tests/email/templates/test_foo.txt.mako b/tests/email-templates/test_foo.txt.mako similarity index 100% rename from tests/email/templates/test_foo.txt.mako rename to tests/email-templates/test_foo.txt.mako diff --git a/tests/email/__init__.py b/tests/email/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/tests/email/test_message.py b/tests/email/test_message.py deleted file mode 100644 index f8ff67a..0000000 --- a/tests/email/test_message.py +++ /dev/null @@ -1,76 +0,0 @@ -# -*- 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/email/test_handler.py b/tests/test_email.py similarity index 85% rename from tests/email/test_handler.py rename to tests/test_email.py index 8432144..2d5272b 100644 --- a/tests/email/test_handler.py +++ b/tests/test_email.py @@ -5,8 +5,7 @@ from unittest.mock import patch, MagicMock import pytest -from wuttjamaican.email import handler as mod -from wuttjamaican.email import Message +from wuttjamaican import email as mod from wuttjamaican.conf import WuttaConfig from wuttjamaican.util import resource_path from wuttjamaican.exc import ConfigurationError @@ -36,28 +35,28 @@ class TestEmailHandler(TestCase): # provider may specify paths as list providers = { - 'wuttatest': MagicMock(email_templates=['wuttjamaican.email:templates']), + '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') + 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'), + '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') + 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) + self.assertIsInstance(msg, mod.Message) def test_make_auto_message(self): handler = self.make_handler() @@ -70,7 +69,7 @@ class TestEmailHandler(TestCase): # message is empty by default msg = handler.make_auto_message('foo') - self.assertIsInstance(msg, Message) + self.assertIsInstance(msg, mod.Message) self.assertEqual(msg.key, 'foo') self.assertEqual(msg.sender, 'bob@example.com') self.assertEqual(msg.subject, "Automated message") @@ -85,7 +84,7 @@ class TestEmailHandler(TestCase): # 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') + 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') @@ -240,7 +239,7 @@ class TestEmailHandler(TestCase): # but returns a template if it exists providers = { - 'wuttatest': MagicMock(email_templates=['tests.email:templates']), + 'wuttatest': MagicMock(email_templates=['tests:email-templates']), } with patch.object(self.app, 'providers', new=providers): handler = self.make_handler() @@ -257,7 +256,7 @@ class TestEmailHandler(TestCase): # but returns body if template exists providers = { - 'wuttatest': MagicMock(email_templates=['tests.email:templates']), + 'wuttatest': MagicMock(email_templates=['tests:email-templates']), } with patch.object(self.app, 'providers', new=providers): handler = self.make_handler() @@ -273,7 +272,7 @@ class TestEmailHandler(TestCase): # but returns body if template exists providers = { - 'wuttatest': MagicMock(email_templates=['tests.email:templates']), + 'wuttatest': MagicMock(email_templates=['tests:email-templates']), } with patch.object(self.app, 'providers', new=providers): handler = self.make_handler() @@ -456,3 +455,74 @@ class TestEmailHandler(TestCase): self.config.setdefault('wutta.email.default.enabled', False) handler.send_email('bar', sender='bar@example.com', txt_body="hello world") self.assertFalse(deliver_message.called) + + +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) From 491df09f2fe13b5950e2fd1b5ad76227871bc9b0 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Mon, 23 Dec 2024 15:37:37 -0600 Subject: [PATCH 3/3] feat: add "email settings" feature for admin, previews --- docs/glossary.rst | 14 ++ src/wuttjamaican/app.py | 25 +++- src/wuttjamaican/email.py | 214 +++++++++++++++++++++++++++--- tests/test_email.py | 266 ++++++++++++++++++++++++++------------ 4 files changed, 421 insertions(+), 98 deletions(-) diff --git a/docs/glossary.rst b/docs/glossary.rst index f696c66..3a16ea2 100644 --- a/docs/glossary.rst +++ b/docs/glossary.rst @@ -196,6 +196,20 @@ Glossary must be unique across the app, so the correct template files and other settings are used when sending etc. + email module + This refers to a Python module which contains :term:`email + setting` definitions. + + email setting + This refers to the settings for a particular :term:`email type`, + i.e. its sender and recipients, subject etc. So each email type + has a "collection" of settings, and that collection is referred + to simply as an "email setting" in the singular. + + email template + Usually this refers to the HTML or TXT template file, used to + render the message body when sending an email. + email type The :term:`app` is capable of sending many types of emails, e.g. daily reports, alerts of various kinds etc. Each "type" of diff --git a/src/wuttjamaican/app.py b/src/wuttjamaican/app.py index becbf70..a6ea9b3 100644 --- a/src/wuttjamaican/app.py +++ b/src/wuttjamaican/app.py @@ -837,7 +837,7 @@ class AppProvider: :param config: The app :term:`config object`. - Instances have the following attributes: + ``AppProvider`` instances have the following attributes: .. attribute:: config @@ -846,6 +846,29 @@ class AppProvider: .. attribute:: app Reference to the parent app handler. + + Some things which a subclass may define, in order to register + various features with the app: + + .. attribute:: email_modules + + List of :term:`email modules ` provided. Should + be a list of strings; each is a dotted module path, e.g.:: + + email_modules = ['poser.emails'] + + .. attribute:: email_templates + + List of :term:`email template` folders provided. Can be a list + of paths, or a single path as string:: + + email_templates = ['poser:templates/email'] + + email_templates = 'poser:templates/email' + + Note the syntax, which specifies python module, then colon + (``:``), then filesystem path below that. However absolute + file paths may be used as well, when applicable. """ def __init__(self, config): diff --git a/src/wuttjamaican/email.py b/src/wuttjamaican/email.py index df4961e..4265483 100644 --- a/src/wuttjamaican/email.py +++ b/src/wuttjamaican/email.py @@ -24,11 +24,16 @@ Email Handler """ +import importlib import logging import smtplib from email.mime.multipart import MIMEMultipart from email.mime.text import MIMEText +from mako.lookup import TemplateLookup +from mako.template import Template +from mako.exceptions import TopLevelLookupException + from wuttjamaican.app import GenericHandler from wuttjamaican.util import resource_path @@ -36,6 +41,75 @@ from wuttjamaican.util import resource_path log = logging.getLogger(__name__) +class EmailSetting: + """ + Base class for all :term:`email settings `. + + Each :term:`email type` which needs to have settings exposed + e.g. for editing, should define a subclass within the appropriate + :term:`email module`. + + The name of each subclass should match the :term:`email key` which + it represents. For instance:: + + from wuttjamaican.email import EmailSetting + + class poser_alert_foo(EmailSetting): + \""" + Sent when something happens that we think deserves an alert. + \""" + + default_subject = "Something happened!" + + def sample_data(self): + return { + 'foo': 1234, + 'msg': "Something happened, thought you should know.", + } + + # (and elsewhere..) + app.send_email('poser_alert_foo', { + 'foo': 5678, + 'msg': "Can't take much more, she's gonna blow!", + }) + + Defining a subclass for each email type can be a bit tedious, so + why do it? In fact there is no need, if you just want to *send* + emails. + + The purpose of defining a subclass for each email type is 2-fold, + but really the answer is "for maintenance sake" - + + * gives the app a way to discover all emails, so settings for each + can be exposed for editing + * allows for hard-coded sample context which can be used to render + templates for preview + + .. attribute:: default_subject + + Default :attr:`Message.subject` for the email, if none is + configured. + + This is technically a Mako template string, so it will be + rendered with the email context. But in most cases that + feature can be ignored, and this will be a simple string. + """ + default_subject = None + + def __init__(self, config): + self.config = config + self.app = config.get_app() + self.key = self.__class__.__name__ + + def sample_data(self): + """ + Should return a dict with sample context needed to render the + :term:`email template` for message body. This can be used to + show a "preview" of the email. + """ + return {} + + class Message: """ Represents an email message to be sent. @@ -185,8 +259,6 @@ class EmailHandler(GenericHandler): 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: @@ -213,6 +285,67 @@ class EmailHandler(GenericHandler): # TODO: sounds great but i forget why? default_filters=['h']) + def get_email_modules(self): + """ + Returns a list of all known :term:`email modules `. + + This will discover all email modules exposed by the + :term:`app`, and/or its :term:`providers `. + """ + if not hasattr(self, '_email_modules'): + self._email_modules = [] + for provider in self.app.providers.values(): + if hasattr(provider, 'email_modules'): + modules = provider.email_modules + if modules: + if isinstance(modules, str): + modules = [modules] + for module in modules: + module = importlib.import_module(module) + self._email_modules.append(module) + + return self._email_modules + + def get_email_settings(self): + """ + Returns a dict of all known :term:`email settings `, keyed by :term:`email key`. + + This calls :meth:`get_email_modules()` and for each module, it + discovers all the email settings it contains. + """ + if not hasattr(self, '_email_settings'): + self._email_settings = {} + for module in self.get_email_modules(): + for name in dir(module): + obj = getattr(module, name) + if (isinstance(obj, type) + and obj is not EmailSetting + and issubclass(obj, EmailSetting)): + self._email_settings[obj.__name__] = obj + + return self._email_settings + + def get_email_setting(self, key, instance=True): + """ + Retrieve the :term:`email setting` for the given :term:`email + key` (if it exists). + + :param key: Key for the :term:`email type`. + + :param instance: Whether to return the class, or an instance. + + :returns: :class:`EmailSetting` class or instance, or ``None`` + if the setting could not be found. + """ + settings = self.get_email_settings() + if key in settings: + setting = settings[key] + if instance: + setting = setting(self.config) + return setting + def make_message(self, **kwargs): """ Make and return a new email message. @@ -289,26 +422,49 @@ class EmailHandler(GenericHandler): # 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): + def get_auto_replyto(self, key): + """ + Returns automatic :attr:`~wuttjamaican.email.Message.replyto` + address for a message, as determined by config. + """ + # prefer configured replyto specific to key + replyto = self.config.get(f'{self.config.appname}.email.{key}.replyto') + if replyto: + return replyto + + # fall back to global default, if present + return self.config.get(f'{self.config.appname}.email.default.replyto') + + def get_auto_subject(self, key, context={}, rendered=True, setting=None): """ Returns automatic :attr:`~wuttjamaican.email.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. + (usually) renders the result using the given context. + + :param key: Key for the :term:`email type`. + + :param context: Dict of context for rendering the subject + template, if applicable. :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) + :param setting: Optional :class:`EmailSetting` class or + instance. This is passed along to + :meth:`get_auto_subject_template()`. + + :returns: Final subject text, either "raw" or rendered. + """ + template = self.get_auto_subject_template(key, setting=setting) if not rendered: return template + return Template(template).render(**context) - def get_auto_subject_template(self, key): + def get_auto_subject_template(self, key, setting=None): """ Returns the template string to use for automatic subject line of a message, as determined by config. @@ -318,12 +474,28 @@ class EmailHandler(GenericHandler): The template returned from this method is used to render the final subject line in :meth:`get_auto_subject()`. + + :param key: Key for the :term:`email type`. + + :param setting: Optional :class:`EmailSetting` class or + instance. This may be used to determine the "default" + subject if none is configured. You can specify this as an + optimization; otherwise it will be fetched if needed via + :meth:`get_email_setting()`. + + :returns: Final subject template, as raw text. """ # prefer configured subject specific to key template = self.config.get(f'{self.config.appname}.email.{key}.subject') if template: return template + # or subject from email setting, if defined + if not setting: + setting = self.get_email_setting(key) + if setting and setting.default_subject: + return setting.default_subject + # fall back to global default return self.config.get(f'{self.config.appname}.email.default.subject', default=self.universal_subject) @@ -385,24 +557,32 @@ class EmailHandler(GenericHandler): if template: return template.render(**context) - def get_auto_body_template(self, key, typ): + def get_auto_body_template(self, key, mode): """ """ - from mako.exceptions import TopLevelLookupException + mode = mode.lower() + if mode not in ('txt', 'html'): + raise ValueError("requested mode not supported") - typ = typ.lower() - if typ not in ('txt', 'html'): - raise ValueError("requested type not supported") - - if typ == 'txt': + if mode == 'txt': templates = self.txt_templates - elif typ == 'html': + elif mode == 'html': templates = self.html_templates try: - return templates.get_template(f'{key}.{typ}.mako') + return templates.get_template(f'{key}.{mode}.mako') except TopLevelLookupException: pass + def get_notes(self, key): + """ + Returns configured "notes" for the given :term:`email key`. + + :param key: Key for the :term:`email type`. + + :returns: Notes as string if found; otherwise ``None``. + """ + return self.config.get(f'{self.config.appname}.email.{key}.notes') + def is_enabled(self, key): """ Returns flag indicating whether the given email type is diff --git a/tests/test_email.py b/tests/test_email.py index 2d5272b..d4c05bd 100644 --- a/tests/test_email.py +++ b/tests/test_email.py @@ -6,21 +6,102 @@ from unittest.mock import patch, MagicMock import pytest from wuttjamaican import email as mod -from wuttjamaican.conf import WuttaConfig from wuttjamaican.util import resource_path from wuttjamaican.exc import ConfigurationError +from wuttjamaican.testing import ConfigTestCase -class TestEmailHandler(TestCase): +class TestEmailSetting(ConfigTestCase): - def setUp(self): - try: - import mako - except ImportError: - pytest.skip("test not relevant without mako") + def test_constructor(self): + setting = mod.EmailSetting(self.config) + self.assertIs(setting.config, self.config) + self.assertIs(setting.app, self.app) + self.assertEqual(setting.key, 'EmailSetting') - self.config = WuttaConfig() - self.app = self.config.get_app() + def test_sample_data(self): + setting = mod.EmailSetting(self.config) + self.assertEqual(setting.sample_data(), {}) + + +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) + + +class mock_foo(mod.EmailSetting): + default_subject = "MOCK FOO!" + def sample_data(self): + return {'foo': 'mock'} + + +class TestEmailHandler(ConfigTestCase): def make_handler(self, **kwargs): return mod.EmailHandler(self.config, **kwargs) @@ -53,6 +134,69 @@ class TestEmailHandler(TestCase): self.assertEqual(handler.txt_templates.directories, [path]) self.assertEqual(handler.html_templates.directories, [path]) + def test_get_email_modules(self): + + # no providers, no email modules + with patch.object(self.app, 'providers', new={}): + handler = self.make_handler() + self.assertEqual(handler.get_email_modules(), []) + + # provider may specify modules as list + providers = { + 'wuttatest': MagicMock(email_modules=['wuttjamaican.email']), + } + with patch.object(self.app, 'providers', new=providers): + handler = self.make_handler() + modules = handler.get_email_modules() + self.assertEqual(len(modules), 1) + self.assertIs(modules[0], mod) + + # provider may specify modules as list + providers = { + 'wuttatest': MagicMock(email_modules='wuttjamaican.email'), + } + with patch.object(self.app, 'providers', new=providers): + handler = self.make_handler() + modules = handler.get_email_modules() + self.assertEqual(len(modules), 1) + self.assertIs(modules[0], mod) + + def test_get_email_settings(self): + + # no providers, no email settings + with patch.object(self.app, 'providers', new={}): + handler = self.make_handler() + self.assertEqual(handler.get_email_settings(), {}) + + # provider may define email settings (via modules) + providers = { + 'wuttatest': MagicMock(email_modules=['tests.test_email']), + } + with patch.object(self.app, 'providers', new=providers): + handler = self.make_handler() + settings = handler.get_email_settings() + self.assertEqual(len(settings), 1) + self.assertIn('mock_foo', settings) + + def test_get_email_setting(self): + + providers = { + 'wuttatest': MagicMock(email_modules=['tests.test_email']), + } + + with patch.object(self.app, 'providers', new=providers): + handler = self.make_handler() + + # as instance + setting = handler.get_email_setting('mock_foo') + self.assertIsInstance(setting, mod.EmailSetting) + self.assertIsInstance(setting, mock_foo) + + # as class + setting = handler.get_email_setting('mock_foo', instance=False) + self.assertTrue(issubclass(setting, mod.EmailSetting)) + self.assertIs(setting, mock_foo) + def test_make_message(self): handler = self.make_handler() msg = handler.make_message() @@ -166,6 +310,20 @@ class TestEmailHandler(TestCase): self.config.setdefault('wutta.email.foo.sender', 'sally@example.com') self.assertEqual(handler.get_auto_sender('foo'), 'sally@example.com') + def test_get_auto_replyto(self): + handler = self.make_handler() + + # null by default + self.assertIsNone(handler.get_auto_replyto('foo')) + + # can set global default + self.config.setdefault('wutta.email.default.replyto', 'george@example.com') + self.assertEqual(handler.get_auto_replyto('foo'), 'george@example.com') + + # can set for key + self.config.setdefault('wutta.email.foo.replyto', 'kathy@example.com') + self.assertEqual(handler.get_auto_replyto('foo'), 'kathy@example.com') + def test_get_auto_subject_template(self): handler = self.make_handler() @@ -183,6 +341,15 @@ class TestEmailHandler(TestCase): template = handler.get_auto_subject_template('foo') self.assertEqual(template, "Foo Message") + # setting can provide default subject + providers = { + 'wuttatest': MagicMock(email_modules=['tests.test_email']), + } + with patch.object(self.app, 'providers', new=providers): + handler = self.make_handler() + template = handler.get_auto_subject_template('mock_foo') + self.assertEqual(template, "MOCK FOO!") + def test_get_auto_subject(self): handler = self.make_handler() @@ -279,6 +446,16 @@ class TestEmailHandler(TestCase): body = handler.get_auto_html_body('test_foo') self.assertEqual(body, '

hello from foo html template

\n') + def test_get_notes(self): + handler = self.make_handler() + + # null by default + self.assertIsNone(handler.get_notes('foo')) + + # configured notes + self.config.setdefault('wutta.email.foo.notes', 'hello world') + self.assertEqual(handler.get_notes('foo'), 'hello world') + def test_is_enabled(self): handler = self.make_handler() @@ -455,74 +632,3 @@ class TestEmailHandler(TestCase): self.config.setdefault('wutta.email.default.enabled', False) handler.send_email('bar', sender='bar@example.com', txt_body="hello world") self.assertFalse(deliver_message.called) - - -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)