mirror of
https://github.com/pretix/pretix.git
synced 2026-05-16 17:03:58 +00:00
* Mail: Handle all rendering in mail.py, return values for log * Apply suggestions from code review
240 lines
9.6 KiB
Python
240 lines
9.6 KiB
Python
#
|
|
# This file is part of pretix (Community Edition).
|
|
#
|
|
# Copyright (C) 2014-2020 Raphael Michel and contributors
|
|
# Copyright (C) 2020-today pretix GmbH and contributors
|
|
#
|
|
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
|
|
# Public License as published by the Free Software Foundation in version 3 of the License.
|
|
#
|
|
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
|
|
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
|
|
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
|
|
# this file, see <https://pretix.eu/about/en/license>.
|
|
#
|
|
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
|
|
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
|
# details.
|
|
#
|
|
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
|
|
# <https://www.gnu.org/licenses/>.
|
|
#
|
|
import uuid
|
|
|
|
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
|
|
|
|
|
|
def CASCADE_IF_QUEUED(collector, field, sub_objs, using):
|
|
# If the email is still queued and the thing it is related to vanishes, the email can vanish as well
|
|
cascade_objs = [
|
|
o for o in sub_objs if o.status == OutgoingMail.STATUS_QUEUED
|
|
]
|
|
if cascade_objs:
|
|
models.CASCADE(collector, field, cascade_objs, using)
|
|
|
|
# In all other cases, set to NULL to keep the email on record
|
|
models.SET_NULL(collector, field, [o for o in sub_objs if o not in cascade_objs], using)
|
|
|
|
|
|
class OutgoingMail(models.Model):
|
|
STATUS_QUEUED = "queued"
|
|
STATUS_WITHHELD = "withheld"
|
|
STATUS_INFLIGHT = "inflight"
|
|
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_AWAITING_RETRY, _("awaiting retry")),
|
|
(STATUS_WITHHELD, _("withheld")), # for plugin use
|
|
(STATUS_FAILED, _("failed")),
|
|
(STATUS_ABORTED, _("aborted")),
|
|
(STATUS_SENT, _("sent")),
|
|
(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)
|
|
|
|
# There is a conflict here between the different purposes of the model. As a system administrator,
|
|
# one wants *all* emails to be persisted as long as possible to debug issues. This means that if
|
|
# e.g. the event or order is deleted, we want SET_NULL behavior. However, in that case, the email
|
|
# would be an "orphan" forever and there's no way to remove the personal information.
|
|
# We try to find a middle-ground with the following behaviour:
|
|
# - The email is always deleted if the entire organizer or user is deleted
|
|
# - The email is always deleted if it has not yet been sent
|
|
# - The email is kept in all other cases
|
|
# This is only an acceptable trade-off since emails are stored for a short period only, and because
|
|
# orders and customers are never deleted during normal operation. If we ever make this a long-term
|
|
# storage / email archive, we'd need to find another way to make sure personal information is removed
|
|
# if personal information of orders etc is removed.
|
|
organizer = models.ForeignKey(
|
|
'pretixbase.Organizer',
|
|
on_delete=models.CASCADE,
|
|
related_name='outgoing_mails',
|
|
null=True, blank=True,
|
|
)
|
|
event = models.ForeignKey(
|
|
'pretixbase.Event',
|
|
on_delete=CASCADE_IF_QUEUED,
|
|
related_name='outgoing_mails',
|
|
null=True, blank=True,
|
|
)
|
|
order = models.ForeignKey(
|
|
'pretixbase.Order',
|
|
on_delete=CASCADE_IF_QUEUED,
|
|
related_name='outgoing_mails',
|
|
null=True, blank=True,
|
|
)
|
|
orderposition = models.ForeignKey(
|
|
'pretixbase.OrderPosition',
|
|
on_delete=CASCADE_IF_QUEUED,
|
|
related_name='outgoing_mails',
|
|
null=True, blank=True,
|
|
)
|
|
customer = models.ForeignKey(
|
|
'pretixbase.Customer',
|
|
on_delete=CASCADE_IF_QUEUED,
|
|
related_name='outgoing_mails',
|
|
null=True, blank=True,
|
|
)
|
|
user = models.ForeignKey(
|
|
'pretixbase.User',
|
|
on_delete=models.CASCADE,
|
|
related_name='outgoing_mails',
|
|
null=True, blank=True,
|
|
)
|
|
|
|
sensitive = models.BooleanField(default=False)
|
|
subject = models.TextField()
|
|
body_plain = models.TextField()
|
|
body_html = models.TextField(null=True)
|
|
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)
|
|
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
|
|
# version might actually be more useful.
|
|
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)
|
|
|
|
# clean_cached_files makes sure not to delete these as long as the email is in a retryable state
|
|
should_attach_cached_files = models.ManyToManyField(
|
|
'pretixbase.CachedFile',
|
|
related_name='outgoing_mails',
|
|
)
|
|
|
|
# This is used to send files stored in settings. In most cases, these aren't short-lived and should still be there
|
|
# 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:
|
|
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
|
|
|
|
@property
|
|
def is_failed(self):
|
|
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:
|
|
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
|
|
self.recipient_count = len(self.to) + len(self.cc) + len(self.bcc)
|
|
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
|
|
|
|
def log_data(self):
|
|
return {
|
|
"subject": self.subject,
|
|
"message": self.body_plain,
|
|
"to": self.to,
|
|
"cc": self.cc,
|
|
"bcc": self.bcc,
|
|
|
|
"invoices": [i.pk for i in self.should_attach_invoices.all()],
|
|
"attach_tickets": self.should_attach_tickets,
|
|
"attach_ical": self.should_attach_ical,
|
|
"attach_other_files": self.should_attach_other_files,
|
|
"attach_cached_files": [cf.filename for cf in self.should_attach_cached_files.all()],
|
|
|
|
"position": self.orderposition.positionid if self.orderposition else None,
|
|
}
|