diff --git a/src/pretix/api/serializers/organizer.py b/src/pretix/api/serializers/organizer.py index ce3ed39b72..6d8f6743b9 100644 --- a/src/pretix/api/serializers/organizer.py +++ b/src/pretix/api/serializers/organizer.py @@ -49,7 +49,7 @@ from pretix.base.plugins import ( PLUGIN_LEVEL_EVENT, PLUGIN_LEVEL_EVENT_ORGANIZER_HYBRID, PLUGIN_LEVEL_ORGANIZER, ) -from pretix.base.services.mail import SendMailException, mail +from pretix.base.services.mail import mail from pretix.base.settings import validate_organizer_settings from pretix.helpers.urls import build_absolute_uri as build_global_uri from pretix.multidomain.urlreverse import build_absolute_uri @@ -363,24 +363,21 @@ class TeamInviteSerializer(serializers.ModelSerializer): ) def _send_invite(self, instance): - try: - mail( - instance.email, - _('pretix account invitation'), - 'pretixcontrol/email/invitation.txt', - { - 'user': self, - 'organizer': self.context['organizer'].name, - 'team': instance.team.name, - 'url': build_global_uri('control:auth.invite', kwargs={ - 'token': instance.token - }) - }, - event=None, - locale=get_language_without_region() # TODO: expose? - ) - except SendMailException: - pass # Already logged + mail( + instance.email, + _('pretix account invitation'), + 'pretixcontrol/email/invitation.txt', + { + 'user': self, + 'organizer': self.context['organizer'].name, + 'team': instance.team.name, + 'url': build_global_uri('control:auth.invite', kwargs={ + 'token': instance.token + }) + }, + event=None, + locale=get_language_without_region() # TODO: expose? + ) def create(self, validated_data): if 'email' in validated_data: diff --git a/src/pretix/api/views/order.py b/src/pretix/api/views/order.py index 51d463047a..6fe7e15174 100644 --- a/src/pretix/api/views/order.py +++ b/src/pretix/api/views/order.py @@ -90,7 +90,6 @@ from pretix.base.services.invoices import ( generate_cancellation, generate_invoice, invoice_pdf, invoice_qualified, regenerate_invoice, transmit_invoice, ) -from pretix.base.services.mail import SendMailException from pretix.base.services.orders import ( OrderChangeManager, OrderError, _order_placed_email, _order_placed_email_attendee, approve_order, cancel_order, deny_order, @@ -439,8 +438,6 @@ class EventOrderViewSet(OrderViewSetMixin, viewsets.ModelViewSet): return Response({'detail': str(e)}, status=status.HTTP_400_BAD_REQUEST) except PaymentException as e: return Response({'detail': str(e)}, status=status.HTTP_400_BAD_REQUEST) - except SendMailException: - pass return self.retrieve(request, [], **kwargs) return Response( @@ -634,10 +631,7 @@ class EventOrderViewSet(OrderViewSetMixin, viewsets.ModelViewSet): order = self.get_object() if not order.email: return Response({'detail': 'There is no email address associated with this order.'}, status=status.HTTP_400_BAD_REQUEST) - try: - order.resend_link(user=self.request.user, auth=self.request.auth) - except SendMailException: - return Response({'detail': _('There was an error sending the mail. Please try again later.')}, status=status.HTTP_503_SERVICE_UNAVAILABLE) + order.resend_link(user=self.request.user, auth=self.request.auth) return Response( status=status.HTTP_204_NO_CONTENT @@ -1616,8 +1610,6 @@ class PaymentViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet): ) except Quota.QuotaExceededException: pass - except SendMailException: - pass serializer = OrderPaymentSerializer(r, context=serializer.context) @@ -1655,8 +1647,6 @@ class PaymentViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet): return Response({'detail': str(e)}, status=status.HTTP_400_BAD_REQUEST) except PaymentException as e: return Response({'detail': str(e)}, status=status.HTTP_400_BAD_REQUEST) - except SendMailException: - pass return self.retrieve(request, [], **kwargs) @action(detail=True, methods=['POST']) diff --git a/src/pretix/base/invoicing/email.py b/src/pretix/base/invoicing/email.py index d3064af778..155e7906a0 100644 --- a/src/pretix/base/invoicing/email.py +++ b/src/pretix/base/invoicing/email.py @@ -33,7 +33,7 @@ from pretix.base.invoicing.transmission import ( transmission_types, ) from pretix.base.models import Invoice, InvoiceAddress -from pretix.base.services.mail import SendMailException, mail, render_mail +from pretix.base.services.mail import mail, render_mail from pretix.helpers.format import format_map @@ -133,41 +133,37 @@ class EmailTransmissionProvider(TransmissionProvider): template = invoice.order.event.settings.get('mail_text_order_invoice', as_type=LazyI18nString) subject = invoice.order.event.settings.get('mail_subject_order_invoice', as_type=LazyI18nString) - try: - # Do not set to completed because that is done by the email sending task - subject = format_map(subject, context) - email_content = render_mail(template, context) - mail( - [recipient], - subject, - template, - context=context, - event=invoice.order.event, - locale=invoice.order.locale, - order=invoice.order, - invoices=[invoice], - attach_tickets=False, - auto_email=True, - attach_ical=False, - plain_text_only=True, - no_order_links=True, - ) - except SendMailException: - raise - else: - invoice.order.log_action( - 'pretix.event.order.email.invoice', - user=None, - auth=None, - data={ - 'subject': subject, - 'message': email_content, - 'position': None, - 'recipient': recipient, - 'invoices': [invoice.pk], - 'attach_tickets': False, - 'attach_ical': False, - 'attach_other_files': [], - 'attach_cached_files': [], - } - ) + # Do not set to completed because that is done by the email sending task + subject = format_map(subject, context) + email_content = render_mail(template, context) + mail( + [recipient], + subject, + template, + context=context, + event=invoice.order.event, + locale=invoice.order.locale, + order=invoice.order, + invoices=[invoice], + attach_tickets=False, + auto_email=True, + attach_ical=False, + plain_text_only=True, + no_order_links=True, + ) + invoice.order.log_action( + 'pretix.event.order.email.invoice', + user=None, + auth=None, + data={ + 'subject': subject, + 'message': email_content, + 'position': None, + 'recipient': recipient, + 'invoices': [invoice.pk], + 'attach_tickets': False, + 'attach_ical': False, + 'attach_other_files': [], + 'attach_cached_files': [], + } + ) diff --git a/src/pretix/base/migrations/0289_outgoingmail.py b/src/pretix/base/migrations/0289_outgoingmail.py new file mode 100644 index 0000000000..4c383592af --- /dev/null +++ b/src/pretix/base/migrations/0289_outgoingmail.py @@ -0,0 +1,112 @@ +# Generated by Django 4.2.17 on 2025-09-04 12:35 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("pretixbase", "0288_invoice_transmission"), + ] + + operations = [ + migrations.CreateModel( + name="OutgoingMail", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, primary_key=True, serialize=False + ), + ), + ("status", models.CharField(default="queued", max_length=200)), + ("created", models.DateTimeField(auto_now_add=True)), + ("sent", models.DateTimeField(blank=True, null=True)), + ("error", models.TextField(null=True)), + ("error_detail", models.TextField(null=True)), + ("subject", models.TextField()), + ("body_plain", models.TextField()), + ("body_html", models.TextField()), + ("sender", models.CharField(max_length=500)), + ("headers", models.JSONField(default=dict)), + ("to", models.JSONField(default=list)), + ("cc", models.JSONField(default=list)), + ("bcc", models.JSONField(default=list)), + ("should_attach_tickets", models.BooleanField(default=False)), + ("should_attach_ical", models.BooleanField(default=False)), + ("should_attach_other_files", models.JSONField(default=list)), + ("actual_attachments", models.JSONField(default=list)), + ( + "customer", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="outgoing_mails", + to="pretixbase.customer", + ), + ), + ( + "event", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="outgoing_mails", + to="pretixbase.event", + ), + ), + ( + "order", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="outgoing_mails", + to="pretixbase.order", + ), + ), + ( + "orderposition", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="outgoing_mails", + to="pretixbase.orderposition", + ), + ), + ( + "organizer", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="outgoing_mails", + to="pretixbase.organizer", + ), + ), + ( + "should_attach_cached_files", + models.ManyToManyField( + related_name="outgoing_mails", to="pretixbase.cachedfile" + ), + ), + ( + "should_attach_invoices", + models.ManyToManyField( + related_name="outgoing_mails", to="pretixbase.invoice" + ), + ), + ( + "user", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="outgoing_mails", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "ordering": ("-created",), + }, + ), + ] diff --git a/src/pretix/base/models/__init__.py b/src/pretix/base/models/__init__.py index 372fc8287c..3657cfb4c2 100644 --- a/src/pretix/base/models/__init__.py +++ b/src/pretix/base/models/__init__.py @@ -41,6 +41,7 @@ from .items import ( itempicture_upload_to, ) from .log import LogEntry +from .mail import OutgoingMail from .media import ReusableMedium from .memberships import Membership, MembershipType from .notifications import NotificationSetting diff --git a/src/pretix/base/models/auth.py b/src/pretix/base/models/auth.py index 44ff3587d6..0d2ba97a27 100644 --- a/src/pretix/base/models/auth.py +++ b/src/pretix/base/models/auth.py @@ -334,27 +334,24 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin): return self.email def send_security_notice(self, messages, email=None): - from pretix.base.services.mail import SendMailException, mail + from pretix.base.services.mail import mail - try: - with language(self.locale): - msg = '- ' + '\n- '.join(str(m) for m in messages) + with language(self.locale): + msg = '- ' + '\n- '.join(str(m) for m in messages) - mail( - email or self.email, - _('Account information changed'), - 'pretixcontrol/email/security_notice.txt', - { - 'user': self, - 'messages': msg, - 'url': build_absolute_uri('control:user.settings') - }, - event=None, - user=self, - locale=self.locale - ) - except SendMailException: - pass # Already logged + mail( + email or self.email, + _('Account information changed'), + 'pretixcontrol/email/security_notice.txt', + { + 'user': self, + 'messages': msg, + 'url': build_absolute_uri('control:user.settings') + }, + event=None, + user=self, + locale=self.locale + ) def send_confirmation_code(self, session, reason, email=None, state=None): """ diff --git a/src/pretix/base/models/mail.py b/src/pretix/base/models/mail.py new file mode 100644 index 0000000000..cc9e72ab95 --- /dev/null +++ b/src/pretix/base/models/mail.py @@ -0,0 +1,149 @@ +# +# This file is part of pretix (Community Edition). +# +# Copyright (C) 2014-2020 Raphael Michel and contributors +# Copyright (C) 2020-2021 rami.io 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 +# . +# +from django.core.mail import get_connection +from django.db import models +from django.utils.translation import gettext_lazy as _ +from django_scopes import scope, scopes_disabled + + +class OutgoingMail(models.Model): + STATUS_QUEUED = "queued" + STATUS_INFLIGHT = "inflight" + STATUS_AWAWITING_RETRY = "awaiting_retry" + STATUS_FAILED = "failed" + STATUS_SENT = "sent" + STATUS_CHOICES = ( + (STATUS_QUEUED, _("queued")), + (STATUS_INFLIGHT, _("being sent")), + (STATUS_AWAWITING_RETRY, _("awaiting retry")), + (STATUS_FAILED, _("failed")), + (STATUS_SENT, _("sent")), + ) + + 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) + error = models.TextField(null=True, blank=True) + error_detail = models.TextField(null=True, blank=True) + + organizer = models.ForeignKey( + 'pretixbase.Organizer', + on_delete=models.CASCADE, + related_name='outgoing_mails', + null=True, blank=True, + ) + event = models.ForeignKey( + 'pretixbase.Event', + on_delete=models.SET_NULL, # todo think, only for non-queued! + related_name='outgoing_mails', + null=True, blank=True, + ) + order = models.ForeignKey( + 'pretixbase.Order', + on_delete=models.SET_NULL, + related_name='outgoing_mails', + null=True, blank=True, + ) + orderposition = models.ForeignKey( + 'pretixbase.OrderPosition', + on_delete=models.SET_NULL, + related_name='outgoing_mails', + null=True, blank=True, + ) + customer = models.ForeignKey( + 'pretixbase.Customer', + on_delete=models.SET_NULL, + related_name='outgoing_mails', + null=True, blank=True, + ) + user = models.ForeignKey( + 'pretixbase.User', + on_delete=models.SET_NULL, + related_name='outgoing_mails', + null=True, blank=True, + ) + + subject = models.TextField() + body_plain = models.TextField() + body_html = models.TextField() + sender = models.CharField(max_length=500) + headers = models.JSONField(default=dict) + to = models.JSONField(default=list) + cc = models.JSONField(default=list) + bcc = models.JSONField(default=list) + + should_attach_invoices = models.ManyToManyField( + 'pretixbase.Invoice', + related_name='outgoing_mails' + ) + should_attach_tickets = models.BooleanField(default=False) + should_attach_ical = models.BooleanField(default=False) + should_attach_cached_files = models.ManyToManyField( + 'pretixbase.CachedFile', + related_name='outgoing_mails', + ) # todo: prevent deletion? + should_attach_other_files = models.JSONField(default=list) # todo_ prevent deletion? + + actual_attachments = models.JSONField(default=list) + + class Meta: + ordering = ('-created',) + + def get_mail_backend(self): + if self.event: + return self.event.get_mail_backend() + elif self.organizer: + return self.organizer.get_mail_backend() + else: + return get_connection(fail_silently=False) + + def scope_manager(self): + if self.organizer: + return scope(organizer=self.organizer) # noqa + else: + return scopes_disabled() # noqa + + def save(self, *args, **kwargs): + if self.orderposition_id and not self.order_id: + self.order = self.orderposition.order + if self.order_id and not self.event_id: + self.event = self.order.event + if self.event_id and not self.organizer_id: + self.organizer = self.event.organizer + if self.customer_id and not self.organizer_id: + self.organizer = self.customer.organizer + super().save(*args, **kwargs) + + def log_parameters(self): + if self.order: + error_log_action_type = 'pretix.event.order.email.error' + log_target = self.order + elif self.customer: + error_log_action_type = 'pretix.customer.email.error' + log_target = self.customer + elif self.user: + error_log_action_type = 'pretix.user.email.error' + log_target = self.user + else: + error_log_action_type = 'pretix.email.error' + log_target = None + return log_target, error_log_action_type diff --git a/src/pretix/base/models/orders.py b/src/pretix/base/models/orders.py index c9c4da8719..6ae0e027af 100644 --- a/src/pretix/base/models/orders.py +++ b/src/pretix/base/models/orders.py @@ -1167,9 +1167,7 @@ class Order(LockModel, LoggedModel): only be attached for this position and child positions, the link will only point to the position and the attendee email will be used if available. """ - from pretix.base.services.mail import ( - SendMailException, mail, render_mail, - ) + from pretix.base.services.mail import mail, render_mail if not self.email and not (position and position.attendee_email): return @@ -1179,35 +1177,31 @@ class Order(LockModel, LoggedModel): if position and position.attendee_email: recipient = position.attendee_email - try: - email_content = render_mail(template, context) - subject = format_map(subject, context) - mail( - recipient, subject, template, context, - self.event, self.locale, self, headers=headers, sender=sender, - invoices=invoices, attach_tickets=attach_tickets, - position=position, auto_email=auto_email, attach_ical=attach_ical, - attach_other_files=attach_other_files, attach_cached_files=attach_cached_files, - ) - except SendMailException: - raise - else: - self.log_action( - log_entry_type, - user=user, - auth=auth, - data={ - 'subject': subject, - 'message': email_content, - 'position': position.positionid if position else None, - 'recipient': recipient, - 'invoices': [i.pk for i in invoices] if invoices else [], - 'attach_tickets': attach_tickets, - 'attach_ical': attach_ical, - 'attach_other_files': attach_other_files, - 'attach_cached_files': [cf.filename for cf in attach_cached_files] if attach_cached_files else [], - } - ) + email_content = render_mail(template, context) + subject = format_map(subject, context) + mail( + recipient, subject, template, context, + self.event, self.locale, self, headers=headers, sender=sender, + invoices=invoices, attach_tickets=attach_tickets, + position=position, auto_email=auto_email, attach_ical=attach_ical, + attach_other_files=attach_other_files, attach_cached_files=attach_cached_files, + ) + self.log_action( + log_entry_type, + user=user, + auth=auth, + data={ + 'subject': subject, + 'message': email_content, + 'position': position.positionid if position else None, + 'recipient': recipient, + 'invoices': [i.pk for i in invoices] if invoices else [], + 'attach_tickets': attach_tickets, + 'attach_ical': attach_ical, + 'attach_other_files': attach_other_files, + 'attach_cached_files': [cf.filename for cf in attach_cached_files] if attach_cached_files else [], + } + ) def resend_link(self, user=None, auth=None): with language(self.locale, self.event.settings.region): @@ -2024,40 +2018,30 @@ class OrderPayment(models.Model): transmit_invoice.apply_async(args=(self.order.event_id, invoice.pk, False)) def _send_paid_mail_attendee(self, position, user): - from pretix.base.services.mail import SendMailException - with language(self.order.locale, self.order.event.settings.region): email_template = self.order.event.settings.mail_text_order_paid_attendee email_subject = self.order.event.settings.mail_subject_order_paid_attendee email_context = get_email_context(event=self.order.event, order=self.order, position=position) - try: - position.send_mail( - email_subject, email_template, email_context, - 'pretix.event.order.email.order_paid', user, - invoices=[], - attach_tickets=True, - attach_ical=self.order.event.settings.mail_attach_ical - ) - except SendMailException: - logger.exception('Order paid email could not be sent') + position.send_mail( + email_subject, email_template, email_context, + 'pretix.event.order.email.order_paid', user, + invoices=[], + attach_tickets=True, + attach_ical=self.order.event.settings.mail_attach_ical + ) def _send_paid_mail(self, invoice, user, mail_text): - from pretix.base.services.mail import SendMailException - with language(self.order.locale, self.order.event.settings.region): email_template = self.order.event.settings.mail_text_order_paid email_subject = self.order.event.settings.mail_subject_order_paid email_context = get_email_context(event=self.order.event, order=self.order, payment_info=mail_text) - try: - self.order.send_mail( - email_subject, email_template, email_context, - 'pretix.event.order.email.order_paid', user, - invoices=[invoice] if invoice else [], - attach_tickets=True, - attach_ical=self.order.event.settings.mail_attach_ical - ) - except SendMailException: - logger.exception('Order paid email could not be sent') + self.order.send_mail( + email_subject, email_template, email_context, + 'pretix.event.order.email.order_paid', user, + invoices=[invoice] if invoice else [], + attach_tickets=True, + attach_ical=self.order.event.settings.mail_attach_ical + ) @property def refunded_amount(self): @@ -2915,45 +2899,39 @@ class OrderPosition(AbstractPosition): :param attach_tickets: Attach tickets of this order, if they are existing and ready to download :param attach_ical: Attach relevant ICS files """ - from pretix.base.services.mail import ( - SendMailException, mail, render_mail, - ) + from pretix.base.services.mail import mail, render_mail if not self.attendee_email: return with language(self.order.locale, self.order.event.settings.region): recipient = self.attendee_email - try: - email_content = render_mail(template, context) - subject = format_map(subject, context) - mail( - recipient, subject, template, context, - self.event, self.order.locale, order=self.order, headers=headers, sender=sender, - position=self, - invoices=invoices, - attach_tickets=attach_tickets, - attach_ical=attach_ical, - attach_other_files=attach_other_files, - ) - except SendMailException: - raise - else: - self.order.log_action( - log_entry_type, - user=user, - auth=auth, - data={ - 'subject': subject, - 'message': email_content, - 'recipient': recipient, - 'invoices': [i.pk for i in invoices] if invoices else [], - 'attach_tickets': attach_tickets, - 'attach_ical': attach_ical, - 'attach_other_files': attach_other_files, - 'attach_cached_files': [], - } - ) + email_content = render_mail(template, context) + subject = format_map(subject, context) + mail( + recipient, subject, template, context, + self.event, self.order.locale, order=self.order, headers=headers, sender=sender, + position=self, + invoices=invoices, + attach_tickets=attach_tickets, + attach_ical=attach_ical, + attach_other_files=attach_other_files, + ) + self.order.log_action( + log_entry_type, + user=user, + auth=auth, + data={ + 'subject': subject, + 'message': email_content, + 'recipient': recipient, + 'invoices': [i.pk for i in invoices] if invoices else [], + 'attach_tickets': attach_tickets, + 'attach_ical': attach_ical, + 'attach_other_files': attach_other_files, + 'attach_cached_files': [], + } + ) def resend_link(self, user=None, auth=None): diff --git a/src/pretix/base/models/waitinglist.py b/src/pretix/base/models/waitinglist.py index 68d46cac41..6ae14591c2 100644 --- a/src/pretix/base/models/waitinglist.py +++ b/src/pretix/base/models/waitinglist.py @@ -34,7 +34,7 @@ from phonenumber_field.modelfields import PhoneNumberField from pretix.base.email import get_email_context from pretix.base.i18n import language from pretix.base.models import User, Voucher -from pretix.base.services.mail import SendMailException, mail, render_mail +from pretix.base.services.mail import mail, render_mail from pretix.helpers import OF_SELF from ...helpers.format import format_map @@ -272,34 +272,30 @@ class WaitingListEntry(LoggedModel): with language(self.locale, self.event.settings.region): recipient = self.email - try: - email_content = render_mail(template, context) - subject = format_map(subject, context) - mail( - recipient, subject, template, context, - self.event, - self.locale, - headers=headers, - sender=sender, - auto_email=auto_email, - attach_other_files=attach_other_files, - attach_cached_files=attach_cached_files, - ) - except SendMailException: - raise - else: - self.log_action( - log_entry_type, - user=user, - auth=auth, - data={ - 'subject': subject, - 'message': email_content, - 'recipient': recipient, - 'attach_other_files': attach_other_files, - 'attach_cached_files': [cf.filename for cf in attach_cached_files] if attach_cached_files else [], - } - ) + email_content = render_mail(template, context) + subject = format_map(subject, context) + mail( + recipient, subject, template, context, + self.event, + self.locale, + headers=headers, + sender=sender, + auto_email=auto_email, + attach_other_files=attach_other_files, + attach_cached_files=attach_cached_files, + ) + self.log_action( + log_entry_type, + user=user, + auth=auth, + data={ + 'subject': subject, + 'message': email_content, + 'recipient': recipient, + 'attach_other_files': attach_other_files, + 'attach_cached_files': [cf.filename for cf in attach_cached_files] if attach_cached_files else [], + } + ) @staticmethod def clean_itemvar(event, item, variation): diff --git a/src/pretix/base/services/cancelevent.py b/src/pretix/base/services/cancelevent.py index 694ea35987..012f01b85d 100644 --- a/src/pretix/base/services/cancelevent.py +++ b/src/pretix/base/services/cancelevent.py @@ -36,7 +36,7 @@ from pretix.base.models import ( SubEvent, TaxRule, User, WaitingListEntry, ) from pretix.base.services.locking import LockTimeoutException -from pretix.base.services.mail import SendMailException, mail +from pretix.base.services.mail import mail from pretix.base.services.orders import ( OrderChangeManager, OrderError, _cancel_order, _try_auto_refund, ) @@ -53,17 +53,14 @@ logger = logging.getLogger(__name__) def _send_wle_mail(wle: WaitingListEntry, subject: LazyI18nString, message: LazyI18nString, subevent: SubEvent): with language(wle.locale, wle.event.settings.region): email_context = get_email_context(event_or_subevent=subevent or wle.event, event=wle.event) - try: - mail( - wle.email, - format_map(subject, email_context), - message, - email_context, - wle.event, - locale=wle.locale - ) - except SendMailException: - logger.exception('Waiting list canceled email could not be sent') + mail( + wle.email, + format_map(subject, email_context), + message, + email_context, + wle.event, + locale=wle.locale + ) def _send_mail(order: Order, subject: LazyI18nString, message: LazyI18nString, subevent: SubEvent, @@ -77,14 +74,11 @@ def _send_mail(order: Order, subject: LazyI18nString, message: LazyI18nString, s email_context = get_email_context(event_or_subevent=subevent or order.event, refund_amount=refund_amount, order=order, position_or_address=ia, event=order.event) real_subject = format_map(subject, email_context) - try: - order.send_mail( - real_subject, message, email_context, - 'pretix.event.order.email.event_canceled', - user, - ) - except SendMailException: - logger.exception('Order canceled email could not be sent') + order.send_mail( + real_subject, message, email_context, + 'pretix.event.order.email.event_canceled', + user, + ) for p in positions: if subevent and p.subevent_id != subevent.id: @@ -97,15 +91,12 @@ def _send_mail(order: Order, subject: LazyI18nString, message: LazyI18nString, s refund_amount=refund_amount, position_or_address=p, order=order, position=p) - try: - order.send_mail( - real_subject, message, email_context, - 'pretix.event.order.email.event_canceled', - position=p, - user=user - ) - except SendMailException: - logger.exception('Order canceled email could not be sent to attendee') + order.send_mail( + real_subject, message, email_context, + 'pretix.event.order.email.event_canceled', + position=p, + user=user + ) @app.task(base=ProfiledEventTask, bind=True, max_retries=5, default_retry_delay=1, throws=(OrderError,)) diff --git a/src/pretix/base/services/mail.py b/src/pretix/base/services/mail.py index a21eec4194..3bf039f524 100644 --- a/src/pretix/base/services/mail.py +++ b/src/pretix/base/services/mail.py @@ -42,7 +42,7 @@ import smtplib import warnings from email.mime.image import MIMEImage from email.utils import formataddr -from typing import Any, Dict, List, Sequence, Union +from typing import Any, Dict, Sequence, Union from urllib.parse import urljoin, urlparse from zoneinfo import ZoneInfo @@ -51,16 +51,13 @@ from celery import chain from celery.exceptions import MaxRetriesExceededError from django.conf import settings from django.core.files.storage import default_storage -from django.core.mail import ( - EmailMultiAlternatives, SafeMIMEMultipart, get_connection, -) +from django.core.mail import EmailMultiAlternatives, SafeMIMEMultipart from django.core.mail.message import SafeMIMEText from django.db import transaction from django.template.loader import get_template from django.utils.html import escape from django.utils.timezone import now, override from django.utils.translation import gettext as _, pgettext -from django_scopes import scope, scopes_disabled from i18nfield.strings import LazyI18nString from text_unidecode import unidecode @@ -68,13 +65,15 @@ from pretix.base.email import ClassicMailRenderer from pretix.base.i18n import language from pretix.base.models import ( CachedFile, Customer, Event, Invoice, InvoiceAddress, Order, OrderPosition, - Organizer, User, + Organizer, ) +from pretix.base.models.mail import OutgoingMail from pretix.base.services.invoices import invoice_pdf_task from pretix.base.services.tasks import TransactionAwareTask from pretix.base.services.tickets import get_tickets_for_order from pretix.base.signals import email_filter, global_email_filter from pretix.celery_app import app +from pretix.helpers import OF_SELF from pretix.helpers.format import SafeFormatter, format_map from pretix.helpers.hierarkey import clean_filename from pretix.multidomain.urlreverse import build_absolute_uri @@ -91,6 +90,9 @@ class TolerantDict(dict): class SendMailException(Exception): + """ + Deprecated, not thrown any more. + """ pass @@ -202,162 +204,120 @@ def mail(email: Union[str, Sequence[str]], subject: str, template: Union[str, La if no_order_links and not plain_text_only: raise ValueError('If you set no_order_links, you also need to set plain_text_only.') + settings_holder = event or organizer + headers = headers or {} if auto_email: headers['X-Auto-Response-Suppress'] = 'OOF, NRN, AutoReply, RN' headers['Auto-Submitted'] = 'auto-generated' headers.setdefault('X-Mailer', 'pretix') + bcc = list(bcc or []) + if settings_holder and settings_holder.settings.mail_bcc: + for bcc_mail in settings_holder.settings.mail_bcc.split(','): + bcc.append(bcc_mail.strip()) + + if (settings_holder + and settings_holder.settings.mail_from in (settings.DEFAULT_FROM_EMAIL, settings.MAIL_FROM_ORGANIZERS) + and settings_holder.settings.contact_mail and not headers.get('Reply-To')): + headers['Reply-To'] = settings_holder.settings.contact_mail + + if settings_holder: + timezone = settings_holder.timezone + elif user: + timezone = ZoneInfo(user.timezone) + else: + timezone = ZoneInfo(settings.TIME_ZONE) + + if event and attach_tickets and not event.settings.mail_attach_tickets: + attach_tickets = False + with language(locale): if isinstance(context, dict) and order: - try: - context.update({ - 'invoice_name': order.invoice_address.name, - 'invoice_company': order.invoice_address.company - }) - except InvoiceAddress.DoesNotExist: - context.update({ - 'invoice_name': '', - 'invoice_company': '' - }) - renderer = ClassicMailRenderer(None, organizer) + _autoextend_context(context, order) + + # Build raw content body_plain = render_mail(template, context, placeholder_mode=SafeFormatter.MODE_RICH_TO_PLAIN) - subject = str(subject).format_map(TolerantDict(context)) - sender = ( - sender or - (event.settings.get('mail_from') if event else None) or - (organizer.settings.get('mail_from') if organizer else None) or - settings.MAIL_FROM - ) - if event: - sender_name = clean_sender_name(event.settings.mail_from_name or str(event.name)) - sender = formataddr((sender_name, sender)) - elif organizer: - sender_name = clean_sender_name(organizer.settings.mail_from_name or str(organizer.name)) - sender = formataddr((sender_name, sender)) - else: - sender = formataddr((clean_sender_name(settings.PRETIX_INSTANCE_NAME), sender)) - - subject = raw_subject = str(subject).replace('\n', ' ').replace('\r', '')[:900] - signature = "" - - bcc = list(bcc or []) - - settings_holder = event or organizer - - if event: - timezone = event.timezone - elif user: - timezone = ZoneInfo(user.timezone) - elif organizer: - timezone = organizer.timezone - else: - timezone = ZoneInfo(settings.TIME_ZONE) - if settings_holder: - if settings_holder.settings.mail_bcc: - for bcc_mail in settings_holder.settings.mail_bcc.split(','): - bcc.append(bcc_mail.strip()) - - if settings_holder.settings.mail_from in (settings.DEFAULT_FROM_EMAIL, settings.MAIL_FROM_ORGANIZERS) \ - and settings_holder.settings.contact_mail and not headers.get('Reply-To'): - headers['Reply-To'] = settings_holder.settings.contact_mail - - subject = prefix_subject(settings_holder, subject) - - body_plain += "\r\n\r\n-- \r\n" - signature = str(settings_holder.settings.get('mail_text_signature')) - if signature: - signature = signature.format(event=event.name if event else '') - body_plain += signature - body_plain += "\r\n\r\n-- \r\n" - - if event: - renderer = event.get_html_mail_renderer() - - if order and order.testmode: - subject = "[TESTMODE] " + subject - - if order and position and not no_order_links: - body_plain += _( - "You are receiving this email because someone placed an order for {event} for you." - ).format(event=event.name) - body_plain += "\r\n" - body_plain += _( - "You can view your order details at the following URL:\n{orderurl}." - ).replace("\n", "\r\n").format( - event=event.name, orderurl=build_absolute_uri( - order.event, 'presale:event.order.position', kwargs={ - 'order': order.code, - 'secret': position.web_secret, - 'position': position.positionid, - } - ) - ) - elif order and not no_order_links: - body_plain += _( - "You are receiving this email because you placed an order for {event}." - ).format(event=event.name) - body_plain += "\r\n" - body_plain += _( - "You can view your order details at the following URL:\n{orderurl}." - ).replace("\n", "\r\n").format( - event=event.name, orderurl=build_absolute_uri( - order.event, 'presale:event.order.open', kwargs={ - 'order': order.code, - 'secret': order.secret, - 'hash': order.email_confirm_secret() - } - ) - ) - body_plain += "\r\n" - - with override(timezone): - try: - content_plain = render_mail(template, context, placeholder_mode=None) - if plain_text_only: - body_html = None - elif 'context' in inspect.signature(renderer.render).parameters: - body_html = renderer.render(content_plain, signature, raw_subject, order, position, context) - elif 'position' in inspect.signature(renderer.render).parameters: - # Backwards compatibility - warnings.warn('Email renderer called without context argument because context argument is not ' - 'supported.', - DeprecationWarning) - body_html = renderer.render(content_plain, signature, raw_subject, order, position) - else: - # Backwards compatibility - warnings.warn('Email renderer called without position argument because position argument is not ' - 'supported.', - DeprecationWarning) - body_html = renderer.render(content_plain, signature, raw_subject, order) - except: - logger.exception('Could not render HTML body') - body_html = None + else: + signature = "" + # Build full plain-text body + body_plain = _wrap_plain_body(body_plain, signature, event, order, position, no_order_links) body_plain = format_map(body_plain, context, mode=SafeFormatter.MODE_RICH_TO_PLAIN) - send_task = mail_send_task.si( + # Build subject + subject = str(subject).format_map(TolerantDict(context)) + subject = raw_subject = subject.replace('\n', ' ').replace('\r', '')[:900] + if settings_holder: + subject = prefix_subject(settings_holder, subject) + if (order and order.testmode) or (not order and event and event.testmode): + subject = "[TESTMODE] " + subject + + # Build sender + sender = _full_sender(sender, event, organizer) + + # Build HTML body + if plain_text_only: + body_html = None + else: + if event: + renderer = event.get_html_mail_renderer() + else: + renderer = ClassicMailRenderer(None, organizer) + + with override(timezone): + content_plain = render_mail(template, context, placeholder_mode=None) + try: + if 'context' in inspect.signature(renderer.render).parameters: + body_html = renderer.render(content_plain, signature, raw_subject, order, position, context) + elif 'position' in inspect.signature(renderer.render).parameters: + # Backwards compatibility + warnings.warn('Email renderer called without context argument because context argument is not ' + 'supported.', + DeprecationWarning) + body_html = renderer.render(content_plain, signature, raw_subject, order, position) + else: + # Backwards compatibility + warnings.warn('Email renderer called without position argument because position argument is not ' + 'supported.', + DeprecationWarning) + body_html = renderer.render(content_plain, signature, raw_subject, order) + except: + logger.exception('Could not render HTML body') + body_html = None + + m = OutgoingMail.objects.create( + organizer=organizer, + event=event, + order=order, + orderposition=position, + customer=customer, + user=user, to=[email] if isinstance(email, str) else list(email), - cc=cc, - bcc=bcc, + cc=cc or [], + bcc=bcc or [], subject=subject, - body=body_plain, - html=body_html, + body_plain=body_plain, + body_html=body_html, sender=sender, - event=event.id if event else None, headers=headers, - invoices=[i.pk for i in invoices] if invoices and not position else [], - order=order.pk if order else None, - position=position.pk if position else None, - attach_tickets=attach_tickets, - attach_ical=attach_ical, - user=user.pk if user else None, - organizer=organizer.pk if organizer else None, - customer=customer.pk if customer else None, - attach_cached_files=[(cf.id if isinstance(cf, CachedFile) else cf) for cf in attach_cached_files] if attach_cached_files else [], - attach_other_files=attach_other_files, + should_attach_tickets=attach_tickets, + should_attach_ical=attach_ical, + should_attach_other_files=attach_other_files or [], + ) + if invoices and not position: + m.should_attach_invoices.add(*invoices) + if attach_cached_files: + for cf in attach_cached_files: + if not isinstance(cf, CachedFile): + m.should_attach_cached_files.add(CachedFile.objects.get(pk=cf)) + else: + m.should_attach_cached_files.add(cf) + + send_task = mail_send_task.si( + outgoing_mail=m.id ) if invoices: @@ -392,194 +352,197 @@ class CustomEmail(EmailMultiAlternatives): @app.task(base=TransactionAwareTask, bind=True, acks_late=True) -def mail_send_task(self, *args, to: List[str], subject: str, body: str, html: str, sender: str, - event: int = None, position: int = None, headers: dict = None, cc: List[str] = None, bcc: List[str] = None, - invoices: List[int] = None, order: int = None, attach_tickets=False, user=None, - organizer=None, customer=None, attach_ical=False, attach_cached_files: List[int] = None, - attach_other_files: List[str] = None) -> bool: - email = CustomEmail(subject, body, sender, to=to, cc=cc, bcc=bcc, headers=headers) - if html is not None: +def mail_send_task(self, *args, outgoing_mail: int) -> bool: + with transaction.atomic(): + outgoing_mail = OutgoingMail.objects.select_for_update(of=OF_SELF).get(pk=outgoing_mail) + if outgoing_mail.status == OutgoingMail.STATUS_INFLIGHT: + logger.info("Ignoring job for inflight email") + return False + elif outgoing_mail.status in (OutgoingMail.STATUS_SENT, OutgoingMail.STATUS_FAILED): + logger.info(f"Ignoring job for email in final state {outgoing_mail.status}") + return False + outgoing_mail.status = OutgoingMail.STATUS_INFLIGHT + outgoing_mail.save(update_fields=["status"]) + + email = CustomEmail( + subject=outgoing_mail.subject, + body=outgoing_mail.body_plain, + from_email=outgoing_mail.sender, + to=outgoing_mail.to, + cc=outgoing_mail.cc, + bcc=outgoing_mail.bcc, + headers=outgoing_mail.headers, + ) + + # Rewrite all tags from real URLs or data URLs to inline attachments referred to by content ID + if outgoing_mail.body_html is not None: html_message = SafeMIMEMultipart(_subtype='related', encoding=settings.DEFAULT_CHARSET) - html_with_cid, cid_images = replace_images_with_cid_paths(html) + html_with_cid, cid_images = replace_images_with_cid_paths(outgoing_mail.body_html) html_message.attach(SafeMIMEText(html_with_cid, 'html', settings.DEFAULT_CHARSET)) attach_cid_images(html_message, cid_images, verify_ssl=True) email.attach_alternative(html_message, "multipart/related") - log_target = None + log_target, error_log_action_type = outgoing_mail.log_parameters() + invoices_attached = [] + actual_attachments = [] - if user: - user = User.objects.get(pk=user) - error_log_action_type = 'pretix.user.email.error' - log_target = user + with outgoing_mail.scope_manager(): + # Attach tickets + if outgoing_mail.should_attach_tickets and outgoing_mail.order: + with language(outgoing_mail.order.locale, outgoing_mail.event.settings.region): + args = [] + attach_size = 0 + for name, ct in get_tickets_for_order(outgoing_mail.order, base_position=outgoing_mail.orderposition): + try: + content = ct.file.read() + args.append((name, content, ct.type)) + attach_size += len(content) + except Exception: + # This sometimes fails e.g. with FileNotFoundError. We haven't been able to figure out + # why (probably some race condition with ticket cache invalidation?), so retry later. + try: + self.retry(max_retries=5, countdown=60) + except MaxRetriesExceededError: + # Well then, something is really wrong, let's send it without attachment before we + # don't sent at all + logger.exception('Could not attach tickets to email') + pass - if event: - with scopes_disabled(): - event = Event.objects.get(id=event) - organizer = event.organizer - backend = event.get_mail_backend() - cm = lambda: scope(organizer=event.organizer) # noqa - elif organizer: - with scopes_disabled(): - organizer = Organizer.objects.get(id=organizer) - backend = organizer.get_mail_backend() - cm = lambda: scope(organizer=organizer) # noqa - else: - backend = get_connection(fail_silently=False) - cm = lambda: scopes_disabled() # noqa - - with cm(): - if customer: - customer = Customer.objects.get(pk=customer) - if not user: - error_log_action_type = 'pretix.customer.email.error' - log_target = customer - - if event: - if order: - try: - order = event.orders.get(pk=order) - error_log_action_type = 'pretix.event.order.email.error' - log_target = order - except Order.DoesNotExist: - order = None + if attach_size * 1.37 < settings.FILE_UPLOAD_MAX_SIZE_EMAIL_ATTACHMENT - 1024 * 1024: + # Do not attach more than (limit - 1 MB) in tickets (1MB space for invoice, email itself, …), + # it will bounce way too often. + # 1 MB is the buffer for the rest of the email (text, invoice, calendar, pictures) + # 1.37 is the factor for base64 encoding https://en.wikipedia.org/wiki/Base64 + for a in args: + try: + email.attach(*a) + except: + pass else: - with language(order.locale, event.settings.region): - if not event.settings.mail_attach_tickets: - attach_tickets = False - if position: - try: - position = order.positions.get(pk=position) - except OrderPosition.DoesNotExist: - attach_tickets = False - if attach_tickets: - args = [] - attach_size = 0 - for name, ct in get_tickets_for_order(order, base_position=position): - try: - content = ct.file.read() - args.append((name, content, ct.type)) - attach_size += len(content) - except: - # This sometimes fails e.g. with FileNotFoundError. We haven't been able to figure out - # why (probably some race condition with ticket cache invalidation?), so retry later. - try: - self.retry(max_retries=5, countdown=60) - except MaxRetriesExceededError: - # Well then, something is really wrong, let's send it without attachment before we - # don't sent at all - logger.exception('Could not attach invoice to email') - pass + outgoing_mail.order.log_action( + 'pretix.event.order.email.attachments.skipped', + data={ + 'subject': 'Attachments skipped', + 'message': 'Attachment have not been send because {} bytes are likely too large to arrive.'.format(attach_size), + 'recipient': '', + 'invoices': [], + } + ) - if attach_size * 1.37 < settings.FILE_UPLOAD_MAX_SIZE_EMAIL_ATTACHMENT - 1024 * 1024: - # Do not attach more than (limit - 1 MB) in tickets (1MB space for invoice, email itself, …), - # it will bounce way to often. - # 1 MB is the buffer for the rest of the email (text, invoice, calendar, pictures) - # 1.37 is the factor for base64 encoding https://en.wikipedia.org/wiki/Base64 - for a in args: - try: - email.attach(*a) - except: - pass - else: - order.log_action( - 'pretix.event.order.email.attachments.skipped', - data={ - 'subject': 'Attachments skipped', - 'message': 'Attachment have not been send because {} bytes are likely too large to arrive.'.format(attach_size), - 'recipient': '', - 'invoices': [], - } - ) - if attach_ical: - fname = re.sub('[^a-zA-Z0-9 ]', '-', unidecode(pgettext('attachment_filename', 'Calendar invite'))) - for i, cal in enumerate(get_private_icals(event, [position] if position else order.positions.all())): - email.attach('{}{}.ics'.format(fname, f'-{i + 1}' if i > 0 else ''), cal.serialize(), 'text/calendar') - - email = email_filter.send_chained(event, 'message', message=email, order=order, user=user) + # Attach calendar files + if outgoing_mail.should_attach_ical and outgoing_mail.order: + fname = re.sub('[^a-zA-Z0-9 ]', '-', unidecode(pgettext('attachment_filename', 'Calendar invite'))) + icals = get_private_icals( + outgoing_mail.event, + [outgoing_mail.orderposition] if outgoing_mail.orderposition else outgoing_mail.order.positions.all() + ) + for i, cal in enumerate(icals): + name = '{}{}.ics'.format(fname, f'-{i + 1}' if i > 0 else '') + content = cal.serialize() + mimetype = 'text/calendar' + email.attach(name, content, mimetype) invoices_to_mark_transmitted = [] - if invoices: - invoices = Invoice.objects.filter(pk__in=invoices) - for inv in invoices: - if inv.file: - try: - # We try to give the invoice a more human-readable name, e.g. "Invoice_ABC-123.pdf" instead of - # just "ABC-123.pdf", but we only do so if our currently selected language allows to do this - # as ASCII text. For example, we would not want a "فاتورة_" prefix for our filename since this - # has shown to cause deliverability problems of the email and deliverability wins. - with language(order.locale if order else inv.locale, event.settings.region if event else None): - filename = pgettext('invoice', 'Invoice {num}').format(num=inv.number).replace(' ', '_') + '.pdf' - if not re.match("^[a-zA-Z0-9-_%./,&:# ]+$", filename): - filename = inv.number.replace(' ', '_') + '.pdf' - filename = re.sub("[^a-zA-Z0-9-_.]+", "_", filename) - with language(inv.order.locale): - email.attach( - filename, - inv.file.file.read(), - 'application/pdf' + for inv in outgoing_mail.should_attach_invoices.all(): + if inv.file: + try: + # We try to give the invoice a more human-readable name, e.g. "Invoice_ABC-123.pdf" instead of + # just "ABC-123.pdf", but we only do so if our currently selected language allows to do this + # as ASCII text. For example, we would not want a "فاتورة_" prefix for our filename since this + # has shown to cause deliverability problems of the email and deliverability wins. + with language(outgoing_mail.order.locale if outgoing_mail.order else inv.locale, outgoing_mail.event.settings.region): + filename = pgettext('invoice', 'Invoice {num}').format(num=inv.number).replace(' ', '_') + '.pdf' + if not re.match("^[a-zA-Z0-9-_%./,&:# ]+$", filename): + filename = inv.number.replace(' ', '_') + '.pdf' + filename = re.sub("[^a-zA-Z0-9-_.]+", "_", filename) + content = inv.file.file.read() + with language(inv.order.locale): + email.attach( + filename, + content, + 'application/pdf' + ) + invoices_attached.append(inv) + except Exception: + logger.exception('Could not attach invoice to email') + pass + else: + if inv.transmission_type == "email": + # Mark invoice as sent when it was sent to the requested address *either* at the time of invoice + # creation *or* as of right now. + expected_recipients = [ + (inv.invoice_to_transmission_info or {}).get("transmission_email_address") + or inv.order.email, + ] + try: + expected_recipients.append( + (inv.order.invoice_address.transmission_info or {}).get("transmission_email_address") + or inv.order.email ) + except InvoiceAddress.DoesNotExist: + pass + if any(t in expected_recipients for t in outgoing_mail.to): + invoices_to_mark_transmitted.append(inv) - if inv.transmission_type == "email": - # Mark invoice as sent when it was sent to the requested address *either* at the time of - # invoice creation *or* as of right now. - expected_recipients = [ - (inv.invoice_to_transmission_info or {}).get("transmission_email_address") - or inv.order.email, - ] - try: - expected_recipients.append( - (inv.order.invoice_address.transmission_info or {}).get("transmission_email_address") - or inv.order.email - ) - except InvoiceAddress.DoesNotExist: - pass - if any(t in expected_recipients for t in to): - invoices_to_mark_transmitted.append(inv) - except: - logger.exception('Could not attach invoice to email') - pass + for fname in outgoing_mail.should_attach_other_files: + ftype, _ = mimetypes.guess_type(fname) + data = default_storage.open(fname).read() + try: + email.attach( + clean_filename(os.path.basename(fname)), + data, + ftype + ) + except: + logger.exception('Could not attach file to email') + pass - if attach_other_files: - for fname in attach_other_files: - ftype, _ = mimetypes.guess_type(fname) - data = default_storage.open(fname).read() + for cf in outgoing_mail.should_attach_cached_files.all(): + if cf.file: try: email.attach( - clean_filename(os.path.basename(fname)), - data, - ftype + cf.filename, + cf.file.file.read(), + cf.type, ) except: logger.exception('Could not attach file to email') pass - if attach_cached_files: - for cf in CachedFile.objects.filter(id__in=attach_cached_files): - if cf.file: - try: - email.attach( - cf.filename, - cf.file.file.read(), - cf.type, - ) - except: - logger.exception('Could not attach file to email') - pass + if outgoing_mail.event: + with outgoing_mail.scope_manager(): + email = email_filter.send_chained( + outgoing_mail.event, 'message', message=email, order=outgoing_mail.order, user=outgoing_mail.user + ) - email = global_email_filter.send_chained(event, 'message', message=email, user=user, order=order, - organizer=organizer, customer=customer) + email = global_email_filter.send_chained( + outgoing_mail.event, 'message', message=email, user=outgoing_mail.user, order=outgoing_mail.order, + organizer=outgoing_mail.organizer, customer=outgoing_mail.customer + ) + outgoing_mail.actual_attachments = [ + { + "name": a[0], + "size": len(a[1]), + "type": a[2], + } for a in email.attachments + ] + + backend = outgoing_mail.get_mail_backend() try: backend.send_messages([email]) - except (smtplib.SMTPResponseException, smtplib.SMTPSenderRefused) as e: - if e.smtp_code in (101, 111, 421, 422, 431, 432, 442, 447, 452): - if e.smtp_code == 432 and settings.HAS_REDIS: - # This is likely Microsoft Exchange Online which has a pretty bad rate limit of max. 3 concurrent - # SMTP connections which is *easily* exceeded with many celery threads. Just retrying with exponential - # backoff won't be good enough if we have a lot of emails, instead we'll need to make sure our retry - # intervals scatter such that the email won't all be retried at the same time again and cause the - # same problem. - # See also https://docs.microsoft.com/en-us/exchange/troubleshoot/send-emails/smtp-submission-improvements + except Exception as e: + logger.exception('Error sending email') + retry_strategy = _retry_strategy(e) + err, err_detail = _format_error(e) + + outgoing_mail.error = err + outgoing_mail.error_detail = err_detail + outgoing_mail.sent = now() + + # Run retries + try: + if retry_strategy == "microsoft_concurrency" and settings.HAS_REDIS: from django_redis import get_redis_connection redis_key = "pretix_mail_retry_" + hashlib.sha1(f"{getattr(backend, 'username', '_')}@{getattr(backend, 'host', '_')}".encode()).hexdigest() @@ -589,124 +552,70 @@ def mail_send_task(self, *args, to: List[str], subject: str, body: str, html: st max_retries = 10 retry_after = min(30 + cnt * 10, 1800) - else: - # Most likely some other kind of temporary failure, retry again (but pretty soon) + + outgoing_mail.status = OutgoingMail.STATUS_AWAWITING_RETRY + outgoing_mail.save(upate_fields=["status", "error", "error_detail", "sent"]) + 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.save(upate_fields=["status", "error", "error_detail", "sent"]) + self.retry(max_retries=max_retries, countdown=retry_after) # throws RetryException, ends function flow - try: - self.retry(max_retries=max_retries, countdown=retry_after) - except MaxRetriesExceededError: - if log_target: - log_target.log_action( - error_log_action_type, - data={ - 'subject': 'SMTP code {}, max retries exceeded'.format(e.smtp_code), - 'message': e.smtp_error.decode() if isinstance(e.smtp_error, bytes) else str(e.smtp_error), - 'recipient': '', - 'invoices': [], - } - ) - for i in invoices_to_mark_transmitted: - i.set_transmission_failed(provider="email_pdf", data={ - "reason": "exception", - "exception": "SMTP code {}, max retries exceeded".format(e.smtp_code), - }) - raise e + elif retry_strategy == "slow": + outgoing_mail.status = OutgoingMail.STATUS_AWAWITING_RETRY + outgoing_mail.save(upate_fields=["status", "error", "error_detail", "sent"]) + self.retry(max_retries=5, countdown=[60, 300, 600, 1200, 1800, 1800][self.request.retries]) # throws RetryException, ends function flow - logger.exception('Error sending email') + except MaxRetriesExceededError: + for i in invoices_to_mark_transmitted: + i.set_transmission_failed(provider="email_pdf", data={ + "reason": "exception", + "exception": "{}, max retries exceeded".format(err), + "detail": err_detail, + }) + + if log_target: + log_target.log_action( + error_log_action_type, + data={ + 'subject': f'{err} (max retries exceeded)', + 'message': err_detail, + 'recipient': '', + 'invoices': [], + } + ) + return False + + # If we reach this, it's a non-retryable error + outgoing_mail.status = OutgoingMail.STATUS_FAILED + outgoing_mail.sent = now() + outgoing_mail.save(upate_fields=["status", "error", "error_detail", "sent"]) + for i in invoices_to_mark_transmitted: + i.set_transmission_failed(provider="email_pdf", data={ + "reason": "exception", + "exception": err, + "detail": err_detail, + }) if log_target: log_target.log_action( error_log_action_type, data={ - 'subject': 'SMTP code {}'.format(e.smtp_code), - 'message': e.smtp_error.decode() if isinstance(e.smtp_error, bytes) else str(e.smtp_error), + 'subject': err, + 'message': err_detail, 'recipient': '', 'invoices': [], } ) - for i in invoices_to_mark_transmitted: - i.set_transmission_failed(provider="email_pdf", data={ - "reason": "exception", - "exception": "SMTP code {}".format(e.smtp_code), - }) - - raise SendMailException('Failed to send an email to {}.'.format(to)) - except smtplib.SMTPRecipientsRefused as e: - smtp_codes = [a[0] for a in e.recipients.values()] - - if not any(c >= 500 for c in smtp_codes) or any(b'Message is too large' in a[1] for a in e.recipients.values()): - # This is not a permanent failure (mailbox full, service unavailable), retry later, but with large - # intervals. One would think that "Message is too lage" is a permanent failure, but apparently it is not. - # We have documented cases of emails to Microsoft returning the error occasionally and then later - # allowing the very same email. - try: - self.retry(max_retries=5, countdown=[60, 300, 600, 1200, 1800, 1800][self.request.retries]) - except MaxRetriesExceededError: - # ignore and go on with logging the error - pass - - logger.exception('Error sending email') - if log_target: - message = [] - for e, val in e.recipients.items(): - message.append(f'{e}: {val[0]} {val[1].decode()}') - - log_target.log_action( - error_log_action_type, - data={ - 'subject': 'SMTP error', - 'message': '\n'.join(message), - 'recipient': '', - 'invoices': [], - } - ) - for i in invoices_to_mark_transmitted: - i.set_transmission_failed(provider="email_pdf", data={ - "reason": "exception", - "exception": "SMTP error", - }) - - raise SendMailException('Failed to send an email to {}.'.format(to)) - except Exception as e: - if isinstance(e, OSError) and not isinstance(e, smtplib.SMTPNotSupportedError): - try: - self.retry(max_retries=5, countdown=[10, 30, 60, 300, 900, 900][self.request.retries]) - except MaxRetriesExceededError: - if log_target: - log_target.log_action( - error_log_action_type, - data={ - 'subject': 'Internal error', - 'message': f'Max retries exceeded after error "{str(e)}"', - 'recipient': '', - 'invoices': [], - } - ) - for i in invoices_to_mark_transmitted: - i.set_transmission_failed(provider="email_pdf", data={ - "reason": "exception", - "exception": "Internal error", - }) - raise e - if log_target: - log_target.log_action( - error_log_action_type, - data={ - 'subject': 'Internal error', - 'message': str(e), - 'recipient': '', - 'invoices': [], - } - ) - for i in invoices_to_mark_transmitted: - i.set_transmission_failed(provider="email_pdf", data={ - "reason": "exception", - "exception": "Internal error", - }) - logger.exception('Error sending email') - raise SendMailException('Failed to send an email to {}.'.format(to)) + return False else: + 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.save(upate_fields=["status", "error", "error_detail", "sent", "actual_attachments"]) for i in invoices_to_mark_transmitted: if i.transmission_status != Invoice.TRANSMISSION_STATUS_COMPLETED: i.transmission_date = now() @@ -751,7 +660,7 @@ def mail_send(*args, **kwargs): mail_send_task.apply_async(args=args, kwargs=kwargs) -def render_mail(template, context, placeholder_mode=SafeFormatter.MODE_RICH_TO_PLAIN): +def render_mail(template, context, placeholder_mode: Optional[int]=SafeFormatter.MODE_RICH_TO_PLAIN): if isinstance(template, LazyI18nString): body = str(template) if context and placeholder_mode: @@ -866,3 +775,121 @@ def normalize_image_url(url): else: url = urljoin(settings.MEDIA_URL, url) return url + + +def _autoextend_context(context, order): + try: + context.update({ + 'invoice_name': order.invoice_address.name, + 'invoice_company': order.invoice_address.company + }) + except InvoiceAddress.DoesNotExist: + context.update({ + 'invoice_name': '', + 'invoice_company': '' + }) + + +def _full_sender(sender_address, event, organizer): + sender_address = ( + sender_address or + (event.settings.get('mail_from') if event else None) or + (organizer.settings.get('mail_from') if organizer else None) or + settings.MAIL_FROM + ) + if event: + sender_name = event.settings.mail_from_name or str(event.name) + elif organizer: + sender_name = organizer.settings.mail_from_name or str(organizer.name) + else: + sender_name = settings.PRETIX_INSTANCE_NAME + + sender = formataddr((clean_sender_name(sender_name), sender_address)) + return sender + + +def _wrap_plain_body(content_plain, signature, event, order, position, no_order_links): + body_plain = content_plain + body_plain += "\r\n\r\n-- \r\n" + + if signature: + signature = signature.format(event=event.name if event else '') + body_plain += signature + body_plain += "\r\n\r\n-- \r\n" + + if event and order and position and not no_order_links: + body_plain += _( + "You are receiving this email because someone placed an order for {event} for you." + ).format(event=event.name) + body_plain += "\r\n" + body_plain += _( + "You can view your order details at the following URL:\n{orderurl}." + ).replace("\n", "\r\n").format( + event=event.name, orderurl=build_absolute_uri( + order.event, 'presale:event.order.position', kwargs={ + 'order': order.code, + 'secret': position.web_secret, + 'position': position.positionid, + } + ) + ) + elif event and order and not no_order_links: + body_plain += _( + "You are receiving this email because you placed an order for {event}." + ).format(event=event.name) + body_plain += "\r\n" + body_plain += _( + "You can view your order details at the following URL:\n{orderurl}." + ).replace("\n", "\r\n").format( + event=event.name, orderurl=build_absolute_uri( + order.event, 'presale:event.order.open', kwargs={ + 'order': order.code, + 'secret': order.secret, + 'hash': order.email_confirm_secret() + } + ) + ) + body_plain += "\r\n" + + return body_plain + + +def _retry_strategy(e: Exception): + if isinstance(e, (smtplib.SMTPResponseException, smtplib.SMTPSenderRefused)): + if e.smtp_code == 432: + # This is likely Microsoft Exchange Online which has a pretty bad rate limit of max. 3 concurrent + # SMTP connections which is *easily* exceeded with many celery threads. Just retrying with exponential + # backoff won't be good enough if we have a lot of emails, instead we'll need to make sure our retry + # intervals scatter such that the email won't all be retried at the same time again and cause the + # same problem. + # See also https://docs.microsoft.com/en-us/exchange/troubleshoot/send-emails/smtp-submission-improvements + return "microsoft_concurrncy" + + if e.smtp_code in (101, 111, 421, 422, 431, 432, 442, 447, 452): + return "quick" + + elif isinstance(e, smtplib.SMTPRecipientsRefused): + smtp_codes = [a[0] for a in e.recipients.values()] + + if not any(c >= 500 for c in smtp_codes) or any(b'Message is too large' in a[1] for a in e.recipients.values()): + # This is not a permanent failure (mailbox full, service unavailable), retry later, but with large + # intervals. One would think that "Message is too lage" is a permanent failure, but apparently it is not. + # We have documented cases of emails to Microsoft returning the error occasionally and then later + # allowing the very same email. + return "slow" + + elif isinstance(e, OSError) and not isinstance(e, smtplib.SMTPNotSupportedError): + # Most likely some other kind of temporary failure, retry again (but pretty soon) + return "quick" + + +def _format_error(e: Exception): + if isinstance(e, (smtplib.SMTPResponseException, smtplib.SMTPSenderRefused)): + return 'SMTP code {}'.format(e.smtp_code), e.smtp_error.decode() if isinstance(e.smtp_error, bytes) else str(e.smtp_error) + elif isinstance(e, smtplib.SMTPRecipientsRefused): + message = [] + for e, val in e.recipients.items(): + message.append(f'{e}: {val[0]} {val[1].decode()}') + return 'SMTP recipients refudes', '\n'.join(message) + else: + return 'Internal error', str(e) diff --git a/src/pretix/base/services/orders.py b/src/pretix/base/services/orders.py index 365247483f..daed61d68d 100644 --- a/src/pretix/base/services/orders.py +++ b/src/pretix/base/services/orders.py @@ -90,7 +90,6 @@ from pretix.base.services.invoices import ( from pretix.base.services.locking import ( LOCK_TRUST_WINDOW, LockTimeoutException, lock_objects, ) -from pretix.base.services.mail import SendMailException from pretix.base.services.memberships import ( create_membership, validate_memberships_in_order, ) @@ -438,33 +437,27 @@ def approve_order(order, user=None, send_mail: bool=True, auth=None, force=False email_attendee_subject = order.event.settings.mail_subject_order_approved_attendee email_context = get_email_context(event=order.event, order=order) - try: - order.send_mail( - email_subject, email_template, email_context, - 'pretix.event.order.email.order_approved', user, - attach_tickets=True, - attach_ical=order.event.settings.mail_attach_ical and ( - not order.event.settings.mail_attach_ical_paid_only or - order.total == Decimal('0.00') or - order.valid_if_pending - ), - invoices=[invoice] if invoice and transmit_invoice_mail else [] - ) - except SendMailException: - logger.exception('Order approved email could not be sent') + order.send_mail( + email_subject, email_template, email_context, + 'pretix.event.order.email.order_approved', user, + attach_tickets=True, + attach_ical=order.event.settings.mail_attach_ical and ( + not order.event.settings.mail_attach_ical_paid_only or + order.total == Decimal('0.00') or + order.valid_if_pending + ), + invoices=[invoice] if invoice and transmit_invoice_mail else [] + ) if email_attendees: for p in order.positions.all(): if p.addon_to_id is None and p.attendee_email and p.attendee_email != order.email: email_attendee_context = get_email_context(event=order.event, order=order, position=p) - try: - p.send_mail( - email_attendee_subject, email_attendee_template, email_attendee_context, - 'pretix.event.order.email.order_approved', user, - attach_tickets=True, - ) - except SendMailException: - logger.exception('Order approved email could not be sent to attendee') + p.send_mail( + email_attendee_subject, email_attendee_template, email_attendee_context, + 'pretix.event.order.email.order_approved', user, + attach_tickets=True, + ) return order.pk @@ -501,13 +494,10 @@ def deny_order(order, comment='', user=None, send_mail: bool=True, auth=None): email_template = order.event.settings.mail_text_order_denied email_subject = order.event.settings.mail_subject_order_denied email_context = get_email_context(event=order.event, order=order, comment=comment) - try: - order.send_mail( - email_subject, email_template, email_context, - 'pretix.event.order.email.order_denied', user - ) - except SendMailException: - logger.exception('Order denied email could not be sent') + order.send_mail( + email_subject, email_template, email_context, + 'pretix.event.order.email.order_denied', user + ) return order.pk @@ -660,14 +650,11 @@ def _cancel_order(order, user=None, send_mail: bool=True, api_token=None, device email_template = order.event.settings.mail_text_order_canceled email_subject = order.event.settings.mail_subject_order_canceled email_context = get_email_context(event=order.event, order=order, comment=comment or "") - try: - order.send_mail( - email_subject, email_template, email_context, - 'pretix.event.order.email.order_canceled', user, - invoices=transmit_invoices_mail, - ) - except SendMailException: - logger.exception('Order canceled email could not be sent') + order.send_mail( + email_subject, email_template, email_context, + 'pretix.event.order.email.order_canceled', user, + invoices=transmit_invoices_mail, + ) for p in order.payments.filter(state__in=(OrderPayment.PAYMENT_STATE_CREATED, OrderPayment.PAYMENT_STATE_PENDING)): try: @@ -1108,46 +1095,40 @@ def _order_placed_email(event: Event, order: Order, email_template, subject_temp log_entry: str, invoice, payments: List[OrderPayment], is_free=False): email_context = get_email_context(event=event, order=order, payments=payments) - try: - order.send_mail( - subject_template, email_template, email_context, - log_entry, - invoices=[invoice] if invoice else [], - attach_tickets=True, - attach_ical=event.settings.mail_attach_ical and ( - not event.settings.mail_attach_ical_paid_only or - is_free or - order.valid_if_pending - ), - attach_other_files=[a for a in [ - event.settings.get('mail_attachment_new_order', as_type=str, default='')[len('file://'):] - ] if a], - ) - except SendMailException: - logger.exception('Order received email could not be sent') + order.send_mail( + subject_template, email_template, email_context, + log_entry, + invoices=[invoice] if invoice else [], + attach_tickets=True, + attach_ical=event.settings.mail_attach_ical and ( + not event.settings.mail_attach_ical_paid_only or + is_free or + order.valid_if_pending + ), + attach_other_files=[a for a in [ + event.settings.get('mail_attachment_new_order', as_type=str, default='')[len('file://'):] + ] if a], + ) def _order_placed_email_attendee(event: Event, order: Order, position: OrderPosition, email_template, subject_template, log_entry: str, is_free=False): email_context = get_email_context(event=event, order=order, position=position) - try: - position.send_mail( - subject_template, email_template, email_context, - log_entry, - invoices=[], - attach_tickets=True, - attach_ical=event.settings.mail_attach_ical and ( - not event.settings.mail_attach_ical_paid_only or - is_free or - order.valid_if_pending - ), - attach_other_files=[a for a in [ - event.settings.get('mail_attachment_new_order', as_type=str, default='')[len('file://'):] - ] if a], - ) - except SendMailException: - logger.exception('Order received email could not be sent to attendee') + position.send_mail( + subject_template, email_template, email_context, + log_entry, + invoices=[], + attach_tickets=True, + attach_ical=event.settings.mail_attach_ical and ( + not event.settings.mail_attach_ical_paid_only or + is_free or + order.valid_if_pending + ), + attach_other_files=[a for a in [ + event.settings.get('mail_attachment_new_order', as_type=str, default='')[len('file://'):] + ] if a], + ) def _perform_order(event: Event, payment_requests: List[dict], position_ids: List[str], @@ -1476,13 +1457,10 @@ def send_expiry_warnings(sender, **kwargs): email_template = settings.mail_text_order_pending_warning email_subject = settings.mail_subject_order_pending_warning - try: - o.send_mail( - email_subject, email_template, email_context, - 'pretix.event.order.email.expire_warning_sent' - ) - except SendMailException: - logger.exception('Reminder email could not be sent') + o.send_mail( + email_subject, email_template, email_context, + 'pretix.event.order.email.expire_warning_sent' + ) @receiver(signal=periodic_task) @@ -1543,14 +1521,11 @@ def send_download_reminders(sender, **kwargs): email_template = event.settings.mail_text_download_reminder email_subject = event.settings.mail_subject_download_reminder email_context = get_email_context(event=event, order=o) - try: - o.send_mail( - email_subject, email_template, email_context, - 'pretix.event.order.email.download_reminder_sent', - attach_tickets=True - ) - except SendMailException: - logger.exception('Reminder email could not be sent') + o.send_mail( + email_subject, email_template, email_context, + 'pretix.event.order.email.download_reminder_sent', + attach_tickets=True + ) if event.settings.mail_send_download_reminder_attendee: for p in positions: @@ -1564,14 +1539,11 @@ def send_download_reminders(sender, **kwargs): email_template = event.settings.mail_text_download_reminder_attendee email_subject = event.settings.mail_subject_download_reminder_attendee email_context = get_email_context(event=event, order=o, position=p) - try: - o.send_mail( - email_subject, email_template, email_context, - 'pretix.event.order.email.download_reminder_sent', - attach_tickets=True, position=p - ) - except SendMailException: - logger.exception('Reminder email could not be sent to attendee') + o.send_mail( + email_subject, email_template, email_context, + 'pretix.event.order.email.download_reminder_sent', + attach_tickets=True, position=p + ) def notify_user_changed_order(order, user=None, auth=None, invoices=[]): @@ -1579,13 +1551,10 @@ def notify_user_changed_order(order, user=None, auth=None, invoices=[]): email_template = order.event.settings.mail_text_order_changed email_context = get_email_context(event=order.event, order=order) email_subject = order.event.settings.mail_subject_order_changed - try: - order.send_mail( - email_subject, email_template, email_context, - 'pretix.event.order.email.order_changed', user, auth=auth, invoices=invoices, attach_tickets=True, - ) - except SendMailException: - logger.exception('Order changed email could not be sent') + order.send_mail( + email_subject, email_template, email_context, + 'pretix.event.order.email.order_changed', user, auth=auth, invoices=invoices, attach_tickets=True, + ) class OrderChangeManager: diff --git a/src/pretix/base/services/shredder.py b/src/pretix/base/services/shredder.py index 84ef4eee00..6211ad4a85 100644 --- a/src/pretix/base/services/shredder.py +++ b/src/pretix/base/services/shredder.py @@ -48,7 +48,7 @@ from django.utils.translation import gettext_lazy as _ from pretix.base.i18n import language from pretix.base.models import CachedFile, Event, User, cachedfile_name -from pretix.base.services.mail import SendMailException, mail +from pretix.base.services.mail import mail from pretix.base.services.tasks import ProfiledEventTask from pretix.base.shredder import ShredError from pretix.celery_app import app @@ -171,21 +171,18 @@ def shred(self, event: Event, fileid: str, confirm_code: str, user: int=None, lo if user: with language(user.locale): - try: - mail( - user.email, - _('Data shredding completed'), - 'pretixbase/email/shred_completed.txt', - { - 'user': user, - 'organizer': event.organizer.name, - 'event': str(event.name), - 'start_time': date_format(parse(indexdata['time']).astimezone(event.timezone), 'SHORT_DATETIME_FORMAT'), - 'shredders': ', '.join([str(s.verbose_name) for s in shredders]) - }, - event=None, - user=user, - locale=user.locale, - ) - except SendMailException: - pass # Already logged + mail( + user.email, + _('Data shredding completed'), + 'pretixbase/email/shred_completed.txt', + { + 'user': user, + 'organizer': event.organizer.name, + 'event': str(event.name), + 'start_time': date_format(parse(indexdata['time']).astimezone(event.timezone), 'SHORT_DATETIME_FORMAT'), + 'shredders': ', '.join([str(s.verbose_name) for s in shredders]) + }, + event=None, + user=user, + locale=user.locale, + ) diff --git a/src/pretix/control/views/auth.py b/src/pretix/control/views/auth.py index f4cc13335c..2f0d0fa37c 100644 --- a/src/pretix/control/views/auth.py +++ b/src/pretix/control/views/auth.py @@ -65,7 +65,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 @@ -346,9 +345,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 diff --git a/src/pretix/control/views/orders.py b/src/pretix/control/views/orders.py index 39e515d89e..07eea4d948 100644 --- a/src/pretix/control/views/orders.py +++ b/src/pretix/control/views/orders.py @@ -97,9 +97,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, @@ -1064,10 +1062,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: @@ -1538,9 +1532,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': @@ -1778,15 +1769,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()) @@ -2430,24 +2417,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): @@ -2500,23 +2481,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) diff --git a/src/pretix/control/views/organizer.py b/src/pretix/control/views/organizer.py index 3c3c72c753..66b8565cc3 100644 --- a/src/pretix/control/views/organizer.py +++ b/src/pretix/control/views/organizer.py @@ -102,7 +102,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 @@ -1036,24 +1036,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): diff --git a/src/pretix/control/views/users.py b/src/pretix/control/views/users.py index d6ed6b96e4..62c3d75ea5 100644 --- a/src/pretix/control/views/users.py +++ b/src/pretix/control/views/users.py @@ -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) diff --git a/src/pretix/helpers/security.py b/src/pretix/helpers/security.py index baa8f3a159..2e5b545b57 100644 --- a/src/pretix/helpers/security.py +++ b/src/pretix/helpers/security.py @@ -32,7 +32,7 @@ from django_countries.fields import Country from geoip2.errors import AddressNotFoundError from pretix.base.i18n import language -from pretix.base.services.mail import SendMailException, mail +from pretix.base.services.mail import mail from pretix.helpers.http import get_client_ip from pretix.helpers.urls import build_absolute_uri @@ -159,21 +159,18 @@ def handle_login_source(user, request): }) if user.known_login_sources.count() > 1: # Do not send on first login or first login after introduction of this feature: - try: - with language(user.locale): - mail( - user.email, - _('Login from new source detected'), - 'pretixcontrol/email/login_notice.txt', - { - 'source': src, - 'country': Country(str(country)).name if country else _('Unknown country'), - 'instance': settings.PRETIX_INSTANCE_NAME, - 'url': build_absolute_uri('control:user.settings') - }, - event=None, - user=user, - locale=user.locale - ) - except SendMailException: - pass # Not much we can do + with language(user.locale): + mail( + user.email, + _('Login from new source detected'), + 'pretixcontrol/email/login_notice.txt', + { + 'source': src, + 'country': Country(str(country)).name if country else _('Unknown country'), + 'instance': settings.PRETIX_INSTANCE_NAME, + 'url': build_absolute_uri('control:user.settings') + }, + event=None, + user=user, + locale=user.locale + ) diff --git a/src/pretix/plugins/banktransfer/tasks.py b/src/pretix/plugins/banktransfer/tasks.py index 464fa6b16b..1d2144668c 100644 --- a/src/pretix/plugins/banktransfer/tasks.py +++ b/src/pretix/plugins/banktransfer/tasks.py @@ -56,7 +56,6 @@ from pretix.base.models import ( ) from pretix.base.payment import PaymentException from pretix.base.services.locking import LockTimeoutException -from pretix.base.services.mail import SendMailException from pretix.base.services.orders import change_payment_provider from pretix.base.services.tasks import TransactionAwareTask from pretix.celery_app import app @@ -72,13 +71,10 @@ def notify_incomplete_payment(o: Order): email_context = get_email_context(event=o.event, order=o, pending_sum=o.pending_sum) email_subject = o.event.settings.mail_subject_order_incomplete_payment - try: - o.send_mail( - email_subject, email_template, email_context, - 'pretix.event.order.email.expire_warning_sent' - ) - except SendMailException: - logger.exception('Reminder email could not be sent') + o.send_mail( + email_subject, email_template, email_context, + 'pretix.event.order.email.expire_warning_sent' + ) def cancel_old_payments(order): @@ -288,9 +284,6 @@ def _handle_transaction(trans: BankTransaction, matches: tuple, regex_match_to_s except Quota.QuotaExceededException: # payment confirmed but order status could not be set, no longer problem of this plugin cancel_old_payments(order) - except SendMailException: - # payment confirmed but order status could not be set, no longer problem of this plugin - cancel_old_payments(order) else: cancel_old_payments(order) diff --git a/src/pretix/plugins/banktransfer/views.py b/src/pretix/plugins/banktransfer/views.py index 29926aa2cb..9733e32034 100644 --- a/src/pretix/plugins/banktransfer/views.py +++ b/src/pretix/plugins/banktransfer/views.py @@ -58,7 +58,6 @@ from localflavor.generic.forms import BICFormField, IBANFormField from pretix.base.forms.widgets import DatePickerWidget from pretix.base.models import Event, Order, OrderPayment, OrderRefund, Quota -from pretix.base.services.mail import SendMailException from pretix.base.settings import SettingsSandbox from pretix.base.templatetags.money import money_filter from pretix.control.permissions import ( @@ -160,11 +159,6 @@ class ActionView(View): p.confirm(user=self.request.user) except Quota.QuotaExceededException: pass - except SendMailException: - return JsonResponse({ - 'status': 'error', - 'message': _('Problem sending email.') - }) trans.state = BankTransaction.STATE_VALID trans.save() trans.order.payments.filter( diff --git a/src/pretix/plugins/paypal/payment.py b/src/pretix/plugins/paypal/payment.py index 63cb035014..9acc5ae4b2 100644 --- a/src/pretix/plugins/paypal/payment.py +++ b/src/pretix/plugins/paypal/payment.py @@ -57,7 +57,6 @@ from pretix.base.decimal import round_decimal from pretix.base.forms import SecretKeySettingsField from pretix.base.models import Event, Order, OrderPayment, OrderRefund, Quota from pretix.base.payment import BasePaymentProvider, PaymentException -from pretix.base.services.mail import SendMailException from pretix.base.settings import SettingsSandbox from pretix.multidomain.urlreverse import build_absolute_uri from pretix.plugins.paypal.api import Api @@ -468,9 +467,6 @@ class Paypal(BasePaymentProvider): payment_obj.confirm() except Quota.QuotaExceededException as e: raise PaymentException(str(e)) - - except SendMailException: - messages.warning(request, _('There was an error sending the confirmation mail.')) return None def payment_pending_render(self, request, payment) -> str: diff --git a/src/pretix/plugins/paypal2/payment.py b/src/pretix/plugins/paypal2/payment.py index 84a0e5dbae..6f1d3774b9 100644 --- a/src/pretix/plugins/paypal2/payment.py +++ b/src/pretix/plugins/paypal2/payment.py @@ -54,7 +54,6 @@ from pretix.base.forms import SecretKeySettingsField from pretix.base.forms.questions import guess_country from pretix.base.models import Event, Order, OrderPayment, OrderRefund, Quota from pretix.base.payment import BasePaymentProvider, PaymentException -from pretix.base.services.mail import SendMailException from pretix.base.settings import SettingsSandbox from pretix.helpers import OF_SELF from pretix.helpers.urls import build_absolute_uri as build_global_uri @@ -821,9 +820,6 @@ class PaypalMethod(BasePaymentProvider): payment.confirm() except Quota.QuotaExceededException as e: raise PaymentException(str(e)) - - except SendMailException: - messages.warning(request, _('There was an error sending the confirmation mail.')) finally: if 'payment_paypal_oid' in request.session: del request.session['payment_paypal_oid'] diff --git a/src/pretix/plugins/sendmail/models.py b/src/pretix/plugins/sendmail/models.py index 0f5733493c..8235eec9c0 100644 --- a/src/pretix/plugins/sendmail/models.py +++ b/src/pretix/plugins/sendmail/models.py @@ -38,7 +38,6 @@ from pretix.base.models import ( fields, ) from pretix.base.models.base import LoggingMixin -from pretix.base.services.mail import SendMailException class ScheduledMail(models.Model): @@ -180,13 +179,10 @@ class ScheduledMail(models.Model): invoice_address=ia, event_or_subevent=self.subevent or e, ) - try: - o.send_mail(self.rule.subject, self.rule.template, email_ctx, - attach_ical=self.rule.attach_ical, - log_entry_type='pretix.plugins.sendmail.rule.order.email.sent') - o_sent = True - except SendMailException: - ... # ¯\_(ツ)_/¯ + o.send_mail(self.rule.subject, self.rule.template, email_ctx, + attach_ical=self.rule.attach_ical, + log_entry_type='pretix.plugins.sendmail.rule.order.email.sent') + o_sent = True if send_to_attendees: if not self.rule.all_products: @@ -195,31 +191,28 @@ class ScheduledMail(models.Model): positions = [p for p in positions if p.subevent_id == self.subevent_id] for p in positions: - try: - if p.attendee_email and (p.attendee_email != o.email or not o_sent): - email_ctx = get_email_context( - event=e, - order=o, - invoice_address=ia, - position=p, - event_or_subevent=self.subevent or e, - ) - p.send_mail(self.rule.subject, self.rule.template, email_ctx, - attach_ical=self.rule.attach_ical, - log_entry_type='pretix.plugins.sendmail.rule.order.position.email.sent') - elif not o_sent and o.email: - email_ctx = get_email_context( - event=e, - order=o, - invoice_address=ia, - event_or_subevent=self.subevent or e, - ) - o.send_mail(self.rule.subject, self.rule.template, email_ctx, - attach_ical=self.rule.attach_ical, - log_entry_type='pretix.plugins.sendmail.rule.order.email.sent') - o_sent = True - except SendMailException: - ... # ¯\_(ツ)_/¯ + if p.attendee_email and (p.attendee_email != o.email or not o_sent): + email_ctx = get_email_context( + event=e, + order=o, + invoice_address=ia, + position=p, + event_or_subevent=self.subevent or e, + ) + p.send_mail(self.rule.subject, self.rule.template, email_ctx, + attach_ical=self.rule.attach_ical, + log_entry_type='pretix.plugins.sendmail.rule.order.position.email.sent') + elif not o_sent and o.email: + email_ctx = get_email_context( + event=e, + order=o, + invoice_address=ia, + event_or_subevent=self.subevent or e, + ) + o.send_mail(self.rule.subject, self.rule.template, email_ctx, + attach_ical=self.rule.attach_ical, + log_entry_type='pretix.plugins.sendmail.rule.order.email.sent') + o_sent = True self.last_successful_order_id = o.pk diff --git a/src/pretix/plugins/sendmail/tasks.py b/src/pretix/plugins/sendmail/tasks.py index 172b1e8c4c..1c5f5e03ee 100644 --- a/src/pretix/plugins/sendmail/tasks.py +++ b/src/pretix/plugins/sendmail/tasks.py @@ -41,7 +41,7 @@ from pretix.base.i18n import language from pretix.base.models import ( CachedFile, Checkin, Event, InvoiceAddress, Order, User, ) -from pretix.base.services.mail import SendMailException, mail +from pretix.base.services.mail import mail from pretix.base.services.tasks import ProfiledEventTask from pretix.celery_app import app from pretix.helpers.format import format_map @@ -61,7 +61,6 @@ def send_mails_to_orders(event: Event, user: int, subject: dict, message: dict, recipients: str, filter_checkins: bool, not_checked_in: bool, checkin_lists: list, attachments: list = None, attach_tickets: bool = False, attach_ical: bool = False) -> None: - failures = [] user = User.objects.get(pk=user) if user else None subject = LazyI18nString(subject) message = LazyI18nString(message) @@ -121,70 +120,64 @@ def send_mails_to_orders(event: Event, user: int, subject: dict, message: dict, if subevents_to and p.subevent.date_from >= subevents_to: continue - try: - with language(o.locale, event.settings.region): - email_context = get_email_context(event=event, order=o, invoice_address=ia, position=p) - mail( - p.attendee_email, - subject, - message, - email_context, - event, - locale=o.locale, - order=o, - position=p, - attach_tickets=attach_tickets, - attach_ical=attach_ical, - attach_cached_files=attachments - ) - o.log_action( - 'pretix.plugins.sendmail.order.email.sent.attendee', - user=user, - data={ - 'position': p.positionid, - 'subject': format_map(subject.localize(o.locale), email_context), - 'message': format_map(message.localize(o.locale), email_context), - 'recipient': p.attendee_email, - 'attach_tickets': attach_tickets, - 'attach_ical': attach_ical, - 'attach_other_files': [], - 'attach_cached_files': attachments_for_log, - } - ) - except SendMailException: - failures.append(p.attendee_email) - - if send_to_order and o.email: - try: with language(o.locale, event.settings.region): - email_context = get_email_context(event=event, order=o, invoice_address=ia) + email_context = get_email_context(event=event, order=o, invoice_address=ia, position=p) mail( - o.email, + p.attendee_email, subject, message, email_context, event, locale=o.locale, order=o, + position=p, attach_tickets=attach_tickets, attach_ical=attach_ical, - attach_cached_files=attachments, + attach_cached_files=attachments ) o.log_action( - 'pretix.plugins.sendmail.order.email.sent', + 'pretix.plugins.sendmail.order.email.sent.attendee', user=user, data={ + 'position': p.positionid, 'subject': format_map(subject.localize(o.locale), email_context), 'message': format_map(message.localize(o.locale), email_context), - 'recipient': o.email, + 'recipient': p.attendee_email, 'attach_tickets': attach_tickets, 'attach_ical': attach_ical, 'attach_other_files': [], 'attach_cached_files': attachments_for_log, } ) - except SendMailException: - failures.append(o.email) + + if send_to_order and o.email: + with language(o.locale, event.settings.region): + email_context = get_email_context(event=event, order=o, invoice_address=ia) + mail( + o.email, + subject, + message, + email_context, + event, + locale=o.locale, + order=o, + attach_tickets=attach_tickets, + attach_ical=attach_ical, + attach_cached_files=attachments, + ) + o.log_action( + 'pretix.plugins.sendmail.order.email.sent', + user=user, + data={ + 'subject': format_map(subject.localize(o.locale), email_context), + 'message': format_map(message.localize(o.locale), email_context), + 'recipient': o.email, + 'attach_tickets': attach_tickets, + 'attach_ical': attach_ical, + 'attach_other_files': [], + 'attach_cached_files': attachments_for_log, + } + ) for chunk in _chunks(objects, 1000): orders = Order.objects.filter(pk__in=chunk, event=event) diff --git a/src/pretix/plugins/stripe/payment.py b/src/pretix/plugins/stripe/payment.py index b87a4efdb7..9900882c8f 100644 --- a/src/pretix/plugins/stripe/payment.py +++ b/src/pretix/plugins/stripe/payment.py @@ -71,7 +71,6 @@ from pretix.base.payment import ( BasePaymentProvider, PaymentException, WalletQueries, ) from pretix.base.plugins import get_all_plugins -from pretix.base.services.mail import SendMailException from pretix.base.settings import SettingsSandbox from pretix.helpers import OF_SELF from pretix.helpers.countries import CachedCountries @@ -1000,9 +999,6 @@ class StripeMethod(BasePaymentProvider): payment.confirm() except Quota.QuotaExceededException as e: raise PaymentException(str(e)) - - except SendMailException: - raise PaymentException(_('There was an error sending the confirmation mail.')) elif intent.status == 'processing': if request: messages.warning(request, _('Your payment is pending completion. We will inform you as soon as the ' diff --git a/src/pretix/presale/checkoutflow.py b/src/pretix/presale/checkoutflow.py index 0629af00f0..980b728612 100644 --- a/src/pretix/presale/checkoutflow.py +++ b/src/pretix/presale/checkoutflow.py @@ -1654,11 +1654,6 @@ class ConfirmStep(CartMixin, AsyncAction, TemplateFlowStep): order = Order.objects.get(id=value) return self.get_order_url(order) - def get_error_message(self, exception): - if exception.__class__.__name__ == 'SendMailException': - return _('There was an error sending the confirmation mail. Please try again later.') - return super().get_error_message(exception) - def get_error_url(self): return self.get_step_url(self.request) diff --git a/src/pretix/presale/views/order.py b/src/pretix/presale/views/order.py index 685b785069..b5304e105d 100644 --- a/src/pretix/presale/views/order.py +++ b/src/pretix/presale/views/order.py @@ -77,7 +77,6 @@ from pretix.base.services.invoices import ( generate_cancellation, generate_invoice, invoice_pdf, invoice_pdf_task, invoice_qualified, ) -from pretix.base.services.mail import SendMailException from pretix.base.services.orders import ( OrderChangeManager, OrderError, _try_auto_refund, cancel_order, change_payment_provider, error_messages, @@ -656,10 +655,7 @@ class OrderPayChangeMethod(EventViewMixin, OrderDetailMixin, TemplateView): amount=Decimal('0.00'), fee=None ) - try: - p.confirm() - except SendMailException: - pass + p.confirm() else: p._mark_order_paid( payment_refund_sum=self.order.payment_refund_sum diff --git a/src/pretix/presale/views/user.py b/src/pretix/presale/views/user.py index a95118408e..7ff3f46380 100644 --- a/src/pretix/presale/views/user.py +++ b/src/pretix/presale/views/user.py @@ -32,8 +32,6 @@ # distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the # License for the specific language governing permissions and limitations under the License. -import logging - from django.conf import settings from django.contrib import messages from django.utils.functional import cached_property @@ -42,7 +40,7 @@ from django.views import View from django.views.generic import TemplateView from pretix.base.email import get_email_context -from pretix.base.services.mail import INVALID_ADDRESS, SendMailException, mail +from pretix.base.services.mail import INVALID_ADDRESS, mail from pretix.helpers.http import redirect_to_url from pretix.multidomain.urlreverse import eventreverse from pretix.presale.forms.user import ResendLinkForm @@ -83,13 +81,7 @@ class ResendLinkView(EventViewMixin, TemplateView): subject = self.request.event.settings.mail_subject_resend_all_links template = self.request.event.settings.mail_text_resend_all_links context = get_email_context(event=self.request.event, orders=orders) - try: - mail(user, subject, template, context, event=self.request.event, locale=self.request.LANGUAGE_CODE) - except SendMailException: - logger = logging.getLogger('pretix.presale.user') - logger.exception('A mail resending order links to {} could not be sent.'.format(user)) - messages.error(self.request, _('We have trouble sending emails right now, please check back later.')) - return self.get(request, *args, **kwargs) + mail(user, subject, template, context, event=self.request.event, locale=self.request.LANGUAGE_CODE) messages.success(self.request, _('If there were any orders by this user, they will receive an email with their order codes.')) return redirect_to_url(eventreverse(self.request.event, 'presale:event.index'))