From 902412322e6cd6866681864049c4cbc85d5881d8 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 19 Dec 2024 18:20:57 -0600 Subject: [PATCH] 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)