From eb946e5d8e6115f3874fc98b4b097e380d47972d Mon Sep 17 00:00:00 2001 From: Raphael Michel Date: Fri, 23 Jan 2026 17:01:11 +0100 Subject: [PATCH] Outbox view --- .../base/migrations/0297_outgoingmail.py | 2 + src/pretix/base/models/mail.py | 9 + src/pretix/base/services/mail.py | 30 +-- src/pretix/base/services/notifications.py | 11 +- src/pretix/control/forms/filter.py | 63 +++++- src/pretix/control/logdisplay.py | 1 + src/pretix/control/navigation.py | 9 + .../organizers/outgoing_mail.html | 192 ++++++++++++++++++ .../organizers/outgoing_mails.html | 174 ++++++++++++++++ src/pretix/control/urls.py | 8 +- src/pretix/control/views/mail.py | 159 +++++++++++++++ src/pretix/helpers/database.py | 15 +- .../pretixcontrol/js/ui/outgoingmail.js | 42 ++++ .../pretixcontrol/scss/_mail_preview.scss | 5 + 14 files changed, 702 insertions(+), 18 deletions(-) create mode 100644 src/pretix/control/templates/pretixcontrol/organizers/outgoing_mail.html create mode 100644 src/pretix/control/templates/pretixcontrol/organizers/outgoing_mails.html create mode 100644 src/pretix/control/views/mail.py create mode 100644 src/pretix/static/pretixcontrol/js/ui/outgoingmail.js diff --git a/src/pretix/base/migrations/0297_outgoingmail.py b/src/pretix/base/migrations/0297_outgoingmail.py index edc5f36565..6f52877391 100644 --- a/src/pretix/base/migrations/0297_outgoingmail.py +++ b/src/pretix/base/migrations/0297_outgoingmail.py @@ -1,4 +1,5 @@ # Generated by Django 4.2.26 on 2026-01-22 13:44 +import uuid import django.db.models.deletion from django.conf import settings @@ -23,6 +24,7 @@ class Migration(migrations.Migration): auto_created=True, primary_key=True, serialize=False ), ), + ("guid", models.UUIDField(db_index=True, default=uuid.uuid4)), ("status", models.CharField(default="queued", max_length=200)), ("created", models.DateTimeField(auto_now_add=True)), ("sent", models.DateTimeField(blank=True, null=True)), diff --git a/src/pretix/base/models/mail.py b/src/pretix/base/models/mail.py index 50cd15d2cf..257125d165 100644 --- a/src/pretix/base/models/mail.py +++ b/src/pretix/base/models/mail.py @@ -19,6 +19,8 @@ # You should have received a copy of the GNU Affero General Public License along with this program. If not, see # . # +import uuid + from django.core.mail import get_connection from django.db import models from django.utils.translation import gettext_lazy as _ @@ -44,14 +46,17 @@ class OutgoingMail(models.Model): STATUS_AWAWITING_RETRY = "awaiting_retry" STATUS_FAILED = "failed" STATUS_SENT = "sent" + STATUS_BOUNCED = "bounced" STATUS_CHOICES = ( (STATUS_QUEUED, _("queued")), (STATUS_INFLIGHT, _("being sent")), (STATUS_AWAWITING_RETRY, _("awaiting retry")), (STATUS_FAILED, _("failed")), (STATUS_SENT, _("sent")), + (STATUS_BOUNCED, _("bounced")), ) + guid = models.UUIDField(db_index=True, default=uuid.uuid4) status = models.CharField(max_length=200, choices=STATUS_CHOICES, default=STATUS_QUEUED) created = models.DateTimeField(auto_now_add=True) sent = models.DateTimeField(null=True, blank=True) @@ -157,6 +162,10 @@ class OutgoingMail(models.Model): else: return scopes_disabled() # noqa + @property + def is_failed(self): + return self.status in (OutgoingMail.STATUS_FAILED, OutgoingMail.STATUS_AWAWITING_RETRY, OutgoingMail.STATUS_BOUNCED) + def save(self, *args, **kwargs): if self.orderposition_id and not self.order_id: self.order = self.orderposition.order diff --git a/src/pretix/base/services/mail.py b/src/pretix/base/services/mail.py index d0b9352b2f..498adcfb7b 100644 --- a/src/pretix/base/services/mail.py +++ b/src/pretix/base/services/mail.py @@ -39,6 +39,7 @@ import mimetypes import os import re import smtplib +import uuid import warnings from datetime import timedelta from email.mime.image import MIMEImage @@ -213,10 +214,12 @@ def mail(email: Union[str, Sequence[str]], subject: str, template: Union[str, La settings_holder = event or organizer headers = headers or {} + guid = uuid.uuid4() if auto_email: headers['X-Auto-Response-Suppress'] = 'OOF, NRN, AutoReply, RN' headers['Auto-Submitted'] = 'auto-generated' headers.setdefault('X-Mailer', 'pretix') + headers.setdefault('X-PX-Correlation', str(guid)) bcc = list(bcc or []) if settings_holder and settings_holder.settings.mail_bcc: @@ -301,9 +304,9 @@ def mail(email: Union[str, Sequence[str]], subject: str, template: Union[str, La orderposition=position, customer=customer, user=user, - to=[email] if isinstance(email, str) else list(email), - cc=cc or [], - bcc=bcc or [], + to=[email.lower()] if isinstance(email, str) else [e.lower() for e in email], + cc=[e.lower() for e in cc] if cc else [], + bcc=[e.lower() for e in bcc] if bcc else [], subject=subject, body_plain=body_plain, body_html=body_html, @@ -395,7 +398,6 @@ def mail_send_task(self, *args, outgoing_mail: int) -> bool: log_target, error_log_action_type = outgoing_mail.log_parameters() invoices_attached = [] - actual_attachments = [] with outgoing_mail.scope_manager(): # Attach tickets @@ -566,21 +568,21 @@ def mail_send_task(self, *args, outgoing_mail: int) -> bool: outgoing_mail.status = OutgoingMail.STATUS_AWAWITING_RETRY outgoing_mail.retry_after = now() + timedelta(seconds=retry_after) - outgoing_mail.save(update_fields=["status", "error", "error_detail", "sent", "retry_after"]) + outgoing_mail.save(update_fields=["status", "error", "error_detail", "sent", "retry_after", "actual_attachments"]) self.retry(max_retries=max_retries, countdown=retry_after) # throws RetryException, ends function flow elif retry_strategy in ("microsoft_concurrency", "quick"): max_retries = 5 retry_after = [10, 30, 60, 300, 900, 900][self.request.retries] outgoing_mail.status = OutgoingMail.STATUS_AWAWITING_RETRY outgoing_mail.retry_after = now() + timedelta(seconds=retry_after) - outgoing_mail.save(update_fields=["status", "error", "error_detail", "sent", "retry_after"]) + outgoing_mail.save(update_fields=["status", "error", "error_detail", "sent", "retry_after", "actual_attachments"]) self.retry(max_retries=max_retries, countdown=retry_after) # throws RetryException, ends function flow elif retry_strategy == "slow": retry_after = [60, 300, 600, 1200, 1800, 1800][self.request.retries] outgoing_mail.status = OutgoingMail.STATUS_AWAWITING_RETRY outgoing_mail.retry_after = now() + timedelta(seconds=retry_after) - outgoing_mail.save(update_fields=["status", "error", "error_detail", "sent", "retry_after"]) + outgoing_mail.save(update_fields=["status", "error", "error_detail", "sent", "retry_after", "actual_attachments"]) self.retry(max_retries=5, countdown=retry_after) # throws RetryException, ends function flow except MaxRetriesExceededError: @@ -605,14 +607,14 @@ def mail_send_task(self, *args, outgoing_mail: int) -> bool: outgoing_mail.status = OutgoingMail.STATUS_FAILED outgoing_mail.sent = now() outgoing_mail.retry_after = None - outgoing_mail.save(update_fields=["status", "error", "error_detail", "sent", "retry_after"]) + outgoing_mail.save(update_fields=["status", "error", "error_detail", "sent", "retry_after", "actual_attachments"]) return False # If we reach this, it's a non-retryable error outgoing_mail.status = OutgoingMail.STATUS_FAILED outgoing_mail.sent = now() outgoing_mail.retry_after = None - outgoing_mail.save(update_fields=["status", "error", "error_detail", "sent", "retry_after"]) + outgoing_mail.save(update_fields=["status", "error", "error_detail", "sent", "retry_after", "actual_attachments"]) for i in invoices_to_mark_transmitted: i.set_transmission_failed(provider="email_pdf", data={ "reason": "exception", @@ -634,7 +636,6 @@ def mail_send_task(self, *args, outgoing_mail: int) -> bool: outgoing_mail.status = OutgoingMail.STATUS_SENT outgoing_mail.error = None outgoing_mail.error_detail = None - outgoing_mail.actual_attachments = actual_attachments outgoing_mail.sent = now() outgoing_mail.retry_after = None outgoing_mail.save(update_fields=["status", "error", "error_detail", "sent", "actual_attachments", "retry_after"]) @@ -974,8 +975,13 @@ def retry_stuck_queued_mails(sender, **kwargs): return for m in OutgoingMail.objects.filter( - status=OutgoingMail.STATUS_QUEUED, - created__lt=now() - timedelta(hours=1), + Q( + status=OutgoingMail.STATUS_QUEUED, + created__lt=now() - timedelta(hours=1), + ) | Q( + status=OutgoingMail.STATUS_AWAWITING_RETRY, + retry_after__lt=now() - timedelta(hours=1), + ) ): mail_send_task.apply_async(kwargs={"outgoing_mail": m.pk}) diff --git a/src/pretix/base/services/notifications.py b/src/pretix/base/services/notifications.py index b63eefaeca..0a70e7c32d 100644 --- a/src/pretix/base/services/notifications.py +++ b/src/pretix/base/services/notifications.py @@ -19,6 +19,8 @@ # You should have received a copy of the GNU Affero General Public License along with this program. If not, see # . # +import uuid + import css_inline from django.conf import settings from django.template.loader import get_template @@ -155,7 +157,9 @@ def send_notification_mail(notification: Notification, user: User): tpl_plain = get_template('pretixbase/email/notification.txt') body_plain = tpl_plain.render(ctx) + guid = uuid.uuid4() m = OutgoingMail.objects.create( + guid=guid, user=user, to=[user.email], subject='[{}] {}: {}'.format( @@ -166,7 +170,12 @@ def send_notification_mail(notification: Notification, user: User): body_plain=body_plain, body_html=body_html, sender=settings.MAIL_FROM_NOTIFICATIONS, - headers={}, + headers={ + 'X-Auto-Response-Suppress': 'OOF, NRN, AutoReply, RN', + 'Auto-Submitted': 'auto-generated', + 'X-Mailer': 'pretix', + 'X-PX-Correlation': str(guid), + }, ) mail_send_task.apply_async(kwargs={ 'outgoing_mail': m.pk, diff --git a/src/pretix/control/forms/filter.py b/src/pretix/control/forms/filter.py index 0f3b2a1e44..258ce064ba 100644 --- a/src/pretix/control/forms/filter.py +++ b/src/pretix/control/forms/filter.py @@ -57,8 +57,9 @@ from pretix.base.forms.widgets import ( from pretix.base.models import ( Checkin, CheckinList, Device, Event, EventMetaProperty, EventMetaValue, Gate, Invoice, InvoiceAddress, Item, Order, OrderPayment, OrderPosition, - OrderRefund, Organizer, Question, QuestionAnswer, Quota, SalesChannel, - SubEvent, SubEventMetaValue, Team, TeamAPIToken, TeamInvite, Voucher, + OrderRefund, Organizer, OutgoingMail, Question, QuestionAnswer, Quota, + SalesChannel, SubEvent, SubEventMetaValue, Team, TeamAPIToken, TeamInvite, + Voucher, ) from pretix.base.signals import register_payment_providers from pretix.base.timeframes import ( @@ -2815,3 +2816,61 @@ class DeviceFilterForm(FilterForm): qs = qs.order_by('-device_id') return qs + + +class OutgoingMailFilterForm(FilterForm): + orders = { + 'date': 'd', + '-date': '-d', + } + query = forms.CharField( + label=_('Search email address or subject'), + widget=forms.TextInput(attrs={ + 'placeholder': _('Search email address or subject'), + }), + required=False + ) + event = forms.ModelChoiceField( + queryset=Event.objects.none(), + label=_('Event'), + empty_label=_('All events'), + required=False, + ) + status = forms.ChoiceField( + label=_('Status'), + choices=[ + ('', _('All')), + *OutgoingMail.STATUS_CHOICES, + ], + required=False + ) + + def __init__(self, *args, **kwargs): + request = kwargs.pop('request') + super().__init__(*args, **kwargs) + self.fields['event'].queryset = request.organizer.events.all() + + def filter_qs(self, qs): + fdata = self.cleaned_data + + if fdata.get('query'): + query = fdata.get('query') + qs = qs.filter( + Q(to__containsstring=query) + | Q(cc__containsstring=query) + | Q(bcc_containsstring=query) + | Q(subject__icontains=query) + ) + + if fdata.get('event'): + qs = qs.filter(event=fdata['event']) + + if fdata.get('status'): + qs = qs.filter(status=fdata['status']) + + if fdata.get('ordering'): + qs = qs.order_by(self.get_order_by()) + else: + qs = qs.order_by("-created", "-pk") + + return qs diff --git a/src/pretix/control/logdisplay.py b/src/pretix/control/logdisplay.py index 02ccce2e4c..c34cfa104a 100644 --- a/src/pretix/control/logdisplay.py +++ b/src/pretix/control/logdisplay.py @@ -699,6 +699,7 @@ class CoreUserImpersonatedLogEntryType(UserImpersonatedLogEntryType): 'pretix.organizer.export.schedule.deleted': _('A scheduled export has been deleted.'), 'pretix.organizer.export.schedule.executed': _('A scheduled export has been executed.'), 'pretix.organizer.export.schedule.failed': _('A scheduled export has failed: {reason}.'), + 'pretix.organizer.outgoingmails.retried': _('Failed emails have been scheduled to be retried.'), 'pretix.giftcards.acceptance.added': _('Gift card acceptance for another organizer has been added.'), 'pretix.giftcards.acceptance.removed': _('Gift card acceptance for another organizer has been removed.'), 'pretix.giftcards.acceptance.acceptor.invited': _('A new gift card acceptor has been invited.'), diff --git a/src/pretix/control/navigation.py b/src/pretix/control/navigation.py index c3a0cbb8f9..a6e3209788 100644 --- a/src/pretix/control/navigation.py +++ b/src/pretix/control/navigation.py @@ -679,6 +679,15 @@ def get_organizer_navigation(request): 'active': (url.url_name == 'organizer.datasync.failedjobs'), }]) + nav.append({ + 'label': _('Outgoing emails'), + 'url': reverse('control:organizer.outgoingmails', kwargs={ + 'organizer': request.organizer.slug, + }), + 'active': 'organizer.outgoingmail' in url.url_name, + 'icon': 'send', + }) + merge_in(nav, sorted( sum((list(a[1]) for a in nav_organizer.send(request.organizer, request=request, organizer=request.organizer)), []), diff --git a/src/pretix/control/templates/pretixcontrol/organizers/outgoing_mail.html b/src/pretix/control/templates/pretixcontrol/organizers/outgoing_mail.html new file mode 100644 index 0000000000..ae033f3043 --- /dev/null +++ b/src/pretix/control/templates/pretixcontrol/organizers/outgoing_mail.html @@ -0,0 +1,192 @@ +{% extends "pretixcontrol/organizers/base.html" %} +{% load i18n %} +{% load bootstrap3 %} +{% load urlreplace %} +{% load icon %} +{% load compress %} +{% load static %} +{% block inner %} +

+ {% trans "Outgoing email" %} +

+
+
+

{% trans "Email details" %}

+
+
+
+
+ +
+
{% trans "From" context "email" %}
+
{{ mail.sender }}
+
{% trans "To" context "email" %}
+
{{ mail.to|join:", " }}
+ {% if mail.cc %} +
{% trans "Cc" context "email" %}
+
{{ mail.cc|join:", " }}
+ {% endif %} + {% if mail.bcc %} +
{% trans "Bcc" context "email" %}
+
{{ mail.bcc|join:", " }}
+ {% endif %} +
{% trans "Subject" %}
+
{{ mail.subject }}
+
{% trans "Status" %}
+
+ {% if mail.status == "queued" %} + {% icon "clock" %} {% trans "queued" %} + {% elif mail.status == "inflight" %} + {% icon "send" %} {% trans "being sent" %} + {% elif mail.status == "awaiting_retry" %} + {% icon "repeat" %} {% trans "will be retried" %} + {% elif mail.status == "failed" %} + {% icon "warning" %} {% trans "failed" %} + {% elif mail.status == "bounced" %} + {% icon "ban" %} {% trans "bounced" %} + {% elif mail.status == "sent" %} + {% icon "check" %} {% trans "sent" %} + {% endif %} +
+
{% trans "Creation" %}
+
{{ mail.created|date:"SHORT_DATETIME_FORMAT" }}
+ {% if mail.sent %} +
{% trans "Sent" %}
+
{{ mail.sent|date:"SHORT_DATETIME_FORMAT" }}
+ {% endif %} + {% if mail.retry_after and mail.status == "awaiting_retry" %} +
{% trans "Next attempt (estimate)" %}
+
{{ mail.retry_after|date:"SHORT_DATETIME_FORMAT" }}
+ {% endif %} + {% if mail.event %} +
{% trans "Event" %}
+
+ + {{ mail.event }} + +
+ {% endif %} + {% if mail.order %} +
{% trans "Order" %}
+
+ + {{ mail.order.code }}{% if mail.orderposition %}- + {{ mail.orderposition.positionid }}{% endif %} +
+ {% endif %} + {% if mail.customer %} +
{% trans "Customer" %}
+
+ {% icon "user fa-fw" %} + + {{ mail.customer }} + +
+ {% endif %} +
+
+ {% if mail.actual_attachments %} +
+ {% trans "Attachments" %}
+
    + {% for a in mail.actual_attachments %} +
  • + {% if a.type == "text/calendar" %} + {% icon "calendar-plus-o fa-fw" %} + {% elif a.type == "application/pdf" %} + {% icon "file-pdf-o fa-fw" %} + {% elif "image/" in a.type %} + {% icon "file-image-o fa-fw" %} + {% elif "msword" in a.type or "document" in a.type %} + {% icon "file-word-o fa-fw" %} + {% elif "excel" in a.type or "spreadsheet" in a.type %} + {% icon "file-excel-o fa-fw" %} + {% elif "powerpoint" in a.type or "presentation" in a.type %} + {% icon "file-powerpoint-o fa-fw" %} + {% elif "pkpass" in a.type %} + {% icon "qrcode fa-fw" %} + {% else %} + {% icon "file-o fa-fw" %} + {% endif %} + {{ a.name }} + + ({{ a.size|filesizeformat }}) + +
  • + {% endfor %} +
+
+ {% endif %} +
+
+
+
+ + +
+ {% if mail.is_failed %} +
+ + {{ mail.error }} + +
{{ mail.error_detail }}
+
+ {% endif %} + {% if mail.body_html %} +
+ {{ data_url|json_script:"mail_body_html" }} +
+ {% endif %} +
+
{{ mail.body_plain }}
+
+
+
{% for k, v in mail.headers.items %}{{ k }}: {{ v }}
{% endfor %}
+

+ {% trans "Additional headers will be added by the mail server and are not visible here." %} +

+
+
+ +
+ {% compress js %} + + {% endcompress %} +{% endblock %} diff --git a/src/pretix/control/templates/pretixcontrol/organizers/outgoing_mails.html b/src/pretix/control/templates/pretixcontrol/organizers/outgoing_mails.html new file mode 100644 index 0000000000..1384e2a440 --- /dev/null +++ b/src/pretix/control/templates/pretixcontrol/organizers/outgoing_mails.html @@ -0,0 +1,174 @@ +{% extends "pretixcontrol/organizers/base.html" %} +{% load i18n %} +{% load bootstrap3 %} +{% load urlreplace %} +{% load icon %} +{% block inner %} +

+ {% trans "Outgoing emails" %} +

+

+ {% blocktrans trimmed with days=days %} + This is an overview of all emails sent by your organizer account in the last {{ days }} days. + {% endblocktrans %} +

+ {% if mails|length == 0 and not filter_form.filtered %} +
+

+ {% blocktrans trimmed %} + You haven't sent any emails recently. + {% endblocktrans %} +

+
+ {% else %} +
+
+

{% trans "Filter" %}

+
+
+
+
+ {% bootstrap_field filter_form.query %} +
+
+ {% bootstrap_field filter_form.status %} +
+
+ {% bootstrap_field filter_form.event %} +
+
+
+ +
+
+
+
+ {% csrf_token %} + {% for field in filter_form %} + {{ field.as_hidden }} + {% endfor %} +
+ + + + + + + + + + + + {% if page_obj.paginator.num_pages > 1 %} + + + + + {% endif %} + + + {% for m in mails %} + + + + + + + + + + {% endfor %} + +
+ + {% trans "Subject" %}{% trans "Recipients" %}{% trans "Context" %}{% trans "Status" %}{% trans "Date" %} + + +
+ + + + {{ m.subject }} + + + {{ m.to|join:", " }} + {% if m.cc %} +
{% trans "Cc" context "email" %}: {{ m.cc|join:", " }} + {% endif %} + {% if m.bcc %} +
{% trans "Bcc" context "email" %} {{ m.bcc|join:", " }} + {% endif %} +
+ {% if m.event %} +
+ {% icon "calendar fa-fw" %} + + {{ m.event }} + +
+ {% endif %} + {% if m.order %} +
+ {% icon "shopping-cart fa-fw" %} + + {{ m.order.code }}{% if m.orderposition %}-{{ m.orderposition.positionid }}{% endif %} +
+ {% endif %} + {% if m.customer %} +
+ {% icon "user fa-fw" %} + + {{ m.customer }} + +
+ {% endif %} +
+ {% if m.status == "queued" %} + {% icon "clock" %} {% trans "queued" %} + {% elif m.status == "inflight" %} + {% icon "send" %} {% trans "being sent" %} + {% elif m.status == "awaiting_retry" %} + {% icon "repeat" %} {% trans "will be retried" %} + {% elif m.status == "failed" %} + {% icon "warning" %} {% trans "failed" %} + {% elif m.status == "bounced" %} + {% icon "ban" %} {% trans "bounced" %} + {% elif m.status == "sent" %} + {% icon "check" %} {% trans "sent" %} + {% endif %} + + {{ m.created|date:"SHORT_DATETIME_FORMAT" }} + {% if m.sent %} +
+ {% trans "Sent:" %} {{ m.sent|date:"SHORT_DATETIME_FORMAT" }} + {% endif %} +
+ {% icon "eye" %} +
+
+
+ +
+
+ {% include "pretixcontrol/pagination.html" %} + {% endif %} +{% endblock %} diff --git a/src/pretix/control/urls.py b/src/pretix/control/urls.py index d310572e30..9d92c90815 100644 --- a/src/pretix/control/urls.py +++ b/src/pretix/control/urls.py @@ -38,8 +38,9 @@ from django.views.generic.base import RedirectView from pretix.control.views import ( auth, checkin, dashboards, datasync, discounts, event, geo, - global_settings, item, main, modelimport, oauth, orders, organizer, pdf, - search, shredder, subevents, typeahead, user, users, vouchers, waitinglist, + global_settings, item, mail, main, modelimport, oauth, orders, organizer, + pdf, search, shredder, subevents, typeahead, user, users, vouchers, + waitinglist, ) urlpatterns = [ @@ -240,6 +241,9 @@ urlpatterns = [ name='organizer.gate.edit'), re_path(r'^organizer/(?P[^/]+)/gate/(?P[^/]+)/delete$', organizer.GateDeleteView.as_view(), name='organizer.gate.delete'), + re_path(r'^organizer/(?P[^/]+)/outgoingmails$', mail.OutgoingMailListView.as_view(), name='organizer.outgoingmails'), + re_path(r'^organizer/(?P[^/]+)/outgoingmail/bulk_action$', mail.OutgoingMailBulkAction.as_view(), name='organizer.outgoingmails.bulk_action'), + re_path(r'^organizer/(?P[^/]+)/outgoingmail/(?P[0-9]+)/$', mail.OutgoingMailDetailView.as_view(), name='organizer.outgoingmail'), re_path(r'^organizer/(?P[^/]+)/teams$', organizer.TeamListView.as_view(), name='organizer.teams'), re_path(r'^organizer/(?P[^/]+)/team/add$', organizer.TeamCreateView.as_view(), name='organizer.team.add'), re_path(r'^organizer/(?P[^/]+)/team/(?P[^/]+)/$', organizer.TeamMemberView.as_view(), diff --git a/src/pretix/control/views/mail.py b/src/pretix/control/views/mail.py new file mode 100644 index 0000000000..a8a439bb41 --- /dev/null +++ b/src/pretix/control/views/mail.py @@ -0,0 +1,159 @@ +# +# This file is part of pretix (Community Edition). +# +# Copyright (C) 2014-2020 Raphael Michel and contributors +# Copyright (C) 2020-today pretix GmbH and contributors +# +# This program 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 in version 3 of the License. +# +# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are +# applicable granting you additional permissions and placing additional restrictions on your usage of this software. +# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive +# this file, see . +# +# 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 Affero General Public License for more +# details. +# +# You should have received a copy of the GNU Affero General Public License along with this program. If not, see +# . +# +import base64 +import logging + +from django.conf import settings +from django.contrib import messages +from django.core.exceptions import BadRequest +from django.db import transaction +from django.shortcuts import get_object_or_404, redirect +from django.urls import reverse +from django.utils.functional import cached_property +from django.utils.translation import ngettext +from django.views import View +from django.views.generic import DetailView, ListView + +from pretix.base.middleware import _merge_csp, _parse_csp, _render_csp +from pretix.base.models import OutgoingMail +from pretix.base.services.mail import mail_send_task +from pretix.control.forms.filter import OutgoingMailFilterForm +from pretix.control.permissions import OrganizerPermissionRequiredMixin +from pretix.control.views.organizer import OrganizerDetailViewMixin + +logger = logging.getLogger(__name__) + + +class OutgoingMailQueryMixin: + + @cached_property + def request_data(self): + if self.request.method == "POST": + d = self.request.POST + else: + d = self.request.GET + d = d.copy() + return d + + @cached_property + def filter_form(self): + return OutgoingMailFilterForm( + data=self.request_data, + request=self.request, + ) + + def get_queryset(self): + qs = self.request.organizer.outgoing_mails.select_related( + 'event', 'order', 'orderposition', 'customer' + ) + + if 'outgoingmail' in self.request_data and '__ALL' not in self.request_data: + qs = qs.filter( + id__in=self.request_data.getlist('outgoingmail') + ) + elif self.request.method == 'GET' or '__ALL' in self.request_data: + if self.filter_form.is_valid(): + qs = self.filter_form.filter_qs(qs) + else: + raise BadRequest("No mails selected") + + return qs + + +class OutgoingMailListView(OutgoingMailQueryMixin, OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin, ListView): + model = OutgoingMail + template_name = 'pretixcontrol/organizers/outgoing_mails.html' + # Assume "the highest" permission level for now because emails could belog to any event, order, or customer. + # We plan to add a special permissoin in the future + permission = 'can_change_organizer_settings' + context_object_name = 'mails' + paginate_by = 100 + + def get_context_data(self, **kwargs): + ctx = super().get_context_data(**kwargs) + ctx['filter_form'] = self.filter_form + ctx['days'] = int(settings.OUTGOING_MAIL_RETENTION / (24 * 3600)) + return ctx + + +class OutgoingMailDetailView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin, DetailView): + model = OutgoingMail + template_name = 'pretixcontrol/organizers/outgoing_mail.html' + permission = 'can_change_organizer_settings' + context_object_name = 'mail' + + def get_object(self, queryset=None): + return get_object_or_404(OutgoingMail, organizer=self.request.organizer, pk=self.kwargs.get('mail')) + + def dispatch(self, request, *args, **kwargs): + response = super().dispatch(request, *args, **kwargs) + if 'Content-Security-Policy' in response: + h = _parse_csp(response['Content-Security-Policy']) + else: + h = {} + csps = { + 'frame-src': ['data:'], + } + _merge_csp(h, csps) + response['Content-Security-Policy'] = _render_csp(h) + return response + + def get_context_data(self, **kwargs): + ctx = super().get_context_data(**kwargs) + if self.object.body_html: + ctx['data_url'] = "data:text/html;charset=utf-8;base64," + base64.b64encode(self.object.body_html.encode()).decode() + return ctx + + +class OutgoingMailBulkAction(OutgoingMailQueryMixin, OrganizerPermissionRequiredMixin, OrganizerDetailViewMixin, View): + permission = 'can_change_organizer_settings' + + @transaction.atomic + def post(self, request, *args, **kwargs): + if request.POST.get('action') == 'retry': + ids = set( + self.get_queryset().filter(status=OutgoingMail.STATUS_FAILED).values_list("pk", flat=True) + ) + with transaction.atomic(): + OutgoingMail.objects.filter(pk__in=ids).update( + status=OutgoingMail.STATUS_QUEUED, + sent=None, + ) + self.request.organizer.log_action( + 'pretix.organizer.outgoingmails.retried', user=self.request.user, data={ + 'mails': list(ids) + }, save=False + ) + for i in ids: + mail_send_task.apply_async(kwargs={"outgoing_mail": i}) + + messages.success(request, ngettext( + "A retry of one email was scheduled.", + "A retry of {num} emails was scheduled.", + len(ids), + ).format(num=len(ids))) + return redirect(self.get_success_url()) + + def get_success_url(self) -> str: + return reverse('control:organizer.outgoingmails', kwargs={ + 'organizer': self.request.organizer.slug, + }) diff --git a/src/pretix/helpers/database.py b/src/pretix/helpers/database.py index 6caf7499f6..1289f4f1af 100644 --- a/src/pretix/helpers/database.py +++ b/src/pretix/helpers/database.py @@ -25,7 +25,7 @@ from django.conf import settings from django.core.exceptions import FieldDoesNotExist, ImproperlyConfigured from django.db import connection, transaction from django.db.models import ( - Aggregate, Expression, F, Field, Lookup, OrderBy, Value, + Aggregate, Expression, F, Field, JSONField, Lookup, OrderBy, Value, ) from django.utils.functional import lazy @@ -154,6 +154,19 @@ class NotEqual(Lookup): return '%s <> %s' % (lhs, rhs), params +@JSONField.register_lookup +class ContainsString(Lookup): + lookup_name = 'containsstring' + + def as_sql(self, compiler, connection): + if connection.vendor != "postgresql": + raise NotImplementedError("Lookup in JSON Array not supported on this database") + lhs, lhs_params = self.process_lhs(compiler, connection) + rhs, rhs_params = self.process_rhs(compiler, connection) + params = lhs_params + rhs_params + return '%s ? %s' % (lhs, rhs), params + + class PostgresWindowFrame(Expression): template = "%(frame_type)s BETWEEN %(start)s AND %(end)s" diff --git a/src/pretix/static/pretixcontrol/js/ui/outgoingmail.js b/src/pretix/static/pretixcontrol/js/ui/outgoingmail.js new file mode 100644 index 0000000000..d82803bafb --- /dev/null +++ b/src/pretix/static/pretixcontrol/js/ui/outgoingmail.js @@ -0,0 +1,42 @@ +function is_sandbox_supported() { + const iframe = document.createElement('iframe'); + return 'sandbox' in iframe; +} + +function safe_render(url, parent) { + const height = ( + window.innerHeight - parent.parent().get(0).getBoundingClientRect().top - document.querySelector("footer").getBoundingClientRect().height - 20 + ) + "px"; + + const iframe = ( + // Per the HTML spec, a data: URL in an iframe is treated as its own origin: + // https://github.com/whatwg/html/pull/1756 + // It is unclear, if Firefox complies, and the behaviour around data URLs is quite wild: + // https://github.com/whatwg/html/issues/12091 + // Together with the sandbox attribute disallowing all JavaScript, and the fact + // that we sanitize the HTML before we even save it to the database, this should + // still be the safest way to render HTML in the context of our backend. + $("