diff --git a/docs/api/wuttjamaican/email.handler.rst b/docs/api/wuttjamaican/email.handler.rst
new file mode 100644
index 0000000..4e4900f
--- /dev/null
+++ b/docs/api/wuttjamaican/email.handler.rst
@@ -0,0 +1,6 @@
+
+``wuttjamaican.email.handler``
+==============================
+
+.. automodule:: wuttjamaican.email.handler
+ :members:
diff --git a/docs/api/wuttjamaican/email.message.rst b/docs/api/wuttjamaican/email.message.rst
new file mode 100644
index 0000000..1656196
--- /dev/null
+++ b/docs/api/wuttjamaican/email.message.rst
@@ -0,0 +1,6 @@
+
+``wuttjamaican.email.message``
+==============================
+
+.. automodule:: wuttjamaican.email.message
+ :members:
diff --git a/docs/api/wuttjamaican/email.rst b/docs/api/wuttjamaican/email.rst
new file mode 100644
index 0000000..d187d98
--- /dev/null
+++ b/docs/api/wuttjamaican/email.rst
@@ -0,0 +1,6 @@
+
+``wuttjamaican.email``
+======================
+
+.. automodule:: wuttjamaican.email
+ :members:
diff --git a/docs/api/wuttjamaican/index.rst b/docs/api/wuttjamaican/index.rst
index 91a1cf8..69a754e 100644
--- a/docs/api/wuttjamaican/index.rst
+++ b/docs/api/wuttjamaican/index.rst
@@ -17,6 +17,9 @@
db.model.base
db.model.upgrades
db.sess
+ email
+ email.handler
+ email.message
enum
exc
people
diff --git a/docs/glossary.rst b/docs/glossary.rst
index 2c70691..3b87762 100644
--- a/docs/glossary.rst
+++ b/docs/glossary.rst
@@ -124,6 +124,12 @@ Glossary
In practice this generally refers to a
:class:`~wuttjamaican.db.sess.Session` instance.
+ email handler
+ The :term:`handler` responsible for sending email on behalf of
+ the :term:`app`.
+
+ Default is :class:`~wuttjamaican.email.handler.EmailHandler`.
+
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/pyproject.toml b/pyproject.toml
index 4e985e5..8b7419e 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -35,6 +35,7 @@ dependencies = [
[project.optional-dependencies]
db = ["SQLAlchemy<2", "alembic", "alembic-postgresql-enum", "passlib"]
+email = ["Mako"]
docs = ["Sphinx", "sphinxcontrib-programoutput", "enum-tools[sphinx]", "furo"]
tests = ["pytest-cov", "tox"]
diff --git a/src/wuttjamaican/app.py b/src/wuttjamaican/app.py
index 1a98b75..5d67df2 100644
--- a/src/wuttjamaican/app.py
+++ b/src/wuttjamaican/app.py
@@ -81,6 +81,7 @@ class AppHandler:
default_model_spec = 'wuttjamaican.db.model'
default_enum_spec = 'wuttjamaican.enum'
default_auth_handler_spec = 'wuttjamaican.auth:AuthHandler'
+ default_email_handler_spec = 'wuttjamaican.email:EmailHandler'
default_people_handler_spec = 'wuttjamaican.people:PeopleHandler'
def __init__(self, config):
@@ -606,6 +607,21 @@ class AppHandler:
self.handlers['auth'] = factory(self.config, **kwargs)
return self.handlers['auth']
+ def get_email_handler(self, **kwargs):
+ """
+ Get the configured :term:`email handler`.
+
+ See also :meth:`send_email()`.
+
+ :rtype: :class:`~wuttjamaican.email.handler.EmailHandler`
+ """
+ if 'email' not in self.handlers:
+ spec = self.config.get(f'{self.appname}.email.handler',
+ default=self.default_email_handler_spec)
+ factory = self.load_object(spec)
+ self.handlers['email'] = factory(self.config, **kwargs)
+ return self.handlers['email']
+
def get_people_handler(self, **kwargs):
"""
Get the configured "people" :term:`handler`.
@@ -634,6 +650,15 @@ class AppHandler:
"""
return self.get_people_handler().get_person(obj, **kwargs)
+ def send_email(self, *args, **kwargs):
+ """
+ Send an email message.
+
+ This is a convenience wrapper around
+ :meth:`~wuttjamaican.email.handler.EmailHandler.send_email()`.
+ """
+ self.get_email_handler().send_email(*args, **kwargs)
+
class AppProvider:
"""
diff --git a/src/wuttjamaican/conf.py b/src/wuttjamaican/conf.py
index 6c3adf5..c04b603 100644
--- a/src/wuttjamaican/conf.py
+++ b/src/wuttjamaican/conf.py
@@ -440,8 +440,8 @@ class WuttaConfig:
# raise error if required value not found
if require:
- message = message or "missing or invalid config"
- raise ConfigurationError(f"{message}; please set config value for: {key}")
+ message = message or "missing config"
+ raise ConfigurationError(f"{message}; set value for: {key}")
# give the default value if specified
if default is not UNSPECIFIED:
diff --git a/src/wuttjamaican/email/__init__.py b/src/wuttjamaican/email/__init__.py
new file mode 100644
index 0000000..8702f9d
--- /dev/null
+++ b/src/wuttjamaican/email/__init__.py
@@ -0,0 +1,33 @@
+# -*- coding: utf-8; -*-
+################################################################################
+#
+# WuttJamaican -- Base package for Wutta Framework
+# Copyright © 2023-2024 Lance Edgar
+#
+# This file is part of Wutta Framework.
+#
+# Wutta Framework is free software: you can redistribute it and/or modify it
+# under the terms of the GNU General Public License as published by the Free
+# Software Foundation, either version 3 of the License, or (at your option) any
+# later version.
+#
+# Wutta Framework is distributed in the hope that it will be useful, but
+# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+# more details.
+#
+# You should have received a copy of the GNU General Public License along with
+# Wutta Framework. If not, see
hello from foo html template
diff --git a/tests/email/templates/test_foo.txt.mako b/tests/email/templates/test_foo.txt.mako new file mode 100644 index 0000000..dcbc4c6 --- /dev/null +++ b/tests/email/templates/test_foo.txt.mako @@ -0,0 +1 @@ +hello from foo txt template diff --git a/tests/email/test_handler.py b/tests/email/test_handler.py new file mode 100644 index 0000000..63c4874 --- /dev/null +++ b/tests/email/test_handler.py @@ -0,0 +1,403 @@ +# -*- coding: utf-8; -*- + +from unittest import TestCase +from unittest.mock import patch, MagicMock + +from wuttjamaican.email import handler as mod +from wuttjamaican.email import Message +from wuttjamaican.conf import WuttaConfig +from wuttjamaican.util import resource_path +from wuttjamaican.exc import ConfigurationError + + +class TestEmailHandler(TestCase): + + def setUp(self): + self.config = WuttaConfig() + self.app = self.config.get_app() + + def make_handler(self, **kwargs): + return mod.EmailHandler(self.config, **kwargs) + + def test_constructor_lookups(self): + + # empty lookup paths by default, if no providers + with patch.object(self.app, 'providers', new={}): + handler = self.make_handler() + self.assertEqual(handler.txt_templates.directories, []) + self.assertEqual(handler.html_templates.directories, []) + + # provider may specify paths as list + providers = { + 'wuttatest': MagicMock(email_templates=['wuttjamaican.email:templates']), + } + with patch.object(self.app, 'providers', new=providers): + handler = self.make_handler() + path = resource_path('wuttjamaican.email:templates') + self.assertEqual(handler.txt_templates.directories, [path]) + self.assertEqual(handler.html_templates.directories, [path]) + + # provider may specify paths as string + providers = { + 'wuttatest': MagicMock(email_templates='wuttjamaican.email:templates'), + } + with patch.object(self.app, 'providers', new=providers): + handler = self.make_handler() + path = resource_path('wuttjamaican.email:templates') + self.assertEqual(handler.txt_templates.directories, [path]) + self.assertEqual(handler.html_templates.directories, [path]) + + def test_make_message(self): + handler = self.make_handler() + msg = handler.make_message() + self.assertIsInstance(msg, Message) + + def test_make_auto_message(self): + handler = self.make_handler() + + # error if default sender not defined + self.assertRaises(ConfigurationError, handler.make_auto_message, 'foo') + + # so let's define that + self.config.setdefault('wutta.email.default.sender', 'bob@example.com') + + # message is empty by default + msg = handler.make_auto_message('foo') + self.assertIsInstance(msg, Message) + self.assertEqual(msg.key, 'foo') + self.assertEqual(msg.sender, 'bob@example.com') + self.assertEqual(msg.subject, "Automated message") + self.assertEqual(msg.to, []) + self.assertEqual(msg.cc, []) + self.assertEqual(msg.bcc, []) + self.assertIsNone(msg.replyto) + self.assertIsNone(msg.txt_body) + self.assertIsNone(msg.html_body) + + # but if there is a proper email profile configured for key, + # then we should get back a more complete message + self.config.setdefault('wutta.email.test_foo.subject', "hello foo") + self.config.setdefault('wutta.email.test_foo.to', 'sally@example.com') + self.config.setdefault('wutta.email.templates', 'tests.email:templates') + handler = self.make_handler() + 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.to, ['sally@example.com']) + self.assertEqual(msg.cc, []) + self.assertEqual(msg.bcc, []) + self.assertIsNone(msg.replyto) + self.assertEqual(msg.txt_body, "hello from foo txt template\n") + self.assertEqual(msg.html_body, "hello from foo html template
\n") + + # *some* auto methods get skipped if caller specifies the + # kwarg at all; others get skipped if kwarg is empty + + # sender + with patch.object(handler, 'get_auto_sender') as get_auto_sender: + msg = handler.make_auto_message('foo', sender=None) + get_auto_sender.assert_not_called() + msg = handler.make_auto_message('foo') + get_auto_sender.assert_called_once_with('foo') + + # subject + with patch.object(handler, 'get_auto_subject') as get_auto_subject: + 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', {}) + + # to + with patch.object(handler, 'get_auto_to') as get_auto_to: + msg = handler.make_auto_message('foo', to=None) + get_auto_to.assert_not_called() + get_auto_to.return_value = None + msg = handler.make_auto_message('foo') + get_auto_to.assert_called_once_with('foo') + + # cc + with patch.object(handler, 'get_auto_cc') as get_auto_cc: + msg = handler.make_auto_message('foo', cc=None) + get_auto_cc.assert_not_called() + get_auto_cc.return_value = None + msg = handler.make_auto_message('foo') + get_auto_cc.assert_called_once_with('foo') + + # bcc + with patch.object(handler, 'get_auto_bcc') as get_auto_bcc: + msg = handler.make_auto_message('foo', bcc=None) + get_auto_bcc.assert_not_called() + get_auto_bcc.return_value = None + msg = handler.make_auto_message('foo') + get_auto_bcc.assert_called_once_with('foo') + + # txt_body + with patch.object(handler, 'get_auto_txt_body') as get_auto_txt_body: + 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', {}) + + # 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', {}) + + def test_get_auto_sender(self): + handler = self.make_handler() + + # error if none configured + self.assertRaises(ConfigurationError, handler.get_auto_sender, 'foo') + + # can set global default + self.config.setdefault('wutta.email.default.sender', 'bob@example.com') + self.assertEqual(handler.get_auto_sender('foo'), 'bob@example.com') + + # can set for key + self.config.setdefault('wutta.email.foo.sender', 'sally@example.com') + self.assertEqual(handler.get_auto_sender('foo'), 'sally@example.com') + + def test_get_auto_subject_template(self): + handler = self.make_handler() + + # global default + template = handler.get_auto_subject_template('foo') + self.assertEqual(template, "Automated message") + + # can configure alternate global default + self.config.setdefault('wutta.email.default.subject', "Wutta Message") + template = handler.get_auto_subject_template('foo') + self.assertEqual(template, "Wutta Message") + + # can configure just for key + self.config.setdefault('wutta.email.foo.subject', "Foo Message") + template = handler.get_auto_subject_template('foo') + self.assertEqual(template, "Foo Message") + + def test_get_auto_subject(self): + handler = self.make_handler() + + # global default + subject = handler.get_auto_subject('foo') + self.assertEqual(subject, "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") + + # 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") + + # 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") + + # unless we ask it not to + subject = handler.get_auto_subject('bar', {'foo': "FOO"}, rendered=False) + self.assertEqual(subject, "${foo} Message") + + def test_get_auto_recips(self): + handler = self.make_handler() + + # error if bad type requested + self.assertRaises(ValueError, handler.get_auto_recips, 'foo', 'doesnotexist') + + # can configure global default + self.config.setdefault('wutta.email.default.to', 'admin@example.com') + recips = handler.get_auto_recips('foo', 'to') + self.assertEqual(recips, ['admin@example.com']) + + # can configure just for key + self.config.setdefault('wutta.email.foo.to', 'bob@example.com') + recips = handler.get_auto_recips('foo', 'to') + 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') + + # empty by default + template = handler.get_auto_body_template('foo', 'txt') + self.assertIsNone(template) + + # but returns a template if it exists + 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') + + def test_get_auto_txt_body(self): + handler = self.make_handler() + + # empty by default + body = handler.get_auto_txt_body('some-random-email') + self.assertIsNone(body) + + # but returns body if template exists + providers = { + 'wuttatest': MagicMock(email_templates=['tests.email:templates']), + } + with patch.object(self.app, 'providers', new=providers): + handler = self.make_handler() + body = handler.get_auto_txt_body('test_foo') + self.assertEqual(body, 'hello from foo txt template\n') + + def test_get_auto_html_body(self): + handler = self.make_handler() + + # empty by default + body = handler.get_auto_html_body('some-random-email') + self.assertIsNone(body) + + # but returns body if template exists + providers = { + 'wuttatest': MagicMock(email_templates=['tests.email:templates']), + } + with patch.object(self.app, 'providers', new=providers): + handler = self.make_handler() + body = handler.get_auto_html_body('test_foo') + self.assertEqual(body, 'hello from foo html template
\n') + + def test_deliver_message(self): + handler = self.make_handler() + + msg = handler.make_message(sender='bob@example.com', to='sally@example.com') + with patch.object(msg, 'as_string', return_value='msg-str'): + + # no smtp session since sending email is disabled by default + with patch.object(mod, 'smtplib') as smtplib: + session = MagicMock() + smtplib.SMTP.return_value = session + handler.deliver_message(msg) + smtplib.SMTP.assert_not_called() + session.login.assert_not_called() + session.sendmail.assert_not_called() + + # now let's enable sending + self.config.setdefault('wutta.mail.send_emails', 'true') + + # smtp login not attempted by default + with patch.object(mod, 'smtplib') as smtplib: + session = MagicMock() + smtplib.SMTP.return_value = session + handler.deliver_message(msg) + smtplib.SMTP.assert_called_once_with('localhost') + session.login.assert_not_called() + session.sendmail.assert_called_once_with('bob@example.com', {'sally@example.com'}, 'msg-str') + + # but login attempted if config has credentials + self.config.setdefault('wutta.mail.smtp.username', 'bob') + self.config.setdefault('wutta.mail.smtp.password', 'seekrit') + with patch.object(mod, 'smtplib') as smtplib: + session = MagicMock() + smtplib.SMTP.return_value = session + handler.deliver_message(msg) + smtplib.SMTP.assert_called_once_with('localhost') + session.login.assert_called_once_with('bob', 'seekrit') + session.sendmail.assert_called_once_with('bob@example.com', {'sally@example.com'}, 'msg-str') + + # error if no sender + msg = handler.make_message(to='sally@example.com') + self.assertRaises(ValueError, handler.deliver_message, msg) + + # error if no recips + msg = handler.make_message(sender='bob@example.com') + self.assertRaises(ValueError, handler.deliver_message, msg) + + # can set recips as list + msg = handler.make_message(sender='bob@example.com') + with patch.object(msg, 'as_string', return_value='msg-str'): + with patch.object(mod, 'smtplib') as smtplib: + session = MagicMock() + smtplib.SMTP.return_value = session + handler.deliver_message(msg, recips=['sally@example.com']) + smtplib.SMTP.assert_called_once_with('localhost') + session.sendmail.assert_called_once_with('bob@example.com', {'sally@example.com'}, 'msg-str') + + # can set recips as string + msg = handler.make_message(sender='bob@example.com') + with patch.object(msg, 'as_string', return_value='msg-str'): + with patch.object(mod, 'smtplib') as smtplib: + session = MagicMock() + smtplib.SMTP.return_value = session + handler.deliver_message(msg, recips='sally@example.com') + smtplib.SMTP.assert_called_once_with('localhost') + session.sendmail.assert_called_once_with('bob@example.com', {'sally@example.com'}, 'msg-str') + + # can set recips via to + msg = handler.make_message(sender='bob@example.com', to='sally@example.com') + with patch.object(msg, 'as_string', return_value='msg-str'): + with patch.object(mod, 'smtplib') as smtplib: + session = MagicMock() + smtplib.SMTP.return_value = session + handler.deliver_message(msg) + smtplib.SMTP.assert_called_once_with('localhost') + session.sendmail.assert_called_once_with('bob@example.com', {'sally@example.com'}, 'msg-str') + + # can set recips via cc + msg = handler.make_message(sender='bob@example.com', cc='sally@example.com') + with patch.object(msg, 'as_string', return_value='msg-str'): + with patch.object(mod, 'smtplib') as smtplib: + session = MagicMock() + smtplib.SMTP.return_value = session + handler.deliver_message(msg) + smtplib.SMTP.assert_called_once_with('localhost') + session.sendmail.assert_called_once_with('bob@example.com', {'sally@example.com'}, 'msg-str') + + # can set recips via bcc + msg = handler.make_message(sender='bob@example.com', bcc='sally@example.com') + with patch.object(msg, 'as_string', return_value='msg-str'): + with patch.object(mod, 'smtplib') as smtplib: + session = MagicMock() + smtplib.SMTP.return_value = session + handler.deliver_message(msg) + smtplib.SMTP.assert_called_once_with('localhost') + session.sendmail.assert_called_once_with('bob@example.com', {'sally@example.com'}, 'msg-str') + + def test_sending_is_enabled(self): + handler = self.make_handler() + + # off by default + self.assertFalse(handler.sending_is_enabled()) + + # but can be turned on + self.config.setdefault('wutta.mail.send_emails', 'true') + 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() + + # 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() + + # make_auto_message() called only if needed + with patch.object(handler, 'make_auto_message') as make_auto_message: + + msg = handler.make_message() + handler.send_email(message=msg) + make_auto_message.assert_not_called() + + 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') diff --git a/tests/email/test_message.py b/tests/email/test_message.py new file mode 100644 index 0000000..f8ff67a --- /dev/null +++ b/tests/email/test_message.py @@ -0,0 +1,76 @@ +# -*- coding: utf-8; -*- + +from unittest import TestCase + +from wuttjamaican.email import message as mod + + +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) diff --git a/tests/test_app.py b/tests/test_app.py index bbf0bf9..ef4f254 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -397,12 +397,25 @@ class TestAppHandler(FileConfigTestCase): auth = self.app.get_auth_handler() self.assertIsInstance(auth, AuthHandler) + def test_get_email_handler(self): + from wuttjamaican.email import EmailHandler + + mail = self.app.get_email_handler() + self.assertIsInstance(mail, EmailHandler) + def test_get_people_handler(self): from wuttjamaican.people import PeopleHandler people = self.app.get_people_handler() self.assertIsInstance(people, PeopleHandler) + def test_get_send_email(self): + from wuttjamaican.email import EmailHandler + + with patch.object(EmailHandler, 'send_email') as send_email: + self.app.send_email('foo') + send_email.assert_called_once_with('foo') + class TestAppProvider(TestCase):