Changeset - 4c78223eb95e
[Not reviewed]
0 2 0
Lance Edgar (lance) - 5 years ago 2020-02-07 16:21:14
lance@edbob.org
Add new `ProblemReportEmail` base class, for simpler email previews

just so some of the template context can be provided automatically. although,
this *is* a bit heavy currently - should let caller pass in email handler etc.
2 files changed with 48 insertions and 2 deletions:
0 comments (0 inline, 0 general)
rattail/emails.py
Show inline comments
 
# -*- coding: utf-8; -*-
 
################################################################################
 
#
 
#  Rattail -- Retail Software Framework
 
#  Copyright © 2010-2019 Lance Edgar
 
#  Copyright © 2010-2020 Lance Edgar
 
#
 
#  This file is part of Rattail.
 
#
 
#  Rattail 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.
 
#
 
#  Rattail 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
 
#  Rattail.  If not, see <http://www.gnu.org/licenses/>.
 
#
 
################################################################################
 
"""
 
Common email config objects
 
"""
 

	
 
from __future__ import unicode_literals, absolute_import
 

	
 
import sys
 
import socket
 
from traceback import format_exception
 

	
 
import six
 

	
 
from rattail.mail import Email
 
from rattail.util import load_object
 
from rattail.core import Object
 
from rattail.time import make_utc, localtime
 
from rattail.problems import ProblemReport, get_problem_report_handler
 

	
 

	
 
class ProblemReportEmail(Email):
 
    """
 
    Base class for all "problem report" emails
 
    """
 
    abstract = True
 

	
 
    def obtain_sample_data(self, request):
 
        data = self.sample_data(request)
 
        handler = get_problem_report_handler(self.config)
 

	
 
        if 'report' not in data:
 
            reports = handler.get_all_problem_reports()
 
            email_key = self.__class__.__name__
 
            for report in reports:
 
                if report.email_key == email_key:
 
                    data['report'] = report(self.config)
 
                    break
 

	
 
            if 'report' not in data:
 
                report = ProblemReport(self.config)
 
                report.problem_title = "Generic Title (problem report not found)"
 
                data['report'] = report
 

	
 
        if 'system_title' not in data:
 
            system_key = data['report'].system_key or 'rattail'
 
            data['system_title'] = handler.get_system_title(system_key)
 

	
 
        return data
 

	
 

	
 
class datasync_error_watcher_get_changes(Email):
 
    """
 
    When any datasync watcher thread encounters an error trying to get changes,
 
    this email is sent out.
 
    """
 
    default_subject = "Watcher failed to get changes"
 

	
 
    def sample_data(self, request):
 
        from rattail.datasync import DataSyncWatcher
 
        try:
 
            raise RuntimeError("Fake error for preview")
 
        except:
 
            exc_type, exc, traceback = sys.exc_info()
 
        watcher = DataSyncWatcher(self.config, 'test')
 
        watcher.consumes_self = True
 
        return {
 
            'watcher': watcher,
 
            'error': exc,
 
            'traceback': ''.join(format_exception(exc_type, exc, traceback)).strip(),
 
            'datasync_url': '/datasyncchanges',
 
            'attempts': 2,
 
        }
 

	
 

	
 
class datasync_error_consumer_process_changes(Email):
 
    """
 
    When any datasync consumer thread encounters an error trying to process
 
    changes, this email is sent out.
 
    """
 
    default_subject = "Consumer failed to process changes"
 

	
 
    def sample_data(self, request):
 
        from rattail.datasync import DataSyncWatcher, DataSyncConsumer
 

	
 
        try:
 
            raise RuntimeError("Fake error for preview")
 
        except:
 
            exc_type, exc, traceback = sys.exc_info()
 

	
 
        watcher = DataSyncWatcher(self.config, 'testwatcher')
 
        consumer = DataSyncConsumer(self.config, 'testconsumer')
 
        return {
 
            'watcher': watcher,
 
            'consumer': consumer,
 
            'error': exc,
 
            'attempts': 2,
 
            'traceback': ''.join(format_exception(exc_type, exc, traceback)).strip(),
 
            'datasync_url': '/datasync/changes',
 
        }
 

	
 

	
 
class filemon_action_error(Email):
 
    """
 
    When any filemon thread encounters an error (and the retry attempts have
 
    been exhausted) then it will send out this email.
 
    """
 
    default_subject = "Error invoking action(s)"
 

	
 
    def sample_data(self, request):
 
        from rattail.filemon import Action
 
        action = Action(self.config)
 
        action.spec = 'rattail.filemon.actions:Action'
 
        action.retry_delay = 10
 
        try:
 
            raise RuntimeError("Fake error for preview")
 
        except:
 
            exc_type, exc, traceback = sys.exc_info()
 
        return {
 
            'hostname': socket.gethostname(),
 
            'path': '/tmp/foo.csv',
 
            'action': action,
 
            'attempts': 3,
 
            'error': exc,
 
            'traceback': ''.join(format_exception(exc_type, exc, traceback)).strip(),
 
        }
 

	
 

	
 
class ImporterEmail(Email):
 
    """
 
    Sent when a "version catch-up" import is performed, which involves changes.
 
    """
 
    abstract = True
 
    fallback_key = 'rattail_import_updates'
 
    handler_spec = None
 

	
 
    def get_handler(self, config):
 
        return load_object(self.handler_spec)(config)
 

	
 
    def sample_data(self, request):
 
        handler = self.get_handler(request.rattail_config)
 
        obj = Object()
 
        local_data = {
 
            'foo': 42,
 
            'bar': True,
rattail/mail.py
Show inline comments
 
# -*- coding: utf-8; -*-
 
################################################################################
 
#
 
#  Rattail -- Retail Software Framework
 
#  Copyright © 2010-2018 Lance Edgar
 
#  Copyright © 2010-2020 Lance Edgar
 
#
 
#  This file is part of Rattail.
 
#
 
#  Rattail 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.
 
#
 
#  Rattail 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
 
#  Rattail.  If not, see <http://www.gnu.org/licenses/>.
 
#
 
################################################################################
 
"""
 
Email Framework
 
"""
 

	
 
from __future__ import unicode_literals, absolute_import
 

	
 
import os
 
import smtplib
 
import logging
 
from email.charset import Charset
 
from email.message import Message
 
from email.mime.multipart import MIMEMultipart
 
from email.mime.application import MIMEApplication
 
from email.mime.text import MIMEText
 

	
 
import six
 

	
 
from mako.template import Template
 
from mako.lookup import TemplateLookup
 
from mako.exceptions import TopLevelLookupException
 

	
 
from rattail import exceptions
 
from rattail.core import UNSPECIFIED
 
from rattail.files import resource_path
 
from rattail.util import import_module_path
 
from rattail.time import localtime, make_utc
 

	
 

	
 
# NOTE: this bit of magic was stolen from Django
 
# Don't BASE64-encode UTF-8 messages so that we avoid unwanted attention from
 
# some spam filters.
 
utf8_charset = Charset('utf-8')
 
utf8_charset.body_encoding = None  # Python defaults to BASE64
 

	
 
log = logging.getLogger(__name__)
 

	
 

	
 
def send_email(config, key, data={}, attachments=[],
 
               fallback_key=None, default_subject=None,
 
               enabled=UNSPECIFIED, **kwargs):
 
    """
 
    Send an email message of the given type, per config, with the given data
 
    and/or attachments.
 
    """
 
    # TODO: should let config override which handler we use
 
    handler = EmailHandler(config)
 
    email = handler.get_email(key, fallback_key=fallback_key,
 
                              default_subject=default_subject)
 

	
 
    if enabled is UNSPECIFIED:
 
        enabled = handler.get_enabled(email)
 

	
 
    if enabled:
 
        kwargs['attachments'] = attachments
 
        handler.send_message(email, data, **kwargs)
 
    else:
 
        log.debug("skipping email of type '%s' per config", key)
 

	
 

	
 
# TODO: deprecate / remove this (used only for tailbone preview?)
 
def deliver_message(config, key, msg, recipients=UNSPECIFIED):
 
    """
 
    Deliver an email message using the given SMTP configuration.
 
    """
 
    if recipients is UNSPECIFIED:
 
        recips = set()
 
        to = msg.get_all('To')
 
        if to:
 
            recips = recips.union(set(to))
 
        cc = msg.get_all('Cc')
 
        if cc:
 
            recips = recips.union(set(cc))
 
        bcc = msg.get_all('Bcc')
 
        if bcc:
 
            recips = recips.union(set(bcc))
 
    else:
 
        recips = set(recipients)
 
    if not recips:
 
        raise RuntimeError("No recipients for email: {0}".format(repr(msg)))
 
@@ -216,193 +216,208 @@ class EmailHandler(object):
 
        session.close()
 

	
 
    def make_message(self, email, data, **kwargs):
 
        context = self.make_context(**data)
 
        return email.make_message(context, **kwargs)
 

	
 
    def make_context(self, **context):
 
        context['rattail_config'] = self.config
 
        context['app_title'] = self.config.app_title(default="Rattail")
 
        context['localtime'] = localtime
 
        return context
 

	
 
    def deliver_message(self, email, msg, recipients=UNSPECIFIED):
 
        """
 
        Deliver an email message using the given SMTP configuration.
 
        """
 
        if recipients is UNSPECIFIED:
 
            recips = set()
 
            to = msg.get_all('To')
 
            if to:
 
                recips = recips.union(set(to))
 
            cc = msg.get_all('Cc')
 
            if cc:
 
                recips = recips.union(set(cc))
 
            bcc = msg.get_all('Bcc')
 
            if bcc:
 
                recips = recips.union(set(bcc))
 
        else:
 
            recips = set(recipients)
 
        if not recips:
 
            raise RuntimeError("No recipients for email: {0}".format(repr(msg)))
 

	
 
        server = self.config.get('rattail.mail', 'smtp.server', default='localhost')
 
        username = self.config.get('rattail.mail', 'smtp.username')
 
        password = self.config.get('rattail.mail', 'smtp.password')
 

	
 
        if self.config.getbool('rattail.mail', 'send_feedback_only', usedb=False, default=False):
 
            send = email.key == 'user_feedback'
 
        else:
 
            send = self.config.getbool('rattail.mail', 'send_emails', usedb=False, default=True)
 

	
 
        if send:
 

	
 
            log.debug("attempting to send mail of type: %s", email.key)
 
            log.debug("connecting to server: %s", server)
 
            session = smtplib.SMTP(server)
 
            if username and password:
 
                result = session.login(username, password)
 
                log.debug("login() result is: %s", repr(result))
 

	
 
            result = session.sendmail(msg['From'], recips, msg.as_string())
 
            log.debug("sendmail() result is: %s", repr(result))
 
            session.quit()
 
            return True
 

	
 
        log.debug("config says no emails for '%s', but would have sent one to: %s", email.key, recips)
 
        return False
 

	
 

	
 
class Email(object):
 
    # Note: The docstring of an email is leveraged by code, hence this odd one.
 
    """
 
    (This email has no description.)
 
    """
 
    key = None
 
    fallback_key = None
 
    abstract = False
 
    default_prefix = "[rattail]"
 
    default_subject = "Automated message"
 

	
 
    # Whether or not the email's :attr:`to` attribute is dynamically determined
 
    # at run-time, i.e. via some logic other than typical reading from config.
 
    dynamic_to = False
 
    dynamic_to_help = None
 

	
 
    def __init__(self, config, key=None, fallback_key=None, default_subject=None):
 
        self.config = config
 
        self.enum = config.get_enum()
 

	
 
        if key:
 
            self.key = key
 
        elif not self.key:
 
            self.key = self.__class__.__name__
 
            if self.key == 'Email':
 
                raise exceptions.ConfigurationError("Email instance has no key: {0}".format(repr(self)))
 

	
 
        if fallback_key:
 
            self.fallback_key = fallback_key
 
        if default_subject:
 
            self.default_subject = default_subject
 

	
 
        templates = config.getlist('rattail.mail', 'templates')
 
        if templates:
 
            templates = [resource_path(p) for p in templates]
 
        self.templates = TemplateLookup(directories=templates)
 

	
 
    def obtain_sample_data(self, request):
 
        """
 
        This method is responsible for obtaining the full set of sample data,
 
        to be used as context when generating a preview for the email.
 

	
 
        Note, you normally should not override this method!  Please see also
 
        the :meth:`sample_data()` method.
 
        """
 
        return self.sample_data(request)
 

	
 
    def sample_data(self, request):
 
        """
 
        This method can return a dict of sample data, to be used as context
 
        when generating a preview for the email.  Subclasses are welcome to
 
        override this method.
 
        """
 
        return {}
 

	
 
    def get_enabled(self):
 
        """
 
        Get the enabled flag for the email's message type.
 
        """
 
        enabled = self.config.getbool('rattail.mail', '{0}.enabled'.format(self.key))
 
        if enabled is not None:
 
            return enabled
 
        enabled = self.config.getbool('rattail.mail', 'default.enabled')
 
        if enabled is not None:
 
            return enabled
 
        return self.config.getbool('rattail.mail', 'send_emails', default=True)
 

	
 
    def get_sender(self):
 
        """
 
        Returns the value for the message's ``From:`` header.
 

	
 
        :rtype: str
 
        """
 
        sender = self.config.get('rattail.mail', '{0}.from'.format(self.key))
 
        if not sender:
 
            sender = self.config.get('rattail.mail', 'default.from')
 
            if not sender:
 
                raise exceptions.SenderNotFound(self.key)
 
        return sender
 

	
 
    def get_replyto(self):
 
        """
 
        Get the Reply-To address for the message.
 
        """
 
        replyto = self.config.get('rattail.mail', '{0}.replyto'.format(self.key))
 
        if not replyto:
 
            replyto = self.config.get('rattail.mail', 'default.replyto')
 
        return replyto
 

	
 
    def get_recips(self, type_='to'):
 
        """
 
        Returns a list of recipients of the given type for the message.
 

	
 
        :param type_: Must be one of: ``('to', 'cc', 'bcc')``.
 

	
 
        :rtype: list
 
        """
 
        try:
 
            if type_.lower() not in ('to', 'cc', 'bcc'):
 
                raise Exception
 
        except:
 
            raise ValueError("Recipient type must be one of ('to', 'cc', 'bcc'); "
 
                             "not: {0}".format(repr(type_)))
 
        type_ = type_.lower()
 
        recips = self.config.getlist('rattail.mail', '{0}.{1}'.format(self.key, type_))
 
        if not recips:
 
            recips = self.config.getlist('rattail.mail', 'default.{0}'.format(type_))
 
        return recips
 

	
 
    def get_prefix(self, data={}, magic=True):
 
        """
 
        Returns a string to be used as the subject prefix for the message.
 

	
 
        :rtype: str
 
        """
 
        prefix = self.config.get('rattail.mail', '{0}.prefix'.format(self.key))
 
        if not prefix:
 
            prefix = self.config.get('rattail.mail', 'default.prefix')
 
        prefix = prefix or self.default_prefix
 
        if magic and not self.config.production():
 
            prefix = "[STAGE] {}".format(prefix)
 
        return prefix
 

	
 
    def get_subject(self, data={}, render=True, template=UNSPECIFIED):
 
        """
 
        Returns the base value for the message's subject header, i.e. minus
 
        prefix.
 

	
 
        :rtype: str
 
        """
 
        if template is UNSPECIFIED:
 
            template = self.config.get('rattail.mail', '{}.subject'.format(self.key),
 
                                       default=self.default_subject)
 
            if not template:
 
                template = self.config.get('rattail.mail', 'default.subject')
 
        if template and render:
 
            return Template(template).render(**data)
 
        return template
 

	
 
    def get_complete_subject(self, data={}, render=True, prefix=UNSPECIFIED, template=UNSPECIFIED):
 
        """
 
        Returns the value for the message's ``Subject:`` header, i.e. the base
 
        subject with the prefix applied.  Note that config may provide the
 
        complete subject also, in which case the prefix and base subject are
 
        not considered.
 

	
 
        :rtype: str
 
        """
 
        if prefix is UNSPECIFIED:
0 comments (0 inline, 0 general)