diff --git a/tailbone/forms/renderers/bouncer.py b/tailbone/forms/renderers/bouncer.py
new file mode 100644
index 00000000..e9522668
--- /dev/null
+++ b/tailbone/forms/renderers/bouncer.py
@@ -0,0 +1,63 @@
+# -*- coding: utf-8 -*-
+################################################################################
+#
+# Rattail -- Retail Software Framework
+# Copyright © 2010-2015 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 Affero 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 Affero General Public License for
+# more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with Rattail. If not, see .
+#
+################################################################################
+"""
+Batch Field Renderers
+"""
+
+from __future__ import unicode_literals
+
+import os
+import stat
+import random
+
+from formalchemy.ext import fsblob
+
+
+class BounceMessageFieldRenderer(fsblob.FileFieldRenderer):
+ """
+ Custom file field renderer for email bounce messages. In readonly mode,
+ shows the filename and size.
+ """
+
+ @classmethod
+ def new(cls, request, handler):
+ name = 'Configured%s_%s' % (cls.__name__, unicode(random.random())[2:])
+ return type(str(name), (cls,), dict(request=request, handler=handler))
+
+ @property
+ def storage_path(self):
+ return self.handler.root_msgdir
+
+ def get_size(self):
+ size = super(BounceMessageFieldRenderer, self).get_size()
+ if size:
+ return size
+ bounce = self.field.parent.model
+ path = os.path.join(self.handler.msgpath(bounce))
+ if os.path.isfile(path):
+ return os.stat(path)[stat.ST_SIZE]
+ return 0
+
+ def get_url(self, filename):
+ bounce = self.field.parent.model
+ return self.request.route_url('emailbounce.download', uuid=bounce.uuid)
diff --git a/tailbone/templates/emailbounces/crud.mako b/tailbone/templates/emailbounces/crud.mako
new file mode 100644
index 00000000..666c28d9
--- /dev/null
+++ b/tailbone/templates/emailbounces/crud.mako
@@ -0,0 +1,14 @@
+## -*- coding: utf-8 -*-
+<%inherit file="/crud.mako" />
+
+<%def name="context_menu_items()">
+ <% bounce = form.fieldset.model %>
+
${h.link_to("Back to Email Bounces", url('emailbounces'))}
+ % if not bounce.processed and request.has_perm('emailbounces.process'):
+ ${h.link_to("Mark this Email Bounce as Processed", url('emailbounce.process', uuid=bounce.uuid))}
+ % elif bounce.processed and request.has_perm('emailbounces.unprocess'):
+ ${h.link_to("Mark this Email Bounce as UN-processed", url('emailbounce.unprocess', uuid=bounce.uuid))}
+ % endif
+%def>
+
+${parent.body()}
diff --git a/tailbone/templates/emailbounces/index.mako b/tailbone/templates/emailbounces/index.mako
new file mode 100644
index 00000000..01f5beb7
--- /dev/null
+++ b/tailbone/templates/emailbounces/index.mako
@@ -0,0 +1,6 @@
+## -*- coding: utf-8 -*-
+<%inherit file="/grid.mako" />
+
+<%def name="title()">Email Bounces%def>
+
+${parent.body()}
diff --git a/tailbone/views/__init__.py b/tailbone/views/__init__.py
index 6d0b0c80..fe0bd02c 100644
--- a/tailbone/views/__init__.py
+++ b/tailbone/views/__init__.py
@@ -55,6 +55,7 @@ def includeme(config):
config.include('tailbone.views.auth')
config.include('tailbone.views.batches')
+ config.include('tailbone.views.bouncer')
config.include('tailbone.views.brands')
config.include('tailbone.views.categories')
config.include('tailbone.views.customergroups')
diff --git a/tailbone/views/bouncer.py b/tailbone/views/bouncer.py
new file mode 100644
index 00000000..1eb743ab
--- /dev/null
+++ b/tailbone/views/bouncer.py
@@ -0,0 +1,245 @@
+# -*- coding: utf-8 -*-
+################################################################################
+#
+# Rattail -- Retail Software Framework
+# Copyright © 2010-2015 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 Affero 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 Affero General Public License for
+# more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with Rattail. If not, see .
+#
+################################################################################
+"""
+Views for Email Bounces
+"""
+
+from __future__ import unicode_literals
+
+import os
+import datetime
+
+from rattail.db import model
+from rattail.bouncer import get_handler
+
+import formalchemy
+from pyramid.response import FileResponse
+from pyramid.httpexceptions import HTTPFound, HTTPNotFound
+from webhelpers.html import literal
+
+from tailbone.db import Session
+from tailbone.views import SearchableAlchemyGridView, CrudView
+from tailbone.forms import renderers
+from tailbone.forms.renderers.bouncer import BounceMessageFieldRenderer
+from tailbone.grids.search import BooleanSearchFilter
+
+
+class EmailBouncesGrid(SearchableAlchemyGridView):
+ """
+ Main grid view for email bounces.
+ """
+ mapped_class = model.EmailBounce
+ config_prefix = 'emailbounces'
+
+ def join_map(self):
+ return {
+ 'processed_by': lambda q: q.outerjoin(model.User),
+ }
+
+ def filter_map(self):
+
+ def processed_is(q, v):
+ if v == 'True':
+ return q.filter(model.EmailBounce.processed != None)
+ else:
+ return q.filter(model.EmailBounce.processed == None)
+
+ def processed_nt(q, v):
+ if v == 'True':
+ return q.filter(model.EmailBounce.processed == None)
+ else:
+ return q.filter(model.EmailBounce.processed != None)
+
+ return self.make_filter_map(
+ ilike=['config_key', 'bounce_recipient_address', 'intended_recipient_address'],
+ processed={'is': processed_is, 'nt': processed_nt},
+ processed_by=self.filter_ilike(model.User.username))
+
+ def filter_config(self):
+ return self.make_filter_config(
+ include_filter_config_key=True,
+ filter_type_config_key='lk',
+ filter_label_config_key="Source",
+ filter_factory_processed=BooleanSearchFilter,
+ filter_type_processed='is',
+ processed=False,
+ include_filter_processed=True,
+ filter_label_bounce_recipient_address="Bounced To",
+ filter_label_intended_recipient_address="Intended For")
+
+ def sort_config(self):
+ return self.make_sort_config(sort='bounced', dir='desc')
+
+ def sort_map(self):
+ return self.make_sort_map(
+ 'config_key', 'bounced', 'bounce_recipient_address', 'intended_recipient_address',
+ processed_by=self.sorter(model.User.username))
+
+ def grid(self):
+ g = self.make_grid()
+ g.bounced.set(renderer=renderers.DateTimeFieldRenderer(self.rattail_config))
+ g.configure(
+ include=[
+ g.config_key.label("Source"),
+ g.bounced,
+ g.bounce_recipient_address.label("Bounced To"),
+ g.intended_recipient_address.label("Intended For"),
+ g.processed_by,
+ ],
+ readonly=True)
+ if self.request.has_perm('emailbounces.view'):
+ g.viewable = True
+ g.view_route_name = 'emailbounce'
+ if self.request.has_perm('emailbounces.delete'):
+ g.deletable = True
+ g.delete_route_name = 'emailbounce.delete'
+ return g
+
+
+class LinksFieldRenderer(formalchemy.FieldRenderer):
+
+ def render_readonly(self, **kwargs):
+ value = self.raw_value
+ if not value:
+ return 'n/a'
+ html = literal('')
+ for link in value:
+ html += literal('- {0}: {2}
'.format(
+ link.type, link.url, link.title))
+ html += literal('
')
+ return html
+
+
+class EmailBounceCrud(CrudView):
+ """
+ Main CRUD view for email bounces.
+ """
+ mapped_class = model.EmailBounce
+ home_route = 'emailbounces'
+ pretty_name = "Email Bounce"
+
+ def get_handler(self, bounce):
+ return get_handler(self.rattail_config, bounce.config_key)
+
+ def fieldset(self, bounce):
+ assert isinstance(bounce, model.EmailBounce)
+ handler = self.get_handler(bounce)
+ fs = self.make_fieldset(bounce)
+ fs.bounced.set(renderer=renderers.DateTimeFieldRenderer(self.rattail_config))
+ fs.processed.set(renderer=renderers.DateTimeFieldRenderer(self.rattail_config))
+ fs.append(formalchemy.Field('message',
+ value=handler.msgpath(bounce),
+ renderer=BounceMessageFieldRenderer.new(self.request, handler)))
+ fs.append(formalchemy.Field('links',
+ value=list(handler.make_links(Session(), bounce.intended_recipient_address)),
+ renderer=LinksFieldRenderer))
+ fs.configure(
+ include=[
+ fs.config_key.label("Source"),
+ fs.message,
+ fs.bounced,
+ fs.intended_recipient_address.label("Intended For"),
+ fs.bounce_recipient_address.label("Bounced To"),
+ fs.links,
+ fs.processed,
+ fs.processed_by,
+ ],
+ readonly=True)
+ return fs
+
+ def template_kwargs(self, form):
+ kwargs = super(EmailBounceCrud, self).template_kwargs(form)
+ bounce = form.fieldset.model
+ kwargs['handler'] = self.get_handler(bounce)
+ return kwargs
+
+ def process(self):
+ """
+ View for marking a bounce as processed.
+ """
+ bounce = self.get_model_from_request()
+ if not bounce:
+ return HTTPNotFound()
+ bounce.processed = datetime.datetime.utcnow()
+ bounce.processed_by = self.request.user
+ self.request.session.flash("Email bounce has been marked processed.")
+ return HTTPFound(location=self.request.route_url('emailbounces'))
+
+ def unprocess(self):
+ """
+ View for marking a bounce as *unprocessed*.
+ """
+ bounce = self.get_model_from_request()
+ if not bounce:
+ return HTTPNotFound()
+ bounce.processed = None
+ bounce.processed_by = None
+ self.request.session.flash("Email bounce has been marked UN-processed.")
+ return HTTPFound(location=self.request.route_url('emailbounces'))
+
+ def download(self):
+ """
+ View for downloading the message file associated with a bounce.
+ """
+ bounce = self.get_model_from_request()
+ if not bounce:
+ return HTTPNotFound()
+ handler = self.get_handler(bounce)
+ path = handler.msgpath(bounce)
+ response = FileResponse(path, request=self.request)
+ response.headers[b'Content-Length'] = str(os.path.getsize(path))
+ response.headers[b'Content-Disposition'] = b'attachment; filename="bounce.eml"'
+ return response
+
+
+def add_routes(config):
+ config.add_route('emailbounces', '/emailbounces/')
+ config.add_route('emailbounce', '/emailbounces/{uuid}')
+ config.add_route('emailbounce.process', '/emailbounces/{uuid}/process')
+ config.add_route('emailbounce.unprocess', '/emailbounces/{uuid}/unprocess')
+ config.add_route('emailbounce.delete', '/emailbounces/{uuid}/delete')
+ config.add_route('emailbounce.download', '/emailbounces/{uuid}/download')
+
+
+def includeme(config):
+ add_routes(config)
+
+ config.add_view(EmailBouncesGrid, route_name='emailbounces',
+ renderer='/emailbounces/index.mako',
+ permission='emailbounces.list')
+
+ config.add_view(EmailBounceCrud, attr='read', route_name='emailbounce',
+ renderer='/emailbounces/crud.mako',
+ permission='emailbounces.view')
+
+ config.add_view(EmailBounceCrud, attr='process', route_name='emailbounce.process',
+ permission='emailbounces.process')
+
+ config.add_view(EmailBounceCrud, attr='unprocess', route_name='emailbounce.unprocess',
+ permission='emailbounces.unprocess')
+
+ config.add_view(EmailBounceCrud, attr='download', route_name='emailbounce.download',
+ permission='emailbounces.download')
+
+ config.add_view(EmailBounceCrud, attr='delete', route_name='emailbounce.delete',
+ permission='emailbounces.delete')