3
0
Fork 0

feat: add "email settings" feature for admin, previews

This commit is contained in:
Lance Edgar 2024-12-23 15:37:37 -06:00
parent 6c8f1c973d
commit 491df09f2f
4 changed files with 421 additions and 98 deletions

View file

@ -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

View file

@ -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 <email module>` 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):

View file

@ -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 <email setting>`.
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 <email
module>`.
This will discover all email modules exposed by the
:term:`app`, and/or its :term:`providers <provider>`.
"""
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 <email
setting>`, 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

View file

@ -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="<p>hello world</p>")
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="<p>hello world</p>")
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="<p>hello world</p>")
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, '<p>hello from foo html template</p>\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="<p>hello world</p>")
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="<p>hello world</p>")
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="<p>hello world</p>")
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)