mirror of
https://github.com/pretix/pretix.git
synced 2026-05-21 17:54:08 +00:00
Fix mail_send
This commit is contained in:
@@ -40,6 +40,7 @@ class Migration(migrations.Migration):
|
||||
("to", models.JSONField(default=list)),
|
||||
("cc", models.JSONField(default=list)),
|
||||
("bcc", models.JSONField(default=list)),
|
||||
("recipient_count", models.IntegerField(default=1)),
|
||||
("should_attach_tickets", models.BooleanField(default=False)),
|
||||
("should_attach_ical", models.BooleanField(default=False)),
|
||||
("should_attach_other_files", models.JSONField(default=list)),
|
||||
|
||||
@@ -42,26 +42,48 @@ def CASCADE_IF_QUEUED(collector, field, sub_objs, using):
|
||||
|
||||
class OutgoingMail(models.Model):
|
||||
STATUS_QUEUED = "queued"
|
||||
STATUS_WITHHELD = "withheld"
|
||||
STATUS_INFLIGHT = "inflight"
|
||||
STATUS_AWAWITING_RETRY = "awaiting_retry"
|
||||
STATUS_AWAITING_RETRY = "awaiting_retry"
|
||||
STATUS_FAILED = "failed"
|
||||
STATUS_SENT = "sent"
|
||||
STATUS_BOUNCED = "bounced"
|
||||
STATUS_ABORTED = "aborted"
|
||||
STATUS_CHOICES = (
|
||||
(STATUS_QUEUED, _("queued")),
|
||||
(STATUS_INFLIGHT, _("being sent")),
|
||||
(STATUS_AWAWITING_RETRY, _("awaiting retry")),
|
||||
(STATUS_AWAITING_RETRY, _("awaiting retry")),
|
||||
(STATUS_WITHHELD, _("withheld")), # for plugin use
|
||||
(STATUS_FAILED, _("failed")),
|
||||
(STATUS_ABORTED, _("aborted")),
|
||||
(STATUS_SENT, _("sent")),
|
||||
(STATUS_BOUNCED, _("bounced")),
|
||||
(STATUS_BOUNCED, _("bounced")), # for plugin use
|
||||
)
|
||||
STATUS_LIST_ABORTABLE = {
|
||||
STATUS_QUEUED,
|
||||
STATUS_WITHHELD,
|
||||
STATUS_AWAITING_RETRY,
|
||||
}
|
||||
STATUS_LIST_RETRYABLE = {
|
||||
STATUS_FAILED,
|
||||
STATUS_WITHHELD,
|
||||
}
|
||||
|
||||
# The GUID is a globally unique ID for the email added to a header of the email for later tracing
|
||||
# in bug reports etc. We could theoretically also use this as a basis for the Message-ID header, but
|
||||
# we currently don't since we are unsure if some intermediary SMTP servers have opinions on setting
|
||||
# their own Message-ID headers.
|
||||
guid = models.UUIDField(db_index=True, default=uuid.uuid4)
|
||||
|
||||
status = models.CharField(max_length=200, choices=STATUS_CHOICES, default=STATUS_QUEUED)
|
||||
created = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
# sent will be the time the email was sent or the email failed
|
||||
sent = models.DateTimeField(null=True, blank=True)
|
||||
|
||||
inflight_since = models.DateTimeField(null=True, blank=True)
|
||||
retry_after = models.DateTimeField(null=True, blank=True)
|
||||
|
||||
error = models.TextField(null=True, blank=True)
|
||||
error_detail = models.TextField(null=True, blank=True)
|
||||
|
||||
@@ -122,6 +144,7 @@ class OutgoingMail(models.Model):
|
||||
to = models.JSONField(default=list)
|
||||
cc = models.JSONField(default=list)
|
||||
bcc = models.JSONField(default=list)
|
||||
recipient_count = models.IntegerField()
|
||||
|
||||
# We don't store the actual invoices, tickets or calendar invites, so if the email is re-sent at a later time, a
|
||||
# newer version of the files might be used. We accept that risk to save on storage and also because the new
|
||||
@@ -143,6 +166,7 @@ class OutgoingMail(models.Model):
|
||||
# if the email is sent. Otherwise, they will be skipped. We accept that risk.
|
||||
should_attach_other_files = models.JSONField(default=list)
|
||||
|
||||
# [{name, type size}] of the attachments we actually setn
|
||||
actual_attachments = models.JSONField(default=list)
|
||||
|
||||
class Meta:
|
||||
@@ -164,7 +188,11 @@ class OutgoingMail(models.Model):
|
||||
|
||||
@property
|
||||
def is_failed(self):
|
||||
return self.status in (OutgoingMail.STATUS_FAILED, OutgoingMail.STATUS_AWAWITING_RETRY, OutgoingMail.STATUS_BOUNCED)
|
||||
return self.status in (
|
||||
OutgoingMail.STATUS_FAILED,
|
||||
OutgoingMail.STATUS_AWAITING_RETRY,
|
||||
OutgoingMail.STATUS_BOUNCED,
|
||||
)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if self.orderposition_id and not self.order_id:
|
||||
@@ -175,6 +203,7 @@ class OutgoingMail(models.Model):
|
||||
self.organizer = self.event.organizer
|
||||
if self.customer_id and not self.organizer_id:
|
||||
self.organizer = self.customer.organizer
|
||||
self.recipient_count = len(self.to) + len(self.cc) + len(self.bcc)
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def log_parameters(self):
|
||||
|
||||
@@ -56,7 +56,7 @@ def clean_cached_files(sender, **kwargs):
|
||||
status__in=(
|
||||
OutgoingMail.STATUS_QUEUED,
|
||||
OutgoingMail.STATUS_INFLIGHT,
|
||||
OutgoingMail.STATUS_AWAWITING_RETRY,
|
||||
OutgoingMail.STATUS_AWAITING_RETRY,
|
||||
OutgoingMail.STATUS_FAILED,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -103,6 +103,12 @@ class SendMailException(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class WithholdMailException(Exception):
|
||||
def __init__(self, error, error_detail):
|
||||
self.error = error
|
||||
self.error_detail = error_detail
|
||||
|
||||
|
||||
def clean_sender_name(sender_name: str) -> str:
|
||||
# Even though we try to properly escape sender names, some characters seem to cause problems when the escaping
|
||||
# fails due to some forwardings, etc.
|
||||
@@ -366,13 +372,13 @@ def mail_send_task(self, *args, outgoing_mail: int) -> bool:
|
||||
try:
|
||||
outgoing_mail = OutgoingMail.objects.select_for_update(of=OF_SELF).get(pk=outgoing_mail)
|
||||
except OutgoingMail.DoesNotExist:
|
||||
logger.info(f"Ignoring job for non existing email {outgoing_mail}")
|
||||
logger.info(f"Ignoring job for non existing email {outgoing_mail.guid}")
|
||||
return False
|
||||
if outgoing_mail.status == OutgoingMail.STATUS_INFLIGHT:
|
||||
logger.info(f"Ignoring job for inflight email {outgoing_mail.pk}")
|
||||
logger.info(f"Ignoring job for inflight email {outgoing_mail.guid}")
|
||||
return False
|
||||
elif outgoing_mail.status in (OutgoingMail.STATUS_SENT, OutgoingMail.STATUS_FAILED):
|
||||
logger.info(f"Ignoring job for email {outgoing_mail.pk} in final state {outgoing_mail.status}")
|
||||
elif outgoing_mail.status not in (OutgoingMail.STATUS_AWAITING_RETRY, OutgoingMail.STATUS_QUEUED):
|
||||
logger.info(f"Ignoring job for email {outgoing_mail.guid} in final state {outgoing_mail.status}")
|
||||
return False
|
||||
outgoing_mail.status = OutgoingMail.STATUS_INFLIGHT
|
||||
outgoing_mail.inflight_since = now()
|
||||
@@ -387,7 +393,7 @@ def mail_send_task(self, *args, outgoing_mail: int) -> bool:
|
||||
to=outgoing_mail.to,
|
||||
cc=outgoing_mail.cc,
|
||||
bcc=outgoing_mail.bcc,
|
||||
headers=outgoing_mail.headers,
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
# Rewrite all <img> tags from real URLs or data URLs to inline attachments referred to by content ID
|
||||
@@ -420,7 +426,7 @@ def mail_send_task(self, *args, outgoing_mail: int) -> bool:
|
||||
except MaxRetriesExceededError:
|
||||
# Well then, something is really wrong, let's send it without attachment before we
|
||||
# don't sent at all
|
||||
logger.exception(f'Could not attach tickets to email {outgoing_mail.pk}')
|
||||
logger.exception(f'Could not attach tickets to email {outgoing_mail.guid}')
|
||||
pass
|
||||
|
||||
if attach_size * 1.37 < settings.FILE_UPLOAD_MAX_SIZE_EMAIL_ATTACHMENT - 1024 * 1024:
|
||||
@@ -479,7 +485,7 @@ def mail_send_task(self, *args, outgoing_mail: int) -> bool:
|
||||
)
|
||||
invoices_attached.append(inv)
|
||||
except Exception:
|
||||
logger.exception(f'Could not attach invoice to email {outgoing_mail.pk}')
|
||||
logger.exception(f'Could not attach invoice to email {outgoing_mail.guid}')
|
||||
pass
|
||||
else:
|
||||
if inv.transmission_type == "email":
|
||||
@@ -509,7 +515,7 @@ def mail_send_task(self, *args, outgoing_mail: int) -> bool:
|
||||
ftype
|
||||
)
|
||||
except:
|
||||
logger.exception(f'Could not attach file to email {outgoing_mail.pk}')
|
||||
logger.exception(f'Could not attach file to email {outgoing_mail.guid}')
|
||||
pass
|
||||
|
||||
for cf in outgoing_mail.should_attach_cached_files.all():
|
||||
@@ -521,20 +527,9 @@ def mail_send_task(self, *args, outgoing_mail: int) -> bool:
|
||||
cf.type,
|
||||
)
|
||||
except:
|
||||
logger.exception(f'Could not attach file to email {outgoing_mail.pk}')
|
||||
logger.exception(f'Could not attach file to email {outgoing_mail.guid}')
|
||||
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(
|
||||
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],
|
||||
@@ -543,11 +538,58 @@ def mail_send_task(self, *args, outgoing_mail: int) -> bool:
|
||||
} for a in email.attachments
|
||||
]
|
||||
|
||||
try:
|
||||
if outgoing_mail.event:
|
||||
with outgoing_mail.scope_manager():
|
||||
email = email_filter.send_chained(
|
||||
sender=outgoing_mail.event,
|
||||
chain_kwarg_name='message',
|
||||
message=email,
|
||||
order=outgoing_mail.order,
|
||||
user=outgoing_mail.user,
|
||||
outgoing_mail=outgoing_mail,
|
||||
)
|
||||
|
||||
email = global_email_filter.send_chained(
|
||||
sender=outgoing_mail.event,
|
||||
chain_kwarg_name='message',
|
||||
message=email,
|
||||
user=outgoing_mail.user,
|
||||
order=outgoing_mail.order,
|
||||
organizer=outgoing_mail.organizer,
|
||||
customer=outgoing_mail.customer,
|
||||
outgoing_mail=outgoing_mail,
|
||||
)
|
||||
except WithholdMailException as e:
|
||||
outgoing_mail.status = OutgoingMail.STATUS_WITHHELD
|
||||
outgoing_mail.error = e.error
|
||||
outgoing_mail.error_detail = e.error_detail
|
||||
outgoing_mail.sent = now()
|
||||
outgoing_mail.retry_after = None
|
||||
outgoing_mail.actual_attachments = [
|
||||
{
|
||||
"name": a[0],
|
||||
"size": len(a[1]),
|
||||
"type": a[2],
|
||||
} for a in email.attachments
|
||||
]
|
||||
outgoing_mail.save(update_fields=["status", "error", "error_detail", "sent", "retry_after", "actual_attachments"])
|
||||
logger.info(f"Email {outgoing_mail.guid} withheld")
|
||||
return False
|
||||
|
||||
# Seems duplicate, but needs to be in this order since plugins might change this
|
||||
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 Exception as e:
|
||||
logger.exception(f'Error sending email {outgoing_mail.pk}')
|
||||
logger.exception(f'Error sending email {outgoing_mail.guid}')
|
||||
retry_strategy = _retry_strategy(e)
|
||||
err, err_detail = _format_error(e)
|
||||
|
||||
@@ -568,21 +610,21 @@ def mail_send_task(self, *args, outgoing_mail: int) -> bool:
|
||||
max_retries = 10
|
||||
retry_after = min(30 + cnt * 10, 1800)
|
||||
|
||||
outgoing_mail.status = OutgoingMail.STATUS_AWAWITING_RETRY
|
||||
outgoing_mail.status = OutgoingMail.STATUS_AWAITING_RETRY
|
||||
outgoing_mail.retry_after = now() + timedelta(seconds=retry_after)
|
||||
outgoing_mail.save(update_fields=["status", "error", "error_detail", "sent", "retry_after", "actual_attachments"])
|
||||
self.retry(max_retries=max_retries, countdown=retry_after) # throws RetryException, ends function flow
|
||||
elif retry_strategy in ("microsoft_concurrency", "quick"):
|
||||
max_retries = 5
|
||||
retry_after = [10, 30, 60, 300, 900, 900][self.request.retries]
|
||||
outgoing_mail.status = OutgoingMail.STATUS_AWAWITING_RETRY
|
||||
outgoing_mail.status = OutgoingMail.STATUS_AWAITING_RETRY
|
||||
outgoing_mail.retry_after = now() + timedelta(seconds=retry_after)
|
||||
outgoing_mail.save(update_fields=["status", "error", "error_detail", "sent", "retry_after", "actual_attachments"])
|
||||
self.retry(max_retries=max_retries, countdown=retry_after) # throws RetryException, ends function flow
|
||||
|
||||
elif retry_strategy == "slow":
|
||||
retry_after = [60, 300, 600, 1200, 1800, 1800][self.request.retries]
|
||||
outgoing_mail.status = OutgoingMail.STATUS_AWAWITING_RETRY
|
||||
outgoing_mail.status = OutgoingMail.STATUS_AWAITING_RETRY
|
||||
outgoing_mail.retry_after = now() + timedelta(seconds=retry_after)
|
||||
outgoing_mail.save(update_fields=["status", "error", "error_detail", "sent", "retry_after", "actual_attachments"])
|
||||
self.retry(max_retries=5, countdown=retry_after) # throws RetryException, ends function flow
|
||||
@@ -681,7 +723,7 @@ def mail_send_task(self, *args, outgoing_mail: int) -> bool:
|
||||
)
|
||||
|
||||
|
||||
def mail_send(to: List[str], subject: str, body: str, html: str, sender: str,
|
||||
def mail_send(to: List[str], subject: str, body: str, html: Optional[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,
|
||||
@@ -708,6 +750,15 @@ def mail_send(to: List[str], subject: str, body: str, html: str, sender: str,
|
||||
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)
|
||||
|
||||
mail_send_task.apply_async(kwargs={"outgoing_mail": m.pk})
|
||||
|
||||
|
||||
@@ -1007,7 +1058,7 @@ def retry_stuck_queued_mails(sender, **kwargs):
|
||||
status=OutgoingMail.STATUS_QUEUED,
|
||||
created__lt=now() - timedelta(hours=1),
|
||||
) | Q(
|
||||
status=OutgoingMail.STATUS_AWAWITING_RETRY,
|
||||
status=OutgoingMail.STATUS_AWAITING_RETRY,
|
||||
retry_after__lt=now() - timedelta(hours=1),
|
||||
)
|
||||
):
|
||||
|
||||
@@ -944,32 +944,40 @@ As with all event-plugin signals, the ``sender`` keyword argument will contain t
|
||||
|
||||
email_filter = EventPluginSignal()
|
||||
"""
|
||||
Arguments: ``message``, ``order``, ``user``
|
||||
Arguments: ``message``, ``order``, ``user``, ``outgoing_mail``
|
||||
|
||||
This signal allows you to implement a middleware-style filter on all outgoing emails. You are expected to
|
||||
return a (possibly modified) copy of the message object passed to you.
|
||||
|
||||
As with all event-plugin signals, the ``sender`` keyword argument will contain the event.
|
||||
The ``message`` argument will contain an ``EmailMultiAlternatives`` object.
|
||||
The ``outgoing_mail`` argument will contain the ``OutgoingMail`` model instance. Note that the ``message`` object
|
||||
might have newer information if a previous plugin already modified the email.
|
||||
If the email is associated with a specific order, the ``order`` argument will be passed as well, otherwise
|
||||
it will be ``None``.
|
||||
If the email is associated with a specific user, e.g. a notification email, the ``user`` argument will be passed as
|
||||
well, otherwise it will be ``None``.
|
||||
|
||||
You can raise ``WithholdMailException`` to prevent the email from being sent, e.g. when implementing rate limiting.
|
||||
"""
|
||||
|
||||
global_email_filter = GlobalSignal()
|
||||
"""
|
||||
Arguments: ``message``, ``order``, ``user``, ``customer``, ``organizer``
|
||||
Arguments: ``message``, ``order``, ``user``, ``customer``, ``organizer``, ``outgoing_mail``
|
||||
|
||||
This signal allows you to implement a middleware-style filter on all outgoing emails. You are expected to
|
||||
return a (possibly modified) copy of the message object passed to you.
|
||||
|
||||
This signal is called on all events and even if there is no known event. ``sender`` is an event or None.
|
||||
The ``message`` argument will contain an ``EmailMultiAlternatives`` object.
|
||||
The ``outgoing_mail`` argument will contain the ``OutgoingMail`` model instance. Note that the ``message`` object
|
||||
might have newer information if a previous plugin already modified the email.
|
||||
If the email is associated with a specific order, the ``order`` argument will be passed as well, otherwise
|
||||
it will be ``None``.
|
||||
If the email is associated with a specific user, e.g. a notification email, the ``user`` argument will be passed as
|
||||
well, otherwise it will be ``None``.
|
||||
|
||||
You can raise ``WithholdMailException`` to prevent the email from being sent, e.g. when implementing rate limiting.
|
||||
"""
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user