3
0
Fork 0
wuttjamaican/src/wuttjamaican/email.py

844 lines
28 KiB
Python

# -*- coding: utf-8; -*-
################################################################################
#
# WuttJamaican -- Base package for Wutta Framework
# Copyright © 2023-2025 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 email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from mako.lookup import TemplateLookup
from mako.template import Template
from mako.exceptions import TopLevelLookupException
from wuttjamaican.app import GenericHandler
from wuttjamaican.util import resource_path
log = logging.getLogger(__name__)
class EmailSetting: # pylint: disable=too-few-public-methods
"""
Base class for all :term:`email settings <email setting>`.
Each :term:`email type` which needs to have settings exposed
e.g. for editing, should define a subclass within the appropriate
:term:`email module`.
The name of each subclass should match the :term:`email key` which
it represents. For instance::
from wuttjamaican.email import EmailSetting
class poser_alert_foo(EmailSetting):
\"""
Sent when something happens that we think deserves an alert.
\"""
default_subject = "Something happened!"
def sample_data(self):
return {
'foo': 1234,
'msg': "Something happened, thought you should know.",
}
# (and elsewhere..)
app.send_email('poser_alert_foo', {
'foo': 5678,
'msg': "Can't take much more, she's gonna blow!",
})
Defining a subclass for each email type can be a bit tedious, so
why do it? In fact there is no need, if you just want to *send*
emails.
The purpose of defining a subclass for each email type is 2-fold,
but really the answer is "for maintenance sake" -
* gives the app a way to discover all emails, so settings for each
can be exposed for editing
* allows for hard-coded sample context which can be used to render
templates for preview
.. attribute:: default_subject
Default :attr:`Message.subject` for the email, if none is
configured.
This is technically a Mako template string, so it will be
rendered with the email context. But in most cases that
feature can be ignored, and this will be a simple string.
"""
default_subject = None
def __init__(self, config):
self.config = config
self.app = config.get_app()
self.key = self.__class__.__name__
def sample_data(self):
"""
Should return a dict with sample context needed to render the
:term:`email template` for message body. This can be used to
show a "preview" of the email.
"""
return {}
class Message: # pylint: disable=too-many-instance-attributes
"""
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.
.. attribute:: attachments
List of file attachments for the message.
"""
def __init__( # pylint: disable=too-many-arguments,too-many-positional-arguments
self,
key=None,
sender=None,
subject=None,
to=None,
cc=None,
bcc=None,
replyto=None,
txt_body=None,
html_body=None,
attachments=None,
):
self.key = key
self.sender = sender
self.subject = subject
self.to = self.get_recips(to)
self.cc = self.get_recips(cc)
self.bcc = self.get_recips(bcc)
self.replyto = replyto
self.txt_body = txt_body
self.html_body = html_body
self.attachments = attachments or []
def get_recips(self, value): # pylint: disable=empty-docstring
""" """
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 = []
return 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")
if self.attachments:
for attachment in self.attachments:
if isinstance(attachment, str):
raise ValueError(
"must specify valid MIME attachments; this class cannot "
"auto-create them from file path etc."
)
msg = MIMEMultipart(_subtype="mixed", _subparts=[msg] + self.attachments)
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): # pylint: disable=too-many-public-methods
"""
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)
# 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 get_email_modules(self):
"""
Returns a list of all known :term:`email modules <email
module>`.
This will discover all email modules exposed by the
:term:`app`, and/or its :term:`providers <provider>`.
Calls
:meth:`~wuttjamaican.app.GenericHandler.get_provider_modules()`
under the hood, for ``email`` module type.
"""
return self.get_provider_modules("email")
def get_email_settings(self):
"""
Returns a dict of all known :term:`email settings <email
setting>`, keyed by :term:`email key`.
This calls :meth:`get_email_modules()` and for each module, it
discovers all the email settings it contains.
"""
if "email_settings" not in self.classes:
self.classes["email_settings"] = {}
for module in self.get_email_modules():
for name in dir(module):
obj = getattr(module, name)
if (
isinstance(obj, type)
and obj is not EmailSetting
and issubclass(obj, EmailSetting)
):
self.classes["email_settings"][obj.__name__] = obj
return self.classes["email_settings"]
def get_email_setting(self, key, instance=True):
"""
Retrieve the :term:`email setting` for the given :term:`email
key` (if it exists).
:param key: Key for the :term:`email type`.
:param instance: Whether to return the class, or an instance.
:returns: :class:`EmailSetting` class or instance, or ``None``
if the setting could not be found.
"""
settings = self.get_email_settings()
if key in settings:
setting = settings[key]
if instance:
setting = setting(self.config)
return setting
return None
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` object.
"""
return Message(**kwargs)
def make_auto_message(self, key, context=None, default_subject=None, **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 default_subject: Optional :attr:`~Message.subject`
template/string to use, if config does not specify one.
:param \\**kwargs: Any remaining kwargs are passed as-is to
:meth:`make_message()`. More on this below.
: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
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()`
"""
context = context or {}
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, default=default_subject
)
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.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
return self.config.get(
f"{self.config.appname}.email.default.sender", default="root@localhost"
)
def get_auto_replyto(self, key):
"""
Returns automatic :attr:`~wuttjamaican.email.Message.replyto`
address for a message, as determined by config.
"""
# prefer configured replyto specific to key
replyto = self.config.get(f"{self.config.appname}.email.{key}.replyto")
if replyto:
return replyto
# fall back to global default, if present
return self.config.get(f"{self.config.appname}.email.default.replyto")
def get_auto_subject( # pylint: disable=too-many-arguments,too-many-positional-arguments
self, key, context=None, rendered=True, setting=None, default=None
):
"""
Returns automatic :attr:`~wuttjamaican.email.Message.subject`
line for a message, as determined by config.
This calls :meth:`get_auto_subject_template()` and then
(usually) renders the result using the given context.
:param key: Key for the :term:`email type`.
:param context: Dict of context for rendering the subject
template, if applicable.
:param rendered: If this is ``False``, the "raw" subject
template will be returned, instead of the final/rendered
subject text.
:param setting: Optional :class:`EmailSetting` class or
instance. This is passed along to
:meth:`get_auto_subject_template()`.
:param default: Default subject to use if none is configured.
:returns: Final subject text, either "raw" or rendered.
"""
template = self.get_auto_subject_template(key, setting=setting, default=default)
if not rendered:
return template
context = context or {}
return Template(template).render(**context)
def get_auto_subject_template(self, key, setting=None, default=None):
"""
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()`.
:param key: Key for the :term:`email type`.
:param setting: Optional :class:`EmailSetting` class or
instance. This may be used to determine the "default"
subject if none is configured. You can specify this as an
optimization; otherwise it will be fetched if needed via
:meth:`get_email_setting()`.
:param default: Default subject to use if none is configured.
:returns: Final subject template, as raw text.
"""
# prefer configured subject specific to key
template = self.config.get(f"{self.config.appname}.email.{key}.subject")
if template:
return template
# or use caller-specified default, if applicable
if default:
return default
# or subject from email setting, if defined
if not setting:
setting = self.get_email_setting(key)
if setting and setting.default_subject:
return setting.default_subject
# 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.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.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.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): # pylint: disable=empty-docstring
""" """
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=None):
"""
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:
context = context or {}
return template.render(**context)
return None
def get_auto_html_body(self, key, context=None):
"""
Returns automatic
: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:
context = context or {}
return template.render(**context)
return None
def get_auto_body_template(self, key, mode): # pylint: disable=empty-docstring
""" """
mode = mode.lower()
if mode == "txt":
templates = self.txt_templates
elif mode == "html":
templates = self.html_templates
else:
raise ValueError("requested mode not supported")
try:
return templates.get_template(f"{key}.{mode}.mako")
except TopLevelLookupException:
pass
return None
def get_notes(self, key):
"""
Returns configured "notes" for the given :term:`email key`.
:param key: Key for the :term:`email type`.
:returns: Notes as string if found; otherwise ``None``.
"""
return self.config.get(f"{self.config.appname}.email.{key}.notes")
def is_enabled(self, key):
"""
Returns flag indicating whether the given email type is
"enabled" - i.e. whether it should ever be sent out (enabled)
or always suppressed (disabled).
All email types are enabled by default, unless config says
otherwise; e.g. to disable ``foo`` emails:
.. code-block:: ini
[wutta.email]
# nb. this is fallback if specific type is not configured
default.enabled = true
# this disables 'foo' but e.g 'bar' is still enabled per default above
foo.enabled = false
In a development setup you may want a reverse example, where
all emails are disabled by default but you can turn on just
one type for testing:
.. code-block:: ini
[wutta.email]
# do not send any emails unless explicitly enabled
default.enabled = false
# turn on 'bar' for testing
bar.enabled = true
See also :meth:`sending_is_enabled()` which is more of a
master shutoff switch.
:param key: Unique identifier for the email type.
:returns: True if this email type is enabled, otherwise false.
"""
for k in set([key, "default"]):
enabled = self.config.get_bool(f"{self.config.appname}.email.{k}.enabled")
if enabled is not None:
return enabled
return True
def deliver_message(self, message, sender=None, recips=None):
"""
Deliver a message via SMTP smarthost.
: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``.
: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` 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( # pylint: disable=too-many-arguments,too-many-positional-arguments
self, key=None, context=None, 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.
The most common use case is assumed to be the latter, where
caller does not provide the message proper, but specifies key
and context so the message is auto-created. In that case this
method will also check :meth:`is_enabled()` and skip the
sending if that returns false.
:param key: When auto-creating a message, this is the
:term:`email key` identifying the type of 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. If specified, nothing about the message will be
auto-assigned from config.
: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 key and not self.is_enabled(key):
log.debug("skipping disabled email: %s", key)
return
if message is None:
if not key:
raise ValueError("must specify email key (and/or message object)")
# auto-create message from key + context
if sender:
kwargs["sender"] = sender
message = self.make_auto_message(key, context or {}, **kwargs)
if not (message.txt_body or message.html_body):
raise RuntimeError(
f"message (type: {key}) has no body - "
"perhaps template file not found?"
)
if not (message.txt_body or message.html_body):
if key:
msg = f"message (type: {key}) has no body content"
else:
msg = "message has no body content"
raise ValueError(msg)
self.deliver_message(message, recips=recips)