diff --git a/docs/api/wuttjamaican.email.handler.rst b/docs/api/wuttjamaican.email.handler.rst deleted file mode 100644 index 4e4900f..0000000 --- a/docs/api/wuttjamaican.email.handler.rst +++ /dev/null @@ -1,6 +0,0 @@ - -``wuttjamaican.email.handler`` -============================== - -.. automodule:: wuttjamaican.email.handler - :members: diff --git a/docs/api/wuttjamaican.email.message.rst b/docs/api/wuttjamaican.email.message.rst deleted file mode 100644 index 1656196..0000000 --- a/docs/api/wuttjamaican.email.message.rst +++ /dev/null @@ -1,6 +0,0 @@ - -``wuttjamaican.email.message`` -============================== - -.. automodule:: wuttjamaican.email.message - :members: diff --git a/docs/glossary.rst b/docs/glossary.rst index dd3768f..3a16ea2 100644 --- a/docs/glossary.rst +++ b/docs/glossary.rst @@ -189,7 +189,32 @@ 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 + must be unique across the app, so the correct template files and + other settings are used when sending etc. + + email module + This refers to a Python module which contains :term:`email + setting` definitions. + + email setting + This refers to the settings for a particular :term:`email type`, + i.e. its sender and recipients, subject etc. So each email type + has a "collection" of settings, and that collection is referred + to simply as an "email setting" in the singular. + + email template + Usually this refers to the HTML or TXT template file, used to + render the message body when sending an email. + + email type + The :term:`app` is capable of sending many types of emails, + e.g. daily reports, alerts of various kinds etc. Each "type" of + email then will have its own template(s) and sender/recipient + settings etc. See also :term:`email key`. entry point This refers to a "setuptools-style" entry point specifically, diff --git a/docs/index.rst b/docs/index.rst index cb99327..ef8acce 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -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 diff --git a/docs/narr/email/custom.rst b/docs/narr/email/custom.rst index f91d844..dac70fc 100644 --- a/docs/narr/email/custom.rst +++ b/docs/narr/email/custom.rst @@ -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 ` -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: diff --git a/src/wuttjamaican/app.py b/src/wuttjamaican/app.py index 102d5db..a6ea9b3 100644 --- a/src/wuttjamaican/app.py +++ b/src/wuttjamaican/app.py @@ -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) @@ -837,7 +837,7 @@ class AppProvider: :param config: The app :term:`config object`. - Instances have the following attributes: + ``AppProvider`` instances have the following attributes: .. attribute:: config @@ -846,6 +846,29 @@ class AppProvider: .. attribute:: app Reference to the parent app handler. + + Some things which a subclass may define, in order to register + various features with the app: + + .. attribute:: email_modules + + List of :term:`email modules ` provided. Should + be a list of strings; each is a dotted module path, e.g.:: + + email_modules = ['poser.emails'] + + .. attribute:: email_templates + + List of :term:`email template` folders provided. Can be a list + of paths, or a single path as string:: + + email_templates = ['poser:templates/email'] + + email_templates = 'poser:templates/email' + + Note the syntax, which specifies python module, then colon + (``:``), then filesystem path below that. However absolute + file paths may be used as well, when applicable. """ def __init__(self, config): diff --git a/src/wuttjamaican/email.py b/src/wuttjamaican/email.py new file mode 100644 index 0000000..4265483 --- /dev/null +++ b/src/wuttjamaican/email.py @@ -0,0 +1,800 @@ +# -*- 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 . +# +################################################################################ +""" +Email Handler +""" + +import importlib +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: + """ + Base class for all :term:`email settings `. + + 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: + """ + 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 + 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 `. + + This will discover all email modules exposed by the + :term:`app`, and/or its :term:`providers `. + """ + if not hasattr(self, '_email_modules'): + self._email_modules = [] + for provider in self.app.providers.values(): + if hasattr(provider, 'email_modules'): + modules = provider.email_modules + if modules: + if isinstance(modules, str): + modules = [modules] + for module in modules: + module = importlib.import_module(module) + self._email_modules.append(module) + + return self._email_modules + + def get_email_settings(self): + """ + Returns a dict of all known :term:`email settings `, keyed by :term:`email key`. + + This calls :meth:`get_email_modules()` and for each module, it + discovers all the email settings it contains. + """ + if not hasattr(self, '_email_settings'): + self._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._email_settings[obj.__name__] = obj + + return self._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 + + 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={}, **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` 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.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_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(self, key, context={}, rendered=True, setting=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()`. + + :returns: Final subject text, either "raw" or rendered. + """ + template = self.get_auto_subject_template(key, setting=setting) + if not rendered: + return template + + return Template(template).render(**context) + + def get_auto_subject_template(self, key, 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 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 + template = self.config.get(f'{self.config.appname}.email.{key}.subject') + if template: + return template + + # 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): + """ """ + 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.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.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, mode): + """ """ + mode = mode.lower() + if mode not in ('txt', 'html'): + raise ValueError("requested mode not supported") + + if mode == 'txt': + templates = self.txt_templates + elif mode == 'html': + templates = self.html_templates + + try: + return templates.get_template(f'{key}.{mode}.mako') + except TopLevelLookupException: + pass + + 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 key in set([key, 'default']): + enabled = self.config.get_bool(f'{self.config.appname}.email.{key}.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(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. + + 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, **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) diff --git a/src/wuttjamaican/email/__init__.py b/src/wuttjamaican/email/__init__.py deleted file mode 100644 index 8702f9d..0000000 --- a/src/wuttjamaican/email/__init__.py +++ /dev/null @@ -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 . -# -################################################################################ -""" -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 diff --git a/src/wuttjamaican/email/handler.py b/src/wuttjamaican/email/handler.py deleted file mode 100644 index 59f328c..0000000 --- a/src/wuttjamaican/email/handler.py +++ /dev/null @@ -1,423 +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 . -# -################################################################################ -""" -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) diff --git a/src/wuttjamaican/email/message.py b/src/wuttjamaican/email/message.py deleted file mode 100644 index 0e9f25e..0000000 --- a/src/wuttjamaican/email/message.py +++ /dev/null @@ -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 . -# -################################################################################ -""" -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() diff --git a/tests/email/templates/test_foo.html.mako b/tests/email-templates/test_foo.html.mako similarity index 100% rename from tests/email/templates/test_foo.html.mako rename to tests/email-templates/test_foo.html.mako diff --git a/tests/email/templates/test_foo.txt.mako b/tests/email-templates/test_foo.txt.mako similarity index 100% rename from tests/email/templates/test_foo.txt.mako rename to tests/email-templates/test_foo.txt.mako diff --git a/tests/email/__init__.py b/tests/email/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/tests/email/test_message.py b/tests/email/test_message.py deleted file mode 100644 index f8ff67a..0000000 --- a/tests/email/test_message.py +++ /dev/null @@ -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="

hello world

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

hello world

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

hello world

") - 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) diff --git a/tests/email/test_handler.py b/tests/test_email.py similarity index 60% rename from tests/email/test_handler.py rename to tests/test_email.py index 3735509..d4c05bd 100644 --- a/tests/email/test_handler.py +++ b/tests/test_email.py @@ -5,23 +5,103 @@ from unittest.mock import patch, MagicMock import pytest -from wuttjamaican.email import handler as mod -from wuttjamaican.email import Message -from wuttjamaican.conf import WuttaConfig +from wuttjamaican import email as mod from wuttjamaican.util import resource_path from wuttjamaican.exc import ConfigurationError +from wuttjamaican.testing import ConfigTestCase -class TestEmailHandler(TestCase): +class TestEmailSetting(ConfigTestCase): - def setUp(self): - try: - import mako - except ImportError: - pytest.skip("test not relevant without mako") + def test_constructor(self): + setting = mod.EmailSetting(self.config) + self.assertIs(setting.config, self.config) + self.assertIs(setting.app, self.app) + self.assertEqual(setting.key, 'EmailSetting') - self.config = WuttaConfig() - self.app = self.config.get_app() + def test_sample_data(self): + setting = mod.EmailSetting(self.config) + self.assertEqual(setting.sample_data(), {}) + + +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="

hello world

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

hello world

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

hello world

") + 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) + + +class mock_foo(mod.EmailSetting): + default_subject = "MOCK FOO!" + def sample_data(self): + return {'foo': 'mock'} + + +class TestEmailHandler(ConfigTestCase): def make_handler(self, **kwargs): return mod.EmailHandler(self.config, **kwargs) @@ -36,28 +116,91 @@ 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_get_email_modules(self): + + # no providers, no email modules + with patch.object(self.app, 'providers', new={}): + handler = self.make_handler() + self.assertEqual(handler.get_email_modules(), []) + + # provider may specify modules as list + providers = { + 'wuttatest': MagicMock(email_modules=['wuttjamaican.email']), + } + with patch.object(self.app, 'providers', new=providers): + handler = self.make_handler() + modules = handler.get_email_modules() + self.assertEqual(len(modules), 1) + self.assertIs(modules[0], mod) + + # provider may specify modules as list + providers = { + 'wuttatest': MagicMock(email_modules='wuttjamaican.email'), + } + with patch.object(self.app, 'providers', new=providers): + handler = self.make_handler() + modules = handler.get_email_modules() + self.assertEqual(len(modules), 1) + self.assertIs(modules[0], mod) + + def test_get_email_settings(self): + + # no providers, no email settings + with patch.object(self.app, 'providers', new={}): + handler = self.make_handler() + self.assertEqual(handler.get_email_settings(), {}) + + # provider may define email settings (via modules) + providers = { + 'wuttatest': MagicMock(email_modules=['tests.test_email']), + } + with patch.object(self.app, 'providers', new=providers): + handler = self.make_handler() + settings = handler.get_email_settings() + self.assertEqual(len(settings), 1) + self.assertIn('mock_foo', settings) + + def test_get_email_setting(self): + + providers = { + 'wuttatest': MagicMock(email_modules=['tests.test_email']), + } + + with patch.object(self.app, 'providers', new=providers): + handler = self.make_handler() + + # as instance + setting = handler.get_email_setting('mock_foo') + self.assertIsInstance(setting, mod.EmailSetting) + self.assertIsInstance(setting, mock_foo) + + # as class + setting = handler.get_email_setting('mock_foo', instance=False) + self.assertTrue(issubclass(setting, mod.EmailSetting)) + self.assertIs(setting, mock_foo) + 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 +213,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 +228,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') @@ -167,6 +310,20 @@ class TestEmailHandler(TestCase): self.config.setdefault('wutta.email.foo.sender', 'sally@example.com') self.assertEqual(handler.get_auto_sender('foo'), 'sally@example.com') + def test_get_auto_replyto(self): + handler = self.make_handler() + + # null by default + self.assertIsNone(handler.get_auto_replyto('foo')) + + # can set global default + self.config.setdefault('wutta.email.default.replyto', 'george@example.com') + self.assertEqual(handler.get_auto_replyto('foo'), 'george@example.com') + + # can set for key + self.config.setdefault('wutta.email.foo.replyto', 'kathy@example.com') + self.assertEqual(handler.get_auto_replyto('foo'), 'kathy@example.com') + def test_get_auto_subject_template(self): handler = self.make_handler() @@ -184,6 +341,15 @@ class TestEmailHandler(TestCase): template = handler.get_auto_subject_template('foo') self.assertEqual(template, "Foo Message") + # setting can provide default subject + providers = { + 'wuttatest': MagicMock(email_modules=['tests.test_email']), + } + with patch.object(self.app, 'providers', new=providers): + handler = self.make_handler() + template = handler.get_auto_subject_template('mock_foo') + self.assertEqual(template, "MOCK FOO!") + def test_get_auto_subject(self): handler = self.make_handler() @@ -240,7 +406,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 +423,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,13 +439,39 @@ 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() body = handler.get_auto_html_body('test_foo') self.assertEqual(body, '

hello from foo html template

\n') + def test_get_notes(self): + handler = self.make_handler() + + # null by default + self.assertIsNone(handler.get_notes('foo')) + + # configured notes + self.config.setdefault('wutta.email.foo.notes', 'hello world') + self.assertEqual(handler.get_notes('foo'), 'hello world') + + def test_is_enabled(self): + handler = self.make_handler() + + # enabled by default + self.assertTrue(handler.is_enabled('default')) + self.assertTrue(handler.is_enabled('foo')) + + # specific type disabled + self.config.setdefault('wutta.email.foo.enabled', 'false') + self.assertFalse(handler.is_enabled('foo')) + + # default is disabled + self.assertTrue(handler.is_enabled('bar')) + self.config.setdefault('wutta.email.default.enabled', 'false') + self.assertFalse(handler.is_enabled('bar')) + def test_deliver_message(self): handler = self.make_handler() @@ -387,24 +579,56 @@ class TestEmailHandler(TestCase): 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() + handler = self.make_handler() + with patch.object(handler, 'deliver_message') as deliver_message: - # 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() + # specify message w/ no body + msg = handler.make_message() + self.assertRaises(ValueError, handler.send_email, message=msg) + self.assertFalse(deliver_message.called) - # make_auto_message() called only if needed - with patch.object(handler, 'make_auto_message') as make_auto_message: + # again, but also specify key + msg = handler.make_message() + self.assertRaises(ValueError, handler.send_email, 'foo', message=msg) + self.assertFalse(deliver_message.called) - msg = handler.make_message() - handler.send_email(message=msg) - make_auto_message.assert_not_called() + # specify complete message + deliver_message.reset_mock() + msg = handler.make_message(txt_body="hello world") + handler.send_email(message=msg) + deliver_message.assert_called_once_with(msg, recips=None) - 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') + # again, but also specify key + deliver_message.reset_mock() + msg = handler.make_message(txt_body="hello world") + handler.send_email('foo', message=msg) + deliver_message.assert_called_once_with(msg, recips=None) + + # no key, no message + deliver_message.reset_mock() + self.assertRaises(ValueError, handler.send_email) + + # auto-create message w/ no template + deliver_message.reset_mock() + self.assertRaises(RuntimeError, handler.send_email, 'foo', sender='foo@example.com') + self.assertFalse(deliver_message.called) + + # auto create w/ body + deliver_message.reset_mock() + handler.send_email('foo', sender='foo@example.com', txt_body="hello world") + self.assertTrue(deliver_message.called) + + # type is disabled + deliver_message.reset_mock() + self.config.setdefault('wutta.email.foo.enabled', False) + handler.send_email('foo', sender='foo@example.com', txt_body="hello world") + self.assertFalse(deliver_message.called) + + # default is disabled + deliver_message.reset_mock() + handler.send_email('bar', sender='bar@example.com', txt_body="hello world") + self.assertTrue(deliver_message.called) + deliver_message.reset_mock() + 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)