diff --git a/docs/glossary.rst b/docs/glossary.rst index 6550ea0..0218540 100644 --- a/docs/glossary.rst +++ b/docs/glossary.rst @@ -203,10 +203,11 @@ Glossary 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. + This refers to the :term:`config 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 diff --git a/docs/narr/email/sending.rst b/docs/narr/email/sending.rst index 31b76db..fb09fd4 100644 --- a/docs/narr/email/sending.rst +++ b/docs/narr/email/sending.rst @@ -10,8 +10,8 @@ Basics To send an email you (usually) need 3 things: -* key - unique key identifying the type of email -* template - template file to render message body +* key - unique key identifying the :term:`email type` +* template - :term:`email template` file to render message body * context - context dict for template file rendering And actually the template just needs to exist somewhere it can be diff --git a/src/wuttjamaican/email.py b/src/wuttjamaican/email.py index f752dc7..bbe1be7 100644 --- a/src/wuttjamaican/email.py +++ b/src/wuttjamaican/email.py @@ -23,8 +23,10 @@ """ Email Handler """ +# pylint: disable=too-many-lines import logging +import re import smtplib from email.mime.multipart import MIMEMultipart from email.mime.text import MIMEText @@ -60,6 +62,7 @@ class EmailSetting: # pylint: disable=too-few-public-methods default_subject = "Something happened!" + # nb. this is not used for sending; only preview def sample_data(self): return { 'foo': 1234, @@ -84,23 +87,80 @@ class EmailSetting: # pylint: disable=too-few-public-methods * allows for hard-coded sample context which can be used to render templates for preview + .. attribute:: key + + Unique identifier for this :term:`email type`. + + This is the :term:`email key` used for config/template lookup, + e.g. when sending an email. + + This is automatically set based on the *class name* so there is + no need (or point) to set it. But the attribute is here for + read access, for convenience / code readability:: + + class poser_alert_foo(EmailSetting): + default_subject = "Something happened!" + + handler = app.get_email_handler() + setting = handler.get_email_setting("poser_alert_foo") + assert setting.key == "poser_alert_foo" + + See also :attr:`fallback_key`. + .. attribute:: default_subject - Default :attr:`Message.subject` for the email, if none is - configured. + Default subject for sending emails of this type. + + Usually, if config does not override, this will become + :attr:`Message.subject`. 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. + + Calling code should not access this directly, but instead use + :meth:`get_default_subject()` . """ default_subject = None + fallback_key = None + """ + Optional fallback key to use for config/template lookup, if + nothing is found for :attr:`key`. + """ + def __init__(self, config): self.config = config self.app = config.get_app() self.key = self.__class__.__name__ + def get_description(self): + """ + This must return the full description for the :term:`email + type`. It is not used for the sending of email; only for + settings administration. + + Default logic will use the class docstring. + + :returns: String description for the email type + """ + return self.__class__.__doc__.strip() + + def get_default_subject(self): + """ + This must return the default subject, for sending emails of + this type. + + If config does not override, this will become + :attr:`Message.subject`. + + Default logic here returns :attr:`default_subject` as-is. + + :returns: Default subject as string + """ + return self.default_subject + def sample_data(self): """ Should return a dict with sample context needed to render the @@ -325,13 +385,18 @@ class EmailHandler(GenericHandler): # pylint: disable=too-many-public-methods """ if "email_settings" not in self.classes: self.classes["email_settings"] = {} + + # nb. we only want lower_case_names - all UpperCaseNames + # are assumed to be base classes + pattern = re.compile(r"^[a-z]") + 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) + and pattern.match(obj.__name__) ): self.classes["email_settings"][obj.__name__] = obj @@ -368,7 +433,9 @@ class EmailHandler(GenericHandler): # pylint: disable=too-many-public-methods """ return Message(**kwargs) - def make_auto_message(self, key, context=None, default_subject=None, **kwargs): + def make_auto_message( + self, key, context=None, default_subject=None, fallback_key=None, **kwargs + ): """ Make a new email message using config to determine its properties, and auto-generating body from a template. @@ -379,7 +446,8 @@ class EmailHandler(GenericHandler): # pylint: disable=too-many-public-methods :param key: Unique key for this particular "type" of message. This key is used as a prefix for all config settings and - template names pertinent to the message. + template names pertinent to the message. See also the + ``fallback_key`` param, below. :param context: Context dict used to render template(s) for the message. @@ -387,6 +455,10 @@ class EmailHandler(GenericHandler): # pylint: disable=too-many-public-methods :param default_subject: Optional :attr:`~Message.subject` template/string to use, if config does not specify one. + :param fallback_key: Optional fallback :term:`email key` to + use for config/template lookup, if nothing is found for + ``key``. + :param \\**kwargs: Any remaining kwargs are passed as-is to :meth:`make_message()`. More on this below. @@ -411,7 +483,7 @@ class EmailHandler(GenericHandler): # pylint: disable=too-many-public-methods kwargs["sender"] = self.get_auto_sender(key) if "subject" not in kwargs: kwargs["subject"] = self.get_auto_subject( - key, context, default=default_subject + key, context, default=default_subject, fallback_key=fallback_key ) if "to" not in kwargs: kwargs["to"] = self.get_auto_to(key) @@ -420,11 +492,47 @@ class EmailHandler(GenericHandler): # pylint: disable=too-many-public-methods if "bcc" not in kwargs: kwargs["bcc"] = self.get_auto_bcc(key) if "txt_body" not in kwargs: - kwargs["txt_body"] = self.get_auto_txt_body(key, context) + kwargs["txt_body"] = self.get_auto_txt_body( + key, context, fallback_key=fallback_key + ) if "html_body" not in kwargs: - kwargs["html_body"] = self.get_auto_html_body(key, context) + kwargs["html_body"] = self.get_auto_html_body( + key, context, fallback_key=fallback_key + ) return self.make_message(**kwargs) + def get_email_context(self, key, context=None): # pylint: disable=unused-argument + """ + This must return the "full" context for rendering the email + subject and/or body templates. + + Normally the input ``context`` is coming from the + :meth:`send_email()` param of the same name. + + By default, this method modifies the input context to add the + following: + + * ``config`` - reference to the :term:`config object` + * ``app`` - reference to the :term:`app handler` + + Subclass may further modify as needed. + + :param key: The :term:`email key` for which to get context. + + :param context: Input context dict. + + :returns: Final context dict + """ + if context is None: + context = {} + context.update( + { + "config": self.config, + "app": self.app, + } + ) + return context + def get_auto_sender(self, key): """ Returns automatic @@ -455,7 +563,13 @@ class EmailHandler(GenericHandler): # pylint: disable=too-many-public-methods return self.config.get(f"{self.config.appname}.email.default.replyto") def get_auto_subject( # pylint: disable=too-many-arguments,too-many-positional-arguments - self, key, context=None, rendered=True, setting=None, default=None + self, + key, + context=None, + rendered=True, + setting=None, + default=None, + fallback_key=None, ): """ Returns automatic :attr:`~wuttjamaican.email.Message.subject` @@ -464,7 +578,8 @@ class EmailHandler(GenericHandler): # pylint: disable=too-many-public-methods This calls :meth:`get_auto_subject_template()` and then (usually) renders the result using the given context. - :param key: Key for the :term:`email type`. + :param key: Key for the :term:`email type`. See also the + ``fallback_key`` param, below. :param context: Dict of context for rendering the subject template, if applicable. @@ -479,16 +594,23 @@ class EmailHandler(GenericHandler): # pylint: disable=too-many-public-methods :param default: Default subject to use if none is configured. + :param fallback_key: Optional fallback :term:`email key` to + use for config lookup, if nothing is found for ``key``. + :returns: Final subject text, either "raw" or rendered. """ - template = self.get_auto_subject_template(key, setting=setting, default=default) + template = self.get_auto_subject_template( + key, setting=setting, default=default, fallback_key=fallback_key + ) if not rendered: return template - context = context or {} + context = self.get_email_context(key, context) return Template(template).render(**context) - def get_auto_subject_template(self, key, setting=None, default=None): + def get_auto_subject_template( + self, key, setting=None, default=None, fallback_key=None + ): """ Returns the template string to use for automatic subject line of a message, as determined by config. @@ -509,22 +631,32 @@ class EmailHandler(GenericHandler): # pylint: disable=too-many-public-methods :param default: Default subject to use if none is configured. + :param fallback_key: Optional fallback :term:`email key` to + use for config lookup, if nothing is found for ``key``. + :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: + if template := self.config.get(f"{self.config.appname}.email.{key}.subject"): return template # or use caller-specified default, if applicable if default: return default + # or use fallback key, if provided + if fallback_key: + if template := self.config.get( + f"{self.config.appname}.email.{fallback_key}.subject" + ): + 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 + if setting: + if subject := setting.get_default_subject(): + return subject # fall back to global default return self.config.get( @@ -569,32 +701,34 @@ class EmailHandler(GenericHandler): # pylint: disable=too-many-public-methods f"{self.config.appname}.email.default.{typ}", default=[] ) - def get_auto_txt_body(self, key, context=None): + def get_auto_txt_body(self, key, context=None, fallback_key=None): """ 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") + template = self.get_auto_body_template(key, "txt", fallback_key=fallback_key) if template: - context = context or {} + context = self.get_email_context(key, context) return template.render(**context) return None - def get_auto_html_body(self, key, context=None): + def get_auto_html_body(self, key, context=None, fallback_key=None): """ Returns automatic :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") + template = self.get_auto_body_template(key, "html", fallback_key=fallback_key) if template: - context = context or {} + context = self.get_email_context(key, context) return template.render(**context) return None - def get_auto_body_template(self, key, mode): # pylint: disable=empty-docstring + def get_auto_body_template( # pylint: disable=empty-docstring + self, key, mode, fallback_key=None + ): """ """ mode = mode.lower() if mode == "txt": @@ -605,9 +739,19 @@ class EmailHandler(GenericHandler): # pylint: disable=too-many-public-methods raise ValueError("requested mode not supported") try: + + # prefer specific template for key return templates.get_template(f"{key}.{mode}.mako") + except TopLevelLookupException: - pass + + # but can use fallback if applicable + if fallback_key: + try: + return templates.get_template(f"{fallback_key}.{mode}.mako") + except TopLevelLookupException: + pass + return None def get_notes(self, key): @@ -756,7 +900,14 @@ class EmailHandler(GenericHandler): # pylint: disable=too-many-public-methods ) def send_email( # pylint: disable=too-many-arguments,too-many-positional-arguments - self, key=None, context=None, message=None, sender=None, recips=None, **kwargs + self, + key=None, + context=None, + message=None, + sender=None, + recips=None, + fallback_key=None, + **kwargs, ): """ Send an email message. @@ -773,6 +924,7 @@ class EmailHandler(GenericHandler): # pylint: disable=too-many-public-methods :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. + See also the ``fallback_key`` param, below. :param context: Context dict for rendering automatic email template(s). @@ -812,6 +964,10 @@ class EmailHandler(GenericHandler): # pylint: disable=too-many-public-methods context = {'data': [1, 2, 3]} app.send_email('foo', context, to='me@example.com', cc='bobby@example.com') + :param fallback_key: Optional fallback :term:`email key` to + use for config/template lookup, if nothing is found for + ``key``. + :param \\**kwargs: Any remaining kwargs are passed along to :meth:`make_auto_message()`. So, not used if you provide the ``message``. @@ -827,7 +983,9 @@ class EmailHandler(GenericHandler): # pylint: disable=too-many-public-methods # auto-create message from key + context if sender: kwargs["sender"] = sender - message = self.make_auto_message(key, context or {}, **kwargs) + message = self.make_auto_message( + key, context or {}, fallback_key=fallback_key, **kwargs + ) if not (message.txt_body or message.html_body): raise RuntimeError( f"message (type: {key}) has no body - " diff --git a/tests/test_email.py b/tests/test_email.py index e4b6ff9..c9dca71 100644 --- a/tests/test_email.py +++ b/tests/test_email.py @@ -5,6 +5,7 @@ from unittest import TestCase from unittest.mock import patch, MagicMock import pytest +from mako.template import Template from wuttjamaican import email as mod from wuttjamaican.util import resource_path @@ -20,6 +21,16 @@ class TestEmailSetting(ConfigTestCase): self.assertIs(setting.app, self.app) self.assertEqual(setting.key, "EmailSetting") + def test_get_description(self): + + class MockSetting(mod.EmailSetting): + """ + this should be a good test + """ + + setting = MockSetting(self.config) + self.assertEqual(setting.get_description(), "this should be a good test") + def test_sample_data(self): setting = mod.EmailSetting(self.config) self.assertEqual(setting.sample_data(), {}) @@ -299,7 +310,9 @@ class TestEmailHandler(ConfigTestCase): msg = handler.make_auto_message("foo", subject=None) get_auto_subject.assert_not_called() msg = handler.make_auto_message("foo") - get_auto_subject.assert_called_once_with("foo", {}, default=None) + get_auto_subject.assert_called_once_with( + "foo", {}, default=None, fallback_key=None + ) # to with patch.object(handler, "get_auto_to") as get_auto_to: @@ -330,14 +343,18 @@ class TestEmailHandler(ConfigTestCase): msg = handler.make_auto_message("foo", txt_body=None) get_auto_txt_body.assert_not_called() msg = handler.make_auto_message("foo") - get_auto_txt_body.assert_called_once_with("foo", {}) + get_auto_txt_body.assert_called_once_with( + "foo", {"config": self.config, "app": self.app}, fallback_key=None + ) # html_body with patch.object(handler, "get_auto_html_body") as get_auto_html_body: msg = handler.make_auto_message("foo", html_body=None) get_auto_html_body.assert_not_called() msg = handler.make_auto_message("foo") - get_auto_html_body.assert_called_once_with("foo", {}) + get_auto_html_body.assert_called_once_with( + "foo", {"config": self.config, "app": self.app}, fallback_key=None + ) def test_get_auto_sender(self): handler = self.make_handler() @@ -384,6 +401,11 @@ class TestEmailHandler(ConfigTestCase): template = handler.get_auto_subject_template("foo") self.assertEqual(template, "Foo Message") + # can configure via fallback_key + self.config.setdefault("wutta.email.bar.subject", "Bar Message") + template = handler.get_auto_subject_template("baz", fallback_key="bar") + self.assertEqual(template, "Bar Message") + # EmailSetting can provide default subject providers = { "wuttatest": MagicMock(email_modules=["tests.test_email"]), @@ -446,26 +468,48 @@ class TestEmailHandler(ConfigTestCase): self.assertEqual(recips, ["bob@example.com"]) def test_get_auto_body_template(self): - from mako.template import Template - handler = self.make_handler() - # error if bad request - self.assertRaises(ValueError, handler.get_auto_body_template, "foo", "BADTYPE") + # error if invalid mode (must be 'html' or 'txt') + self.assertRaises(ValueError, handler.get_auto_body_template, "foo", "BAD_MODE") - # empty by default - template = handler.get_auto_body_template("foo", "txt") - self.assertIsNone(template) + # no template by default + self.assertIsNone(handler.get_auto_body_template("foo", "html")) + self.assertIsNone(handler.get_auto_body_template("foo", "txt")) - # but returns a template if it exists + # mock template lookup providers = { "wuttatest": MagicMock(email_templates=["tests:email-templates"]), } with patch.object(self.app, "providers", new=providers): handler = self.make_handler() - template = handler.get_auto_body_template("test_foo", "txt") - self.assertIsInstance(template, Template) - self.assertEqual(template.uri, "test_foo.txt.mako") + + # template exists (txt) + template = handler.get_auto_body_template("test_foo", "txt") + self.assertIsInstance(template, Template) + self.assertEqual(template.uri, "test_foo.txt.mako") + + # template exists (html) + template = handler.get_auto_body_template("test_foo", "html") + self.assertIsInstance(template, Template) + self.assertEqual(template.uri, "test_foo.html.mako") + + # no such template + template = handler.get_auto_body_template("no_such_template", "html") + self.assertIsNone(template) + + # but can use fallback + template = handler.get_auto_body_template( + "no_such_template", "html", fallback_key="test_foo" + ) + self.assertIsInstance(template, Template) + self.assertEqual(template.uri, "test_foo.html.mako") + + # what if fallback is also not found + template = handler.get_auto_body_template( + "no_such_template", "html", fallback_key="this_neither" + ) + self.assertIsNone(template) def test_get_auto_txt_body(self): handler = self.make_handler()