mirror of
https://github.com/pretix/pretix.git
synced 2026-05-21 17:54:08 +00:00
Outbox view
This commit is contained in:
@@ -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)),
|
||||
|
||||
@@ -19,6 +19,8 @@
|
||||
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
|
||||
# <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
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
|
||||
|
||||
@@ -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})
|
||||
|
||||
|
||||
@@ -19,6 +19,8 @@
|
||||
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
|
||||
# <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.'),
|
||||
|
||||
@@ -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)),
|
||||
[]),
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
{% extends "pretixcontrol/organizers/base.html" %}
|
||||
{% load i18n %}
|
||||
{% load bootstrap3 %}
|
||||
{% load urlreplace %}
|
||||
{% load icon %}
|
||||
{% load compress %}
|
||||
{% load static %}
|
||||
{% block inner %}
|
||||
<h1>
|
||||
{% trans "Outgoing email" %}
|
||||
</h1>
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
<h3 class="panel-title">{% trans "Email details" %}</h3>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<div class="row">
|
||||
<div class="col-lg-7 col-md-12">
|
||||
|
||||
<dl class="dl-horizontal">
|
||||
<dt>{% trans "From" context "email" %}</dt>
|
||||
<dd>{{ mail.sender }}</dd>
|
||||
<dt>{% trans "To" context "email" %}</dt>
|
||||
<dd>{{ mail.to|join:", " }}</dd>
|
||||
{% if mail.cc %}
|
||||
<dt>{% trans "Cc" context "email" %}</dt>
|
||||
<dd>{{ mail.cc|join:", " }}</dd>
|
||||
{% endif %}
|
||||
{% if mail.bcc %}
|
||||
<dt>{% trans "Bcc" context "email" %}</dt>
|
||||
<dd>{{ mail.bcc|join:", " }}</dd>
|
||||
{% endif %}
|
||||
<dt>{% trans "Subject" %}</dt>
|
||||
<dd>{{ mail.subject }}</dd>
|
||||
<dt>{% trans "Status" %}</dt>
|
||||
<dd>
|
||||
{% if mail.status == "queued" %}
|
||||
<span class="label label-info">{% icon "clock" %} {% trans "queued" %}</span>
|
||||
{% elif mail.status == "inflight" %}
|
||||
<span class="label label-info">{% icon "send" %} {% trans "being sent" %}</span>
|
||||
{% elif mail.status == "awaiting_retry" %}
|
||||
<span class="label label-warning">{% icon "repeat" %} {% trans "will be retried" %}</span>
|
||||
{% elif mail.status == "failed" %}
|
||||
<span class="label label-danger">{% icon "warning" %} {% trans "failed" %}</span>
|
||||
{% elif mail.status == "bounced" %}
|
||||
<span class="label label-danger">{% icon "ban" %} {% trans "bounced" %}</span>
|
||||
{% elif mail.status == "sent" %}
|
||||
<span class="label label-success">{% icon "check" %} {% trans "sent" %}</span>
|
||||
{% endif %}
|
||||
</dd>
|
||||
<dt>{% trans "Creation" %}</dt>
|
||||
<dd>{{ mail.created|date:"SHORT_DATETIME_FORMAT" }}</dd>
|
||||
{% if mail.sent %}
|
||||
<dt>{% trans "Sent" %}</dt>
|
||||
<dd>{{ mail.sent|date:"SHORT_DATETIME_FORMAT" }}</dd>
|
||||
{% endif %}
|
||||
{% if mail.retry_after and mail.status == "awaiting_retry" %}
|
||||
<dt>{% trans "Next attempt (estimate)" %}</dt>
|
||||
<dd>{{ mail.retry_after|date:"SHORT_DATETIME_FORMAT" }}</dd>
|
||||
{% endif %}
|
||||
{% if mail.event %}
|
||||
<dt>{% trans "Event" %}</dt>
|
||||
<dd>
|
||||
<a href="{% url "control:event.index" organizer=request.organizer.slug event=mail.event.slug %}">
|
||||
{{ mail.event }}
|
||||
</a>
|
||||
</dd>
|
||||
{% endif %}
|
||||
{% if mail.order %}
|
||||
<dt>{% trans "Order" %}</dt>
|
||||
<dd>
|
||||
<a href="{% url "control:event.order" organizer=request.organizer.slug event=mail.event.slug code=mail.order.code %}">
|
||||
{{ mail.order.code }}</a>{% if mail.orderposition %}-
|
||||
{{ mail.orderposition.positionid }}{% endif %}
|
||||
</dd>
|
||||
{% endif %}
|
||||
{% if mail.customer %}
|
||||
<dt>{% trans "Customer" %}</dt>
|
||||
<dd>
|
||||
{% icon "user fa-fw" %}
|
||||
<a href="{% url "control:organizer.customer" organizer=request.organizer.slug customer=mail.customer.identifier %}">
|
||||
{{ mail.customer }}
|
||||
</a>
|
||||
</dd>
|
||||
{% endif %}
|
||||
</dl>
|
||||
</div>
|
||||
{% if mail.actual_attachments %}
|
||||
<div class="col-lg-5 col-md-12">
|
||||
<strong>{% trans "Attachments" %}</strong><br>
|
||||
<ul class="list-unstyled">
|
||||
{% for a in mail.actual_attachments %}
|
||||
<li>
|
||||
{% 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 }}
|
||||
<span class="text-muted">
|
||||
({{ a.size|filesizeformat }})
|
||||
</span>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<ul class="nav nav-tabs" role="tablist">
|
||||
{% if mail.is_failed %}
|
||||
<li role="presentation" class="active">
|
||||
<a href="#tab-error" role="tab" data-toggle="tab">
|
||||
<span class="fa fa-warning"></span>
|
||||
{% trans "Error" %}
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if mail.body_html %}
|
||||
<li role="presentation"
|
||||
{% if not mail.is_failed %}class="active"{% endif %}>
|
||||
<a href="#tab-html" role="tab" data-toggle="tab">
|
||||
<span class="fa fa-eye"></span>
|
||||
{% trans "HTML content" %}
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
<li role="presentation"
|
||||
{% if not mail.is_failed and not mail.body_html %}class="active"{% endif %}>
|
||||
<a href="#tab-text" role="tab" data-toggle="tab">
|
||||
<span class="fa fa-file-text-o"></span>
|
||||
{% trans "Text content" %}
|
||||
</a>
|
||||
</li>
|
||||
<li role="presentation">
|
||||
<a href="#tab-headers" role="tab" data-toggle="tab">
|
||||
<span class="fa fa-code"></span>
|
||||
{% trans "Headers" %}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="tab-content">
|
||||
{% if mail.is_failed %}
|
||||
<div role="tabpanel" class="tab-pane active" id="tab-error">
|
||||
<strong>
|
||||
{{ mail.error }}
|
||||
</strong>
|
||||
<pre>{{ mail.error_detail }}</pre>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if mail.body_html %}
|
||||
<div role="tabpanel"
|
||||
class="tab-pane {% if not mail.is_failed %}active{% endif %}"
|
||||
id="tab-html">
|
||||
{{ data_url|json_script:"mail_body_html" }}
|
||||
</div>
|
||||
{% endif %}
|
||||
<div role="tabpanel"
|
||||
class="tab-pane {% if not mail.is_failed and not mail.body_html %}active{% endif %}"
|
||||
id="tab-text">
|
||||
<pre><code>{{ mail.body_plain }}</code></pre>
|
||||
</div>
|
||||
<div role="tabpanel"
|
||||
class="tab-pane"
|
||||
id="tab-headers">
|
||||
<pre><code>{% for k, v in mail.headers.items %}{{ k }}: {{ v }}<br>{% endfor %}</code></pre>
|
||||
<p class="text-muted">
|
||||
{% trans "Additional headers will be added by the mail server and are not visible here." %}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
{% compress js %}
|
||||
<script type="text/javascript" src="{% static "pretixcontrol/js/ui/outgoingmail.js" %}"></script>
|
||||
{% endcompress %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,174 @@
|
||||
{% extends "pretixcontrol/organizers/base.html" %}
|
||||
{% load i18n %}
|
||||
{% load bootstrap3 %}
|
||||
{% load urlreplace %}
|
||||
{% load icon %}
|
||||
{% block inner %}
|
||||
<h1>
|
||||
{% trans "Outgoing emails" %}
|
||||
</h1>
|
||||
<p>
|
||||
{% blocktrans trimmed with days=days %}
|
||||
This is an overview of all emails sent by your organizer account in the last {{ days }} days.
|
||||
{% endblocktrans %}
|
||||
</p>
|
||||
{% if mails|length == 0 and not filter_form.filtered %}
|
||||
<div class="empty-collection">
|
||||
<p>
|
||||
{% blocktrans trimmed %}
|
||||
You haven't sent any emails recently.
|
||||
{% endblocktrans %}
|
||||
</p>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
<h3 class="panel-title">{% trans "Filter" %}</h3>
|
||||
</div>
|
||||
<form class="panel-body filter-form" action="" method="get">
|
||||
<div class="row">
|
||||
<div class="col-md-4 col-sm-6 col-xs-12">
|
||||
{% bootstrap_field filter_form.query %}
|
||||
</div>
|
||||
<div class="col-md-3 col-sm-6 col-xs-12">
|
||||
{% bootstrap_field filter_form.status %}
|
||||
</div>
|
||||
<div class="col-md-5 col-sm-6 col-xs-12">
|
||||
{% bootstrap_field filter_form.event %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<button class="btn btn-primary btn-lg" type="submit">
|
||||
<span class="fa fa-filter"></span>
|
||||
{% trans "Filter" %}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<form action="{% url "control:organizer.outgoingmails.bulk_action" organizer=request.organizer.slug %}" method="post">
|
||||
{% csrf_token %}
|
||||
{% for field in filter_form %}
|
||||
{{ field.as_hidden }}
|
||||
{% endfor %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-condensed table-hover table-quotas">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>
|
||||
<label aria-label="{% trans "select all rows for batch-operation" %}"
|
||||
class="batch-select-label"><input type="checkbox" data-toggle-table/></label>
|
||||
</th>
|
||||
<th>{% trans "Subject" %}</th>
|
||||
<th>{% trans "Recipients" %}</th>
|
||||
<th>{% trans "Context" %}</th>
|
||||
<th>{% trans "Status" %}</th>
|
||||
<th>{% trans "Date" %}
|
||||
<a href="?{% url_replace request 'ordering' '-date' %}"><i
|
||||
class="fa fa-caret-down"></i></a>
|
||||
<a href="?{% url_replace request 'ordering' 'date' %}"><i class="fa fa-caret-up"></i></a>
|
||||
</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
{% if page_obj.paginator.num_pages > 1 %}
|
||||
<tr class="table-select-all warning hidden">
|
||||
<td>
|
||||
<input type="checkbox" name="__ALL" id="__all"
|
||||
data-results-total="{{ page_obj.paginator.count }}">
|
||||
</td>
|
||||
<td colspan="7">
|
||||
<label for="__all">
|
||||
{% trans "Select all results on other pages as well" %}
|
||||
</label>
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for m in mails %}
|
||||
<tr>
|
||||
<td>
|
||||
<label aria-label="{% trans "select row for batch-operation" %}"
|
||||
class="batch-select-label"><input type="checkbox" name="outgoingmail"
|
||||
class="batch-select-checkbox"
|
||||
value="{{ m.pk }}"/></label>
|
||||
</td>
|
||||
<td>
|
||||
<a href="{% url "control:organizer.outgoingmail" organizer=request.organizer.slug mail=m.id %}">
|
||||
{{ m.subject }}
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
{{ m.to|join:", " }}
|
||||
{% if m.cc %}
|
||||
<br><small class="text-muted">{% trans "Cc" context "email" %}: {{ m.cc|join:", " }}</small>
|
||||
{% endif %}
|
||||
{% if m.bcc %}
|
||||
<br><small class="text-muted">{% trans "Bcc" context "email" %} {{ m.bcc|join:", " }}</small>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if m.event %}
|
||||
<div>
|
||||
{% icon "calendar fa-fw" %}
|
||||
<a href="{% url "control:event.index" organizer=request.organizer.slug event=m.event.slug %}">
|
||||
{{ m.event }}
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if m.order %}
|
||||
<div>
|
||||
{% icon "shopping-cart fa-fw" %}
|
||||
<a href="{% url "control:event.order" organizer=request.organizer.slug event=m.event.slug code=m.order.code %}">
|
||||
{{ m.order.code }}</a>{% if m.orderposition %}-{{ m.orderposition.positionid }}{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if m.customer %}
|
||||
<div>
|
||||
{% icon "user fa-fw" %}
|
||||
<a href="{% url "control:organizer.customer" organizer=request.organizer.slug customer=m.customer.identifier %}">
|
||||
{{ m.customer }}
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if m.status == "queued" %}
|
||||
<span class="label label-info">{% icon "clock" %} {% trans "queued" %}</span>
|
||||
{% elif m.status == "inflight" %}
|
||||
<span class="label label-info">{% icon "send" %} {% trans "being sent" %}</span>
|
||||
{% elif m.status == "awaiting_retry" %}
|
||||
<span class="label label-warning">{% icon "repeat" %} {% trans "will be retried" %}</span>
|
||||
{% elif m.status == "failed" %}
|
||||
<span class="label label-danger">{% icon "warning" %} {% trans "failed" %}</span>
|
||||
{% elif m.status == "bounced" %}
|
||||
<span class="label label-danger">{% icon "ban" %} {% trans "bounced" %}</span>
|
||||
{% elif m.status == "sent" %}
|
||||
<span class="label label-success">{% icon "check" %} {% trans "sent" %}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{{ m.created|date:"SHORT_DATETIME_FORMAT" }}
|
||||
{% if m.sent %}
|
||||
<br>
|
||||
<small class="text-muted">{% trans "Sent:" %} {{ m.sent|date:"SHORT_DATETIME_FORMAT" }}</small>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="text-right flip">
|
||||
<a href="{% url "control:organizer.outgoingmail" organizer=request.organizer.slug mail=m.id %}"
|
||||
class="btn btn-default btn-sm">{% icon "eye" %}</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="batch-select-actions">
|
||||
<button type="submit" class="btn btn-primary btn-save" name="action" value="retry">
|
||||
{% icon "repeat" %}
|
||||
{% trans "Retry failed" %}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{% include "pretixcontrol/pagination.html" %}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -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<organizer>[^/]+)/gate/(?P<gate>[^/]+)/delete$', organizer.GateDeleteView.as_view(),
|
||||
name='organizer.gate.delete'),
|
||||
re_path(r'^organizer/(?P<organizer>[^/]+)/outgoingmails$', mail.OutgoingMailListView.as_view(), name='organizer.outgoingmails'),
|
||||
re_path(r'^organizer/(?P<organizer>[^/]+)/outgoingmail/bulk_action$', mail.OutgoingMailBulkAction.as_view(), name='organizer.outgoingmails.bulk_action'),
|
||||
re_path(r'^organizer/(?P<organizer>[^/]+)/outgoingmail/(?P<mail>[0-9]+)/$', mail.OutgoingMailDetailView.as_view(), name='organizer.outgoingmail'),
|
||||
re_path(r'^organizer/(?P<organizer>[^/]+)/teams$', organizer.TeamListView.as_view(), name='organizer.teams'),
|
||||
re_path(r'^organizer/(?P<organizer>[^/]+)/team/add$', organizer.TeamCreateView.as_view(), name='organizer.team.add'),
|
||||
re_path(r'^organizer/(?P<organizer>[^/]+)/team/(?P<team>[^/]+)/$', organizer.TeamMemberView.as_view(),
|
||||
|
||||
159
src/pretix/control/views/mail.py
Normal file
159
src/pretix/control/views/mail.py
Normal file
@@ -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 <https://pretix.eu/about/en/license>.
|
||||
#
|
||||
# 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
|
||||
# <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
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,
|
||||
})
|
||||
@@ -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"
|
||||
|
||||
|
||||
42
src/pretix/static/pretixcontrol/js/ui/outgoingmail.js
Normal file
42
src/pretix/static/pretixcontrol/js/ui/outgoingmail.js
Normal file
@@ -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.
|
||||
$("<iframe>")
|
||||
.height(height)
|
||||
.attr("class", "html-email")
|
||||
.attr("src", url)
|
||||
.attr("sandbox", "allow-popups allow-popups-to-escape-sandbox")
|
||||
.attr("csp", "script-src 'none'; font-src 'none'; connect-src 'none'; form-action 'none'") // respected only by chrome
|
||||
.prop("credentialless", true) // respected only by chrome
|
||||
);
|
||||
|
||||
console.log(parent, iframe);
|
||||
parent.append(iframe);
|
||||
}
|
||||
|
||||
$(function () {
|
||||
const script_element = $("#mail_body_html");
|
||||
if (!script_element.length) return;
|
||||
if (!is_sandbox_supported()) {
|
||||
// Browser is too old for <iframe sandbox>
|
||||
$(script_element.parent()).text("Please switch to a modern browser to view HTML content safely.");
|
||||
return;
|
||||
}
|
||||
|
||||
safe_render(JSON.parse(script_element.html()), script_element.parent());
|
||||
});
|
||||
@@ -91,3 +91,8 @@ div.mail-preview {
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
|
||||
iframe.html-email {
|
||||
border: 0;
|
||||
width: 100%;
|
||||
}
|
||||
Reference in New Issue
Block a user