Allow to attach files to order confirmation email (#2384)

Co-authored-by: Richard Schreiber <schreiber@rami.io>
This commit is contained in:
Raphael Michel
2021-12-16 18:34:18 +01:00
committed by GitHub
parent 8fcc314f09
commit 9f4b834abc
9 changed files with 108 additions and 8 deletions

View File

@@ -445,8 +445,10 @@ You can configure the maximum file size for uploading various files::
max_size_image = 12 max_size_image = 12
; Max upload size for favicons in MiB, defaults to 1 MiB ; Max upload size for favicons in MiB, defaults to 1 MiB
max_size_favicon = 2 max_size_favicon = 2
; Max upload size for email attachments in MiB, defaults to 10 MiB ; Max upload size for email attachments of manually sent emails in MiB, defaults to 10 MiB
max_size_email_attachment = 15 max_size_email_attachment = 15
; Max upload size for email attachments of automatically sent emails in MiB, defaults to 1 MiB
max_size_email_auto_attachment = 2
; Max upload size for other files in MiB, defaults to 10 MiB ; Max upload size for other files in MiB, defaults to 10 MiB
; This includes all file upload type order questions ; This includes all file upload type order questions
max_size_other = 100 max_size_other = 100

View File

@@ -950,7 +950,7 @@ class Order(LockModel, LoggedModel):
context: Dict[str, Any]=None, log_entry_type: str='pretix.event.order.email.sent', context: Dict[str, Any]=None, log_entry_type: str='pretix.event.order.email.sent',
user: User=None, headers: dict=None, sender: str=None, invoices: list=None, user: User=None, headers: dict=None, sender: str=None, invoices: list=None,
auth=None, attach_tickets=False, position: 'OrderPosition'=None, auto_email=True, auth=None, attach_tickets=False, position: 'OrderPosition'=None, auto_email=True,
attach_ical=False): attach_ical=False, attach_other_files: list=None):
""" """
Sends an email to the user that placed this order. Basically, this method does two things: Sends an email to the user that placed this order. Basically, this method does two things:
@@ -994,7 +994,8 @@ class Order(LockModel, LoggedModel):
recipient, subject, template, context, recipient, subject, template, context,
self.event, self.locale, self, headers=headers, sender=sender, self.event, self.locale, self, headers=headers, sender=sender,
invoices=invoices, attach_tickets=attach_tickets, invoices=invoices, attach_tickets=attach_tickets,
position=position, auto_email=auto_email, attach_ical=attach_ical position=position, auto_email=auto_email, attach_ical=attach_ical,
attach_other_files=attach_other_files,
) )
except SendMailException: except SendMailException:
raise raise
@@ -2316,7 +2317,7 @@ class OrderPosition(AbstractPosition):
def send_mail(self, subject: str, template: Union[str, LazyI18nString], def send_mail(self, subject: str, template: Union[str, LazyI18nString],
context: Dict[str, Any]=None, log_entry_type: str='pretix.event.order.email.sent', context: Dict[str, Any]=None, log_entry_type: str='pretix.event.order.email.sent',
user: User=None, headers: dict=None, sender: str=None, invoices: list=None, user: User=None, headers: dict=None, sender: str=None, invoices: list=None,
auth=None, attach_tickets=False, attach_ical=False): auth=None, attach_tickets=False, attach_ical=False, attach_other_files: list=None):
""" """
Sends an email to the attendee. Basically, this method does two things: Sends an email to the attendee. Basically, this method does two things:
@@ -2357,6 +2358,7 @@ class OrderPosition(AbstractPosition):
invoices=invoices, invoices=invoices,
attach_tickets=attach_tickets, attach_tickets=attach_tickets,
attach_ical=attach_ical, attach_ical=attach_ical,
attach_other_files=attach_other_files,
) )
except SendMailException: except SendMailException:
raise raise

View File

@@ -35,6 +35,7 @@
import hashlib import hashlib
import inspect import inspect
import logging import logging
import mimetypes
import os import os
import re import re
import smtplib import smtplib
@@ -51,6 +52,7 @@ from bs4 import BeautifulSoup
from celery import chain from celery import chain
from celery.exceptions import MaxRetriesExceededError from celery.exceptions import MaxRetriesExceededError
from django.conf import settings from django.conf import settings
from django.core.files.storage import default_storage
from django.core.mail import ( from django.core.mail import (
EmailMultiAlternatives, SafeMIMEMultipart, get_connection, EmailMultiAlternatives, SafeMIMEMultipart, get_connection,
) )
@@ -73,6 +75,7 @@ from pretix.base.services.tasks import TransactionAwareTask
from pretix.base.services.tickets import get_tickets_for_order from pretix.base.services.tickets import get_tickets_for_order
from pretix.base.signals import email_filter, global_email_filter from pretix.base.signals import email_filter, global_email_filter
from pretix.celery_app import app from pretix.celery_app import app
from pretix.helpers.hierarkey import clean_filename
from pretix.multidomain.urlreverse import build_absolute_uri from pretix.multidomain.urlreverse import build_absolute_uri
from pretix.presale.ical import get_ical from pretix.presale.ical import get_ical
@@ -94,7 +97,7 @@ def mail(email: Union[str, Sequence[str]], subject: str, template: Union[str, La
context: Dict[str, Any] = None, event: Event = None, locale: str = None, order: Order = None, 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, 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, customer: Customer = None, invoices: Sequence = None, attach_tickets=False, auto_email=True, user=None,
attach_ical=False, attach_cached_files: Sequence = None): attach_ical=False, attach_cached_files: Sequence = None, attach_other_files: list=None):
""" """
Sends out an email to a user. The mail will be sent synchronously or asynchronously depending on the installation. Sends out an email to a user. The mail will be sent synchronously or asynchronously depending on the installation.
@@ -142,6 +145,8 @@ def mail(email: Union[str, Sequence[str]], subject: str, template: Union[str, La
:param attach_cached_files: A list of cached file to attach to this email. :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.
:raises MailOrderException: on obvious, immediate failures. Not raising an exception does not necessarily mean :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. that the email has been sent, just that it has been queued by the email backend.
""" """
@@ -301,6 +306,7 @@ def mail(email: Union[str, Sequence[str]], subject: str, template: Union[str, La
organizer=organizer.pk if organizer else None, organizer=organizer.pk if organizer else None,
customer=customer.pk if customer 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_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: if invoices:
@@ -338,7 +344,8 @@ class CustomEmail(EmailMultiAlternatives):
def mail_send_task(self, *args, to: List[str], subject: str, body: str, html: str, sender: str, 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, bcc: List[str] = None, event: int = None, position: int = None, headers: dict = None, bcc: List[str] = None,
invoices: List[int] = None, order: int = None, attach_tickets=False, user=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) -> bool: 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, bcc=bcc, headers=headers) email = CustomEmail(subject, body, sender, to=to, bcc=bcc, headers=headers)
if html is not None: if html is not None:
html_message = SafeMIMEMultipart(_subtype='related', encoding=settings.DEFAULT_CHARSET) html_message = SafeMIMEMultipart(_subtype='related', encoding=settings.DEFAULT_CHARSET)
@@ -455,6 +462,20 @@ def mail_send_task(self, *args, to: List[str], subject: str, body: str, html: st
logger.exception('Could not attach invoice to email') logger.exception('Could not attach invoice to email')
pass 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: if attach_cached_files:
for cf in CachedFile.objects.filter(id__in=attach_cached_files): for cf in CachedFile.objects.filter(id__in=attach_cached_files):
if cf.file: if cf.file:

View File

@@ -941,7 +941,10 @@ def _order_placed_email(event: Event, order: Order, pprov: BasePaymentProvider,
log_entry, log_entry,
invoices=[invoice] if invoice and event.settings.invoice_email_attachment else [], invoices=[invoice] if invoice and event.settings.invoice_email_attachment else [],
attach_tickets=True, attach_tickets=True,
attach_ical=event.settings.mail_attach_ical attach_ical=event.settings.mail_attach_ical,
attach_other_files=filter(lambda a: a, [
event.settings.get('mail_attachment_new_order', as_type=str, default='')[len('file://'):]
]),
) )
except SendMailException: except SendMailException:
logger.exception('Order received email could not be sent') logger.exception('Order received email could not be sent')
@@ -958,7 +961,10 @@ def _order_placed_email_attendee(event: Event, order: Order, position: OrderPosi
invoices=[], invoices=[],
attach_tickets=True, attach_tickets=True,
position=position, position=position,
attach_ical=event.settings.mail_attach_ical attach_ical=event.settings.mail_attach_ical,
attach_other_files=filter(lambda a: a, [
event.settings.get('mail_attachment_new_order', as_type=str, default='')[len('file://'):]
]),
) )
except SendMailException: except SendMailException:
logger.exception('Order received email could not be sent to attendee') logger.exception('Order received email could not be sent to attendee')

View File

@@ -1687,6 +1687,30 @@ You can change your order details and view the status of your order at
Best regards, Best regards,
Your {event} team""")) Your {event} team"""))
}, },
'mail_attachment_new_order': {
'default': None,
'type': File,
'form_class': ExtFileField,
'form_kwargs': dict(
label=_('Attachment for new orders'),
ext_whitelist=(".pdf",),
max_size=settings.FILE_UPLOAD_MAX_SIZE_EMAIL_AUTO_ATTACHMENT,
help_text=_('This file will be attached to the first email that we send for every new order. Therefore it will be '
'combined with the "Placed order", "Free order", or "Received order" texts from above. It will be sent '
'to both order contacts and attendees. You can use this e.g. to send your terms of service. Do not use '
'it to send non-public information as this file might be sent before payment is confirmed or the order '
'is approved. To avoid this vital email going to spam, you can only upload PDF files of up to {size} MB.').format(
size=settings.FILE_UPLOAD_MAX_SIZE_EMAIL_AUTO_ATTACHMENT // (1024 * 1024),
)
),
'serializer_class': UploadedFileField,
'serializer_kwargs': dict(
allowed_types=[
'application/pdf'
],
max_size=settings.FILE_UPLOAD_MAX_SIZE_EMAIL_AUTO_ATTACHMENT,
)
},
'mail_send_order_placed_attendee': { 'mail_send_order_placed_attendee': {
'type': bool, 'type': bool,
'default': 'False' 'default': 'False'

View File

@@ -872,6 +872,7 @@ class MailSettingsForm(SMTPSettingsMixin, SettingsForm):
'mail_from_name', 'mail_from_name',
'mail_attach_ical', 'mail_attach_ical',
'mail_attach_tickets', 'mail_attach_tickets',
'mail_attachment_new_order',
] ]
mail_sales_channel_placed_paid = forms.MultipleChoiceField( mail_sales_channel_placed_paid = forms.MultipleChoiceField(

View File

@@ -47,6 +47,7 @@
</fieldset> </fieldset>
<fieldset> <fieldset>
<legend>{% trans "E-mail content" %}</legend> <legend>{% trans "E-mail content" %}</legend>
<h4>{% trans "Text" %}</h4>
<div class="panel-group" id="questions_group"> <div class="panel-group" id="questions_group">
{% blocktrans asvar title_placed_order %}Placed order{% endblocktrans %} {% blocktrans asvar title_placed_order %}Placed order{% endblocktrans %}
{% include "pretixcontrol/event/mail_settings_fragment.html" with pid="order_placed" title=title_placed_order items="mail_text_order_placed,mail_send_order_placed_attendee,mail_text_order_placed_attendee" exclude="mail_send_order_placed_attendee" %} {% include "pretixcontrol/event/mail_settings_fragment.html" with pid="order_placed" title=title_placed_order items="mail_text_order_placed,mail_send_order_placed_attendee,mail_text_order_placed_attendee" exclude="mail_send_order_placed_attendee" %}
@@ -81,6 +82,8 @@
{% blocktrans asvar title_require_approval %}Order approval process{% endblocktrans %} {% blocktrans asvar title_require_approval %}Order approval process{% endblocktrans %}
{% include "pretixcontrol/event/mail_settings_fragment.html" with pid="ticket_reminder" title=title_require_approval items="mail_text_order_placed_require_approval,mail_text_order_approved,mail_text_order_approved_free,mail_text_order_denied" %} {% include "pretixcontrol/event/mail_settings_fragment.html" with pid="ticket_reminder" title=title_require_approval items="mail_text_order_placed_require_approval,mail_text_order_approved,mail_text_order_approved_free,mail_text_order_denied" %}
</div> </div>
<h4>{% trans "Attachments" %}</h4>
{% bootstrap_field form.mail_attachment_new_order layout="control" %}
</fieldset> </fieldset>
<fieldset> <fieldset>
<legend>{% trans "SMTP settings" %}</legend> <legend>{% trans "SMTP settings" %}</legend>

View File

@@ -0,0 +1,40 @@
#
# This file is part of pretix (Community Edition).
#
# Copyright (C) 2014-2020 Raphael Michel and contributors
# Copyright (C) 2020-2021 rami.io GmbH and contributors
#
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
# Public License as published by the Free Software Foundation in version 3 of the License.
#
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
# this file, see <https://pretix.eu/about/en/license>.
#
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>.
#
def clean_filename(fname):
"""
hierarkey.forms.SettingsForm appends a random value to every filename. However, it keeps the
extension around "twice". This leads to:
"Terms.pdf""Terms.pdf.OybgvyAH.pdf"
In pretix Hosted, our storage layer also adds a hash of the file to the filename, so we have
"Terms.pdf""Terms.pdf.OybgvyAH.22c0583727d5bc.pdf"
This function reverses this operation:
"Terms.pdf.OybgvyAH.22c0583727d5bc.pdf""Terms.pdf"
"""
ext = '.' + fname.split('.')[-1]
return fname.rsplit(ext + ".", 1)[0] + ext

View File

@@ -854,6 +854,7 @@ DATA_UPLOAD_MAX_MEMORY_SIZE = 10 * 1024 * 1024 # 10 MB
FILE_UPLOAD_MAX_SIZE_IMAGE = 1024 * 1024 * config.getint("pretix_file_upload", "max_size_image", fallback=10) FILE_UPLOAD_MAX_SIZE_IMAGE = 1024 * 1024 * config.getint("pretix_file_upload", "max_size_image", fallback=10)
FILE_UPLOAD_MAX_SIZE_FAVICON = 1024 * 1024 * config.getint("pretix_file_upload", "max_size_favicon", fallback=1) FILE_UPLOAD_MAX_SIZE_FAVICON = 1024 * 1024 * config.getint("pretix_file_upload", "max_size_favicon", fallback=1)
FILE_UPLOAD_MAX_SIZE_EMAIL_ATTACHMENT = 1024 * 1024 * config.getint("pretix_file_upload", "max_size_email_attachment", fallback=10) FILE_UPLOAD_MAX_SIZE_EMAIL_ATTACHMENT = 1024 * 1024 * config.getint("pretix_file_upload", "max_size_email_attachment", fallback=10)
FILE_UPLOAD_MAX_SIZE_EMAIL_AUTO_ATTACHMENT = 1024 * 1024 * config.getint("pretix_file_upload", "max_size_email_auto_attachment", fallback=1)
FILE_UPLOAD_MAX_SIZE_OTHER = 1024 * 1024 * config.getint("pretix_file_upload", "max_size_other", fallback=10) FILE_UPLOAD_MAX_SIZE_OTHER = 1024 * 1024 * config.getint("pretix_file_upload", "max_size_other", fallback=10)
DEFAULT_AUTO_FIELD = 'django.db.models.AutoField' # sadly. we would prefer BigInt, and should use it for all new models but the migration will be hard DEFAULT_AUTO_FIELD = 'django.db.models.AutoField' # sadly. we would prefer BigInt, and should use it for all new models but the migration will be hard