mirror of
https://github.com/pretix/pretix.git
synced 2026-04-29 00:12:38 +00:00
868 lines
40 KiB
Python
868 lines
40 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/>.
|
||
#
|
||
|
||
# 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 warnings
|
||
from email.mime.image import MIMEImage
|
||
from email.utils import formataddr
|
||
from typing import Any, Dict, List, Sequence, Union
|
||
from urllib.parse import urljoin, urlparse
|
||
from zoneinfo import ZoneInfo
|
||
|
||
import requests
|
||
from bs4 import BeautifulSoup
|
||
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.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
|
||
|
||
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.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.format import 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):
|
||
pass
|
||
|
||
|
||
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: str, 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):
|
||
"""
|
||
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.
|
||
|
||
: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 no_order_links and not plain_text_only:
|
||
raise ValueError('If you set no_order_links, you also need to set plain_text_only.')
|
||
|
||
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')
|
||
|
||
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)
|
||
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
|
||
|
||
body_plain = format_map(body_plain, context, mode=SafeFormatter.MODE_RICH_TO_PLAIN)
|
||
|
||
send_task = mail_send_task.si(
|
||
to=[email] if isinstance(email, str) else list(email),
|
||
cc=cc,
|
||
bcc=bcc,
|
||
subject=subject,
|
||
body=body_plain,
|
||
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,
|
||
)
|
||
|
||
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()
|
||
)
|
||
|
||
|
||
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, *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:
|
||
html_message = SafeMIMEMultipart(_subtype='related', encoding=settings.DEFAULT_CHARSET)
|
||
html_with_cid, cid_images = replace_images_with_cid_paths(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
|
||
|
||
if user:
|
||
user = User.objects.get(pk=user)
|
||
error_log_action_type = 'pretix.user.email.error'
|
||
log_target = user
|
||
|
||
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
|
||
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
|
||
|
||
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)
|
||
|
||
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'
|
||
)
|
||
|
||
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
|
||
|
||
if attach_other_files:
|
||
for fname in 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_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
|
||
|
||
email = global_email_filter.send_chained(event, 'message', message=email, user=user, order=order,
|
||
organizer=organizer, customer=customer)
|
||
|
||
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
|
||
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)
|
||
else:
|
||
# Most likely some other kind of temporary failure, retry again (but pretty soon)
|
||
max_retries = 5
|
||
retry_after = [10, 30, 60, 300, 900, 900][self.request.retries]
|
||
|
||
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
|
||
|
||
logger.exception('Error sending email')
|
||
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),
|
||
'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))
|
||
else:
|
||
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_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):
|
||
mail_send_task.apply_async(args=args, kwargs=kwargs)
|
||
|
||
|
||
def render_mail(template, context, placeholder_mode=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)
|
||
body = tpl.render(context)
|
||
return body
|
||
|
||
|
||
def replace_images_with_cid_paths(body_html):
|
||
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
|