diff --git a/src/pretix/base/email.py b/src/pretix/base/email.py
index e1efce0b03..395513286a 100644
--- a/src/pretix/base/email.py
+++ b/src/pretix/base/email.py
@@ -133,11 +133,11 @@ class TemplateBasedMailRenderer(BaseHTMLMailRenderer):
def template_name(self):
raise NotImplementedError()
- def compile_markdown(self, plaintext):
- return markdown_compile_email(plaintext)
+ def compile_markdown(self, plaintext, context=None):
+ return markdown_compile_email(plaintext, context=context)
def render(self, plain_body: str, plain_signature: str, subject: str, order, position, context) -> str:
- body_md = self.compile_markdown(plain_body)
+ body_md = self.compile_markdown(plain_body, context)
if context:
body_md = format_map(body_md, context=context, mode=SafeFormatter.MODE_RICH_TO_HTML)
htmlctx = {
diff --git a/src/pretix/base/services/mail.py b/src/pretix/base/services/mail.py
index 03bd489e7e..3e05e9792a 100644
--- a/src/pretix/base/services/mail.py
+++ b/src/pretix/base/services/mail.py
@@ -222,7 +222,7 @@ def mail(email: Union[str, Sequence[str]], subject: str, template: Union[str, La
'invoice_company': ''
})
renderer = ClassicMailRenderer(None, organizer)
- content_plain = body_plain = render_mail(template, context)
+ body_plain = render_mail(template, context, placeholder_mode=SafeFormatter.MODE_RICH_TO_PLAIN)
subject = str(subject).format_map(TolerantDict(context))
sender = (
sender or
@@ -316,6 +316,7 @@ def mail(email: Union[str, Sequence[str]], subject: str, template: Union[str, La
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:
@@ -751,11 +752,11 @@ def mail_send(*args, **kwargs):
mail_send_task.apply_async(args=args, kwargs=kwargs)
-def render_mail(template, context):
+def render_mail(template, context, placeholder_mode=SafeFormatter.MODE_RICH_TO_PLAIN):
if isinstance(template, LazyI18nString):
body = str(template)
- if context:
- body = format_map(body, context, mode=SafeFormatter.MODE_IGNORE_RICH)
+ if context and placeholder_mode:
+ body = format_map(body, context, mode=placeholder_mode)
else:
tpl = get_template(template)
body = tpl.render(context)
diff --git a/src/pretix/base/services/placeholders.py b/src/pretix/base/services/placeholders.py
index c4cdc03c3f..9376748eb6 100644
--- a/src/pretix/base/services/placeholders.py
+++ b/src/pretix/base/services/placeholders.py
@@ -26,7 +26,7 @@ from decimal import Decimal
from django.dispatch import receiver
from django.utils.formats import date_format
-from django.utils.html import escape
+from django.utils.html import escape, mark_safe
from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _
@@ -123,6 +123,10 @@ class BaseRichTextPlaceholder(BaseTextPlaceholder):
def identifier(self):
return self._identifier
+ @property
+ def allowed_in_plain_content(self):
+ return False
+
@property
def required_context(self):
return self._args
@@ -194,6 +198,33 @@ class SimpleButtonPlaceholder(BaseRichTextPlaceholder):
return f'{text}: {url}'
+class MarkdownTextPlaceholder(BaseRichTextPlaceholder):
+ def __init__(self, identifier, args, func, sample, inline):
+ super().__init__(identifier, args)
+ self._func = func
+ self._sample = sample
+ self._snippet = inline
+
+ @property
+ def allowed_in_plain_content(self):
+ return self._snippet
+
+ def render_plain(self, **context):
+ return self._func(**{k: context[k] for k in self._args})
+
+ def render_html(self, **context):
+ return mark_safe(markdown_compile_email(self.render_plain(**context), snippet=self._snippet))
+
+ def render_sample_plain(self, event):
+ if callable(self._sample):
+ return self._sample(event)
+ else:
+ return self._sample
+
+ def render_sample_html(self, event):
+ return mark_safe(markdown_compile_email(self.render_sample_plain(event), snippet=self._snippet))
+
+
class PlaceholderContext(SafeFormatter):
"""
Holds the contextual arguments and corresponding list of available placeholders for formatting
@@ -574,7 +605,7 @@ def base_placeholders(sender, **kwargs):
'invoice_company', ['invoice_address'], lambda invoice_address: invoice_address.company or '',
_('Sample Corporation')
),
- SimpleFunctionalTextPlaceholder(
+ MarkdownTextPlaceholder(
'orders', ['event', 'orders'], lambda event, orders: '\n' + '\n\n'.join(
'* {} - {}'.format(
order.full_code,
@@ -604,6 +635,7 @@ def base_placeholders(sender, **kwargs):
{'code': 'OPKSB', 'secret': '09pjdksflosk3njd', 'hash': 'stuvwxy2z'}
]
),
+ inline=False,
),
SimpleFunctionalTextPlaceholder(
'hours', ['event', 'waiting_list_entry'], lambda event, waiting_list_entry:
@@ -618,12 +650,13 @@ def base_placeholders(sender, **kwargs):
'code', ['waiting_list_voucher'], lambda waiting_list_voucher: waiting_list_voucher.code,
'68CYU2H6ZTP3WLK5'
),
- SimpleFunctionalTextPlaceholder(
+ MarkdownTextPlaceholder(
# join vouchers with two spaces at end of line so markdown-parser inserts a
'voucher_list', ['voucher_list'], lambda voucher_list: ' \n'.join(voucher_list),
- ' 68CYU2H6ZTP3WLK5\n 7MB94KKPVEPSMVF2'
+ '68CYU2H6ZTP3WLK5 \n7MB94KKPVEPSMVF2',
+ inline=False,
),
- SimpleFunctionalTextPlaceholder(
+ MarkdownTextPlaceholder(
# join vouchers with two spaces at end of line so markdown-parser inserts a
'voucher_url_list', ['event', 'voucher_list'],
lambda event, voucher_list: ' \n'.join([
@@ -638,6 +671,7 @@ def base_placeholders(sender, **kwargs):
) + '?voucher=' + c
for c in ['68CYU2H6ZTP3WLK5', '7MB94KKPVEPSMVF2']
]),
+ inline=False,
),
SimpleFunctionalTextPlaceholder(
'url', ['event', 'voucher_list'], lambda event, voucher_list: build_absolute_uri(event, 'presale:event.index', kwargs={
@@ -656,13 +690,13 @@ def base_placeholders(sender, **kwargs):
'comment', ['comment'], lambda comment: comment,
_('An individual text with a reason can be inserted here.'),
),
- SimpleFunctionalTextPlaceholder(
+ MarkdownTextPlaceholder(
'payment_info', ['order', 'payments'], _placeholder_payments,
- _('The amount has been charged to your card.'),
+ _('The amount has been charged to your card.'), inline=False,
),
- SimpleFunctionalTextPlaceholder(
+ MarkdownTextPlaceholder(
'payment_info', ['payment_info'], lambda payment_info: payment_info,
- _('Please transfer money to this bank account: 9999-9999-9999-9999'),
+ _('Please transfer money to this bank account: 9999-9999-9999-9999'), inline=False,
),
SimpleFunctionalTextPlaceholder(
'attendee_name', ['position'], lambda position: position.attendee_name,
@@ -719,13 +753,13 @@ def base_placeholders(sender, **kwargs):
))
for k, v in sender.meta_data.items():
- ph.append(SimpleFunctionalTextPlaceholder(
+ ph.append(MarkdownTextPlaceholder(
'meta_%s' % k, ['event'], lambda event, k=k: event.meta_data[k],
- v
+ v, inline=True,
))
- ph.append(SimpleFunctionalTextPlaceholder(
+ ph.append(MarkdownTextPlaceholder(
'meta_%s' % k, ['event_or_subevent'], lambda event_or_subevent, k=k: event_or_subevent.meta_data[k],
- v
+ v, inline=True,
))
return ph
@@ -753,7 +787,7 @@ def get_available_placeholders(event, base_parameters, rich=False):
if not isinstance(val, (list, tuple)):
val = [val]
for v in val:
- if isinstance(v, BaseRichTextPlaceholder) and not rich:
+ if isinstance(v, BaseRichTextPlaceholder) and not rich and not v.allowed_in_plain_content:
continue
if all(rp in base_parameters for rp in v.required_context):
params[v.identifier] = v
@@ -775,13 +809,13 @@ def get_sample_context(event, context_parameters, rich=True):
)
)
elif str(sample).strip().startswith('* ') or str(sample).startswith(' '):
- context_dict[k] = '
{}
'.format(
+ context_dict[k] = mark_safe('{}
'.format(
lbl,
markdown_compile_email(str(sample))
- )
+ ))
else:
- context_dict[k] = '{}'.format(
+ context_dict[k] = mark_safe('{}'.format(
lbl,
escape(sample)
- )
+ ))
return context_dict
diff --git a/src/pretix/base/templatetags/rich_text.py b/src/pretix/base/templatetags/rich_text.py
index f38cdeab8c..954e093ffd 100644
--- a/src/pretix/base/templatetags/rich_text.py
+++ b/src/pretix/base/templatetags/rich_text.py
@@ -44,6 +44,7 @@ from django.conf import settings
from django.core import signing
from django.urls import reverse
from django.utils.functional import SimpleLazyObject
+from django.utils.html import escape
from django.utils.http import url_has_allowed_host_and_scheme
from django.utils.safestring import mark_safe
from markdown import Extension
@@ -52,6 +53,8 @@ from markdown.postprocessors import Postprocessor
from markdown.treeprocessors import UnescapeTreeprocessor
from tlds import tld_set
+from pretix.helpers.format import SafeFormatter, format_map
+
register = template.Library()
@@ -321,27 +324,44 @@ class LinkifyAndCleanExtension(Extension):
)
-def markdown_compile_email(source, allowed_tags=ALLOWED_TAGS, allowed_attributes=ALLOWED_ATTRIBUTES):
+def markdown_compile_email(source, allowed_tags=None, allowed_attributes=ALLOWED_ATTRIBUTES, snippet=False, context=None):
+ if allowed_tags is None:
+ allowed_tags = ALLOWED_TAGS_SNIPPET if snippet else ALLOWED_TAGS
+
+ context_callbacks = []
+ if context:
+ # This is a workaround to fix placeholders in URL targets
+ def context_callback(attrs, new=False):
+ if (None, "href") in attrs and "{" in attrs[None, "href"]:
+ # Do not use MODE_RICH_TO_HTML to avoid recursive linkification
+ attrs[None, "href"] = escape(format_map(attrs[None, "href"], context=context, mode=SafeFormatter.MODE_RICH_TO_PLAIN))
+ return attrs
+
+ context_callbacks.append(context_callback)
+
linker = bleach.Linker(
url_re=URL_RE,
email_re=EMAIL_RE,
- callbacks=DEFAULT_CALLBACKS + [truelink_callback, abslink_callback],
+ callbacks=context_callbacks + DEFAULT_CALLBACKS + [truelink_callback, abslink_callback],
parse_email=True
)
+ exts = [
+ 'markdown.extensions.sane_lists',
+ 'markdown.extensions.tables',
+ EmailNl2BrExtension(),
+ LinkifyAndCleanExtension(
+ linker,
+ tags=set(allowed_tags),
+ attributes=allowed_attributes,
+ protocols=ALLOWED_PROTOCOLS,
+ strip=snippet,
+ )
+ ]
+ if snippet:
+ exts.append(SnippetExtension())
return markdown.markdown(
source,
- extensions=[
- 'markdown.extensions.sane_lists',
- 'markdown.extensions.tables',
- EmailNl2BrExtension(),
- LinkifyAndCleanExtension(
- linker,
- tags=set(allowed_tags),
- attributes=allowed_attributes,
- protocols=ALLOWED_PROTOCOLS,
- strip=False,
- )
- ]
+ extensions=exts
)
diff --git a/src/pretix/control/forms/vouchers.py b/src/pretix/control/forms/vouchers.py
index 5138b7b7cd..63f8d3069e 100644
--- a/src/pretix/control/forms/vouchers.py
+++ b/src/pretix/control/forms/vouchers.py
@@ -308,8 +308,8 @@ class VoucherBulkForm(VoucherForm):
)
Recipient = namedtuple('Recipient', 'email number name tag')
- def _set_field_placeholders(self, fn, base_parameters):
- placeholders = get_available_placeholders(self.instance.event, base_parameters)
+ def _set_field_placeholders(self, fn, base_parameters, rich=False):
+ placeholders = get_available_placeholders(self.instance.event, base_parameters, rich=rich)
ht = format_placeholders_help_text(placeholders, self.instance.event)
if self.fields[fn].help_text:
@@ -345,7 +345,7 @@ class VoucherBulkForm(VoucherForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._set_field_placeholders('send_subject', ['event', 'name'])
- self._set_field_placeholders('send_message', ['event', 'voucher_list', 'name'])
+ self._set_field_placeholders('send_message', ['event', 'voucher_list', 'name'], rich=True)
with language(self.instance.event.settings.locale, self.instance.event.settings.region):
for f in ("send_subject", "send_message"):
diff --git a/src/pretix/helpers/format.py b/src/pretix/helpers/format.py
index 33549ed8b2..ebbdba8eb9 100644
--- a/src/pretix/helpers/format.py
+++ b/src/pretix/helpers/format.py
@@ -22,6 +22,8 @@
import logging
from string import Formatter
+from django.utils.html import conditional_escape
+
logger = logging.getLogger(__name__)
@@ -40,11 +42,10 @@ class SafeFormatter(Formatter):
Customized version of ``str.format`` that (a) behaves just like ``str.format_map`` and
(b) does not allow any unwanted shenanigans like attribute access or format specifiers.
"""
- MODE_IGNORE_RICH = 0
MODE_RICH_TO_PLAIN = 1
MODE_RICH_TO_HTML = 2
- def __init__(self, context, raise_on_missing=False, mode=MODE_IGNORE_RICH):
+ def __init__(self, context, raise_on_missing=False, mode=MODE_RICH_TO_PLAIN):
self.context = context
self.raise_on_missing = raise_on_missing
self.mode = mode
@@ -55,22 +56,26 @@ class SafeFormatter(Formatter):
def get_value(self, key, args, kwargs):
if not self.raise_on_missing and key not in self.context:
return '{' + str(key) + '}'
- r = self.context[key]
- if isinstance(r, PlainHtmlAlternativeString):
- if self.mode == self.MODE_IGNORE_RICH:
- return '{' + str(key) + '}'
- elif self.mode == self.MODE_RICH_TO_PLAIN:
- return r.plain
+ return self.context[key]
+
+ def _prepare_value(self, value):
+ if isinstance(value, PlainHtmlAlternativeString):
+ if self.mode == self.MODE_RICH_TO_PLAIN:
+ return value.plain
elif self.mode == self.MODE_RICH_TO_HTML:
- return r.html
- return r
+ return value.html
+ else:
+ value = str(value)
+ if self.mode == self.MODE_RICH_TO_HTML:
+ value = conditional_escape(value)
+ return value
def format_field(self, value, format_spec):
# Ignore format_spec
- return super().format_field(value, '')
+ return super().format_field(self._prepare_value(value), '')
-def format_map(template, context, raise_on_missing=False, mode=SafeFormatter.MODE_IGNORE_RICH):
+def format_map(template, context, raise_on_missing=False, mode=SafeFormatter.MODE_RICH_TO_PLAIN):
if not isinstance(template, str):
template = str(template)
return SafeFormatter(context, raise_on_missing, mode=mode).format(template)
diff --git a/src/tests/base/test_mail.py b/src/tests/base/test_mail.py
index a9ca4f20e1..da276aa970 100644
--- a/src/tests/base/test_mail.py
+++ b/src/tests/base/test_mail.py
@@ -33,6 +33,8 @@
# License for the specific language governing permissions and limitations under the License.
import os
+import re
+from email.mime.text import MIMEText
import pytest
from django.conf import settings
@@ -40,7 +42,9 @@ from django.core import mail as djmail
from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _
from django_scopes import scope
+from i18nfield.strings import LazyI18nString
+from pretix.base.email import get_email_context
from pretix.base.models import Event, Organizer, User
from pretix.base.services.mail import mail
@@ -48,10 +52,14 @@ from pretix.base.services.mail import mail
@pytest.fixture
def env():
o = Organizer.objects.create(name='Dummy', slug='dummy')
+ prop1 = o.meta_properties.get_or_create(name="Test")[0]
+ prop2 = o.meta_properties.get_or_create(name="Website")[0]
event = Event.objects.create(
organizer=o, name='Dummy', slug='dummy',
date_from=now()
)
+ event.meta_values.update_or_create(property=prop1, defaults={'value': "*Beep*"})
+ event.meta_values.update_or_create(property=prop2, defaults={'value': "https://example.com"})
user = User.objects.create_user('dummy@dummy.dummy', 'dummy')
user.email = 'dummy@dummy.dummy'
user.save()
@@ -158,8 +166,95 @@ def test_send_mail_with_user_locale(env):
def test_sendmail_placeholder(env):
djmail.outbox = []
event, user, organizer = env
- mail('dummy@dummy.dummy', '{event} Test subject', 'mailtest.txt', {"event": event}, event)
+ mail('dummy@dummy.dummy', '{event} Test subject', 'mailtest.txt', {"event": event.name}, event)
assert len(djmail.outbox) == 1
assert djmail.outbox[0].to == [user.email]
assert djmail.outbox[0].subject == 'Dummy Test subject'
+
+
+def _extract_html(mail):
+ for content, mimetype in mail.alternatives:
+ if "multipart/related" in mimetype:
+ for sp in content._payload:
+ if isinstance(sp, MIMEText):
+ return sp._payload
+ break
+ elif "text/html" in mimetype:
+ return content
+
+
+@pytest.mark.django_db
+def test_placeholder_html_rendering_from_template(env):
+ djmail.outbox = []
+ event, user, organizer = env
+ event.name = "event & co. kg"
+ event.save()
+ mail('dummy@dummy.dummy', '{event} Test subject', 'mailtest.txt', get_email_context(
+ event=event,
+ payment_info="**IBAN**: 123 \n**BIC**: 456",
+ ), event)
+
+ assert len(djmail.outbox) == 1
+ assert djmail.outbox[0].to == [user.email]
+ assert 'Event name: event & co. kg' in djmail.outbox[0].body
+ assert '**IBAN**: 123 \n**BIC**: 456' in djmail.outbox[0].body
+ assert '**Meta**: *Beep*' in djmail.outbox[0].body
+ assert 'Event website: [event & co. kg](https://example.org/dummy)' in djmail.outbox[0].body
+ assert 'Other website: [event & co. kg](https://example.com)' in djmail.outbox[0].body
+ assert '<' not in djmail.outbox[0].body
+ assert '&' not in djmail.outbox[0].body
+ html = _extract_html(djmail.outbox[0])
+
+ assert 'event' not in html
+ assert 'Event name: <strong>event & co. kg</strong>' in html
+ assert 'IBAN: 123
\nBIC: 456' in html
+ assert 'Meta: Beep' in html
+ assert re.search(
+ r'Event website: <strong>event & co. kg</strong>',
+ html
+ )
+ assert re.search(
+ r'Other website: <strong>event & co. kg</strong>',
+ html
+ )
+
+
+@pytest.mark.django_db
+def test_placeholder_html_rendering_from_string(env):
+ template = LazyI18nString({
+ "en": "Event name: {event}\n\nPayment info:\n{payment_info}\n\n**Meta**: {meta_Test}\n\n"
+ "Event website: [{event}](https://example.org/{event_slug})\n\n"
+ "Other website: [{event}]({meta_Website})"
+ })
+ djmail.outbox = []
+ event, user, organizer = env
+ event.name = "event & co. kg"
+ event.save()
+ mail('dummy@dummy.dummy', '{event} Test subject', template, get_email_context(
+ event=event,
+ payment_info="**IBAN**: 123 \n**BIC**: 456",
+ ), event)
+
+ assert len(djmail.outbox) == 1
+ assert djmail.outbox[0].to == [user.email]
+ assert 'Event name: event & co. kg' in djmail.outbox[0].body
+ assert 'Event website: [event & co. kg](https://example.org/dummy)' in djmail.outbox[0].body
+ assert 'Other website: [event & co. kg](https://example.com)' in djmail.outbox[0].body
+ assert '**IBAN**: 123 \n**BIC**: 456' in djmail.outbox[0].body
+ assert '**Meta**: *Beep*' in djmail.outbox[0].body
+ assert '<' not in djmail.outbox[0].body
+ assert '&' not in djmail.outbox[0].body
+ html = _extract_html(djmail.outbox[0])
+ assert 'event' not in html
+ assert 'Event name: <strong>event & co. kg</strong>' in html
+ assert 'IBAN: 123
\nBIC: 456' in html
+ assert 'Meta: Beep' in html
+ assert re.search(
+ r'Event website: <strong>event & co. kg</strong>',
+ html
+ )
+ assert re.search(
+ r'Other website: <strong>event & co. kg</strong>',
+ html
+ )
diff --git a/src/tests/helpers/test_format.py b/src/tests/helpers/test_format.py
index 5ab5c2569b..5ed3ac9b15 100644
--- a/src/tests/helpers/test_format.py
+++ b/src/tests/helpers/test_format.py
@@ -40,6 +40,5 @@ def test_format_alternatives():
)
}
- assert format_map("Foo {bar}", ctx, mode=SafeFormatter.MODE_IGNORE_RICH) == "Foo {bar}"
assert format_map("Foo {bar}", ctx, mode=SafeFormatter.MODE_RICH_TO_PLAIN) == "Foo plain text"
assert format_map("Foo {bar}", ctx, mode=SafeFormatter.MODE_RICH_TO_HTML) == "Foo HTML version"
diff --git a/src/tests/templates/mailtest.txt b/src/tests/templates/mailtest.txt
index 71139e00cb..1bc1487d31 100644
--- a/src/tests/templates/mailtest.txt
+++ b/src/tests/templates/mailtest.txt
@@ -1,4 +1,13 @@
{% load i18n %}
This is a test file for sending mails.
+Event name: {event}
{% get_current_language as LANGUAGE_CODE %}
The language code used for rendering this email is {{ LANGUAGE_CODE }}.
+
+Payment info:
+{payment_info}
+
+**Meta**: {meta_Test}
+
+Event website: [{event}](https://example.org/{event_slug})
+Other website: [{event}]({meta_Website})
\ No newline at end of file