diff --git a/src/pretix/base/email.py b/src/pretix/base/email.py
index 155b296573..e9345d3808 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 3dbe0c6cc7..3f3b8e0ea5 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 8c19e7950b..f52198dd28 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 a620f1ca8d..42d381fef0 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