diff --git a/src/postorius/lib/__init__.py b/src/postorius/lib/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/src/postorius/lib/__init__.py
diff --git a/src/postorius/lib/scrub.py b/src/postorius/lib/scrub.py
new file mode 100644
index 0000000..f0995df
--- /dev/null
+++ b/src/postorius/lib/scrub.py
@@ -0,0 +1,173 @@
+# Copyright (C) 2011-2012 by the Free Software Foundation, Inc.
+#
+# This program 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 2
+# of the License, or (at your option) any later version.
+#
+# This program 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 this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301,
+# USA.
+
+"""Cleanse a message for archiving."""
+
+from __future__ import absolute_import, unicode_literals
+
+import os
+import re
+import binascii
+from mimetypes import guess_all_extensions
+from email.header import decode_header, make_header
+from email.errors import HeaderParseError
+
+pre = re.compile(r'[/\\:]')
+sre = re.compile(r'[^-\w.]')
+dre = re.compile(r'^\.*')
+
+BR = '
\n'
+
+NEXT_PART = re.compile(r'--------------[ ]next[ ]part[ ]--------------\n')
+
+
+def guess_extension(ctype, ext):
+ all_exts = guess_all_extensions(ctype, strict=False)
+ if ext in all_exts:
+ return ext
+ return all_exts and all_exts[0]
+
+
+def get_charset(message, default="ascii", guess=False):
+ if message.get_content_charset():
+ return message.get_content_charset().decode("ascii")
+ if message.get_charset():
+ return message.get_charset().decode("ascii")
+ charset = default
+ if not guess:
+ return charset
+ text = message.get_payload(decode=True)
+ for encoding in ["ascii", "utf-8", "iso-8859-15"]:
+ try:
+ text.decode(encoding)
+ except UnicodeDecodeError:
+ continue
+ else:
+ charset = encoding
+ break
+ return charset
+
+
+def oneline(s):
+ """Inspired by mailman.utilities.string.oneline"""
+ try:
+ h = make_header(decode_header(s))
+ ustr = h.__unicode__()
+ return ''.join(ustr.splitlines())
+ except (LookupError, UnicodeError, ValueError, HeaderParseError):
+ return ''.join(s.splitlines())
+
+
+class Scrubber(object):
+ def __init__(self, msg):
+ self.msg = msg
+
+ def scrub(self):
+ attachments = []
+ for part_num, part in enumerate(self.msg.walk()):
+ ctype = part.get_content_type()
+ if not isinstance(ctype, unicode):
+ ctype = ctype.decode("ascii")
+ if ctype == 'text/plain':
+ disposition = part.get('content-disposition')
+ if disposition and disposition.decode(
+ "ascii", "replace").strip().startswith("attachment"):
+ attachments.append(self.parse_attachment(part, part_num))
+ part.set_payload('')
+ elif ctype == 'text/html':
+ attachments.append(self.parse_attachment(part, part_num,
+ filter_html=False))
+ part.set_payload('')
+ elif ctype == 'message/rfc822':
+ attachments.append(self.parse_attachment(part, part_num))
+ part.set_payload('')
+ elif part.get_payload() and not part.is_multipart():
+ payload = part.get_payload(decode=True)
+ ctype = part.get_content_type()
+ if not isinstance(ctype, unicode):
+ ctype.decode("ascii")
+ if payload is None:
+ continue
+ attachments.append(self.parse_attachment(part, part_num))
+ if self.msg.is_multipart():
+ text = []
+ for part in self.msg.walk():
+ if not part.get_payload() or part.is_multipart():
+ continue
+ partctype = part.get_content_type()
+ if partctype != 'text/plain' and partctype != 'text/html':
+ continue
+ try:
+ t = part.get_payload(decode=True) or ''
+ except (binascii.Error, TypeError):
+ t = part.get_payload() or ''
+ partcharset = get_charset(part, guess=True)
+ try:
+ t = t.decode(partcharset, 'replace')
+ except (UnicodeError, LookupError, ValueError,
+ AssertionError):
+ t = t.decode('ascii', 'replace')
+ if isinstance(t, basestring):
+ if not t.endswith('\n'):
+ t += '\n'
+ text.append(t)
+
+ text = u"\n".join(text)
+ else:
+ text = self.msg.get_payload(decode=True)
+ charset = get_charset(self.msg, guess=True)
+ try:
+ text = text.decode(charset, "replace")
+ except (UnicodeError, LookupError, ValueError, AssertionError):
+ text = text.decode('ascii', 'replace')
+
+ next_part_match = NEXT_PART.search(text)
+ if next_part_match:
+ text = text[0:next_part_match.start(0)]
+
+ return (text, attachments)
+
+ def parse_attachment(self, part, counter, filter_html=True):
+ decodedpayload = part.get_payload(decode=True)
+ ctype = part.get_content_type()
+ if not isinstance(ctype, unicode):
+ ctype = ctype.decode("ascii")
+ charset = get_charset(part, default=None, guess=False)
+ try:
+ filename = oneline(part.get_filename(''))
+ except (TypeError, UnicodeDecodeError):
+ filename = u"attachment.bin"
+ filename, fnext = os.path.splitext(filename)
+ ext = fnext or guess_extension(ctype, fnext)
+ if not ext:
+ if ctype == 'message/rfc822':
+ ext = '.txt'
+ else:
+ ext = '.bin'
+ ext = sre.sub('', ext)
+ if not filename:
+ filebase = u'attachment'
+ else:
+ parts = pre.split(filename)
+ filename = parts[-1]
+ filename = dre.sub('', filename)
+ filename = sre.sub('', filename)
+ filebase = filename
+ if ctype == 'message/rfc822':
+ submsg = part.get_payload()
+ decodedpayload = str(submsg)
+ return (counter, filebase+ext, ctype, charset, decodedpayload)
diff --git a/src/postorius/static/postorius/js/held_messages.js b/src/postorius/static/postorius/js/held_messages.js
new file mode 100644
index 0000000..ac58805
--- /dev/null
+++ b/src/postorius/static/postorius/js/held_messages.js
@@ -0,0 +1,56 @@
+var loadjs = function(rest_url, error_message) {
+ rest_url = rest_url.slice(0, rest_url.length - 2);
+ $('#all-messages-checkbox').change(function() {
+ $('.message-checkbox').prop('checked', this.checked);
+ });
+ $('.show-modal-btn').click(function() {
+ var msgid = $(this).data('msgid');
+ $.ajax({
+ url: rest_url + msgid,
+ success: function(data) {
+ $('#message-source-btn').attr('href', rest_url + msgid + '?raw')
+ $('#message-title').html(data.msg.subject);
+ $('.modal-footer form input[name="msgid"]').attr('value', msgid);
+ if (data.msg.body) {
+ $('#held-message-content').text(data.msg.body);
+ } else {
+ $('#held-message-content').html('
Message content could not be extracted
');
+ }
+ attachments = '';
+ for (i = 0; i < data.attachments.length; i++) {
+ attachments += '' + data.attachments[i][1] + '
';
+ }
+ if (attachments != '') {
+ $('#held-message-attachment-header').removeClass('hidden');
+ $('#held-message-attachments').html(attachments);
+ }
+ $('#held-message-content').html($('#held-message-content').html().replace(/\n/g, "
"));
+ $('#held-message-headers').text(data.msg.headers);
+ $('#held-message-headers').html($('#held-message-headers').html().replace(/\n/g, "
") + '
');
+ $('#held-messages-modal').modal('show');
+ },
+ error : function() {
+ alert(error_message);
+ },
+ statusCode: {
+ 500: function() {
+ alert(error_message);
+ }
+ }});
+ return false;
+ });
+ $('#toggle-headers').click(function() {
+ if ($(this).hasClass('active')) {
+ $('#held-message-headers').addClass('hidden');
+ } else {
+ $('#held-message-headers').removeClass('hidden');
+ }
+ });
+ $('#held-messages-modal').on('hidden.bs.modal', function() {
+ $('#held-message-headers').addClass('hidden');
+ $('#message-title').html('');
+ $('#toggle-headers').removeClass('active');
+ $('#held-message-attachment-header').addClass('hidden');
+ $('#held-message-attachments').text('');
+ });
+}
diff --git a/src/postorius/templates/postorius/lists/held_messages.html b/src/postorius/templates/postorius/lists/held_messages.html
index 46d5de6..c433565 100644
--- a/src/postorius/templates/postorius/lists/held_messages.html
+++ b/src/postorius/templates/postorius/lists/held_messages.html
@@ -1,6 +1,7 @@
{% extends postorius_base_template %}
{% load i18n %}
{% load nav_helpers %}
+{% load staticfiles %}
{% block subtitle %}
{% trans 'Held messages' %} | {{ list.fqdn_listname }}
@@ -22,11 +23,13 @@
- {% trans 'Perform action on selected messages' %} |
-
-
-
-
+ |
+ {% trans 'Perform action on selected messages' %}
+
+
+
+
+
|
@@ -35,14 +38,14 @@
{% trans 'Sender' %} |
{% trans 'Reason' %} |
{% trans 'Hold Date' %} |
- |
{% for msg in held_messages %}
|
- {{ msg.subject }} |
+ {{ msg.subject }}
+ |
{{ msg.sender }} |
{% if msg.reasons %}
@@ -54,28 +57,6 @@
{% endif %}
|
{{ msg.hold_date }} |
-
- {% trans 'View' %}
- {% trans 'Accept' %}
- {% trans 'Reject' %}
- {% trans 'Discard' %}
-
-
-
-
-
- {{ msg.msg | linebreaks }}
-
-
-
-
-
- |
{% endfor %}
@@ -83,15 +64,52 @@
{% include 'postorius/lib/pagination.html' with page=held_messages %}
- {% else %}
+
+ {% else %}
{% trans 'There are currently no held messages.' %}
{% endif %}
{% endblock %}
{% block additionaljs %}
+
{% endblock %}
diff --git a/src/postorius/tests/test_urls.py b/src/postorius/tests/test_urls.py
index d1c723e..444ccbb 100644
--- a/src/postorius/tests/test_urls.py
+++ b/src/postorius/tests/test_urls.py
@@ -35,3 +35,7 @@
})
except NoReverseMatch as e:
self.fail(e)
+
+ def test_held_message_url_ends_with_slash(self):
+ url = reverse('rest_held_message', args=('foo', 0))
+ self.assertEquals(url[-2:], '0/')
diff --git a/src/postorius/urls.py b/src/postorius/urls.py
index 8568480..04884a0 100644
--- a/src/postorius/urls.py
+++ b/src/postorius/urls.py
@@ -23,6 +23,7 @@
from postorius.views import list as list_views
from postorius.views import user as user_views
from postorius.views import domain as domain_views
+from postorius.views import rest as rest_views
list_patterns = [
@@ -50,16 +51,10 @@
url(r'^mass_removal/$', list_views.ListMassRemovalView.as_view(),
name='mass_removal'),
url(r'^delete$', list_views.list_delete, name='list_delete'),
- url(r'^held_messages/(?P[^/]+)/accept$',
- list_views.accept_held_message, name='accept_held_message'),
- url(r'^held_messages/(?P[^/]+)/discard$',
- list_views.discard_held_message, name='discard_held_message'),
- url(r'^held_messages/(?P[^/]+)/defer$',
- list_views.defer_held_message, name='defer_held_message'),
- url(r'^held_messages/(?P[^/]+)/reject$',
- list_views.reject_held_message, name='reject_held_message'),
url(r'^held_messages$', list_views.list_moderation,
name='list_held_messages'),
+ url(r'^held_messages/moderate$', list_views.moderate_held_message,
+ name='moderate_held_message'),
url(r'^bans/$', list_views.list_bans, name='list_bans'),
url(r'^header-matches/$', list_views.list_header_matches,
name='list_header_matches'),
@@ -105,4 +100,10 @@
url(r'^users/address_activation/(?P[A-Za-z0-9]+)/$',
user_views.address_activation_link,
name='address_activation_link'),
+ url(r'^api/list/(?P[^/]+)/held_message/(?P\d+)/$',
+ rest_views.get_held_message, name='rest_held_message'),
+ url(r'^api/list/(?P[^/]+)/held_message/(?P\d+)/'
+ 'attachment/(?P\d+)/$',
+ rest_views.get_attachment_for_held_message,
+ name='rest_attachment_for_held_message'),
]
diff --git a/src/postorius/views/list.py b/src/postorius/views/list.py
index 2981b72..55c115f 100644
--- a/src/postorius/views/list.py
+++ b/src/postorius/views/list.py
@@ -20,7 +20,7 @@
import email.utils
import logging
-from django.http import HttpResponse
+from django.http import HttpResponse, HttpResponseNotAllowed, Http404
from django.contrib import messages
from django.contrib.auth.decorators import login_required, user_passes_test
@@ -31,7 +31,6 @@
from django.core.exceptions import ValidationError
from django.utils.decorators import method_decorator
from django.utils.translation import gettext as _
-from django.http import Http404
try:
from urllib2 import HTTPError
except ImportError:
@@ -390,7 +389,7 @@
@login_required
@list_moderator_required
-def list_moderation(request, list_id):
+def list_moderation(request, list_id, held_id=-1):
mailing_list = utils.get_client().get_list(list_id)
if request.method == 'POST':
form = MultipleChoiceForm(request.POST)
@@ -429,6 +428,29 @@
@login_required
+@list_moderator_required
+def moderate_held_message(request, list_id):
+ if request.method != 'POST':
+ return HttpResponseNotAllowed(['POST'])
+ msg_id = request.POST['msgid']
+ try:
+ mailing_list = List.objects.get_or_404(fqdn_listname=list_id)
+ if 'accept' in request.POST:
+ mailing_list.accept_message(msg_id)
+ messages.success(request, _('The message was accepted'))
+ elif 'reject' in request.POST:
+ mailing_list.reject_message(msg_id)
+ messages.success(request, _('The message was rejected'))
+ elif 'discard' in request.POST:
+ mailing_list.discard_message(msg_id)
+ messages.success(request, _('The message was discarded'))
+ except MailmanApiError:
+ return utils.render_api_error(request)
+
+ return redirect('list_held_messages', list_id)
+
+
+@login_required
@list_owner_required
def csv_view(request, list_id):
"""Export all the subscriber in csv
@@ -549,74 +571,6 @@
@login_required
@list_moderator_required
-def accept_held_message(request, list_id, msg_id):
- """Accepts a held message.
- """
- try:
- the_list = List.objects.get_or_404(fqdn_listname=list_id)
- the_list.accept_message(msg_id)
- except MailmanApiError:
- return utils.render_api_error(request)
- except HTTPError as e:
- messages.error(request, e.msg)
- return redirect('list_held_messages', the_list.list_id)
- messages.success(request, _('The message has been accepted.'))
- return redirect('list_held_messages', the_list.list_id)
-
-
-@login_required
-@list_moderator_required
-def discard_held_message(request, list_id, msg_id):
- """Discards a held message.
- """
- try:
- the_list = List.objects.get_or_404(fqdn_listname=list_id)
- the_list.discard_message(msg_id)
- except MailmanApiError:
- return utils.render_api_error(request)
- except HTTPError as e:
- messages.error(request, e.msg)
- return redirect('list_held_messages', the_list.list_id)
- messages.success(request, _('The message has been discarded.'))
- return redirect('list_held_messages', the_list.list_id)
-
-
-@login_required
-@list_moderator_required
-def defer_held_message(request, list_id, msg_id):
- """Defers a held message for a later decision.
- """
- try:
- the_list = List.objects.get_or_404(fqdn_listname=list_id)
- the_list.defer_message(msg_id)
- except MailmanApiError:
- return utils.render_api_error(request)
- except HTTPError as e:
- messages.error(request, e.msg)
- return redirect('list_held_messages', the_list.list_id)
- messages.success(request, _('The message has been deferred.'))
- return redirect('list_held_messages', the_list.list_id)
-
-
-@login_required
-@list_moderator_required
-def reject_held_message(request, list_id, msg_id):
- """Rejects a held message.
- """
- try:
- the_list = List.objects.get_or_404(fqdn_listname=list_id)
- the_list.reject_message(msg_id)
- except MailmanApiError:
- return utils.render_api_error(request)
- except HTTPError as e:
- messages.error(request, e.msg)
- return redirect('list_held_messages', the_list.list_id)
- messages.success(request, _('The message has been rejected.'))
- return redirect('list_held_messages', the_list.list_id)
-
-
-@login_required
-@list_moderator_required
def list_subscription_requests(request, list_id):
"""Shows a list of held messages.
"""
diff --git a/src/postorius/views/rest.py b/src/postorius/views/rest.py
new file mode 100644
index 0000000..550c19b
--- /dev/null
+++ b/src/postorius/views/rest.py
@@ -0,0 +1,111 @@
+# -*- coding: utf-8 -*-
+# Copyright (C) 1998-2016 by the Free Software Foundation, Inc.
+#
+# This file is part of Postorius.
+#
+# Postorius 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.
+#
+# Postorius 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
+# Postorius. If not, see .
+import json
+
+from email.Header import decode_header
+from email.parser import Parser as EmailParser
+from email.parser import HeaderParser
+
+from django.http import HttpResponse, Http404
+from django.contrib.auth.decorators import login_required
+from django.utils.translation import gettext as _
+from django.core.urlresolvers import reverse
+
+from postorius.auth.decorators import list_moderator_required
+from postorius.models import List
+from postorius.lib.scrub import Scrubber
+
+
+def parse(message):
+ msgobj = EmailParser().parsestr(message)
+ header_parser = HeaderParser()
+ if msgobj['Subject'] is not None:
+ decodefrag = decode_header(msgobj['Subject'])
+ subj_fragments = []
+ for s, enc in decodefrag:
+ if enc:
+ s = unicode(s, enc).encode('utf8', 'replace')
+ subj_fragments.append(s)
+ subject = ''.join(subj_fragments)
+ else:
+ subject = None
+
+ headers = []
+ headers_dict = header_parser.parsestr(message)
+ for key in headers_dict.keys():
+ headers += ['{}: {}'.format(key, headers_dict[key])]
+ content = Scrubber(msgobj).scrub()[0]
+ return {
+ 'subject': subject,
+ 'body': content,
+ 'headers': '\n'.join(headers),
+ }
+
+
+def get_attachments(message):
+ message = EmailParser().parsestr(message)
+ return Scrubber(message).scrub()[1]
+
+
+@login_required
+@list_moderator_required
+def get_held_message(request, list_id, held_id=-1):
+ """Return a held message as a json object
+ """
+ if held_id == -1:
+ raise Http404(_('Message does not exist'))
+
+ held_message = List.objects.get_or_404(
+ fqdn_listname=list_id).get_held_message(held_id)
+ if 'raw' in request.GET:
+ return HttpResponse(held_message.msg, content_type='text/plain')
+ response_data = dict()
+ response_data['sender'] = held_message.sender
+ try:
+ response_data['reasons'] = held_message.reasons
+ except AttributeError:
+ pass
+ response_data['moderation_reasons'] = held_message.moderation_reasons
+ response_data['hold_date'] = held_message.hold_date
+ response_data['msg'] = parse(held_message.msg)
+ response_data['msgid'] = held_message.request_id
+ response_data['attachments'] = []
+ attachments = get_attachments(held_message.msg)
+ for attachment in attachments:
+ counter, name, content_type, encoding, content = attachment
+ response_data['attachments'].append(
+ (reverse('rest_attachment_for_held_message',
+ args=(list_id, held_id, counter)), name))
+
+ return HttpResponse(json.dumps(response_data),
+ content_type='application/json')
+
+
+@login_required
+@list_moderator_required
+def get_attachment_for_held_message(request, list_id, held_id, attachment_id):
+ held_message = List.objects.get_or_404(
+ fqdn_listname=list_id).get_held_message(held_id)
+ attachments = get_attachments(held_message.msg)
+ for attachment in attachments:
+ if attachment[0] == int(attachment_id):
+ response = HttpResponse(attachment[4], content_type=attachment[2])
+ response['Content-Disposition'] = \
+ 'attachment;filename="{}"'.format(attachment[1])
+ return response
+ raise Http404(_('Attachment does not exist'))