Files
pretix_original/src/pretix/base/services/mail.py
2026-03-17 15:32:51 +01:00

1150 lines
51 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#
# 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/>.
#
# This file is based on an earlier version of pretix which was released under the Apache License 2.0. The full text of
# the Apache License 2.0 can be obtained at <http://www.apache.org/licenses/LICENSE-2.0>.
#
# This file may have since been changed and any changes are released under the terms of AGPLv3 as described above. A
# full history of changes and contributors is available at <https://github.com/pretix/pretix>.
#
# This file contains Apache-licensed contributions copyrighted by: Daniel, Sanket Dasgupta, Sohalt, Tobias Kunze, Tobias
# Kunze, cherti
#
# Unless required by applicable law or agreed to in writing, software distributed under the Apache License 2.0 is
# 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 hashlib
import inspect
import logging
import mimetypes
import os
import re
import smtplib
import uuid
import warnings
from datetime import timedelta
from email.mime.image import MIMEImage
from email.utils import formataddr
from typing import Any, Dict, List, Optional, Sequence, Union
from urllib.parse import urljoin, urlparse
from zoneinfo import ZoneInfo
import requests
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
from django.core.mail.message import SafeMIMEText
from django.db import connection, transaction
from django.db.models import Q
from django.dispatch import receiver
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 scopes_disabled
from i18nfield.strings import LazyI18nString
from text_unidecode import unidecode
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,
)
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, periodic_task,
)
from pretix.celery_app import app
from pretix.helpers import OF_SELF
from pretix.helpers.format import (
FormattedString, PlainHtmlAlternativeString, SafeFormatter, format_map,
)
from pretix.helpers.hierarkey import clean_filename
from pretix.multidomain.urlreverse import build_absolute_uri
from pretix.presale.ical import get_private_icals
logger = logging.getLogger('pretix.base.mail')
INVALID_ADDRESS = 'invalid-pretix-mail-address'
class TolerantDict(dict):
def __missing__(self, key):
return key
class SendMailException(Exception):
"""
Deprecated, not thrown any more.
"""
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.
# Emails with @ in their sender name are rejected by some mailservers (e.g. Microsoft) because it looks like
# a phishing attempt.
sender_name = sender_name.replace("@", " ")
# Emails with : in their sender name are treated by Microsoft like emails with no From header at all, leading
# to a higher spam likelihood.
sender_name = sender_name.replace(":", " ")
# Emails with , in their sender name look like multiple senders
sender_name = sender_name.replace(",", "")
# Emails with " in their sender name could be escaped, but somehow create issues in reality
sender_name = sender_name.replace("\"", "")
# Emails with excessively long sender names are rejected by some mailservers
if len(sender_name) > 75:
sender_name = sender_name[:75] + "..."
return sender_name
def prefix_subject(settings_holder, subject, highlight=False):
prefix = settings_holder.settings.get('mail_prefix')
if prefix and prefix.startswith('[') and prefix.endswith(']'):
prefix = prefix[1:-1]
if prefix:
prefix = f"[{prefix}]"
if highlight:
prefix = '<span class="placeholder" title="{}">{}</span>'.format(
_('This prefix has been set in your event or organizer settings.'),
escape(prefix)
)
subject = f"{prefix} {subject}"
return subject
def mail(email: Union[str, Sequence[str]], subject: Union[str, FormattedString], template: Union[str, LazyI18nString],
context: Dict[str, Any] = None, event: Event = None, locale: str = None, order: Order = None,
position: OrderPosition = None, *, headers: dict = None, sender: str = None, organizer: Organizer = None,
customer: Customer = None, invoices: Sequence = None, attach_tickets=False, auto_email=True, user=None,
attach_ical=False, attach_cached_files: Sequence = None, attach_other_files: list=None,
plain_text_only=False, no_order_links=False, cc: Sequence[str]=None, bcc: Sequence[str]=None,
sensitive: bool=False) -> Optional[OutgoingMail]:
"""
Sends out an email to a user. The mail will be sent synchronously or asynchronously depending on the installation.
:param email: The email address of the recipient
:param subject: The email subject. Should be localized to the recipients's locale or a lazy object that will be
localized by being casted to a string.
:param template: The filename of a template to be used. It will be rendered with the locale given in the locale
argument and the context given in the next argument. Alternatively, you can pass a LazyI18nString and
``context`` will be used as the argument to a ``pretix.helpers.format.format_map(template, context)`` call on the template.
:param context: The context for rendering the template (see ``template`` parameter)
:param event: The event this email is related to (optional). If set, this will be used to determine the sender,
a possible prefix for the subject and the SMTP server that should be used to send this email.
:param organizer: The event this organizer is related to (optional). If set, this will be used to determine the sender,
a possible prefix for the subject and the SMTP server that should be used to send this email.
:param order: The order this email is related to (optional). If set, this will be used to include a link to the
order below the email.
:param position: The order position this email is related to (optional). If set, this will be used to include a link
to the order position instead of the order below the email.
:param headers: A dict of custom mail headers to add to the mail
:param locale: The locale to be used while evaluating the subject and the template
:param sender: Set the sender email address. If not set and ``event`` is set, the event's default will be used,
otherwise the system default.
:param invoices: A list of invoices to attach to this email.
:param attach_tickets: Whether to attach tickets to this email, if they are available to download.
:param attach_ical: Whether to attach relevant ``.ics`` files to this email
:param auto_email: Whether this email is auto-generated
:param user: The user this email is sent to
:param customer: The customer this email is sent to
:param attach_cached_files: A list of cached file to attach to this email.
:param attach_other_files: A list of file paths on our storage to attach.
:param plain_text_only: If set to ``True``, rendering a HTML version will be skipped.
:param no_order_links: If set to ``True``, no link to the order confirmation page will be auto-appended. Currently
only allowed to use together with ``plain_text_only`` since HTML renderers add their own
links.
:param sensitive: If set to ``True``, the email content will not be shown as part of log entries, used e.g. for
password resets. Bcc will also not be used.
:raises MailOrderException: on obvious, immediate failures. Not raising an exception does not necessarily mean
that the email has been sent, just that it has been queued by the email backend.
"""
if email == INVALID_ADDRESS:
return
if isinstance(template, FormattedString):
raise TypeError("Cannot pass an already formatted body template")
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 {}
guid = uuid.uuid4()
if auto_email:
headers['X-Auto-Response-Suppress'] = 'OOF, NRN, AutoReply, RN'
headers['Auto-Submitted'] = 'auto-generated'
headers.setdefault('X-Mailer', 'pretix')
headers.setdefault('X-PX-Correlation', str(guid))
bcc = list(bcc or [])
if settings_holder and settings_holder.settings.mail_bcc and not sensitive:
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), override(timezone):
if isinstance(context, dict) and order:
_autoextend_context(context, order)
# Build raw content
content_plain = render_mail(template, context, placeholder_mode=None)
if settings_holder:
signature = str(settings_holder.settings.get('mail_text_signature'))
else:
signature = ""
# Build full plain-text body
if not isinstance(content_plain, FormattedString):
body_plain = format_map(content_plain, context, mode=SafeFormatter.MODE_RICH_TO_PLAIN)
else:
body_plain = content_plain
body_plain = _wrap_plain_body(body_plain, signature, event, order, position, no_order_links)
# Build subject
if not isinstance(subject, FormattedString):
subject = format_map(subject, 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)
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.lower()] if isinstance(email, str) else [e.lower() for e in email],
cc=[e.lower() for e in cc] if cc else [],
bcc=[e.lower() for e in bcc] if bcc else [],
subject=subject,
body_plain=body_plain,
body_html=body_html,
sender=sender,
headers=headers or {},
should_attach_tickets=attach_tickets,
should_attach_ical=attach_ical,
should_attach_other_files=attach_other_files or [],
sensitive=sensitive,
)
m._prefetched_objects_cache = {}
if invoices and not position:
m.should_attach_invoices.add(*invoices)
# Hack: For logging, we'll later make a `should_attach_invoices.all()` call. We can prevent a useless
# DB query by filling the cache
m._prefetched_objects_cache[m.should_attach_invoices.prefetch_cache_name] = invoices
else:
m._prefetched_objects_cache[m.should_attach_invoices.prefetch_cache_name] = Invoice.objects.none()
if attach_cached_files:
cf_list = []
for cf in attach_cached_files:
if not isinstance(cf, CachedFile):
cf = CachedFile.objects.get(pk=cf)
m.should_attach_cached_files.add(cf)
cf_list.append(cf)
# Hack: For logging, we'll later make a `should_attach_cached_files.all()` call. We can prevent a useless
# DB query by filling the cache
m._prefetched_objects_cache[m.should_attach_cached_files.prefetch_cache_name] = cf_list
else:
m._prefetched_objects_cache[m.should_attach_cached_files.prefetch_cache_name] = CachedFile.objects.none()
send_task = mail_send_task.si(
outgoing_mail=m.id
)
if invoices:
task_chain = [invoice_pdf_task.si(i.pk).on_error(send_task) for i in invoices if not i.file]
else:
task_chain = []
task_chain.append(send_task)
if 'locmem' in settings.EMAIL_BACKEND:
# This clause is triggered during unit tests, because transaction.on_commit never fires due to the nature
# Django's unit tests work
chain(*task_chain).apply_async()
else:
transaction.on_commit(
lambda: chain(*task_chain).apply_async()
)
return m
class CustomEmail(EmailMultiAlternatives):
def _create_mime_attachment(self, content, mimetype):
"""
Convert the content, mimetype pair into a MIME attachment object.
If the mimetype is message/rfc822, content may be an
email.Message or EmailMessage object, as well as a str.
"""
basetype, subtype = mimetype.split('/', 1)
if basetype == 'multipart' and isinstance(content, SafeMIMEMultipart):
return content
return super()._create_mime_attachment(content, mimetype)
@app.task(base=TransactionAwareTask, bind=True, acks_late=True)
def mail_send_task(self, **kwargs) -> bool:
if "outgoing_mail" in kwargs:
outgoing_mail = kwargs.get("outgoing_mail")
elif "to" in kwargs:
# May only occur while upgrading from pretix versions before OutgoingMail when celery tasks are still in-queue
# during the upgrade. Can be removed after 2026.2.x is released, and then the signature can be changed to
# mail_send_task(self, *, outgoing_mail)
with scopes_disabled():
mail_send(**kwargs)
return False
else:
raise ValueError("Unknown arguments")
with transaction.atomic():
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.guid}")
return False
if outgoing_mail.status == OutgoingMail.STATUS_INFLIGHT:
logger.info(f"Ignoring job for inflight email {outgoing_mail.guid}")
return False
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()
outgoing_mail.save(update_fields=["status", "inflight_since"])
# Performance optimization, saves database queries later on if we resolve the known relationships
if outgoing_mail.event_id:
assert outgoing_mail.event.organizer_id == outgoing_mail.organizer.pk
outgoing_mail.event.organizer = outgoing_mail.organizer
if outgoing_mail.order_id:
assert outgoing_mail.order.event_id == outgoing_mail.event_id
outgoing_mail.order.event = outgoing_mail.event
outgoing_mail.order.organizer = outgoing_mail.organizer
if outgoing_mail.orderposition_id:
assert outgoing_mail.orderposition.order_id == outgoing_mail.order_id
outgoing_mail.orderposition.order = outgoing_mail.order
headers = dict(outgoing_mail.headers)
headers.setdefault('X-PX-Correlation', str(outgoing_mail.guid))
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=headers,
)
# Rewrite all <img> 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(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, error_log_action_type = outgoing_mail.log_parameters()
invoices_attached = []
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 as e:
# 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:
logger.exception(f'Could not attach tickets to email {outgoing_mail.guid}, will retry')
retry_after = 60
outgoing_mail.error = "Tickets not ready"
outgoing_mail.error_detail = str(e)
outgoing_mail.sent = now()
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)
except MaxRetriesExceededError:
# Well then, something is really wrong, let's send it without attachment before we
# don't send at all
logger.exception(f'Too many retries attaching tickets to email {outgoing_mail.guid}, skip attachment')
pass
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:
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': [],
}
)
# Attach calendar files
if outgoing_mail.should_attach_ical and outgoing_mail.order:
with language(outgoing_mail.order.locale, outgoing_mail.event.settings.region):
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 = []
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(f'Could not attach invoice to email {outgoing_mail.guid}')
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
expected_recipients = {e.lower() for e in expected_recipients if e}
if any(t in expected_recipients for t in outgoing_mail.to):
invoices_to_mark_transmitted.append(inv)
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(f'Could not attach file to email {outgoing_mail.guid}')
pass
for cf in outgoing_mail.should_attach_cached_files.all():
if cf.file:
try:
email.attach(
cf.filename,
cf.file.file.read(),
cf.type,
)
except:
logger.exception(f'Could not attach file to email {outgoing_mail.guid}')
pass
outgoing_mail.actual_attachments = [
{
"name": a[0],
"size": len(a[1]),
"type": a[2],
} 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.guid}')
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()
rc = get_redis_connection("redis")
cnt = rc.incr(redis_key)
rc.expire(redis_key, 300)
max_retries = 10
retry_after = min(30 + cnt * 10, 1800)
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_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_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
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': [],
}
)
outgoing_mail.status = OutgoingMail.STATUS_FAILED
outgoing_mail.sent = now()
outgoing_mail.retry_after = None
outgoing_mail.save(update_fields=["status", "error", "error_detail", "sent", "retry_after", "actual_attachments"])
return False
# If we reach this, it's a non-retryable error
outgoing_mail.status = OutgoingMail.STATUS_FAILED
outgoing_mail.sent = now()
outgoing_mail.retry_after = None
outgoing_mail.save(update_fields=["status", "error", "error_detail", "sent", "retry_after", "actual_attachments"])
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': err,
'message': err_detail,
'recipient': '',
'invoices': [],
}
)
return False
else:
outgoing_mail.status = OutgoingMail.STATUS_SENT
outgoing_mail.error = None
outgoing_mail.error_detail = None
outgoing_mail.sent = now()
outgoing_mail.retry_after = None
outgoing_mail.save(update_fields=["status", "error", "error_detail", "sent", "actual_attachments", "retry_after"])
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": outgoing_mail.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(
{
"recipients": outgoing_mail.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": outgoing_mail.to,
},
}
)
return True
def mail_send(to: List[str], subject: str, body: str, html: Optional[str], sender: str,
event: int | Event = None, position: int | OrderPosition = None, headers: dict = None,
cc: List[str] = None, bcc: List[str] = None, invoices: List[int | Invoice] = None, order: int | Order = None,
attach_tickets=False, user: int | User=None, organizer: int | Organizer=None, customer: int | Customer=None,
attach_ical=False, attach_cached_files: List[int | CachedFile] = None, attach_other_files: List[str] = None):
"""
Low-level function to send mails, kept for backwards-compatibility. You should usually use mail() instead.
"""
m = OutgoingMail.objects.create(
organizer_id=organizer.pk if isinstance(organizer, Organizer) else organizer,
event_id=event.pk if isinstance(event, Event) else event,
order_id=order.pk if isinstance(order, Order) else order,
orderposition_id=position.pk if isinstance(position, OrderPosition) else position,
customer_id=customer.pk if isinstance(customer, Customer) else customer,
user_id=user.pk if isinstance(user, User) else user,
to=[to.lower()] if isinstance(to, str) else [e.lower() for e in to],
cc=[e.lower() for e in cc] if cc else [],
bcc=[e.lower() for e in bcc] if bcc else [],
subject=subject,
body_plain=body,
body_html=html,
sender=sender,
headers=headers or {},
should_attach_tickets=attach_tickets,
should_attach_ical=attach_ical,
should_attach_other_files=attach_other_files or [],
)
if invoices and not position:
if isinstance(invoices[0], int):
invoices = Invoice.objects.filter(pk__in=invoices)
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})
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:
body = format_map(body, context, mode=placeholder_mode)
else:
tpl = get_template(template)
context = {
# Known bug, should behave differently for plain and HTML but we'll fix after security release
k: v.html if isinstance(v, PlainHtmlAlternativeString) else v
for k, v in context.items()
}
body = FormattedString(tpl.render(context))
return body
def replace_images_with_cid_paths(body_html):
from bs4 import BeautifulSoup
if body_html:
email = BeautifulSoup(body_html, "lxml")
cid_images = []
for image in email.find_all('img'):
original_image_src = image['src']
try:
cid_id = "image_%s" % cid_images.index(original_image_src)
except ValueError:
cid_images.append(original_image_src)
cid_id = "image_%s" % (len(cid_images) - 1)
image['src'] = "cid:%s" % cid_id
return str(email), cid_images
else:
return body_html, []
def attach_cid_images(msg, cid_images, verify_ssl=True):
if cid_images and len(cid_images) > 0:
msg.mixed_subtype = 'mixed'
for key, image in enumerate(cid_images):
cid = 'image_%s' % key
try:
mime_image = convert_image_to_cid(
image, cid, verify_ssl)
if mime_image:
msg.attach(mime_image)
except:
logger.exception("ERROR attaching CID image %s[%s]" % (cid, image))
def encoder_linelength(msg):
"""
RFC1341 mandates that base64 encoded data may not be longer than 76 characters per line
https://www.w3.org/Protocols/rfc1341/5_Content-Transfer-Encoding.html section 5.2
"""
orig = msg.get_payload(decode=True).replace(b"\n", b"").replace(b"\r", b"")
max_length = 76
pieces = []
for i in range(0, len(orig), max_length):
chunk = orig[i:i + max_length]
pieces.append(chunk)
msg.set_payload(b"\r\n".join(pieces))
def convert_image_to_cid(image_src, cid_id, verify_ssl=True):
image_src = image_src.strip()
try:
if image_src.startswith('data:image/'):
image_type, image_content = image_src.split(',', 1)
image_type = re.findall(r'data:image/(\w+);base64', image_type)[0]
mime_image = MIMEImage(image_content, _subtype=image_type, _encoder=encoder_linelength)
mime_image.add_header('Content-Transfer-Encoding', 'base64')
elif image_src.startswith('data:'):
logger.exception("ERROR creating MIME element %s[%s]" % (cid_id, image_src))
return None
else:
image_src = normalize_image_url(image_src)
path = urlparse(image_src).path
image_type = os.path.splitext(path)[1][1:]
response = requests.get(image_src, verify=verify_ssl)
mime_image = MIMEImage(
response.content, _subtype=image_type)
mime_image.add_header('Content-ID', '<%s>' % cid_id)
mime_image.add_header('Content-Disposition', 'inline;\n filename="{}.{}"'.format(cid_id, image_type))
return mime_image
except:
logger.exception("ERROR creating mime_image %s[%s]" % (cid_id, image_src))
return None
def normalize_image_url(url):
if '://' not in url:
"""
If we see a relative URL in an email, we can't know if it is meant to be a media file
or a static file, so we need to guess. If it is a static file included with the
``{% static %}`` template tag (as it should be), then ``STATIC_URL`` is already prepended.
If ``STATIC_URL`` is absolute, then ``url`` should already be absolute and this
function should not be triggered. Thus, if we see a relative URL and ``STATIC_URL``
is absolute *or* ``url`` does not start with ``STATIC_URL``, we can be sure this
is a media file (or a programmer error …).
Constructing the URL of either a static file or a media file from settings is still
not clean, since custom storage backends might very well use more complex approaches
to build those URLs. However, this is good enough as a best-effort approach. Complex
storage backends (such as cloud storages) will return absolute URLs anyways so this
function is not needed in that case.
"""
if '://' not in settings.STATIC_URL and url.startswith(settings.STATIC_URL):
url = urljoin(settings.SITE_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 = format_map(signature, {"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(
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_concurrency"
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)
def _is_queue_long(queue_name="mail"):
"""
Checks an estimate if there is currently a long celery queue for emails. If so,
there's no reason to retry stuck emails, because they are stuck because of the
queue and we don't need to add more oil to the fire.
This does not need to be perfect, as it is safe to run the same task twice, it just
wastes ressources.
"""
if not settings.HAS_CELERY:
return False
if not settings.CELERY_BROKER_URL.startswith("redis://"):
return False # check not supported
priority_steps = settings.CELERY_BROKER_TRANSPORT_OPTIONS.get("priority_steps", [0])
sep = settings.CELERY_BROKER_TRANSPORT_OPTIONS.get("sep", ":")
client = app.broker_connection().channel().client
queue_length = 0
for prio in priority_steps:
if prio:
qname = f"{queue_name}{sep}{prio}"
else:
qname = queue_name
queue_length += client.llen(qname)
return queue_length > 100
@receiver(signal=periodic_task)
@scopes_disabled()
def retry_stuck_inflight_mails(sender, **kwargs):
"""
Retry emails that are stuck in "inflight" state, e.g. their celery task just died.
"""
with transaction.atomic():
for m in OutgoingMail.objects.filter(
status=OutgoingMail.STATUS_INFLIGHT,
inflight_since__lt=now() - timedelta(hours=1),
).select_for_update(of=OF_SELF, skip_locked=connection.features.has_select_for_update_skip_locked):
m.status = OutgoingMail.STATUS_QUEUED
m.save()
mail_send_task.apply_async(kwargs={"outgoing_mail": m.pk})
@receiver(signal=periodic_task)
@scopes_disabled()
def retry_stuck_queued_mails(sender, **kwargs):
"""
Retry emails that are stuck in "queued" state, e.g. their celery task never started. We do this only
when there is currently almost no queue, to avoid many tasks being scheduled for the same mail if that
mail is still waiting in the queue (even if that would be safe, all tasks except the first one would be a no-op,
but it would create many more useless tasks in a high-load situation).
"""
if _is_queue_long():
logger.info("Do not retry stuck mails as the queue is long.")
return
for m in OutgoingMail.objects.filter(
Q(
status=OutgoingMail.STATUS_QUEUED,
created__lt=now() - timedelta(hours=1),
) | Q(
status=OutgoingMail.STATUS_AWAITING_RETRY,
retry_after__lt=now() - timedelta(hours=1),
)
):
mail_send_task.apply_async(kwargs={"outgoing_mail": m.pk})
@receiver(signal=periodic_task)
@scopes_disabled()
def delete_old_emails(sender, **kwargs):
"""
OutgoingMail is currently not intended to be an archive, because it would be hard to do in a
privacy-first design, so we delete after some time.
"""
cutoff = now() - timedelta(seconds=settings.OUTGOING_MAIL_RETENTION)
OutgoingMail.objects.filter(
Q(sent__lt=cutoff) |
Q(sent__isnull=True, created__lt=cutoff)
).delete()