From 8a0830b35d97ce423efbbbcea7886641c88f0254 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sat, 20 Dec 2025 23:00:24 -0600 Subject: [PATCH] fix: add auto-prefix for message subject when sending email --- src/wuttjamaican/email.py | 146 ++++++++++++++++++++++++++++++++++---- tests/test_email.py | 83 +++++++++++++++++++--- 2 files changed, 203 insertions(+), 26 deletions(-) diff --git a/src/wuttjamaican/email.py b/src/wuttjamaican/email.py index bbe1be7..3a564c5 100644 --- a/src/wuttjamaican/email.py +++ b/src/wuttjamaican/email.py @@ -124,6 +124,14 @@ class EmailSetting: # pylint: disable=too-few-public-methods default_subject = None + default_prefix = None + """ + Default subject prefix for emails of this type. + + Calling code should not access this directly, but instead use + :meth:`get_default_prefix()` . + """ + fallback_key = None """ Optional fallback key to use for config/template lookup, if @@ -147,6 +155,20 @@ class EmailSetting: # pylint: disable=too-few-public-methods """ return self.__class__.__doc__.strip() + def get_default_prefix(self): + """ + This returns the default subject prefix, for sending emails of + this type. + + Default logic here returns :attr:`default_prefix` as-is. + + This method will often return ``None`` in which case the + global default prefix is used. + + :returns: Default subject prefix as string, or ``None`` + """ + return self.default_prefix + def get_default_subject(self): """ This must return the default subject, for sending emails of @@ -434,7 +456,14 @@ class EmailHandler(GenericHandler): # pylint: disable=too-many-public-methods return Message(**kwargs) def make_auto_message( - self, key, context=None, default_subject=None, fallback_key=None, **kwargs + self, + key, + context=None, + default_subject=None, + prefix_subject=True, + default_prefix=None, + fallback_key=None, + **kwargs, ): """ Make a new email message using config to determine its @@ -455,6 +484,12 @@ 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 prefix_subject: Boolean indicating the message subject + should be auto-prefixed. + + :param default_prefix: Default subject prefix to use if none + is configured. + :param fallback_key: Optional fallback :term:`email key` to use for config/template lookup, if nothing is found for ``key``. @@ -483,7 +518,12 @@ 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, fallback_key=fallback_key + key, + context, + default=default_subject, + prefix=prefix_subject, + default_prefix=default_prefix, + fallback_key=fallback_key, ) if "to" not in kwargs: kwargs["to"] = self.get_auto_to(key) @@ -567,16 +607,19 @@ class EmailHandler(GenericHandler): # pylint: disable=too-many-public-methods key, context=None, rendered=True, - setting=None, default=None, fallback_key=None, + setting=None, + prefix=True, + default_prefix=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 - (usually) renders the result using the given context. + (usually) renders the result using the given context, and adds + the :meth:`get_auto_subject_prefix()`. :param key: Key for the :term:`email type`. See also the ``fallback_key`` param, below. @@ -588,15 +631,22 @@ class EmailHandler(GenericHandler): # pylint: disable=too-many-public-methods template will be returned, instead of the final/rendered subject text. - :param setting: Optional :class:`EmailSetting` class or - instance. This is passed along to - :meth:`get_auto_subject_template()`. - :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``. + :param setting: Optional :class:`EmailSetting` class or + instance. This is passed along to + :meth:`get_auto_subject_template()`. + + :param prefix: Boolean indicating the message subject should + be auto-prefixed. This is ignored when ``rendered`` param + is false. + + :param default_prefix: Default subject prefix to use if none + is configured. + :returns: Final subject text, either "raw" or rendered. """ template = self.get_auto_subject_template( @@ -606,10 +656,18 @@ class EmailHandler(GenericHandler): # pylint: disable=too-many-public-methods return template context = self.get_email_context(key, context) - return Template(template).render(**context) + subject = Template(template).render(**context) + + if prefix: + if prefix := self.get_auto_subject_prefix( + key, default=default_prefix, setting=setting, fallback_key=fallback_key + ): + subject = f"{prefix} {subject}" + + return subject def get_auto_subject_template( - self, key, setting=None, default=None, fallback_key=None + self, key, default=None, fallback_key=None, setting=None ): """ Returns the template string to use for automatic subject line @@ -623,17 +681,17 @@ class EmailHandler(GenericHandler): # pylint: disable=too-many-public-methods :param key: Key for the :term:`email type`. + :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``. + :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()`. - :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 @@ -664,6 +722,64 @@ class EmailHandler(GenericHandler): # pylint: disable=too-many-public-methods default=self.universal_subject, ) + def get_auto_subject_prefix( + self, key, default=None, fallback_key=None, setting=None + ): + """ + Returns the string to use for automatic subject prefix, as + determined by config. This is called by + :meth:`get_auto_subject()`. + + Note that unlike the subject proper, the prefix is just a + normal string, not a template. + + Example prefix is ``"[Wutta]"`` - trailing space will be added + automatically when applying the prefix to a message subject. + + :param key: The :term:`email key` requested. + + :param default: Default prefix 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``. + + :param setting: Optional :class:`EmailSetting` class or + instance. This may be used to determine the "default" + prefix 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 prefix string + """ + + # prefer configured prefix specific to key + if prefix := self.config.get(f"{self.config.appname}.email.{key}.prefix"): + return prefix + + # or use caller-specified default, if applicable + if default: + return default + + # or use fallback key, if provided + if fallback_key: + if prefix := self.config.get( + f"{self.config.appname}.email.{fallback_key}.prefix" + ): + return prefix + + # or prefix from email setting, if defined + if not setting: + setting = self.get_email_setting(key) + if setting: + if prefix := setting.get_default_prefix(): + return prefix + + # fall back to global default + return self.config.get( + f"{self.config.appname}.email.default.prefix", + default=f"[{self.app.get_node_title()}]", + ) + def get_auto_to(self, key): """ Returns automatic :attr:`~wuttjamaican.email.Message.to` diff --git a/tests/test_email.py b/tests/test_email.py index c9dca71..37a9adf 100644 --- a/tests/test_email.py +++ b/tests/test_email.py @@ -31,6 +31,17 @@ class TestEmailSetting(ConfigTestCase): setting = MockSetting(self.config) self.assertEqual(setting.get_description(), "this should be a good test") + def test_get_default_prefix(self): + + # empty by default + setting = mod.EmailSetting(self.config) + self.assertIsNone(setting.default_prefix) + self.assertIsNone(setting.get_default_prefix()) + + # but can override + setting.default_prefix = "[foo]" + self.assertEqual(setting.get_default_prefix(), "[foo]") + def test_sample_data(self): setting = mod.EmailSetting(self.config) self.assertEqual(setting.sample_data(), {}) @@ -136,6 +147,7 @@ class TestMessage(FileTestCase): class mock_foo(mod.EmailSetting): default_subject = "MOCK FOO!" + default_prefix = "[mock_foo]" def sample_data(self): return {"foo": "mock"} @@ -253,7 +265,7 @@ class TestEmailHandler(ConfigTestCase): self.assertIsInstance(msg, mod.Message) self.assertEqual(msg.key, "foo") self.assertEqual(msg.sender, "root@localhost") - self.assertEqual(msg.subject, "Automated message") + self.assertEqual(msg.subject, "[WuttJamaican] Automated message") self.assertEqual(msg.to, []) self.assertEqual(msg.cc, []) self.assertEqual(msg.bcc, []) @@ -270,7 +282,7 @@ class TestEmailHandler(ConfigTestCase): self.assertIsInstance(msg, mod.Message) self.assertEqual(msg.key, "foo") self.assertEqual(msg.sender, "bob@example.com") - self.assertEqual(msg.subject, "Attention required") + self.assertEqual(msg.subject, "[WuttJamaican] Attention required") self.assertEqual(msg.to, []) self.assertEqual(msg.cc, []) self.assertEqual(msg.bcc, []) @@ -287,7 +299,7 @@ class TestEmailHandler(ConfigTestCase): msg = handler.make_auto_message("test_foo") self.assertEqual(msg.key, "test_foo") self.assertEqual(msg.sender, "bob@example.com") - self.assertEqual(msg.subject, "hello foo") + self.assertEqual(msg.subject, "[WuttJamaican] hello foo") self.assertEqual(msg.to, ["sally@example.com"]) self.assertEqual(msg.cc, []) self.assertEqual(msg.bcc, []) @@ -311,7 +323,12 @@ class TestEmailHandler(ConfigTestCase): get_auto_subject.assert_not_called() msg = handler.make_auto_message("foo") get_auto_subject.assert_called_once_with( - "foo", {}, default=None, fallback_key=None + "foo", + {}, + default=None, + prefix=True, + default_prefix=None, + fallback_key=None, ) # to @@ -421,36 +438,80 @@ class TestEmailHandler(ConfigTestCase): ) self.assertEqual(template, "whatever is clever") + def test_get_auto_subject_prefix(self): + handler = self.make_handler() + + # global default + prefix = handler.get_auto_subject_prefix("foo") + self.assertEqual(prefix, "[WuttJamaican]") + + # can configure alternate global default + self.config.setdefault("wutta.email.default.prefix", "[bar]") + prefix = handler.get_auto_subject_prefix("foo") + self.assertEqual(prefix, "[bar]") + + # can configure just for key + self.config.setdefault("wutta.email.foo.prefix", "[foo]") + prefix = handler.get_auto_subject_prefix("foo") + self.assertEqual(prefix, "[foo]") + + # can configure via fallback_key + self.config.setdefault("wutta.email.bar.prefix", "[baz]") + prefix = handler.get_auto_subject_prefix("foofoo", fallback_key="bar") + self.assertEqual(prefix, "[baz]") + + # EmailSetting can provide default prefix + providers = { + "wuttatest": MagicMock(email_modules=["tests.test_email"]), + } + with patch.object(self.app, "providers", new=providers): + handler = self.make_handler() + prefix = handler.get_auto_subject_prefix("mock_foo") + self.assertEqual(prefix, "[mock_foo]") + + # or caller can provide default + prefix = handler.get_auto_subject_prefix("mock_foo", default="[zzz]") + self.assertEqual(prefix, "[zzz]") + def test_get_auto_subject(self): handler = self.make_handler() # global default subject = handler.get_auto_subject("foo") - self.assertEqual(subject, "Automated message") + self.assertEqual(subject, "[WuttJamaican] Automated message") # can configure alternate global default self.config.setdefault("wutta.email.default.subject", "Wutta Message") subject = handler.get_auto_subject("foo") - self.assertEqual(subject, "Wutta Message") + self.assertEqual(subject, "[WuttJamaican] Wutta Message") # caller can provide default subject subject = handler.get_auto_subject("foo", default="whatever is clever") - self.assertEqual(subject, "whatever is clever") + self.assertEqual(subject, "[WuttJamaican] whatever is clever") # can configure just for key self.config.setdefault("wutta.email.foo.subject", "Foo Message") subject = handler.get_auto_subject("foo") - self.assertEqual(subject, "Foo Message") + self.assertEqual(subject, "[WuttJamaican] Foo Message") - # proper template is rendered + # proper template is rendered.. self.config.setdefault("wutta.email.bar.subject", "${foo} Message") subject = handler.get_auto_subject("bar", {"foo": "FOO"}) - self.assertEqual(subject, "FOO Message") + self.assertEqual(subject, "[WuttJamaican] FOO Message") - # unless we ask it not to + # ..unless we ask it not to subject = handler.get_auto_subject("bar", {"foo": "FOO"}, rendered=False) + # nb. no prefix for unrendered template self.assertEqual(subject, "${foo} Message") + # now suppress/override the prefix + subject = handler.get_auto_subject("foo") + self.assertEqual(subject, "[WuttJamaican] Foo Message") + subject = handler.get_auto_subject("foo", prefix=False) + self.assertEqual(subject, "Foo Message") + subject = handler.get_auto_subject("foo", default_prefix="[foo]") + self.assertEqual(subject, "[foo] Foo Message") + def test_get_auto_recips(self): handler = self.make_handler()