feat: add basic email handler support
still no way to define "profiles" for admin in web app yet
This commit is contained in:
parent
b401fac04f
commit
131ad88a16
6
docs/api/wuttjamaican/email.handler.rst
Normal file
6
docs/api/wuttjamaican/email.handler.rst
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
|
||||||
|
``wuttjamaican.email.handler``
|
||||||
|
==============================
|
||||||
|
|
||||||
|
.. automodule:: wuttjamaican.email.handler
|
||||||
|
:members:
|
6
docs/api/wuttjamaican/email.message.rst
Normal file
6
docs/api/wuttjamaican/email.message.rst
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
|
||||||
|
``wuttjamaican.email.message``
|
||||||
|
==============================
|
||||||
|
|
||||||
|
.. automodule:: wuttjamaican.email.message
|
||||||
|
:members:
|
6
docs/api/wuttjamaican/email.rst
Normal file
6
docs/api/wuttjamaican/email.rst
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
|
||||||
|
``wuttjamaican.email``
|
||||||
|
======================
|
||||||
|
|
||||||
|
.. automodule:: wuttjamaican.email
|
||||||
|
:members:
|
|
@ -17,6 +17,9 @@
|
||||||
db.model.base
|
db.model.base
|
||||||
db.model.upgrades
|
db.model.upgrades
|
||||||
db.sess
|
db.sess
|
||||||
|
email
|
||||||
|
email.handler
|
||||||
|
email.message
|
||||||
enum
|
enum
|
||||||
exc
|
exc
|
||||||
people
|
people
|
||||||
|
|
|
@ -124,6 +124,12 @@ Glossary
|
||||||
In practice this generally refers to a
|
In practice this generally refers to a
|
||||||
:class:`~wuttjamaican.db.sess.Session` instance.
|
: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
|
entry point
|
||||||
This refers to a "setuptools-style" entry point specifically,
|
This refers to a "setuptools-style" entry point specifically,
|
||||||
which is a mechanism used to register "plugins" and the like.
|
which is a mechanism used to register "plugins" and the like.
|
||||||
|
|
|
@ -35,6 +35,7 @@ dependencies = [
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
db = ["SQLAlchemy<2", "alembic", "alembic-postgresql-enum", "passlib"]
|
db = ["SQLAlchemy<2", "alembic", "alembic-postgresql-enum", "passlib"]
|
||||||
|
email = ["Mako"]
|
||||||
docs = ["Sphinx", "sphinxcontrib-programoutput", "enum-tools[sphinx]", "furo"]
|
docs = ["Sphinx", "sphinxcontrib-programoutput", "enum-tools[sphinx]", "furo"]
|
||||||
tests = ["pytest-cov", "tox"]
|
tests = ["pytest-cov", "tox"]
|
||||||
|
|
||||||
|
|
|
@ -81,6 +81,7 @@ class AppHandler:
|
||||||
default_model_spec = 'wuttjamaican.db.model'
|
default_model_spec = 'wuttjamaican.db.model'
|
||||||
default_enum_spec = 'wuttjamaican.enum'
|
default_enum_spec = 'wuttjamaican.enum'
|
||||||
default_auth_handler_spec = 'wuttjamaican.auth:AuthHandler'
|
default_auth_handler_spec = 'wuttjamaican.auth:AuthHandler'
|
||||||
|
default_email_handler_spec = 'wuttjamaican.email:EmailHandler'
|
||||||
default_people_handler_spec = 'wuttjamaican.people:PeopleHandler'
|
default_people_handler_spec = 'wuttjamaican.people:PeopleHandler'
|
||||||
|
|
||||||
def __init__(self, config):
|
def __init__(self, config):
|
||||||
|
@ -606,6 +607,21 @@ class AppHandler:
|
||||||
self.handlers['auth'] = factory(self.config, **kwargs)
|
self.handlers['auth'] = factory(self.config, **kwargs)
|
||||||
return self.handlers['auth']
|
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):
|
def get_people_handler(self, **kwargs):
|
||||||
"""
|
"""
|
||||||
Get the configured "people" :term:`handler`.
|
Get the configured "people" :term:`handler`.
|
||||||
|
@ -634,6 +650,15 @@ class AppHandler:
|
||||||
"""
|
"""
|
||||||
return self.get_people_handler().get_person(obj, **kwargs)
|
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:
|
class AppProvider:
|
||||||
"""
|
"""
|
||||||
|
|
|
@ -440,8 +440,8 @@ class WuttaConfig:
|
||||||
|
|
||||||
# raise error if required value not found
|
# raise error if required value not found
|
||||||
if require:
|
if require:
|
||||||
message = message or "missing or invalid config"
|
message = message or "missing config"
|
||||||
raise ConfigurationError(f"{message}; please set config value for: {key}")
|
raise ConfigurationError(f"{message}; set value for: {key}")
|
||||||
|
|
||||||
# give the default value if specified
|
# give the default value if specified
|
||||||
if default is not UNSPECIFIED:
|
if default is not UNSPECIFIED:
|
||||||
|
|
33
src/wuttjamaican/email/__init__.py
Normal file
33
src/wuttjamaican/email/__init__.py
Normal 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
|
423
src/wuttjamaican/email/handler.py
Normal file
423
src/wuttjamaican/email/handler.py
Normal 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)
|
158
src/wuttjamaican/email/message.py
Normal file
158
src/wuttjamaican/email/message.py
Normal 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
0
tests/email/__init__.py
Normal file
1
tests/email/templates/test_foo.html.mako
Normal file
1
tests/email/templates/test_foo.html.mako
Normal file
|
@ -0,0 +1 @@
|
||||||
|
<p>hello from foo html template</p>
|
1
tests/email/templates/test_foo.txt.mako
Normal file
1
tests/email/templates/test_foo.txt.mako
Normal file
|
@ -0,0 +1 @@
|
||||||
|
hello from foo txt template
|
403
tests/email/test_handler.py
Normal file
403
tests/email/test_handler.py
Normal 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')
|
76
tests/email/test_message.py
Normal file
76
tests/email/test_message.py
Normal 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)
|
|
@ -397,12 +397,25 @@ class TestAppHandler(FileConfigTestCase):
|
||||||
auth = self.app.get_auth_handler()
|
auth = self.app.get_auth_handler()
|
||||||
self.assertIsInstance(auth, AuthHandler)
|
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):
|
def test_get_people_handler(self):
|
||||||
from wuttjamaican.people import PeopleHandler
|
from wuttjamaican.people import PeopleHandler
|
||||||
|
|
||||||
people = self.app.get_people_handler()
|
people = self.app.get_people_handler()
|
||||||
self.assertIsInstance(people, PeopleHandler)
|
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):
|
class TestAppProvider(TestCase):
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue