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