Invoice email transmission: Handle permanent failures (Z#23205576) (#5420)

* Invoice email transmission: Handle permanent failures (Z#23205576)

* Add missing raise branch

* Fix missing file

* Fix missing license header
This commit is contained in:
Raphael Michel
2025-09-09 10:21:58 +02:00
committed by GitHub
parent 82fcc4fe42
commit 0183f3d40f
5 changed files with 180 additions and 76 deletions
+17
View File
@@ -42,6 +42,7 @@ from django.db.models.functions import Cast
from django.utils import timezone
from django.utils.crypto import get_random_string
from django.utils.functional import cached_property
from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _, pgettext
from django_scopes import ScopedManager
@@ -368,6 +369,22 @@ class Invoice(models.Model):
from pretix.base.invoicing.transmission import transmission_types
return transmission_types.get(identifier=self.transmission_type)[0]
def set_transmission_failed(self, provider, data):
self.transmission_status = Invoice.TRANSMISSION_STATUS_FAILED
self.transmission_date = now()
if not self.transmission_provider and provider:
self.transmission_provider = provider
self.save(update_fields=["transmission_status", "transmission_date", "transmission_provider"])
self.order.log_action(
"pretix.event.order.invoice.sending_failed",
data={
"full_invoice_no": self.full_invoice_no,
"transmission_provider": provider,
"transmission_type": self.transmission_type,
"data": data,
}
)
class InvoiceLine(models.Model):
"""
+5 -29
View File
@@ -664,20 +664,7 @@ def transmit_invoice(sender, invoice_id, allow_retransmission=True, **kwargs):
break
if not provider:
invoice.transmission_status = Invoice.TRANSMISSION_STATUS_FAILED
invoice.transmission_date = now()
invoice.save(update_fields=["transmission_status", "transmission_date"])
invoice.order.log_action(
"pretix.event.order.invoice.sending_failed",
data={
"full_invoice_no": invoice.full_invoice_no,
"transmission_provider": None,
"transmission_type": invoice.transmission_type,
"data": {
"reason": "no_provider",
},
}
)
invoice.set_transmission_failed(provider=None, data={"reason": "no_provider"})
return
if invoice.order.testmode and not provider.testmode_supported:
@@ -698,18 +685,7 @@ def transmit_invoice(sender, invoice_id, allow_retransmission=True, **kwargs):
provider.transmit(invoice)
except Exception as e:
logger.exception(f"Transmission of invoice {invoice.pk} failed with exception.")
invoice.transmission_status = Invoice.TRANSMISSION_STATUS_FAILED
invoice.transmission_date = now()
invoice.save(update_fields=["transmission_status", "transmission_date"])
invoice.order.log_action(
"pretix.event.order.invoice.sending_failed",
data={
"full_invoice_no": invoice.full_invoice_no,
"transmission_provider": None,
"transmission_type": invoice.transmission_type,
"data": {
"reason": "exception",
"exception": str(e),
},
}
)
invoice.set_transmission_failed(provider=provider.identifier, data={
"reason": "exception",
"exception": str(e),
})
+75 -46
View File
@@ -495,7 +495,7 @@ def mail_send_task(self, *args, to: List[str], subject: str, body: str, html: st
email = email_filter.send_chained(event, 'message', message=email, order=order, user=user)
invoices_sent = []
invoices_to_mark_transmitted = []
if invoices:
invoices = Invoice.objects.filter(pk__in=invoices)
for inv in invoices:
@@ -516,7 +516,23 @@ def mail_send_task(self, *args, to: List[str], subject: str, body: str, html: st
inv.file.file.read(),
'application/pdf'
)
invoices_sent.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
@@ -589,6 +605,11 @@ def mail_send_task(self, *args, to: List[str], subject: str, body: str, html: st
'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
logger.exception('Error sending email')
@@ -602,6 +623,11 @@ def mail_send_task(self, *args, to: List[str], subject: str, body: str, html: st
'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:
@@ -633,6 +659,11 @@ def mail_send_task(self, *args, to: List[str], subject: str, body: str, html: st
'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:
@@ -650,6 +681,11 @@ def mail_send_task(self, *args, to: List[str], subject: str, body: str, html: st
'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(
@@ -661,59 +697,52 @@ def mail_send_task(self, *args, to: List[str], subject: str, body: str, html: st
'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))
else:
for i in invoices_sent:
if i.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 = [
(i.invoice_to_transmission_info or {}).get("transmission_email_address") or i.order.email,
]
try:
expected_recipients.append((i.order.invoice_address.transmission_info or {}).get("transmission_email_address") or i.order.email)
except InvoiceAddress.DoesNotExist:
pass
if not any(t in expected_recipients for t in to):
continue
if i.transmission_status != Invoice.TRANSMISSION_STATUS_COMPLETED:
i.transmission_date = now()
i.transmission_status = Invoice.TRANSMISSION_STATUS_COMPLETED
i.transmission_provider = "email_pdf"
i.transmission_info = {
"sent": [
{
"recipients": to,
"datetime": now().isoformat(),
}
]
}
i.save(update_fields=[
"transmission_date", "transmission_provider", "transmission_status",
"transmission_info"
])
elif i.transmission_provider == "email_pdf":
i.transmission_info["sent"].append(
for i in invoices_to_mark_transmitted:
if i.transmission_status != Invoice.TRANSMISSION_STATUS_COMPLETED:
i.transmission_date = now()
i.transmission_status = Invoice.TRANSMISSION_STATUS_COMPLETED
i.transmission_provider = "email_pdf"
i.transmission_info = {
"sent": [
{
"recipients": to,
"datetime": now().isoformat(),
}
)
i.save(update_fields=[
"transmission_info"
])
i.order.log_action(
"pretix.event.order.invoice.sent",
data={
"full_invoice_no": i.full_invoice_no,
"transmission_provider": "email_pdf",
"transmission_type": "email",
"data": {
"recipients": [to],
},
]
}
i.save(update_fields=[
"transmission_date", "transmission_provider", "transmission_status",
"transmission_info"
])
elif i.transmission_provider == "email_pdf":
i.transmission_info["sent"].append(
{
"recipients": to,
"datetime": now().isoformat(),
}
)
i.save(update_fields=[
"transmission_info"
])
i.order.log_action(
"pretix.event.order.invoice.sent",
data={
"full_invoice_no": i.full_invoice_no,
"transmission_provider": "email_pdf",
"transmission_type": "email",
"data": {
"recipients": [to],
},
}
)
def mail_send(*args, **kwargs):
+31
View File
@@ -0,0 +1,31 @@
#
# 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 <https://pretix.eu/about/en/license>.
#
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>.
#
import smtplib
from django.core.mail.backends.locmem import EmailBackend
class FailingEmailBackend(EmailBackend):
def send_messages(self, email_messages):
raise smtplib.SMTPRecipientsRefused({
'recipient@example.org': (450, b'Recipient unknown')
})
+52 -1
View File
@@ -29,7 +29,7 @@ import pytest
from django.conf import settings
from django.core import mail as djmail
from django.db.models import F, Sum
from django.test import TestCase
from django.test import TestCase, override_settings
from django.utils.timezone import make_aware, now
from django_countries.fields import Country
from django_scopes import scope
@@ -811,6 +811,57 @@ def test_mark_invoices_as_sent(event):
assert i.transmission_provider == "email_pdf"
@pytest.mark.django_db(transaction=True)
@override_settings(EMAIL_BACKEND='pretix.testutils.mail.FailingEmailBackend')
def test_mark_invoices_as_failed(event):
djmail.outbox = []
event.settings.invoice_address_asked = True
event.settings.invoice_address_required = True
event.settings.invoice_generate = "True"
event.settings.invoice_email_attachment = True
o1 = Order.objects.create(
code='FOO', event=event, email='dummy@dummy.test',
status=Order.STATUS_PENDING,
datetime=now(), expires=now() - timedelta(days=10),
total=10, locale='en',
sales_channel=event.organizer.sales_channels.get(identifier="web"),
)
ticket = Item.objects.create(event=event, name='Early-bird ticket',
default_price=Decimal('23.00'), admission=True)
OrderPosition.objects.create(
order=o1, item=ticket, variation=None, price=Decimal("23.00"),
attendee_name_parts={'full_name': "Peter"},
positionid=1
)
ia = InvoiceAddress.objects.create(
order=o1,
is_business=True,
country=Country('AT'),
transmission_type="email",
transmission_info={
"transmission_email_address": "invoice@example.org",
}
)
o1.create_transactions()
i = generate_invoice(o1)
assert i.transmission_type == "email"
assert i.transmission_status == Invoice.TRANSMISSION_STATUS_PENDING
assert not i.transmission_provider
# If no other address is there, order address will be accepted
ia.transmission_info = {}
ia.save()
o1.send_mail(
subject=LazyI18nString({"en": "Hey"}),
template=LazyI18nString({"en": "Just wanted to send this invoice"}),
context={},
invoices=[i]
)
i.refresh_from_db()
assert i.transmission_status == Invoice.TRANSMISSION_STATUS_FAILED
assert i.transmission_provider == "email_pdf"
class PaymentReminderTests(TestCase):
def setUp(self):
super().setUp()