diff --git a/tailbone/templates/messages/archive/index.mako b/tailbone/templates/messages/archive/index.mako
new file mode 100644
index 00000000..e8c8fe20
--- /dev/null
+++ b/tailbone/templates/messages/archive/index.mako
@@ -0,0 +1,27 @@
+## -*- coding: utf-8 -*-
+<%inherit file="/messages/index.mako" />
+
+<%def name="title()">Message Archive%def>
+
+<%def name="head_tags()">
+ ${parent.head_tags()}
+
+%def>
+
+<%def name="context_menu_items()">
+ ${parent.context_menu_items()}
+
${h.link_to("Go to my Message Inbox", url('messages.inbox'))}
+ ${h.link_to("Go to my Sent Messages", url('messages.sent'))}
+%def>
+
+<%def name="grid_tools()">
+ ${h.form(url('messages.move_bulk'), name='move-selected')}
+ ${h.hidden('destination', value='inbox')}
+ ${h.hidden('uuids')}
+
+ ${h.end_form()}
+%def>
+
+${parent.body()}
diff --git a/tailbone/templates/messages/inbox/index.mako b/tailbone/templates/messages/inbox/index.mako
new file mode 100644
index 00000000..a9100b71
--- /dev/null
+++ b/tailbone/templates/messages/inbox/index.mako
@@ -0,0 +1,27 @@
+## -*- coding: utf-8 -*-
+<%inherit file="/messages/index.mako" />
+
+<%def name="title()">Message Inbox%def>
+
+<%def name="head_tags()">
+ ${parent.head_tags()}
+
+%def>
+
+<%def name="context_menu_items()">
+ ${parent.context_menu_items()}
+ ${h.link_to("Go to my Message Archive", url('messages.archive'))}
+ ${h.link_to("Go to my Sent Messages", url('messages.sent'))}
+%def>
+
+<%def name="grid_tools()">
+ ${h.form(url('messages.move_bulk'), name='move-selected')}
+ ${h.hidden('destination', value='archive')}
+ ${h.hidden('uuids')}
+
+ ${h.end_form()}
+%def>
+
+${parent.body()}
diff --git a/tailbone/templates/messages/index.mako b/tailbone/templates/messages/index.mako
new file mode 100644
index 00000000..5d60edab
--- /dev/null
+++ b/tailbone/templates/messages/index.mako
@@ -0,0 +1,58 @@
+## -*- coding: utf-8 -*-
+<%inherit file="/master/index.mako" />
+
+<%def name="head_tags()">
+ ${parent.head_tags()}
+
+
+%def>
+
+${parent.body()}
diff --git a/tailbone/templates/messages/sent/index.mako b/tailbone/templates/messages/sent/index.mako
new file mode 100644
index 00000000..6c96d85a
--- /dev/null
+++ b/tailbone/templates/messages/sent/index.mako
@@ -0,0 +1,12 @@
+## -*- coding: utf-8 -*-
+<%inherit file="/master/index.mako" />
+
+<%def name="title()">Sent Messages%def>
+
+<%def name="context_menu_items()">
+ ${parent.context_menu_items()}
+ ${h.link_to("Go to my Message Inbox", url('messages.inbox'))}
+ ${h.link_to("Go to my Message Archive", url('messages.archive'))}
+%def>
+
+${parent.body()}
diff --git a/tailbone/templates/messages/view.mako b/tailbone/templates/messages/view.mako
new file mode 100644
index 00000000..69cc8f94
--- /dev/null
+++ b/tailbone/templates/messages/view.mako
@@ -0,0 +1,31 @@
+## -*- coding: utf-8 -*-
+<%inherit file="/master/view.mako" />
+
+<%def name="context_menu_items()">
+ % if recipient:
+ % if recipient.status == rattail.enum.MESSAGE_STATUS_INBOX:
+ ${h.link_to("Back to Message Inbox", url('messages.inbox'))}
+ ${h.link_to("Go to my Message Archive", url('messages.archive'))}
+ ${h.link_to("Go to my Sent Messages", url('messages.sent'))}
+ ${h.link_to("Move this Message to my Archive", url('messages.move', uuid=instance.uuid) + '?dest=archive')}
+ % else:
+ ${h.link_to("Back to Message Archive", url('messages.archive'))}
+ ${h.link_to("Go to my Message Inbox", url('messages.inbox'))}
+ ${h.link_to("Go to my Sent Messages", url('messages.sent'))}
+ ${h.link_to("Move this Message to my Inbox", url('messages.move', uuid=instance.uuid) + '?dest=inbox')}
+ % endif
+ % else:
+ ${h.link_to("Back to Sent Messages", url('messages.sent'))}
+ ${h.link_to("Go to my Message Inbox", url('messages.inbox'))}
+ ${h.link_to("Go to my Message Archive", url('messages.archive'))}
+ % endif
+%def>
+
+${parent.body()}
+
+
+
+
+
+
+
diff --git a/tailbone/views/messages.py b/tailbone/views/messages.py
new file mode 100644
index 00000000..5105fca6
--- /dev/null
+++ b/tailbone/views/messages.py
@@ -0,0 +1,292 @@
+# -*- coding: utf-8 -*-
+################################################################################
+#
+# Rattail -- Retail Software Framework
+# Copyright © 2010-2016 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 .
+#
+################################################################################
+"""
+Message Views
+"""
+
+from __future__ import unicode_literals, absolute_import
+
+from rattail import enum
+from rattail.db import model
+
+import formalchemy
+from pyramid import httpexceptions
+from webhelpers.html import tags
+
+from tailbone import forms
+from tailbone.db import Session
+from tailbone.views import MasterView
+
+
+class SubjectFieldRenderer(formalchemy.FieldRenderer):
+
+ def render_readonly(self, **kwargs):
+ subject = self.raw_value
+ if not subject:
+ return ''
+ return tags.link_to(subject, self.request.route_url('messages.view', uuid=self.field.parent.model.uuid))
+
+
+class SenderFieldRenderer(forms.renderers.UserFieldRenderer):
+
+ def render_readonly(self, **kwargs):
+ sender = self.raw_value
+ if sender is self.request.user:
+ return 'you'
+ return super(SenderFieldRenderer, self).render_readonly(**kwargs)
+
+
+class RecipientsFieldRenderer(formalchemy.FieldRenderer):
+
+ def render_readonly(self, **kwargs):
+ recipients = self.raw_value
+ if not recipients:
+ return ''
+ recips = filter(lambda r: r.recipient is not self.request.user, recipients)
+ recips = sorted([r.recipient.display_name for r in recips])
+ if len(recips) < len(recipients):
+ recips.insert(0, 'you')
+ return ', '.join(recips)
+
+
+class TerseRecipientsFieldRenderer(formalchemy.FieldRenderer):
+
+ def render_readonly(self, **kwargs):
+ recipients = self.raw_value
+ if not recipients:
+ return ''
+ recips = filter(lambda r: r.recipient is not self.request.user, recipients)
+ recips = sorted([r.recipient.display_name for r in recips])
+ if len(recips) < 5:
+ return ', '.join(recips)
+ return "{}, ...".format(', '.join(recips[:4]))
+
+
+class MessagesView(MasterView):
+ """
+ Base class for message views.
+ """
+ model_class = model.Message
+ creatable = False
+ editable = False
+ deletable = False
+ checkboxes = True
+
+ def get_index_url(self):
+ # not really used, but necessary to make certain other code happy
+ return self.request.route_url('messages.inbox')
+
+ def index(self):
+ if not self.request.user:
+ raise httpexceptions.HTTPForbidden
+ return super(MessagesView, self).index()
+
+ def get_instance(self):
+ if not self.request.user:
+ raise httpexceptions.HTTPForbidden
+ message = super(MessagesView, self).get_instance()
+ if not self.associated_with(message):
+ raise httpexceptions.HTTPForbidden
+ return message
+
+ def associated_with(self, message):
+ if message.sender is self.request.user:
+ return True
+ for recip in message.recipients:
+ if recip.recipient is self.request.user:
+ return True
+ return False
+
+ def query(self, session):
+ return session.query(model.Message)\
+ .outerjoin(model.MessageRecipient)\
+ .filter(model.MessageRecipient.recipient == self.request.user)
+
+ def configure_grid(self, g):
+
+ g.joiners['sender'] = lambda q: q.join(model.User, model.User.uuid == model.Message.sender_uuid).outerjoin(model.Person)
+ g.filters['sender'] = g.make_filter('sender', model.Person.display_name,
+ default_active=True, default_verb='contains')
+ g.sorters['sender'] = g.make_sorter(model.Person.display_name)
+
+ g.filters['subject'].default_active = True
+ g.filters['subject'].default_verb = 'contains'
+
+ g.default_sortkey = 'sent'
+ g.default_sortdir = 'desc'
+
+ g.configure(
+ include=[
+ g.subject.with_renderer(SubjectFieldRenderer),
+ g.sender.with_renderer(SenderFieldRenderer).label("From"),
+ g.recipients.with_renderer(TerseRecipientsFieldRenderer).label("To"),
+ g.sent.with_renderer(forms.renderers.DateTimeFieldRenderer(self.rattail_config)),
+ ],
+ readonly=True)
+
+ def row_attrs(self, row, i):
+ recip = self.get_recipient(row)
+ if recip:
+ return {'data-uuid': recip.uuid}
+ return {}
+
+ def configure_fieldset(self, fs):
+ fs.configure(
+ include=[
+ fs.subject,
+ fs.sender.with_renderer(SenderFieldRenderer).label("From"),
+ fs.recipients.with_renderer(RecipientsFieldRenderer).label("To"),
+ fs.sent.with_renderer(forms.renderers.DateTimeFieldRenderer(self.rattail_config)),
+ fs.body,
+ ])
+ if self.viewing:
+ del fs.body
+
+ def get_recipient(self, message):
+ for recip in message.recipients:
+ if recip.recipient is self.request.user:
+ return recip
+
+ def template_kwargs_view(self, **kwargs):
+ message = kwargs['instance']
+ return {'message': message,
+ 'recipient': self.get_recipient(message)}
+
+ def move(self):
+ """
+ Move a message, either to the archive or back to the inbox.
+ """
+ message = self.get_instance()
+ recipient = None
+ for recip in message.recipients:
+ if recip.recipient is self.request.user:
+ recipient = recip
+ break
+ if not recipient:
+ raise httpexceptions.HTTPForbidden
+
+ dest = self.request.GET.get('dest')
+ if dest not in ('inbox', 'archive'):
+ self.request.session.flash("Sorry, I couldn't make sense out of that request.")
+ return self.redirect(self.request.get_referrer(
+ default=self.request.route_url('messages_inbox')))
+
+ new_status = enum.MESSAGE_STATUS_INBOX if dest == 'inbox' else enum.MESSAGE_STATUS_ARCHIVE
+ if recipient.status != new_status:
+ recipient.status = new_status
+ return self.redirect(self.request.route_url('messages.{}'.format(
+ 'archive' if dest == 'inbox' else 'inbox')))
+
+ def move_bulk(self):
+ """
+ Move messages in bulk, to the archive or back to the inbox.
+ """
+ dest = self.request.POST.get('destination', 'archive')
+ if self.request.method == 'POST':
+ uuids = self.request.POST.get('uuids', '').split(',')
+ if uuids:
+ new_status = enum.MESSAGE_STATUS_INBOX if dest == 'inbox' else enum.MESSAGE_STATUS_ARCHIVE
+ for uuid in uuids:
+ recip = Session.query(model.MessageRecipient).get(uuid) if uuid else None
+ if recip and recip.recipient is self.request.user:
+ if recip.status != new_status:
+ recip.status = new_status
+ route = 'messages.{}'.format('archive' if dest == 'inbox' else 'inbox')
+ return self.redirect(self.request.route_url(route))
+
+
+class InboxView(MessagesView):
+ """
+ Inbox message view.
+ """
+ url_prefix = '/messages/inbox'
+ grid_key = 'messages.inbox'
+
+ def get_index_url(self):
+ return self.request.route_url('messages.inbox')
+
+ def query(self, session):
+ q = super(InboxView, self).query(session)
+ return q.filter(model.MessageRecipient.status == enum.MESSAGE_STATUS_INBOX)
+
+
+class ArchiveView(MessagesView):
+ """
+ Archived message view.
+ """
+ url_prefix = '/messages/archive'
+ grid_key = 'messages.archive'
+
+ def get_index_url(self):
+ return self.request.route_url('messages.archive')
+
+ def query(self, session):
+ q = super(ArchiveView, self).query(session)
+ return q.filter(model.MessageRecipient.status == enum.MESSAGE_STATUS_ARCHIVE)
+
+
+class SentView(MessagesView):
+ """
+ Sent messages view.
+ """
+ url_prefix = '/messages/sent'
+ grid_key = 'messages.sent'
+ checkboxes = False
+
+ def get_index_url(self):
+ return self.request.route_url('messages.sent')
+
+ def query(self, session):
+ return session.query(model.Message)\
+ .filter(model.Message.sender == self.request.user)
+
+ def configure_grid(self, g):
+ super(SentView, self).configure_grid(g)
+ g.filters['sender'].default_active = False
+
+
+def includeme(config):
+
+ # inbox
+ config.add_route('messages.inbox', '/messages/inbox/')
+ config.add_view(InboxView, attr='index', route_name='messages.inbox')
+
+ # archive
+ config.add_route('messages.archive', '/messages/archive/')
+ config.add_view(ArchiveView, attr='index', route_name='messages.archive')
+
+ # move bulk
+ config.add_route('messages.move_bulk', '/messages/move-bulk')
+ config.add_view(MessagesView, attr='move_bulk', route_name='messages.move_bulk')
+
+ # sent
+ config.add_route('messages.sent', '/messages/sent/')
+ config.add_view(SentView, attr='index', route_name='messages.sent')
+
+ # view
+ config.add_route('messages.view', '/messages/{uuid}')
+ config.add_view(MessagesView, attr='view', route_name='messages.view')
+
+ # move (single)
+ config.add_route('messages.move', '/messages/{uuid}/move')
+ config.add_view(MessagesView, attr='move', route_name='messages.move')