mirror of
https://github.com/pretix/pretix.git
synced 2026-05-05 15:14:04 +00:00
Model-based mail queuing
This commit is contained in:
@@ -66,7 +66,6 @@ from pretix.base.forms.auth import (
|
||||
)
|
||||
from pretix.base.metrics import pretix_failed_logins, pretix_successful_logins
|
||||
from pretix.base.models import TeamInvite, U2FDevice, User, WebAuthnDevice
|
||||
from pretix.base.services.mail import SendMailException
|
||||
from pretix.helpers.http import get_client_ip, redirect_to_url
|
||||
from pretix.helpers.security import handle_login_source
|
||||
|
||||
@@ -347,9 +346,6 @@ class Forgot(TemplateView):
|
||||
except User.DoesNotExist:
|
||||
logger.warning('Backend password reset for unregistered e-mail \"' + email + '\" requested.')
|
||||
|
||||
except SendMailException:
|
||||
logger.exception('Sending password reset email to \"' + email + '\" failed.')
|
||||
|
||||
except RepeatedResetDenied:
|
||||
pass
|
||||
|
||||
|
||||
183
src/pretix/control/views/mail.py
Normal file
183
src/pretix/control/views/mail.py
Normal file
@@ -0,0 +1,183 @@
|
||||
#
|
||||
# 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__in=OutgoingMail.STATUS_LIST_RETRYABLE).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)))
|
||||
elif request.POST.get('action') == 'abort':
|
||||
ids = set(
|
||||
self.get_queryset().filter(
|
||||
status__in=(OutgoingMail.STATUS_QUEUED, OutgoingMail.STATUS_AWAITING_RETRY)
|
||||
).values_list("pk", flat=True)
|
||||
)
|
||||
with transaction.atomic():
|
||||
OutgoingMail.objects.filter(pk__in=ids).update(
|
||||
status=OutgoingMail.STATUS_ABORTED,
|
||||
sent=None,
|
||||
)
|
||||
self.request.organizer.log_action(
|
||||
'pretix.organizer.outgoingmails.aborted', 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(
|
||||
"One email was aborted and will not be sent.",
|
||||
"{num} emails were aborted and will not be sent.",
|
||||
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,
|
||||
})
|
||||
@@ -98,9 +98,7 @@ from pretix.base.services.invoices import (
|
||||
invoice_qualified, regenerate_invoice, transmit_invoice,
|
||||
)
|
||||
from pretix.base.services.locking import LockTimeoutException
|
||||
from pretix.base.services.mail import (
|
||||
SendMailException, prefix_subject, render_mail,
|
||||
)
|
||||
from pretix.base.services.mail import prefix_subject, render_mail
|
||||
from pretix.base.services.orders import (
|
||||
OrderChangeManager, OrderError, approve_order, cancel_order, deny_order,
|
||||
extend_order, mark_order_expired, mark_order_refunded,
|
||||
@@ -1066,10 +1064,6 @@ class OrderPaymentConfirm(OrderView):
|
||||
messages.error(self.request, str(e))
|
||||
except PaymentException as e:
|
||||
messages.error(self.request, str(e))
|
||||
except SendMailException:
|
||||
messages.warning(self.request,
|
||||
_('The payment has been marked as complete, but we were unable to send a '
|
||||
'confirmation mail.'))
|
||||
else:
|
||||
messages.success(self.request, _('The payment has been marked as complete.'))
|
||||
else:
|
||||
@@ -1540,9 +1534,6 @@ class OrderTransition(OrderView):
|
||||
'message': str(e)
|
||||
})
|
||||
messages.error(self.request, str(e))
|
||||
except SendMailException:
|
||||
messages.warning(self.request, _('The order has been marked as paid, but we were unable to send a '
|
||||
'confirmation mail.'))
|
||||
else:
|
||||
messages.success(self.request, _('The payment has been created successfully.'))
|
||||
elif self.order.cancel_allowed() and to == 'c':
|
||||
@@ -1781,15 +1772,11 @@ class OrderResendLink(OrderView):
|
||||
permission = 'can_change_orders'
|
||||
|
||||
def post(self, *args, **kwargs):
|
||||
try:
|
||||
if 'position' in kwargs:
|
||||
p = get_object_or_404(self.order.positions, pk=kwargs['position'])
|
||||
p.resend_link(user=self.request.user)
|
||||
else:
|
||||
self.order.resend_link(user=self.request.user)
|
||||
except SendMailException:
|
||||
messages.error(self.request, _('There was an error sending the mail. Please try again later.'))
|
||||
return redirect(self.get_order_url())
|
||||
if 'position' in kwargs:
|
||||
p = get_object_or_404(self.order.positions, pk=kwargs['position'])
|
||||
p.resend_link(user=self.request.user)
|
||||
else:
|
||||
self.order.resend_link(user=self.request.user)
|
||||
|
||||
messages.success(self.request, _('The email has been queued to be sent.'))
|
||||
return redirect(self.get_order_url())
|
||||
@@ -2433,24 +2420,18 @@ class OrderSendMail(EventPermissionRequiredMixin, OrderViewMixin, FormView):
|
||||
}
|
||||
return self.get(self.request, *self.args, **self.kwargs)
|
||||
else:
|
||||
try:
|
||||
order.send_mail(
|
||||
form.cleaned_data['subject'], email_template,
|
||||
email_context, 'pretix.event.order.email.custom_sent',
|
||||
self.request.user, auto_email=False,
|
||||
attach_tickets=form.cleaned_data.get('attach_tickets', False),
|
||||
invoices=form.cleaned_data.get('attach_invoices', []),
|
||||
attach_other_files=[a for a in [
|
||||
self.request.event.settings.get('mail_attachment_new_order', as_type=str, default='')[len('file://'):]
|
||||
] if a] if form.cleaned_data.get('attach_new_order', False) else [],
|
||||
)
|
||||
messages.success(self.request,
|
||||
_('Your message has been queued and will be sent to {}.'.format(order.email)))
|
||||
except SendMailException:
|
||||
messages.error(
|
||||
self.request,
|
||||
_('Failed to send mail to the following user: {}'.format(order.email))
|
||||
)
|
||||
order.send_mail(
|
||||
form.cleaned_data['subject'], email_template,
|
||||
email_context, 'pretix.event.order.email.custom_sent',
|
||||
self.request.user, auto_email=False,
|
||||
attach_tickets=form.cleaned_data.get('attach_tickets', False),
|
||||
invoices=form.cleaned_data.get('attach_invoices', []),
|
||||
attach_other_files=[a for a in [
|
||||
self.request.event.settings.get('mail_attachment_new_order', as_type=str, default='')[len('file://'):]
|
||||
] if a] if form.cleaned_data.get('attach_new_order', False) else [],
|
||||
)
|
||||
messages.success(self.request,
|
||||
_('Your message has been queued and will be sent to {}.'.format(order.email)))
|
||||
return super(OrderSendMail, self).form_valid(form)
|
||||
|
||||
def get_success_url(self):
|
||||
@@ -2503,23 +2484,19 @@ class OrderPositionSendMail(OrderSendMail):
|
||||
}
|
||||
return self.get(self.request, *self.args, **self.kwargs)
|
||||
else:
|
||||
try:
|
||||
position.send_mail(
|
||||
form.cleaned_data['subject'],
|
||||
email_template,
|
||||
email_context,
|
||||
'pretix.event.order.position.email.custom_sent',
|
||||
self.request.user,
|
||||
attach_tickets=form.cleaned_data.get('attach_tickets', False),
|
||||
attach_other_files=[a for a in [
|
||||
self.request.event.settings.get('mail_attachment_new_order', as_type=str, default='')[len('file://'):]
|
||||
] if a] if form.cleaned_data.get('attach_new_order', False) else [],
|
||||
)
|
||||
messages.success(self.request,
|
||||
_('Your message has been queued and will be sent to {}.'.format(position.attendee_email)))
|
||||
except SendMailException:
|
||||
messages.error(self.request,
|
||||
_('Failed to send mail to the following user: {}'.format(position.attendee_email)))
|
||||
position.send_mail(
|
||||
form.cleaned_data['subject'],
|
||||
email_template,
|
||||
email_context,
|
||||
'pretix.event.order.position.email.custom_sent',
|
||||
self.request.user,
|
||||
attach_tickets=form.cleaned_data.get('attach_tickets', False),
|
||||
attach_other_files=[a for a in [
|
||||
self.request.event.settings.get('mail_attachment_new_order', as_type=str, default='')[len('file://'):]
|
||||
] if a] if form.cleaned_data.get('attach_new_order', False) else [],
|
||||
)
|
||||
messages.success(self.request,
|
||||
_('Your message has been queued and will be sent to {}.'.format(position.attendee_email)))
|
||||
return super(OrderSendMail, self).form_valid(form)
|
||||
|
||||
|
||||
|
||||
@@ -103,7 +103,7 @@ from pretix.base.plugins import (
|
||||
PLUGIN_LEVEL_ORGANIZER,
|
||||
)
|
||||
from pretix.base.services.export import multiexport, scheduled_organizer_export
|
||||
from pretix.base.services.mail import SendMailException, mail, prefix_subject
|
||||
from pretix.base.services.mail import mail, prefix_subject
|
||||
from pretix.base.signals import register_multievent_data_exporters
|
||||
from pretix.base.templatetags.rich_text import markdown_compile_email
|
||||
from pretix.base.views.tasks import AsyncAction
|
||||
@@ -1037,24 +1037,21 @@ class TeamMemberView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin,
|
||||
return ctx
|
||||
|
||||
def _send_invite(self, instance):
|
||||
try:
|
||||
mail(
|
||||
instance.email,
|
||||
_('pretix account invitation'),
|
||||
'pretixcontrol/email/invitation.txt',
|
||||
{
|
||||
'user': self,
|
||||
'organizer': self.request.organizer.name,
|
||||
'team': instance.team.name,
|
||||
'url': build_global_uri('control:auth.invite', kwargs={
|
||||
'token': instance.token
|
||||
})
|
||||
},
|
||||
event=None,
|
||||
locale=self.request.LANGUAGE_CODE
|
||||
)
|
||||
except SendMailException:
|
||||
pass # Already logged
|
||||
mail(
|
||||
instance.email,
|
||||
_('pretix account invitation'),
|
||||
'pretixcontrol/email/invitation.txt',
|
||||
{
|
||||
'user': self,
|
||||
'organizer': self.request.organizer.name,
|
||||
'team': instance.team.name,
|
||||
'url': build_global_uri('control:auth.invite', kwargs={
|
||||
'token': instance.token
|
||||
})
|
||||
},
|
||||
event=None,
|
||||
locale=self.request.LANGUAGE_CODE
|
||||
)
|
||||
|
||||
@transaction.atomic
|
||||
def post(self, request, *args, **kwargs):
|
||||
@@ -3027,6 +3024,7 @@ class CustomerDetailView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMi
|
||||
locale=self.customer.locale,
|
||||
customer=self.customer,
|
||||
organizer=self.request.organizer,
|
||||
sensitive=True,
|
||||
)
|
||||
messages.success(
|
||||
self.request,
|
||||
|
||||
@@ -41,7 +41,6 @@ from hijack import signals
|
||||
|
||||
from pretix.base.auth import get_auth_backends
|
||||
from pretix.base.models import User
|
||||
from pretix.base.services.mail import SendMailException
|
||||
from pretix.control.forms.filter import UserFilterForm
|
||||
from pretix.control.forms.users import UserEditForm
|
||||
from pretix.control.permissions import AdministratorPermissionRequiredMixin
|
||||
@@ -139,11 +138,7 @@ class UserResetView(AdministratorPermissionRequiredMixin, RecentAuthenticationRe
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
self.object = get_object_or_404(User, pk=self.kwargs.get("id"))
|
||||
try:
|
||||
self.object.send_password_reset()
|
||||
except SendMailException:
|
||||
messages.error(request, _('There was an error sending the mail. Please try again later.'))
|
||||
return redirect(self.get_success_url())
|
||||
self.object.send_password_reset()
|
||||
|
||||
self.object.log_action('pretix.control.auth.user.forgot_password.mail_sent',
|
||||
user=request.user)
|
||||
|
||||
Reference in New Issue
Block a user