1
0
Fork 0

feat: add basic email handler support

still no way to define "profiles" for admin in web app yet
This commit is contained in:
Lance Edgar 2024-08-26 13:08:19 -05:00
parent b401fac04f
commit 131ad88a16
17 changed files with 1163 additions and 2 deletions

View file

@ -0,0 +1,6 @@
``wuttjamaican.email.handler``
==============================
.. automodule:: wuttjamaican.email.handler
:members:

View file

@ -0,0 +1,6 @@
``wuttjamaican.email.message``
==============================
.. automodule:: wuttjamaican.email.message
:members:

View file

@ -0,0 +1,6 @@
``wuttjamaican.email``
======================
.. automodule:: wuttjamaican.email
:members:

View file

@ -17,6 +17,9 @@
db.model.base
db.model.upgrades
db.sess
email
email.handler
email.message
enum
exc
people

View file

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

View file

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

View file

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

View file

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

View file

@ -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 <http://www.gnu.org/licenses/>.
#
################################################################################
"""
Email Utilities
The following are available in this ``wuttjamaican.email`` namespace:
* :class:`~wuttjamaican.email.handler.EmailHandler`
* :class:`~wuttjamaican.email.message.Message`
"""
from .handler import EmailHandler
from .message import Message

View file

@ -0,0 +1,423 @@
# -*- 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 <http://www.gnu.org/licenses/>.
#
################################################################################
"""
Email Handler
"""
import logging
import smtplib
from wuttjamaican.app import GenericHandler
from wuttjamaican.util import resource_path
from wuttjamaican.email.message import Message
log = logging.getLogger(__name__)
class EmailHandler(GenericHandler):
"""
Base class and default implementation for the :term:`email
handler`.
Responsible for sending email messages on behalf of the
:term:`app`.
You normally would not create this directly, but instead call
:meth:`~wuttjamaican.app.AppHandler.get_email_handler()` on your
:term:`app handler`.
"""
# nb. this is fallback/default subject for auto-message
universal_subject = "Automated message"
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:
# otherwise use all available paths, from app providers
available = []
for provider in self.app.providers.values():
if hasattr(provider, 'email_templates'):
templates = provider.email_templates
if isinstance(templates, str):
templates = [templates]
if templates:
available.extend(templates)
templates = available
# convert all to true file paths
if templates:
templates = [resource_path(p) for p in templates]
# will use these lookups from now on
self.txt_templates = TemplateLookup(directories=templates)
self.html_templates = TemplateLookup(directories=templates,
# nb. escape HTML special chars
# TODO: sounds great but i forget why?
default_filters=['h'])
def make_message(self, **kwargs):
"""
Make and return a new email message.
This is the "raw" factory which is simply a wrapper around the
class constructor. See also :meth:`make_auto_message()`.
:returns: :class:`~wuttjamaican.email.message.Message` object.
"""
return Message(**kwargs)
def make_auto_message(self, key, context={}, **kwargs):
"""
Make a new email message using config to determine its
properties, and auto-generating body from a template.
Once everything has been collected/prepared,
:meth:`make_message()` is called to create the final message,
and that is returned.
: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.
:param context: Context dict used to render template(s) for
the message.
:param \**kwargs: Any remaining kwargs are passed as-is to
:meth:`make_message()`. More on this below.
:returns: :class:`~wuttjamaican.email.message.Message` object.
This method may invoke some others, to gather the message
attributes. Each will check config, or render a template, or
both. However if a particular attribute is provided by the
caller, the corresponding "auto" method is skipped.
* :meth:`get_auto_sender()`
* :meth:`get_auto_subject()`
* :meth:`get_auto_to()`
* :meth:`get_auto_cc()`
* :meth:`get_auto_bcc()`
* :meth:`get_auto_txt_body()`
* :meth:`get_auto_html_body()`
"""
kwargs['key'] = key
if 'sender' not in kwargs:
kwargs['sender'] = self.get_auto_sender(key)
if 'subject' not in kwargs:
kwargs['subject'] = self.get_auto_subject(key, context)
if 'to' not in kwargs:
kwargs['to'] = self.get_auto_to(key)
if 'cc' not in kwargs:
kwargs['cc'] = self.get_auto_cc(key)
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)
if 'html_body' not in kwargs:
kwargs['html_body'] = self.get_auto_html_body(key, context)
return self.make_message(**kwargs)
def get_auto_sender(self, key):
"""
Returns automatic
:attr:`~wuttjamaican.email.message.Message.sender` address for
a message, as determined by config.
"""
# prefer configured sender specific to key
sender = self.config.get(f'{self.config.appname}.email.{key}.sender')
if sender:
return sender
# 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):
"""
Returns automatic
:attr:`~wuttjamaican.email.message.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.
: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)
if not rendered:
return template
return Template(template).render(**context)
def get_auto_subject_template(self, key):
"""
Returns the template string to use for automatic subject line
of a message, as determined by config.
In many cases this will be a simple string and not a
"template" per se; however it is still treated as a template.
The template returned from this method is used to render the
final subject line in :meth:`get_auto_subject()`.
"""
# prefer configured subject specific to key
template = self.config.get(f'{self.config.appname}.email.{key}.subject')
if template:
return template
# fall back to global default
return self.config.get(f'{self.config.appname}.email.default.subject',
default=self.universal_subject)
def get_auto_to(self, key):
"""
Returns automatic
:attr:`~wuttjamaican.email.message.Message.to` recipient
address(es) for a message, as determined by config.
"""
return self.get_auto_recips(key, 'to')
def get_auto_cc(self, key):
"""
Returns automatic
:attr:`~wuttjamaican.email.message.Message.cc` recipient
address(es) for a message, as determined by config.
"""
return self.get_auto_recips(key, 'cc')
def get_auto_bcc(self, key):
"""
Returns automatic
:attr:`~wuttjamaican.email.message.Message.bcc` recipient
address(es) for a message, as determined by config.
"""
return self.get_auto_recips(key, 'bcc')
def get_auto_recips(self, key, typ):
""" """
typ = typ.lower()
if typ not in ('to', 'cc', 'bcc'):
raise ValueError("requested type not supported")
# prefer configured recips specific to key
recips = self.config.get_list(f'{self.config.appname}.email.{key}.{typ}')
if recips:
return recips
# fall back to global default
return self.config.get_list(f'{self.config.appname}.email.default.{typ}',
default=[])
def get_auto_txt_body(self, key, context={}):
"""
Returns automatic
:attr:`~wuttjamaican.email.message.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')
if template:
return template.render(**context)
def get_auto_html_body(self, key, context={}):
"""
Returns automatic
:attr:`~wuttjamaican.email.message.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')
if template:
return template.render(**context)
def get_auto_body_template(self, key, typ):
""" """
from mako.exceptions import TopLevelLookupException
typ = typ.lower()
if typ not in ('txt', 'html'):
raise ValueError("requested type not supported")
if typ == 'txt':
templates = self.txt_templates
elif typ == 'html':
templates = self.html_templates
try:
return templates.get_template(f'{key}.{typ}.mako')
except TopLevelLookupException:
pass
def deliver_message(self, message, sender=None, recips=None):
"""
Deliver a message via SMTP smarthost.
:param message: Either a
:class:`~wuttjamaican.email.message.Message` object or
similar, or a string representing the complete message to
be sent as-is.
:param sender: Optional sender address to use for delivery.
If not specified, will be read from ``message``.
:param recips: Optional recipient address(es) for delivery.
If not specified, will be read from ``message``.
A general rule here is that you can either provide a proper
:class:`~wuttjamaican.email.message.Message` object, **or**
you *must* provide ``sender`` and ``recips``. The logic is
not smart enough (yet?) to parse sender/recips from a simple
string message.
Note also, this method does not (yet?) have robust error
handling, so if an error occurs with the SMTP session, it will
simply raise to caller.
:returns: ``None``
"""
if not sender:
sender = message.sender
if not sender:
raise ValueError("no sender identified for message delivery")
if not recips:
recips = set()
if message.to:
recips.update(message.to)
if message.cc:
recips.update(message.cc)
if message.bcc:
recips.update(message.bcc)
elif isinstance(recips, str):
recips = [recips]
recips = set(recips)
if not recips:
raise ValueError("no recipients identified for message delivery")
if not isinstance(message, str):
message = message.as_string()
# get smtp info
server = self.config.get(f'{self.config.appname}.mail.smtp.server', default='localhost')
username = self.config.get(f'{self.config.appname}.mail.smtp.username')
password = self.config.get(f'{self.config.appname}.mail.smtp.password')
# make sure sending is enabled
log.debug("sending email from %s; to %s", sender, recips)
if not self.sending_is_enabled():
log.debug("nevermind, config says no emails")
return
# smtp connect
session = smtplib.SMTP(server)
if username and password:
session.login(username, password)
# smtp send
session.sendmail(sender, recips, message)
session.quit()
log.debug("email was sent")
def sending_is_enabled(self):
"""
Returns boolean indicating if email sending is enabled.
Set this flag in config like this:
.. code-block:: ini
[wutta.mail]
send_emails = true
Note that it is OFF by default.
"""
return self.config.get_bool(f'{self.config.appname}.mail.send_emails',
default=False)
def send_email(self, key=None, context={}, message=None, sender=None, recips=None, **kwargs):
"""
Send an email message.
This method can send a ``message`` you provide, or it can
construct one automatically from key/config/templates.
:param key: Indicates which "type" of automatic email to send.
Used to lookup config settings and template files.
:param context: Context dict for rendering automatic email
template(s).
:param message: Optional pre-built message instance, to send
as-is.
:param sender: Optional sender address for the
message/delivery.
If ``message`` is not provided, then the ``sender`` (if
provided) will also be used when constructing the
auto-message (i.e. to set the ``From:`` header).
In any case if ``sender`` is provided, it will be used for
the actual SMTP delivery.
:param recips: Optional list of recipient addresses for
delivery. If not specified, will be read from the message
itself (after auto-generating it, if applicable).
.. note::
This param does not affect an auto-generated message; it
is used for delivery only. As such it must contain
*all* true recipients.
If you provide the ``message`` but not the ``recips``,
the latter will be read from message headers: ``To:``,
``Cc:`` and ``Bcc:``
If you want an auto-generated message but also want to
override various recipient headers, then you must
provide those explicitly::
context = {'data': [1, 2, 3]}
app.send_email('foo', context, to='me@example.com', cc='bobby@example.com')
:param \**kwargs: Any remaining kwargs are passed along to
:meth:`make_auto_message()`. So, not used if you provide
the ``message``.
"""
if message is None:
if sender:
kwargs['sender'] = sender
message = self.make_auto_message(key, context, **kwargs)
self.deliver_message(message, recips=recips)

View file

@ -0,0 +1,158 @@
# -*- 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 <http://www.gnu.org/licenses/>.
#
################################################################################
"""
Email Message
"""
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
class Message:
"""
Represents an email message to be sent.
:param to: Recipient(s) for the message. This may be either a
string, or list of strings. If a string, it will be converted
to a list since that is how the :attr:`to` attribute tracks it.
Similar logic is used for :attr:`cc` and :attr:`bcc`.
All attributes shown below may also be specified via constructor.
.. attribute:: key
Unique key indicating the "type" of message. An "ad-hoc"
message created arbitrarily may not have/need a key; however
one created via
:meth:`~wuttjamaican.email.handler.EmailHandler.make_auto_message()`
will always have a key.
This key is not used for anything within the ``Message`` class
logic. It is used by
:meth:`~wuttjamaican.email.handler.EmailHandler.make_auto_message()`
when constructing the message, and the key is set on the final
message only as a reference.
.. attribute:: sender
Sender (``From:``) address for the message.
.. attribute:: subject
Subject text for the message.
.. attribute:: to
List of ``To:`` recipients for the message.
.. attribute:: cc
List of ``Cc:`` recipients for the message.
.. attribute:: bcc
List of ``Bcc:`` recipients for the message.
.. attribute:: replyto
Optional reply-to (``Reply-To:``) address for the message.
.. attribute:: txt_body
String with the ``text/plain`` body content.
.. attribute:: html_body
String with the ``text/html`` body content.
"""
def __init__(
self,
key=None,
sender=None,
subject=None,
to=None,
cc=None,
bcc=None,
replyto=None,
txt_body=None,
html_body=None,
):
self.key = key
self.sender = sender
self.subject = subject
self.set_recips('to', to)
self.set_recips('cc', cc)
self.set_recips('bcc', bcc)
self.replyto = replyto
self.txt_body = txt_body
self.html_body = html_body
def set_recips(self, name, value):
""" """
if value:
if isinstance(value, str):
value = [value]
if not isinstance(value, (list, tuple)):
raise ValueError("must specify a string, tuple or list value")
else:
value = []
setattr(self, name, list(value))
def as_string(self):
"""
Returns the complete message as string. This is called from
within
:meth:`~wuttjamaican.email.handler.EmailHandler.deliver_message()`
to obtain the SMTP payload.
"""
msg = None
if self.txt_body and self.html_body:
txt = MIMEText(self.txt_body, _charset='utf_8')
html = MIMEText(self.html_body, _subtype='html', _charset='utf_8')
msg = MIMEMultipart(_subtype='alternative', _subparts=[txt, html])
elif self.txt_body:
msg = MIMEText(self.txt_body, _charset='utf_8')
elif self.html_body:
msg = MIMEText(self.html_body, 'html', _charset='utf_8')
if not msg:
raise ValueError("message has no body parts")
msg['Subject'] = self.subject
msg['From'] = self.sender
for addr in self.to:
msg['To'] = addr
for addr in self.cc:
msg['Cc'] = addr
for addr in self.bcc:
msg['Bcc'] = addr
if self.replyto:
msg.add_header('Reply-To', self.replyto)
return msg.as_string()

0
tests/email/__init__.py Normal file
View file

View file

@ -0,0 +1 @@
<p>hello from foo html template</p>

View file

@ -0,0 +1 @@
hello from foo txt template

403
tests/email/test_handler.py Normal file
View file

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

View file

@ -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="<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)

View file

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