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 @@ - - @@ -35,14 +38,14 @@ - {% for msg in held_messages %} - + - {% 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'))
{% trans 'Perform action on selected messages' %} - - - + +
{% trans 'Perform action on selected messages' %}
+
+ + + +
{% trans 'Sender' %} {% trans 'Reason' %} {% trans 'Hold Date' %}
{{ 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' %} - -