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 }}
+
+
+
+
+
+ {% 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" %}
+
+
+
+
+ {% 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.
+ $("