3
0
Fork 0

fix: move email stuff from subpackage to module

This commit is contained in:
Lance Edgar 2024-12-19 18:34:03 -06:00
parent 902412322e
commit 6c8f1c973d
14 changed files with 244 additions and 331 deletions

View file

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

View file

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

View file

@ -189,7 +189,7 @@ Glossary
The :term:`handler` responsible for sending email on behalf of
the :term:`app`.
Default is :class:`~wuttjamaican.email.handler.EmailHandler`.
Default is :class:`~wuttjamaican.email.EmailHandler`.
email key
String idenfier for a certain :term:`email type`. Each email key

View file

@ -82,8 +82,6 @@ Contents
api/wuttjamaican.db.sess
api/wuttjamaican.db.util
api/wuttjamaican.email
api/wuttjamaican.email.handler
api/wuttjamaican.email.message
api/wuttjamaican.enum
api/wuttjamaican.exc
api/wuttjamaican.install

View file

@ -50,9 +50,8 @@ for one or both of the ``text/plain`` and ``text/html`` content-types
(using ``txt`` and ``html`` as shorthand name, respectively).
Template files must use the :doc:`Mako template language <mako:index>`
and be named based on the
:attr:`~wuttjamaican.email.message.Message.key` for the email type, as
well as content-type.
and be named based on the :attr:`~wuttjamaican.email.Message.key` for
the email type, as well as content-type.
Therefore a new email of type ``poser_alert_foo`` would need one or
both of these defined:

View file

@ -768,7 +768,7 @@ class AppHandler:
See also :meth:`send_email()`.
:rtype: :class:`~wuttjamaican.email.handler.EmailHandler`
:rtype: :class:`~wuttjamaican.email.EmailHandler`
"""
if 'email' not in self.handlers:
spec = self.config.get(f'{self.appname}.email.handler',
@ -823,7 +823,7 @@ class AppHandler:
Send an email message.
This is a convenience wrapper around
:meth:`~wuttjamaican.email.handler.EmailHandler.send_email()`.
:meth:`~wuttjamaican.email.EmailHandler.send_email()`.
"""
self.get_email_handler().send_email(*args, **kwargs)

View file

@ -26,15 +26,146 @@ Email Handler
import logging
import smtplib
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from wuttjamaican.app import GenericHandler
from wuttjamaican.util import resource_path
from wuttjamaican.email.message import Message
log = logging.getLogger(__name__)
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.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.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.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()
class EmailHandler(GenericHandler):
"""
Base class and default implementation for the :term:`email
@ -89,7 +220,7 @@ class EmailHandler(GenericHandler):
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.
:returns: :class:`~wuttjamaican.email.Message` object.
"""
return Message(**kwargs)
@ -112,7 +243,7 @@ class EmailHandler(GenericHandler):
:param \**kwargs: Any remaining kwargs are passed as-is to
:meth:`make_message()`. More on this below.
:returns: :class:`~wuttjamaican.email.message.Message` object.
:returns: :class:`~wuttjamaican.email.Message` object.
This method may invoke some others, to gather the message
attributes. Each will check config, or render a template, or
@ -147,8 +278,8 @@ class EmailHandler(GenericHandler):
def get_auto_sender(self, key):
"""
Returns automatic
:attr:`~wuttjamaican.email.message.Message.sender` address for
a message, as determined by config.
:attr:`~wuttjamaican.email.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')
@ -160,9 +291,8 @@ class EmailHandler(GenericHandler):
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.
Returns automatic :attr:`~wuttjamaican.email.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.
@ -200,25 +330,22 @@ class EmailHandler(GenericHandler):
def get_auto_to(self, key):
"""
Returns automatic
:attr:`~wuttjamaican.email.message.Message.to` recipient
address(es) for a message, as determined by config.
Returns automatic :attr:`~wuttjamaican.email.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.
Returns automatic :attr:`~wuttjamaican.email.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.
Returns automatic :attr:`~wuttjamaican.email.Message.bcc`
recipient address(es) for a message, as determined by config.
"""
return self.get_auto_recips(key, 'bcc')
@ -239,10 +366,9 @@ class EmailHandler(GenericHandler):
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.
Returns automatic :attr:`~wuttjamaican.email.Message.txt_body`
content for a message, as determined by config. This renders
a template with the given context.
"""
template = self.get_auto_body_template(key, 'txt')
if template:
@ -251,9 +377,9 @@ class EmailHandler(GenericHandler):
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.
:attr:`~wuttjamaican.email.Message.html_body` content for a
message, as determined by config. This renders a template
with the given context.
"""
template = self.get_auto_body_template(key, 'html')
if template:
@ -327,10 +453,9 @@ class EmailHandler(GenericHandler):
"""
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 message: Either a :class:`~wuttjamaican.email.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``.
@ -339,10 +464,10 @@ class EmailHandler(GenericHandler):
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.
:class:`~wuttjamaican.email.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

View file

@ -1,33 +0,0 @@
# -*- 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

@ -1,158 +0,0 @@
# -*- 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()

View file

@ -1,76 +0,0 @@
# -*- 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

@ -5,8 +5,7 @@ from unittest.mock import patch, MagicMock
import pytest
from wuttjamaican.email import handler as mod
from wuttjamaican.email import Message
from wuttjamaican import email as mod
from wuttjamaican.conf import WuttaConfig
from wuttjamaican.util import resource_path
from wuttjamaican.exc import ConfigurationError
@ -36,28 +35,28 @@ class TestEmailHandler(TestCase):
# provider may specify paths as list
providers = {
'wuttatest': MagicMock(email_templates=['wuttjamaican.email:templates']),
'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')
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'),
'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')
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)
self.assertIsInstance(msg, mod.Message)
def test_make_auto_message(self):
handler = self.make_handler()
@ -70,7 +69,7 @@ class TestEmailHandler(TestCase):
# message is empty by default
msg = handler.make_auto_message('foo')
self.assertIsInstance(msg, Message)
self.assertIsInstance(msg, mod.Message)
self.assertEqual(msg.key, 'foo')
self.assertEqual(msg.sender, 'bob@example.com')
self.assertEqual(msg.subject, "Automated message")
@ -85,7 +84,7 @@ class TestEmailHandler(TestCase):
# 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')
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')
@ -240,7 +239,7 @@ class TestEmailHandler(TestCase):
# but returns a template if it exists
providers = {
'wuttatest': MagicMock(email_templates=['tests.email:templates']),
'wuttatest': MagicMock(email_templates=['tests:email-templates']),
}
with patch.object(self.app, 'providers', new=providers):
handler = self.make_handler()
@ -257,7 +256,7 @@ class TestEmailHandler(TestCase):
# but returns body if template exists
providers = {
'wuttatest': MagicMock(email_templates=['tests.email:templates']),
'wuttatest': MagicMock(email_templates=['tests:email-templates']),
}
with patch.object(self.app, 'providers', new=providers):
handler = self.make_handler()
@ -273,7 +272,7 @@ class TestEmailHandler(TestCase):
# but returns body if template exists
providers = {
'wuttatest': MagicMock(email_templates=['tests.email:templates']),
'wuttatest': MagicMock(email_templates=['tests:email-templates']),
}
with patch.object(self.app, 'providers', new=providers):
handler = self.make_handler()
@ -456,3 +455,74 @@ class TestEmailHandler(TestCase):
self.config.setdefault('wutta.email.default.enabled', False)
handler.send_email('bar', sender='bar@example.com', txt_body="hello world")
self.assertFalse(deliver_message.called)
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)