diff --git a/src/pretix/base/email.py b/src/pretix/base/email.py index 155b29657..e9345d380 100644 --- a/src/pretix/base/email.py +++ b/src/pretix/base/email.py @@ -22,7 +22,7 @@ import logging from itertools import groupby from smtplib import SMTPResponseException -from typing import TypeVar +from typing import TypeVar, Union import bleach import css_inline @@ -31,6 +31,7 @@ from django.core.mail.backends.smtp import EmailBackend from django.db.models import Count from django.dispatch import receiver from django.template.loader import get_template +from django.utils.safestring import mark_safe from django.utils.translation import get_language, gettext_lazy as _ from pretix.base.models import Event @@ -39,7 +40,9 @@ from pretix.base.templatetags.rich_text import ( DEFAULT_CALLBACKS, EMAIL_RE, URL_RE, abslink_callback, markdown_compile_email, truelink_callback, ) -from pretix.helpers.format import FormattedString, SafeFormatter, format_map +from pretix.helpers.format import ( + FormattedString, PlainHtmlAlternativeString, SafeFormatter, format_map, +) from pretix.base.services.placeholders import ( # noqa get_available_placeholders, PlaceholderContext @@ -83,8 +86,8 @@ class BaseHTMLMailRenderer: def __str__(self): return self.identifier - def render(self, plain_body: str, plain_signature: str, subject: str, order=None, - position=None, context=None) -> str: + def render(self, content: Union[str, FormattedString, PlainHtmlAlternativeString], plain_signature: str, + subject: str, order=None, position=None, context=None) -> str: """ This method should generate the HTML part of the email. @@ -140,27 +143,37 @@ class TemplateBasedMailRenderer(BaseHTMLMailRenderer): 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: - apply_format_map = not isinstance(plain_body, FormattedString) - body_md = self.compile_markdown(plain_body, context) - if context: - linker = bleach.Linker( - url_re=URL_RE, - email_re=EMAIL_RE, - callbacks=DEFAULT_CALLBACKS + [truelink_callback, abslink_callback], - parse_email=True - ) - if apply_format_map: - body_md = format_map( - body_md, + def render(self, content: Union[str, FormattedString, PlainHtmlAlternativeString], plain_signature: str, + subject: str, order=None, position=None, context=None) -> str: + if isinstance(content, FormattedString): + # Raw string that is already formatted but not markdown-rendered + body_content_html = self.compile_markdown(content, context) + + elif isinstance(content, PlainHtmlAlternativeString): + # HTML already rendered by Django templates + body_content_html = content.html + + else: + # Raw string that is not yet formatted or markdown-rendered + body_content_html = self.compile_markdown(content, context) + if context: + linker = bleach.Linker( + url_re=URL_RE, + email_re=EMAIL_RE, + callbacks=DEFAULT_CALLBACKS + [truelink_callback, abslink_callback], + parse_email=True + ) + body_content_html = format_map( + body_content_html, context=context, mode=SafeFormatter.MODE_RICH_TO_HTML, linkifier=linker ) + htmlctx = { 'site': settings.PRETIX_INSTANCE_NAME, 'site_url': settings.SITE_URL, - 'body': body_md, + 'body': mark_safe(body_content_html), 'subject': str(subject), 'color': settings.PRETIX_PRIMARY_COLOR, 'rtl': get_language() in settings.LANGUAGES_RTL or get_language().split('-')[0] in settings.LANGUAGES_RTL, diff --git a/src/pretix/base/services/mail.py b/src/pretix/base/services/mail.py index 3dbe0c6cc..3f3b8e0ea 100644 --- a/src/pretix/base/services/mail.py +++ b/src/pretix/base/services/mail.py @@ -58,6 +58,7 @@ 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 import Context from django.template.loader import get_template from django.utils.html import escape from django.utils.timezone import now, override @@ -261,17 +262,22 @@ def mail(email: Union[str, Sequence[str]], subject: Union[str, FormattedString], _autoextend_context(context, order) # Build raw content - content_plain = render_mail(template, context, placeholder_mode=None) + content = 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) + if isinstance(content, FormattedString): + # Already formatted by render_mail() from format_values + body_plain = content + elif isinstance(content, PlainHtmlAlternativeString): + # Already formatted by render_mail() form a django template + body_plain = content.plain else: - body_plain = content_plain + # Not yet formatted + body_plain = format_map(content, context, mode=SafeFormatter.MODE_RICH_TO_PLAIN) body_plain = _wrap_plain_body(body_plain, signature, event, order, position, no_order_links) # Build subject @@ -298,19 +304,19 @@ def mail(email: Union[str, Sequence[str]], subject: Union[str, FormattedString], try: if 'context' in inspect.signature(renderer.render).parameters: - body_html = renderer.render(content_plain, signature, raw_subject, order, position, context) + body_html = renderer.render(content, 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) + body_html = renderer.render(content, 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) + body_html = renderer.render(content, signature, raw_subject, order) except: logger.exception('Could not render HTML body') body_html = None @@ -810,15 +816,22 @@ def render_mail(template, context, placeholder_mode: Optional[int]=SafeFormatter body = str(template) if context and placeholder_mode: body = format_map(body, context, mode=placeholder_mode) + return body else: tpl = get_template(template) - context = { - # Known bug, should behave differently for plain and HTML but we'll fix after security release + + plain_context = Context({ + k: v.plain if isinstance(v, PlainHtmlAlternativeString) else v + for k, v in context.items() + } | {"to_html": False}, autoescape=False) + html_context = Context({ k: v.html if isinstance(v, PlainHtmlAlternativeString) else v for k, v in context.items() - } - body = FormattedString(tpl.render(context)) - return body + } | {"to_html": True}, autoescape=True) + return PlainHtmlAlternativeString( + plain=tpl.template.render(plain_context), + html=tpl.template.render(html_context), + ) def replace_images_with_cid_paths(body_html): diff --git a/src/tests/base/test_mail.py b/src/tests/base/test_mail.py index 8c19e7950..f52198dd2 100644 --- a/src/tests/base/test_mail.py +++ b/src/tests/base/test_mail.py @@ -42,7 +42,6 @@ import pytest from django.conf import settings from django.core import mail as djmail from django.test import override_settings -from django.utils.html import escape from django.utils.timezone import now from django.utils.translation import gettext_lazy as _ from django_scopes import scope, scopes_disabled @@ -332,13 +331,14 @@ def test_placeholder_html_rendering_from_template(env): assert len(djmail.outbox) == 1 assert djmail.outbox[0].to == [user.email] - # Known bug for now: These should not have HTML for the plain body, but we'll fix this safter the security release - assert escape('Event name: event & co. kg {currency}') in djmail.outbox[0].body - assert 'IBAN: 123
\nBIC: 456' in djmail.outbox[0].body - assert '**Meta**: Beep' in djmail.outbox[0].body - assert escape('Event website: [event & co. kg {currency}](https://example.org/dummy)') in djmail.outbox[0].body - # todo: assert '<' not in djmail.outbox[0].body - # todo: assert '&' not in djmail.outbox[0].body + assert 'Event name: event & co. kg {currency}' in djmail.outbox[0].body + assert 'Event: event & co. kg {currency}' 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 {currency}](https://example.org/dummy)' in djmail.outbox[0].body + assert 'event' not in html assert 'Event name: <strong>event & co. kg</strong> {currency}' in html assert 'IBAN: 123
\nBIC: 456' in html - assert 'Meta: Beep' in html + assert '**Meta**: Beep' in html assert 'Unevaluated placeholder: {currency}' in html assert 'EUR' not in html + assert 'Event website: [<strong>event & co. kg</strong> {currency}](https://example.org/dummy)' in html + # Links are from raw HTML and therefore trusted, rel and target is not added automatically assert re.search( - r'Event website:
' + r'Event: ' r'<strong>event & co. kg</strong> {currency}', html ) diff --git a/src/tests/templates/mailtest.txt b/src/tests/templates/mailtest.txt index a620f1ca8..42d381fef 100644 --- a/src/tests/templates/mailtest.txt +++ b/src/tests/templates/mailtest.txt @@ -1,13 +1,29 @@ {% load i18n %} This is a test file for sending mails. + +Django variables will get evaluated: + Event name: {{ event }} + +pretix variables will not in a template rendering: + Unevaluated placeholder: {currency} + +We can use advanced Django things: + {% get_current_language as LANGUAGE_CODE %} The language code used for rendering this email is {{ LANGUAGE_CODE }}. +Custom content is rendered safely without HTML/Markdown/parameter injection +unless the parameter is marked as "HTML-safe": + Payment info: {{ payment_info }} **Meta**: {{ meta_Test }} -Event website: [{{event}}](https://example.org/{{event_slug}}) \ No newline at end of file +Markdown will not be evaluated when coming from a template file! + +Event website: [{{event}}](https://example.org/{{event_slug}}) + +Event: {% if to_html %}{% endif %}{{ event }}{% if to_html %}{% endif %} \ No newline at end of file