From 491df09f2fe13b5950e2fd1b5ad76227871bc9b0 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Mon, 23 Dec 2024 15:37:37 -0600 Subject: [PATCH] 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)