Compare commits

...

1 Commits

Author SHA1 Message Date
Raphael Michel
e445c4d11e draft html rendering 2026-02-17 09:27:39 +01:00
4 changed files with 85 additions and 41 deletions

View File

@@ -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,

View File

@@ -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):

View File

@@ -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: <strong>event & co. kg</strong> {currency}') in djmail.outbox[0].body
assert '<strong>IBAN</strong>: 123<br>\n<strong>BIC</strong>: 456' in djmail.outbox[0].body
assert '**Meta**: <em>Beep</em>' in djmail.outbox[0].body
assert escape('Event website: [<strong>event & co. kg</strong> {currency}](https://example.org/dummy)') in djmail.outbox[0].body
# todo: assert '&lt;' not in djmail.outbox[0].body
# todo: assert '&amp;' not in djmail.outbox[0].body
assert 'Event name: <strong>event & co. kg</strong> {currency}' in djmail.outbox[0].body
assert 'Event: <strong>event & co. kg</strong> {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: [<strong>event & co. kg</strong> {currency}](https://example.org/dummy)' in djmail.outbox[0].body
assert '<a ' not in djmail.outbox[0].body
assert '&lt;' not in djmail.outbox[0].body
assert '&amp;' not in djmail.outbox[0].body
assert 'Unevaluated placeholder: {currency}' in djmail.outbox[0].body
assert 'EUR' not in djmail.outbox[0].body
html = _extract_html(djmail.outbox[0])
@@ -346,11 +346,13 @@ def test_placeholder_html_rendering_from_template(env):
assert '<strong>event' not in html
assert 'Event name: &lt;strong&gt;event &amp; co. kg&lt;/strong&gt; {currency}' in html
assert '<strong>IBAN</strong>: 123<br/>\n<strong>BIC</strong>: 456' in html
assert '<strong>Meta</strong>: <em>Beep</em>' in html
assert '**Meta**: <em>Beep</em>' in html
assert 'Unevaluated placeholder: {currency}' in html
assert 'EUR' not in html
assert 'Event website: [&lt;strong&gt;event &amp; co. kg&lt;/strong&gt; {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: <a href="https://example.org/dummy" rel="noopener" style="[^"]+" target="_blank">'
r'Event: <a href="https://example.com/dummy" style="[^"]+">'
r'&lt;strong&gt;event &amp; co. kg&lt;/strong&gt; {currency}</a>',
html
)

View File

@@ -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}})
Markdown will not be evaluated when coming from a template file!
Event website: [{{event}}](https://example.org/{{event_slug}})
Event: {% if to_html %}<a href="https://example.com/{{event_slug}}">{% endif %}{{ event }}{% if to_html %}</a>{% endif %}