Source code for wuttjamaican.email

# -*- 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
"""
# pylint: disable=too-many-lines

import logging
import re
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__)


[docs] 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!" # nb. this is not used for sending; only preview 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:: key Unique identifier for this :term:`email type`. This is the :term:`email key` used for config/template lookup, e.g. when sending an email. This is automatically set based on the *class name* so there is no need (or point) to set it. But the attribute is here for read access, for convenience / code readability:: class poser_alert_foo(EmailSetting): default_subject = "Something happened!" handler = app.get_email_handler() setting = handler.get_email_setting("poser_alert_foo") assert setting.key == "poser_alert_foo" See also :attr:`fallback_key`. .. attribute:: default_subject Default subject for sending emails of this type. Usually, if config does not override, this will become :attr:`Message.subject`. 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. Calling code should not access this directly, but instead use :meth:`get_default_subject()` . """ default_subject = None default_prefix = None """ Default subject prefix for emails of this type. Calling code should not access this directly, but instead use :meth:`get_default_prefix()` . """ fallback_key = None """ Optional fallback key to use for config/template lookup, if nothing is found for :attr:`key`. """ def __init__(self, config): self.config = config self.app = config.get_app() self.key = self.__class__.__name__
[docs] def get_description(self): """ This must return the full description for the :term:`email type`. It is not used for the sending of email; only for settings administration. Default logic will use the class docstring. :returns: String description for the email type """ return self.__class__.__doc__.strip()
[docs] def get_default_prefix(self): """ This returns the default subject prefix, for sending emails of this type. Default logic here returns :attr:`default_prefix` as-is. This method will often return ``None`` in which case the global default prefix is used. :returns: Default subject prefix as string, or ``None`` """ return self.default_prefix
[docs] def get_default_subject(self): """ This must return the default subject, for sending emails of this type. If config does not override, this will become :attr:`Message.subject`. Default logic here returns :attr:`default_subject` as-is. :returns: Default subject as string """ return self.default_subject
[docs] 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 {}
[docs] 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)
[docs] 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()
[docs] 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"], )
[docs] 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")
[docs] 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"] = {} # nb. we only want lower_case_names - all UpperCaseNames # are assumed to be base classes pattern = re.compile(r"^[a-z]") for module in self.get_email_modules(): for name in dir(module): obj = getattr(module, name) if ( isinstance(obj, type) and issubclass(obj, EmailSetting) and pattern.match(obj.__name__) ): self.classes["email_settings"][obj.__name__] = obj return self.classes["email_settings"]
[docs] 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
[docs] 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)
[docs] def make_auto_message( # pylint: disable=too-many-arguments,too-many-positional-arguments self, key, context=None, default_subject=None, prefix_subject=True, default_prefix=None, fallback_key=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. See also the ``fallback_key`` param, below. :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 prefix_subject: Boolean indicating the message subject should be auto-prefixed. :param default_prefix: Default subject prefix to use if none is configured. :param fallback_key: Optional fallback :term:`email key` to use for config/template lookup, if nothing is found for ``key``. :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, prefix=prefix_subject, default_prefix=default_prefix, fallback_key=fallback_key, ) 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, fallback_key=fallback_key ) if "html_body" not in kwargs: kwargs["html_body"] = self.get_auto_html_body( key, context, fallback_key=fallback_key ) return self.make_message(**kwargs)
[docs] def get_email_context(self, key, context=None): # pylint: disable=unused-argument """ This must return the "full" context for rendering the email subject and/or body templates. Normally the input ``context`` is coming from the :meth:`send_email()` param of the same name. By default, this method modifies the input context to add the following: * ``config`` - reference to the :term:`config object` * ``app`` - reference to the :term:`app handler` Subclass may further modify as needed. :param key: The :term:`email key` for which to get context. :param context: Input context dict. :returns: Final context dict """ if context is None: context = {} context.update( { "config": self.config, "app": self.app, } ) return context
[docs] 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" )
[docs] 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")
[docs] def get_auto_subject( # pylint: disable=too-many-arguments,too-many-positional-arguments self, key, context=None, rendered=True, default=None, fallback_key=None, setting=None, prefix=True, default_prefix=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, and adds the :meth:`get_auto_subject_prefix()`. :param key: Key for the :term:`email type`. See also the ``fallback_key`` param, below. :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 default: Default subject to use if none is configured. :param fallback_key: Optional fallback :term:`email key` to use for config lookup, if nothing is found for ``key``. :param setting: Optional :class:`EmailSetting` class or instance. This is passed along to :meth:`get_auto_subject_template()`. :param prefix: Boolean indicating the message subject should be auto-prefixed. This is ignored when ``rendered`` param is false. :param default_prefix: Default subject prefix 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, fallback_key=fallback_key ) if not rendered: return template context = self.get_email_context(key, context) subject = Template(template).render(**context) if prefix: if prefix := self.get_auto_subject_prefix( key, default=default_prefix, setting=setting, fallback_key=fallback_key ): subject = f"{prefix} {subject}" return subject
[docs] def get_auto_subject_template( self, key, default=None, fallback_key=None, setting=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 default: Default subject to use if none is configured. :param fallback_key: Optional fallback :term:`email key` to use for config lookup, if nothing is found for ``key``. :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()`. :returns: Final subject template, as raw text. """ # prefer configured subject specific to key if template := self.config.get(f"{self.config.appname}.email.{key}.subject"): return template # or use caller-specified default, if applicable if default: return default # or use fallback key, if provided if fallback_key: if template := self.config.get( f"{self.config.appname}.email.{fallback_key}.subject" ): return template # or subject from email setting, if defined if not setting: setting = self.get_email_setting(key) if setting: if subject := setting.get_default_subject(): return subject # fall back to global default return self.config.get( f"{self.config.appname}.email.default.subject", default=self.universal_subject, )
[docs] def get_auto_subject_prefix( self, key, default=None, fallback_key=None, setting=None ): """ Returns the string to use for automatic subject prefix, as determined by config. This is called by :meth:`get_auto_subject()`. Note that unlike the subject proper, the prefix is just a normal string, not a template. Example prefix is ``"[Wutta]"`` - trailing space will be added automatically when applying the prefix to a message subject. :param key: The :term:`email key` requested. :param default: Default prefix to use if none is configured. :param fallback_key: Optional fallback :term:`email key` to use for config lookup, if nothing is found for ``key``. :param setting: Optional :class:`EmailSetting` class or instance. This may be used to determine the "default" prefix if none is configured. You can specify this as an optimization; otherwise it will be fetched if needed via :meth:`get_email_setting()`. :returns: Final subject prefix string """ # prefer configured prefix specific to key if prefix := self.config.get(f"{self.config.appname}.email.{key}.prefix"): return prefix # or use caller-specified default, if applicable if default: return default # or use fallback key, if provided if fallback_key: if prefix := self.config.get( f"{self.config.appname}.email.{fallback_key}.prefix" ): return prefix # or prefix from email setting, if defined if not setting: setting = self.get_email_setting(key) if setting: if prefix := setting.get_default_prefix(): return prefix # fall back to global default return self.config.get( f"{self.config.appname}.email.default.prefix", default=f"[{self.app.get_node_title()}]", )
[docs] 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")
[docs] 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")
[docs] 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=[] )
[docs] def get_auto_txt_body(self, key, context=None, fallback_key=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", fallback_key=fallback_key) if template: context = self.get_email_context(key, context) return template.render(**context) return None
[docs] def get_auto_html_body(self, key, context=None, fallback_key=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", fallback_key=fallback_key) if template: context = self.get_email_context(key, context) return template.render(**context) return None
def get_auto_body_template( # pylint: disable=empty-docstring self, key, mode, fallback_key=None ): """ """ 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: # prefer specific template for key return templates.get_template(f"{key}.{mode}.mako") except TopLevelLookupException: # but can use fallback if applicable if fallback_key: try: return templates.get_template(f"{fallback_key}.{mode}.mako") except TopLevelLookupException: pass return None
[docs] 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")
[docs] 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
[docs] 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.warning("email sending is disabled") 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")
[docs] 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 )
[docs] def send_email( # pylint: disable=too-many-arguments,too-many-positional-arguments self, key=None, context=None, message=None, sender=None, recips=None, fallback_key=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. See also the ``fallback_key`` param, below. :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 fallback_key: Optional fallback :term:`email key` to use for config/template lookup, if nothing is found for ``key``. :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 {}, fallback_key=fallback_key, **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)