597 lines
18 KiB
Python
597 lines
18 KiB
Python
# -*- coding: utf-8; -*-
|
|
################################################################################
|
|
#
|
|
# Rattail -- Retail Software Framework
|
|
# Copyright © 2010-2023 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 Views
|
|
"""
|
|
|
|
import re
|
|
import warnings
|
|
|
|
from rattail import mail
|
|
from rattail.db import model
|
|
from rattail.config import parse_list
|
|
from rattail.util import simple_error
|
|
|
|
import colander
|
|
from deform import widget as dfwidget
|
|
from webhelpers2.html import HTML
|
|
|
|
from tailbone import grids
|
|
from tailbone.db import Session
|
|
from tailbone.views import View, MasterView
|
|
|
|
|
|
class EmailSettingView(MasterView):
|
|
"""
|
|
Master view for email admin (settings/preview).
|
|
"""
|
|
normalized_model_name = 'emailprofile'
|
|
model_title = "Email Setting"
|
|
model_key = 'key'
|
|
url_prefix = '/settings/email'
|
|
filterable = False
|
|
pageable = False
|
|
creatable = False
|
|
deletable = False
|
|
configurable = True
|
|
config_title = "Email"
|
|
|
|
grid_columns = [
|
|
'key',
|
|
'prefix',
|
|
'subject',
|
|
'to',
|
|
'enabled',
|
|
'hidden',
|
|
]
|
|
|
|
form_fields = [
|
|
'key',
|
|
'fallback_key',
|
|
'description',
|
|
'prefix',
|
|
'subject',
|
|
'sender',
|
|
'replyto',
|
|
'to',
|
|
'cc',
|
|
'bcc',
|
|
'enabled',
|
|
'hidden',
|
|
]
|
|
|
|
def __init__(self, request):
|
|
super(EmailSettingView, self).__init__(request)
|
|
self.email_handler = self.get_handler()
|
|
|
|
@property
|
|
def handler(self):
|
|
warnings.warn("the `handler` property is deprecated! "
|
|
"please use `email_handler` instead",
|
|
DeprecationWarning, stacklevel=2)
|
|
return self.email_handler
|
|
|
|
def get_handler(self):
|
|
app = self.get_rattail_app()
|
|
return app.get_email_handler()
|
|
|
|
def get_data(self, session=None):
|
|
data = []
|
|
if self.has_perm('configure'):
|
|
emails = self.email_handler.get_all_emails()
|
|
else:
|
|
emails = self.email_handler.get_available_emails()
|
|
for key, Email in emails.items():
|
|
email = Email(self.rattail_config, key)
|
|
data.append(self.normalize(email))
|
|
return data
|
|
|
|
def configure_grid(self, g):
|
|
g.sorters['key'] = g.make_simple_sorter('key', foldcase=True)
|
|
g.sorters['prefix'] = g.make_simple_sorter('prefix', foldcase=True)
|
|
g.sorters['subject'] = g.make_simple_sorter('subject', foldcase=True)
|
|
g.sorters['enabled'] = g.make_simple_sorter('enabled')
|
|
g.set_sort_defaults('key')
|
|
g.set_type('enabled', 'boolean')
|
|
g.set_link('key')
|
|
g.set_link('subject')
|
|
|
|
g.set_searchable('key')
|
|
g.set_searchable('subject')
|
|
|
|
# to
|
|
g.set_renderer('to', self.render_to_short)
|
|
g.sorters['to'] = g.make_simple_sorter('to', foldcase=True)
|
|
|
|
# hidden
|
|
if self.has_perm('configure'):
|
|
g.sorters['hidden'] = g.make_simple_sorter('hidden')
|
|
g.set_type('hidden', 'boolean')
|
|
else:
|
|
g.remove('hidden')
|
|
|
|
# toggle hidden
|
|
if self.has_perm('configure'):
|
|
g.main_actions.append(
|
|
self.make_action('toggle_hidden', url='#', icon='ban',
|
|
click_handler='toggleHidden(props.row)',
|
|
factory=ToggleHidden))
|
|
|
|
def render_to_short(self, email, column):
|
|
profile = email['_email']
|
|
if self.rattail_config.production():
|
|
if profile.dynamic_to:
|
|
if profile.dynamic_to_help:
|
|
return profile.dynamic_to_help
|
|
|
|
value = email['to']
|
|
if not value:
|
|
return ""
|
|
recips = parse_list(value)
|
|
if len(recips) < 3:
|
|
return value
|
|
return "{}, ...".format(', '.join(recips[:2]))
|
|
|
|
def normalize(self, email):
|
|
def get_recips(type_):
|
|
recips = email.get_recips(type_)
|
|
if recips:
|
|
return ', '.join(recips)
|
|
data = email.obtain_sample_data(self.request)
|
|
normal = {
|
|
'_email': email,
|
|
'key': email.key,
|
|
'fallback_key': email.fallback_key,
|
|
'description': email.__doc__,
|
|
'prefix': email.get_prefix(data, magic=False) or '',
|
|
'subject': email.get_subject(data, render=False) or '',
|
|
'sender': email.get_sender() or '',
|
|
'replyto': email.get_replyto() or '',
|
|
'to': get_recips('to') or '',
|
|
'cc': get_recips('cc') or '',
|
|
'bcc': get_recips('bcc') or '',
|
|
'enabled': email.get_enabled(),
|
|
}
|
|
if self.has_perm('configure'):
|
|
normal['hidden'] = self.email_handler.email_is_hidden(email.key)
|
|
return normal
|
|
|
|
def get_instance(self):
|
|
key = self.request.matchdict['key']
|
|
return self.normalize(self.email_handler.get_email(key))
|
|
|
|
def get_instance_title(self, email):
|
|
return email['_email'].get_complete_subject(render=False)
|
|
|
|
def editable_instance(self, profile):
|
|
if self.rattail_config.demo():
|
|
return profile['key'] != 'user_feedback'
|
|
return True
|
|
|
|
def deletable_instance(self, profile):
|
|
if self.rattail_config.demo():
|
|
return profile['key'] != 'user_feedback'
|
|
return True
|
|
|
|
def configure_form(self, f):
|
|
super(EmailSettingView, self).configure_form(f)
|
|
profile = f.model_instance['_email']
|
|
|
|
# key
|
|
f.set_readonly('key')
|
|
|
|
# fallback_key
|
|
f.set_readonly('fallback_key')
|
|
|
|
# description
|
|
f.set_readonly('description')
|
|
|
|
# prefix
|
|
f.set_label('prefix', "Subject Prefix")
|
|
|
|
# subject
|
|
f.set_label('subject', "Subject Text")
|
|
|
|
# sender
|
|
f.set_label('sender', "From")
|
|
|
|
# replyto
|
|
f.set_label('replyto', "Reply-To")
|
|
|
|
# to
|
|
f.set_widget('to', dfwidget.TextAreaWidget(cols=60, rows=6))
|
|
if self.rattail_config.production():
|
|
if profile.dynamic_to:
|
|
f.set_readonly('to')
|
|
if profile.dynamic_to_help:
|
|
f.model_instance['to'] = profile.dynamic_to_help
|
|
|
|
# cc
|
|
f.set_widget('cc', dfwidget.TextAreaWidget(cols=60, rows=2))
|
|
|
|
# bcc
|
|
f.set_widget('bcc', dfwidget.TextAreaWidget(cols=60, rows=2))
|
|
|
|
# enabled
|
|
f.set_type('enabled', 'boolean')
|
|
|
|
# hidden
|
|
if self.has_perm('configure'):
|
|
f.set_type('hidden', 'boolean')
|
|
else:
|
|
f.remove('hidden')
|
|
|
|
def make_form_schema(self):
|
|
schema = EmailProfileSchema()
|
|
|
|
if not self.has_perm('configure'):
|
|
hidden = schema.get('hidden')
|
|
schema.children.remove(hidden)
|
|
|
|
return schema
|
|
|
|
def save_edit_form(self, form):
|
|
key = self.request.matchdict['key']
|
|
data = self.form_deserialized
|
|
app = self.get_rattail_app()
|
|
session = self.Session()
|
|
app.save_setting(session, 'rattail.mail.{}.prefix'.format(key), data['prefix'])
|
|
app.save_setting(session, 'rattail.mail.{}.subject'.format(key), data['subject'])
|
|
app.save_setting(session, 'rattail.mail.{}.from'.format(key), data['sender'])
|
|
app.save_setting(session, 'rattail.mail.{}.replyto'.format(key), data['replyto'])
|
|
app.save_setting(session, 'rattail.mail.{}.to'.format(key), (data['to'] or '').replace('\n', ', '))
|
|
app.save_setting(session, 'rattail.mail.{}.cc'.format(key), (data['cc'] or '').replace('\n', ', '))
|
|
app.save_setting(session, 'rattail.mail.{}.bcc'.format(key), (data['bcc'] or '').replace('\n', ', '))
|
|
app.save_setting(session, 'rattail.mail.{}.enabled'.format(key), str(data['enabled']).lower())
|
|
if self.has_perm('configure'):
|
|
app.save_setting(session, 'rattail.mail.{}.hidden'.format(key), str(data['hidden']).lower())
|
|
return data
|
|
|
|
def template_kwargs_view(self, **kwargs):
|
|
key = self.request.matchdict['key']
|
|
kwargs['email'] = self.email_handler.get_email(key)
|
|
return kwargs
|
|
|
|
def configure_get_simple_settings(self):
|
|
config = self.rattail_config
|
|
return [
|
|
|
|
# general
|
|
{'section': 'rattail.mail',
|
|
'option': 'handler'},
|
|
{'section': 'rattail.mail',
|
|
'option': 'templates'},
|
|
|
|
# sending
|
|
{'section': 'rattail.mail',
|
|
'option': 'record_attempts',
|
|
'type': bool},
|
|
{'section': 'rattail.mail',
|
|
'option': 'send_email_on_failure',
|
|
'type': bool},
|
|
]
|
|
|
|
def configure_get_context(self, *args, **kwargs):
|
|
context = super().configure_get_context(*args, **kwargs)
|
|
|
|
# prettify list of template paths
|
|
templates = self.rattail_config.parse_list(
|
|
context['simple_settings']['rattail.mail.templates'])
|
|
context['simple_settings']['rattail.mail.templates'] = ', '.join(templates)
|
|
|
|
return context
|
|
|
|
def toggle_hidden(self):
|
|
app = self.get_rattail_app()
|
|
data = self.request.json_body
|
|
name = 'rattail.mail.{}.hidden'.format(data['key'])
|
|
app.save_setting(self.Session(), name,
|
|
'true' if data['hidden'] else 'false')
|
|
return {'ok': True}
|
|
|
|
def send_test(self):
|
|
"""
|
|
AJAX view for sending a test email.
|
|
"""
|
|
data = self.request.json_body
|
|
|
|
recip = data.get('recipient')
|
|
if not recip:
|
|
return {'error': "Must specify recipient"}
|
|
|
|
app = self.get_rattail_app()
|
|
app.send_email('hello', to=[recip], cc=None, bcc=None,
|
|
default_subject="Hello world")
|
|
|
|
return {'ok': True}
|
|
|
|
@classmethod
|
|
def defaults(cls, config):
|
|
cls._email_defaults(config)
|
|
cls._defaults(config)
|
|
|
|
@classmethod
|
|
def _email_defaults(cls, config):
|
|
route_prefix = cls.get_route_prefix()
|
|
url_prefix = cls.get_url_prefix()
|
|
permission_prefix = cls.get_permission_prefix()
|
|
model_title_plural = cls.get_model_title_plural()
|
|
|
|
# toggle hidden
|
|
config.add_route('{}.toggle_hidden'.format(route_prefix),
|
|
'{}/toggle-hidden'.format(url_prefix),
|
|
request_method='POST')
|
|
config.add_view(cls, attr='toggle_hidden',
|
|
route_name='{}.toggle_hidden'.format(route_prefix),
|
|
permission='{}.configure'.format(permission_prefix),
|
|
renderer='json')
|
|
|
|
# send test
|
|
config.add_route('{}.send_test'.format(route_prefix),
|
|
'{}/send-test'.format(url_prefix),
|
|
request_method='POST')
|
|
config.add_view(cls, attr='send_test',
|
|
route_name='{}.send_test'.format(route_prefix),
|
|
permission='{}.configure'.format(permission_prefix),
|
|
renderer='json')
|
|
|
|
|
|
# TODO: deprecate / remove this
|
|
ProfilesView = EmailSettingView
|
|
|
|
|
|
class ToggleHidden(grids.GridAction):
|
|
"""
|
|
Grid action for toggling the 'hidden' flag for an email profile.
|
|
"""
|
|
|
|
def render_label(self):
|
|
return '{{ renderLabelToggleHidden(props.row) }}'
|
|
|
|
|
|
class RecipientsType(colander.String):
|
|
"""
|
|
Custom schema type for email recipients. This is used to present the
|
|
recipients as a "list" within the text area, i.e. one recipient per line.
|
|
Then the list is collapsed to a comma-delimited string for storage.
|
|
"""
|
|
|
|
def serialize(self, node, appstruct):
|
|
if appstruct is colander.null:
|
|
return colander.null
|
|
recips = parse_list(appstruct)
|
|
return '\n'.join(recips)
|
|
|
|
def deserialize(self, node, cstruct):
|
|
if cstruct == '' and self.allow_empty:
|
|
return ''
|
|
if not cstruct:
|
|
return colander.null
|
|
recips = parse_list(cstruct)
|
|
return ', '.join(recips)
|
|
|
|
|
|
class EmailProfileSchema(colander.MappingSchema):
|
|
|
|
prefix = colander.SchemaNode(colander.String())
|
|
|
|
subject = colander.SchemaNode(colander.String())
|
|
|
|
sender = colander.SchemaNode(colander.String())
|
|
|
|
replyto = colander.SchemaNode(colander.String(), missing='')
|
|
|
|
to = colander.SchemaNode(RecipientsType())
|
|
|
|
cc = colander.SchemaNode(RecipientsType(), missing='')
|
|
|
|
bcc = colander.SchemaNode(RecipientsType(), missing='')
|
|
|
|
enabled = colander.SchemaNode(colander.Boolean())
|
|
|
|
hidden = colander.SchemaNode(colander.Boolean())
|
|
|
|
|
|
class EmailPreview(View):
|
|
"""
|
|
Lists available email templates, and can show previews of each.
|
|
"""
|
|
|
|
def __init__(self, request):
|
|
super(EmailPreview, self).__init__(request)
|
|
|
|
if hasattr(self, 'get_handler'):
|
|
warnings.warn("defining a get_handler() method is deprecated; "
|
|
"please use AppHandler.get_email_handler() instead",
|
|
DeprecationWarning, stacklevel=2)
|
|
self.email_handler = get_handler()
|
|
else:
|
|
app = self.get_rattail_app()
|
|
self.email_handler = app.get_email_handler()
|
|
|
|
@property
|
|
def handler(self):
|
|
warnings.warn("the `handler` property is deprecated! "
|
|
"please use `email_handler` instead",
|
|
DeprecationWarning, stacklevel=2)
|
|
return self.email_handler
|
|
|
|
def __call__(self):
|
|
|
|
# Forms submitted via POST are only used for sending emails.
|
|
if self.request.method == 'POST':
|
|
self.email_template()
|
|
url = self.request.get_referrer(default=self.request.route_url('emailprofiles'))
|
|
return self.redirect(url)
|
|
|
|
# Maybe render a preview?
|
|
key = self.request.GET.get('key')
|
|
if key:
|
|
type_ = self.request.GET.get('type', 'html')
|
|
return self.preview_template(key, type_)
|
|
|
|
assert False, "should not be here"
|
|
|
|
def email_template(self):
|
|
recipient = self.request.POST.get('recipient')
|
|
if recipient:
|
|
key = self.request.POST.get('email_key')
|
|
if key:
|
|
email = self.email_handler.get_email(key)
|
|
|
|
context = self.email_handler.make_context()
|
|
context.update(email.obtain_sample_data(self.request))
|
|
|
|
try:
|
|
self.email_handler.send_message(email, context,
|
|
subject_prefix="[PREVIEW] ",
|
|
to=[recipient],
|
|
cc=None, bcc=None)
|
|
except Exception as error:
|
|
self.request.session.flash(simple_error(error), 'error')
|
|
else:
|
|
self.request.session.flash(
|
|
"Preview for '{}' was emailed to {}".format(
|
|
key, recipient))
|
|
|
|
def preview_template(self, key, type_):
|
|
email = self.email_handler.get_email(key)
|
|
template = email.get_template(type_)
|
|
|
|
context = self.email_handler.make_context()
|
|
context.update(email.obtain_sample_data(self.request))
|
|
|
|
self.request.response.text = template.render(**context)
|
|
if type_ == 'txt':
|
|
self.request.response.content_type = str('text/plain')
|
|
return self.request.response
|
|
|
|
@classmethod
|
|
def defaults(cls, config):
|
|
# email preview
|
|
config.add_route('email.preview', '/email/preview/')
|
|
config.add_view(cls, route_name='email.preview',
|
|
renderer='/email/preview.mako',
|
|
permission='emailprofiles.preview')
|
|
config.add_tailbone_permission('emailprofiles', 'emailprofiles.preview',
|
|
"Send preview email")
|
|
|
|
|
|
class EmailAttemptView(MasterView):
|
|
"""
|
|
Master view for email attempts.
|
|
"""
|
|
model_class = model.EmailAttempt
|
|
route_prefix = 'email_attempts'
|
|
url_prefix = '/email/attempts'
|
|
creatable = False
|
|
editable = False
|
|
deletable = False
|
|
|
|
labels = {
|
|
'status_code': "Status",
|
|
}
|
|
|
|
grid_columns = [
|
|
'key',
|
|
'sender',
|
|
'subject',
|
|
'to',
|
|
'sent',
|
|
'status_code',
|
|
]
|
|
|
|
form_fields = [
|
|
'key',
|
|
'sender',
|
|
'subject',
|
|
'to',
|
|
'cc',
|
|
'bcc',
|
|
'sent',
|
|
'status_code',
|
|
'status_text',
|
|
]
|
|
|
|
def configure_grid(self, g):
|
|
super(EmailAttemptView, self).configure_grid(g)
|
|
|
|
# sent
|
|
g.set_sort_defaults('sent', 'desc')
|
|
|
|
# status_code
|
|
g.set_enum('status_code', self.enum.EMAIL_ATTEMPT)
|
|
|
|
# to
|
|
g.set_renderer('to', self.render_to_short)
|
|
|
|
# links
|
|
g.set_link('key')
|
|
g.set_link('sender')
|
|
g.set_link('subject')
|
|
g.set_link('to')
|
|
|
|
to_pattern = re.compile(r'^\{(.*)\}$')
|
|
|
|
def render_to_short(self, attempt, column):
|
|
value = attempt.to
|
|
if not value:
|
|
return
|
|
|
|
match = self.to_pattern.match(value)
|
|
if match:
|
|
recips = parse_list(match.group(1))
|
|
if len(recips) > 2:
|
|
recips = recips[:2]
|
|
recips.append('...')
|
|
recips = [HTML.escape(r) for r in recips]
|
|
return ', '.join(recips)
|
|
|
|
return value
|
|
|
|
def configure_form(self, f):
|
|
super(EmailAttemptView, self).configure_form(f)
|
|
|
|
# key
|
|
f.set_renderer('key', self.render_email_key)
|
|
|
|
# status_code
|
|
f.set_enum('status_code', self.enum.EMAIL_ATTEMPT)
|
|
|
|
|
|
def defaults(config, **kwargs):
|
|
base = globals()
|
|
|
|
EmailSettingView = kwargs.get('EmailSettingView', base['EmailSettingView'])
|
|
EmailSettingView.defaults(config)
|
|
|
|
EmailPreview = kwargs.get('EmailPreview', base['EmailPreview'])
|
|
EmailPreview.defaults(config)
|
|
|
|
EmailAttemptView = kwargs.get('EmailAttemptView', base['EmailAttemptView'])
|
|
EmailAttemptView.defaults(config)
|
|
|
|
|
|
def includeme(config):
|
|
defaults(config)
|