diff --git a/src/pretix/base/email.py b/src/pretix/base/email.py
index 08d6f42e5..b17f5b266 100644
--- a/src/pretix/base/email.py
+++ b/src/pretix/base/email.py
@@ -35,6 +35,7 @@ from django.utils.translation import get_language, gettext_lazy as _
from pretix.base.models import Event
from pretix.base.signals import register_html_mail_renderers
from pretix.base.templatetags.rich_text import markdown_compile_email
+from pretix.helpers.format import SafeFormatter, format_map
from pretix.base.services.placeholders import ( # noqa
get_available_placeholders, PlaceholderContext
@@ -79,7 +80,7 @@ class BaseHTMLMailRenderer:
return self.identifier
def render(self, plain_body: str, plain_signature: str, subject: str, order=None,
- position=None) -> str:
+ position=None, context=None) -> str:
"""
This method should generate the HTML part of the email.
@@ -88,6 +89,7 @@ class BaseHTMLMailRenderer:
:param subject: The email subject.
:param order: The order if this email is connected to one, otherwise ``None``.
:param position: The order position if this email is connected to one, otherwise ``None``.
+ :param context: Context to use to render placeholders in the plain body
:return: An HTML string
"""
raise NotImplementedError()
@@ -134,8 +136,10 @@ class TemplateBasedMailRenderer(BaseHTMLMailRenderer):
def compile_markdown(self, plaintext):
return markdown_compile_email(plaintext)
- def render(self, plain_body: str, plain_signature: str, subject: str, order, position) -> str:
+ def render(self, plain_body: str, plain_signature: str, subject: str, order, position, context) -> str:
body_md = self.compile_markdown(plain_body)
+ if context:
+ body_md = format_map(body_md, context=context, mode=SafeFormatter.MODE_RICH_TO_HTML)
htmlctx = {
'site': settings.PRETIX_INSTANCE_NAME,
'site_url': settings.SITE_URL,
diff --git a/src/pretix/base/services/mail.py b/src/pretix/base/services/mail.py
index f44432468..fea30a174 100644
--- a/src/pretix/base/services/mail.py
+++ b/src/pretix/base/services/mail.py
@@ -76,7 +76,7 @@ from pretix.base.services.tasks import TransactionAwareTask
from pretix.base.services.tickets import get_tickets_for_order
from pretix.base.signals import email_filter, global_email_filter
from pretix.celery_app import app
-from pretix.helpers.format import format_map
+from pretix.helpers.format import SafeFormatter, format_map
from pretix.helpers.hierarkey import clean_filename
from pretix.multidomain.urlreverse import build_absolute_uri
from pretix.presale.ical import get_private_icals
@@ -311,7 +311,13 @@ def mail(email: Union[str, Sequence[str]], subject: str, template: Union[str, La
try:
if plain_text_only:
body_html = None
+ elif 'context' in inspect.signature(renderer.render).parameters:
+ body_html = renderer.render(content_plain, 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)
else:
# Backwards compatibility
@@ -323,6 +329,8 @@ def mail(email: Union[str, Sequence[str]], subject: str, template: Union[str, La
logger.exception('Could not render HTML body')
body_html = None
+ body_plain = format_map(body_plain, context, mode=SafeFormatter.MODE_RICH_TO_PLAIN)
+
send_task = mail_send_task.si(
to=[email] if isinstance(email, str) else list(email),
cc=cc,
@@ -655,7 +663,7 @@ def render_mail(template, context):
if isinstance(template, LazyI18nString):
body = str(template)
if context:
- body = format_map(body, context)
+ body = format_map(body, context, mode=SafeFormatter.MODE_IGNORE_RICH)
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 e07b1f868..53fc2918f 100644
--- a/src/pretix/base/services/placeholders.py
+++ b/src/pretix/base/services/placeholders.py
@@ -26,6 +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.timezone import now
from django.utils.translation import gettext_lazy as _
@@ -39,7 +40,8 @@ from pretix.base.settings import PERSON_NAME_SCHEMES, get_name_parts_localized
from pretix.base.signals import (
register_mail_placeholders, register_text_placeholders,
)
-from pretix.helpers.format import SafeFormatter
+from pretix.base.templatetags.rich_text import markdown_compile_email
+from pretix.helpers.format import PlainHtmlAlternativeString, SafeFormatter
logger = logging.getLogger('pretix.base.services.placeholders')
@@ -107,6 +109,91 @@ class SimpleFunctionalTextPlaceholder(BaseTextPlaceholder):
return self._sample
+class BaseRichTextPlaceholder(BaseTextPlaceholder):
+ """
+ This is the base class for all placeholders which can render either to plain text
+ or to a rich HTML element.
+ """
+
+ def __init__(self, identifier, args):
+ self._identifier = identifier
+ self._args = args
+
+ @property
+ def identifier(self):
+ return self._identifier
+
+ @property
+ def required_context(self):
+ return self._args
+
+ @property
+ def is_block(self):
+ return False
+
+ def render(self, context):
+ return PlainHtmlAlternativeString(
+ self.render_plain(**{k: context[k] for k in self._args}),
+ self.render_html(**{k: context[k] for k in self._args}),
+ self.is_block,
+ )
+
+ def render_html(self, **kwargs):
+ """
+ HTML rendering of the placeholder. Should return "safe" HTML, i.e. everything needs to be
+ escaped.
+ """
+ raise NotImplementedError
+
+ def render_plain(self, **kwargs):
+ """
+ Plain text rendering of the placeholder.
+ """
+ raise NotImplementedError
+
+ def render_sample(self, event):
+ return PlainHtmlAlternativeString(
+ self.render_sample_plain(event=event),
+ self.render_sample_html(event=event),
+ self.is_block,
+ )
+
+ def render_sample_html(self, event):
+ raise NotImplementedError
+
+ def render_sample_plain(self, event):
+ raise NotImplementedError
+
+
+class SimpleButtonPlaceholder(BaseRichTextPlaceholder):
+ def __init__(self, identifier, args, url_func, text_func, sample_url_func, sample_text_func):
+ super().__init__(identifier, args)
+ self._url_func = url_func
+ self._text_func = text_func
+ self._sample_url_func = sample_url_func
+ self._sample_text_func = sample_text_func
+
+ def render_html(self, **context):
+ text = self._text_func(**{k: context[k] for k in self._args})
+ url = self._url_func(**{k: context[k] for k in self._args})
+ return f'{escape(text)}'
+
+ def render_plain(self, **context):
+ text = self._text_func(**{k: context[k] for k in self._args})
+ url = self._url_func(**{k: context[k] for k in self._args})
+ return f'{text}: {url}'
+
+ def render_sample_html(self, event):
+ text = self._sample_text_func(event)
+ url = self._sample_url_func(event)
+ return f'{escape(text)}'
+
+ def render_sample_plain(self, event):
+ text = self._sample_text_func(event)
+ url = self._sample_url_func(event)
+ return f'{text}: {url}'
+
+
class PlaceholderContext(SafeFormatter):
"""
Holds the contextual arguments and corresponding list of available placeholders for formatting
@@ -284,6 +371,27 @@ def base_placeholders(sender, **kwargs):
}
),
),
+ SimpleButtonPlaceholder(
+ 'url_button', ['order', 'event'],
+ url_func=lambda order, event: build_absolute_uri(
+ event,
+ 'presale:event.order.open', kwargs={
+ 'order': order.code,
+ 'secret': order.secret,
+ 'hash': order.email_confirm_secret()
+ }
+ ),
+ text_func=lambda order, event: _("View order details"),
+ sample_url_func=lambda event: build_absolute_uri(
+ event,
+ 'presale:event.order.open', kwargs={
+ 'order': 'F8VVL',
+ 'secret': '6zzjnumtsx136ddy',
+ 'hash': '98kusd8ofsj8dnkd'
+ }
+ ),
+ sample_text_func=lambda event: _("View order details"),
+ ),
SimpleFunctionalTextPlaceholder(
'url_info_change', ['order', 'event'], lambda order, event: build_absolute_uri(
event,
@@ -348,6 +456,27 @@ def base_placeholders(sender, **kwargs):
}
),
),
+ SimpleButtonPlaceholder(
+ 'url_button', ['event', 'position'],
+ url_func=lambda event, position: build_absolute_uri(
+ event,
+ 'presale:event.order.position', kwargs={
+ 'order': position.order.code,
+ 'secret': position.web_secret,
+ 'position': position.positionid
+ }
+ ),
+ text_func=lambda event, position: _("View registration details"),
+ sample_url_func=lambda event: build_absolute_uri(
+ event,
+ 'presale:event.order.position', kwargs={
+ 'order': 'F8VVL',
+ 'secret': '6zzjnumtsx136ddy',
+ 'position': '123'
+ }
+ ),
+ sample_text_func=lambda event: _("View registration details"),
+ ),
SimpleFunctionalTextPlaceholder(
'url_info_change', ['position', 'event'], lambda position, event: build_absolute_uri(
event,
@@ -603,8 +732,8 @@ def base_placeholders(sender, **kwargs):
class FormPlaceholderMixin:
- def _set_field_placeholders(self, fn, base_parameters):
- placeholders = get_available_placeholders(self.event, base_parameters)
+ def _set_field_placeholders(self, fn, base_parameters, rich=False):
+ placeholders = get_available_placeholders(self.event, base_parameters, rich=rich)
ht = format_placeholders_help_text(placeholders, self.event)
if self.fields[fn].help_text:
self.fields[fn].help_text += ' ' + str(ht)
@@ -615,7 +744,7 @@ class FormPlaceholderMixin:
)
-def get_available_placeholders(event, base_parameters):
+def get_available_placeholders(event, base_parameters, rich=False):
if 'order' in base_parameters:
base_parameters.append('invoice_address')
base_parameters.append('position_or_address')
@@ -624,6 +753,35 @@ def get_available_placeholders(event, base_parameters):
if not isinstance(val, (list, tuple)):
val = [val]
for v in val:
+ if isinstance(v, BaseRichTextPlaceholder) and not rich:
+ continue
if all(rp in base_parameters for rp in v.required_context):
params[v.identifier] = v
return params
+
+
+def get_sample_context(event, context_parameters, rich=True):
+ context_dict = {}
+ lbl = _('This value will be replaced based on dynamic parameters.')
+ for k, v in get_available_placeholders(event, context_parameters, rich=rich).items():
+ sample = v.render_sample(event)
+ if isinstance(sample, PlainHtmlAlternativeString):
+ context_dict[k] = PlainHtmlAlternativeString(
+ sample.plain,
+ '<{el} class="placeholder placeholder-html" title="{title}">{html}{el}>'.format(
+ el='div' if sample.is_block else 'span',
+ title=lbl,
+ html=sample.html,
+ )
+ )
+ elif str(sample).strip().startswith('* ') or str(sample).startswith(' '):
+ context_dict[k] = '
{}
'.format(
+ lbl,
+ markdown_compile_email(str(sample))
+ )
+ else:
+ context_dict[k] = '{}'.format(
+ lbl,
+ escape(sample)
+ )
+ return context_dict
diff --git a/src/pretix/base/templates/pretixbase/email/base.html b/src/pretix/base/templates/pretixbase/email/base.html
index a6f4066b6..ef8809ac1 100644
--- a/src/pretix/base/templates/pretixbase/email/base.html
+++ b/src/pretix/base/templates/pretixbase/email/base.html
@@ -131,6 +131,9 @@
text-align: left;
padding: 0;
}
+ .content table td.align-right {
+ text-align: right;
+ }
a.button {
display: inline-block;
@@ -178,6 +181,9 @@
pre, pre code {
white-space: pre-line;
}
+ .text-right, .content table td.text-right {
+ text-align: right;
+ }
{% if rtl %}
body {
@@ -186,6 +192,9 @@
.content {
text-align: right;
}
+ .text-right, .content table td.text-right {
+ text-align: left;
+ }
{% endif %}
{% block addcss %}{% endblock %}
diff --git a/src/pretix/base/templatetags/rich_text.py b/src/pretix/base/templatetags/rich_text.py
index 1fe053abc..d77d9ce9c 100644
--- a/src/pretix/base/templatetags/rich_text.py
+++ b/src/pretix/base/templatetags/rich_text.py
@@ -305,6 +305,7 @@ def markdown_compile_email(source, allowed_tags=ALLOWED_TAGS, allowed_attributes
source,
extensions=[
'markdown.extensions.sane_lists',
+ 'markdown.extensions.tables',
EmailNl2BrExtension(),
LinkifyAndCleanExtension(
linker,
diff --git a/src/pretix/control/forms/event.py b/src/pretix/control/forms/event.py
index 3be1ea705..74723f502 100644
--- a/src/pretix/control/forms/event.py
+++ b/src/pretix/control/forms/event.py
@@ -1385,7 +1385,7 @@ class MailSettingsForm(FormPlaceholderMixin, SettingsForm):
self.event.meta_values_cached = self.event.meta_values.select_related('property').all()
for k, v in self.base_context.items():
- self._set_field_placeholders(k, v)
+ self._set_field_placeholders(k, v, rich=k.startswith('mail_text_'))
for k, v in list(self.fields.items()):
if k.endswith('_attendee') and not event.settings.attendee_emails_asked:
diff --git a/src/pretix/control/views/event.py b/src/pretix/control/views/event.py
index 04ac07f00..9e1e064f3 100644
--- a/src/pretix/control/views/event.py
+++ b/src/pretix/control/views/event.py
@@ -62,7 +62,7 @@ from django.http import (
from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse
from django.utils.functional import cached_property
-from django.utils.html import escape
+from django.utils.html import conditional_escape
from django.utils.http import url_has_allowed_host_and_scheme
from django.utils.timezone import now
from django.utils.translation import gettext, gettext_lazy as _, gettext_noop
@@ -100,9 +100,12 @@ from ...base.models.items import (
Item, ItemCategory, ItemMetaProperty, Question, Quota,
)
from ...base.services.mail import prefix_subject
+from ...base.services.placeholders import get_sample_context
from ...base.settings import LazyI18nStringList
from ...helpers.compat import CompatDeleteView
-from ...helpers.format import format_map
+from ...helpers.format import (
+ PlainHtmlAlternativeString, SafeFormatter, format_map,
+)
from ..logdisplay import OVERVIEW_BANLIST
from . import CreateView, PaginationMixin, UpdateView
@@ -716,20 +719,7 @@ class MailSettingsPreview(EventPermissionRequiredMixin, View):
# get all supported placeholders with dummy values
def placeholders(self, item):
- ctx = {}
- for p in get_available_placeholders(self.request.event, MailSettingsForm.base_context[item]).values():
- s = str(p.render_sample(self.request.event))
- if s.strip().startswith('* '):
- ctx[p.identifier] = '{}
'.format(
- _('This value will be replaced based on dynamic parameters.'),
- markdown_compile_email(s)
- )
- else:
- ctx[p.identifier] = '{}'.format(
- _('This value will be replaced based on dynamic parameters.'),
- escape(s)
- )
- return ctx
+ return get_sample_context(self.request.event, MailSettingsForm.base_context[item])
def post(self, request, *args, **kwargs):
preview_item = request.POST.get('item', '')
@@ -751,9 +741,15 @@ class MailSettingsPreview(EventPermissionRequiredMixin, View):
bleach.clean(v), self.placeholders(preview_item), raise_on_missing=True
), highlight=True)
else:
- msgs[self.supported_locale[idx]] = markdown_compile_email(
- format_map(v, self.placeholders(preview_item), raise_on_missing=True)
+ placeholders = self.placeholders(preview_item)
+ msgs[self.supported_locale[idx]] = format_map(
+ markdown_compile_email(
+ format_map(v, placeholders, raise_on_missing=True)
+ ),
+ placeholders,
+ mode=SafeFormatter.MODE_RICH_TO_HTML,
)
+
except ValueError:
msgs[self.supported_locale[idx]] = '{}
'.format(
PlaceholderValidator.error_message)
@@ -776,13 +772,18 @@ class MailSettingsRendererPreview(MailSettingsPreview):
# get all supported placeholders with dummy values
def placeholders(self, item):
ctx = {}
- for p in get_available_placeholders(self.request.event, MailSettingsForm.base_context[item]).values():
- ctx[p.identifier] = escape(str(p.render_sample(self.request.event)))
+ for p in get_available_placeholders(self.request.event, MailSettingsForm.base_context[item], rich=True).values():
+ sample = p.render_sample(self.request.event)
+ if isinstance(sample, PlainHtmlAlternativeString):
+ ctx[p.identifier] = sample
+ else:
+ ctx[p.identifier] = conditional_escape(sample)
return ctx
def get(self, request, *args, **kwargs):
v = str(request.event.settings.mail_text_order_placed)
- v = format_map(v, self.placeholders('mail_text_order_placed'))
+ context = self.placeholders('mail_text_order_placed')
+ v = format_map(v, context)
renderers = request.event.get_html_mail_renderers()
if request.GET.get('renderer') in renderers:
with rolledback_transaction():
@@ -800,7 +801,8 @@ class MailSettingsRendererPreview(MailSettingsPreview):
str(request.event.settings.mail_text_signature),
gettext('Your order: %(code)s') % {'code': order.code},
order,
- position=None
+ position=None,
+ context=context,
)
r = HttpResponse(v, content_type='text/html')
r._csp_ignore = True
diff --git a/src/pretix/control/views/orders.py b/src/pretix/control/views/orders.py
index 9e98e2906..1e00d7fcf 100644
--- a/src/pretix/control/views/orders.py
+++ b/src/pretix/control/views/orders.py
@@ -134,7 +134,7 @@ from pretix.control.signals import order_search_forms
from pretix.control.views import PaginationMixin
from pretix.helpers import OF_SELF
from pretix.helpers.compat import CompatDeleteView
-from pretix.helpers.format import format_map
+from pretix.helpers.format import SafeFormatter, format_map
from pretix.helpers.safedownload import check_token
from pretix.presale.signals import question_form_fields
@@ -2351,7 +2351,7 @@ class OrderSendMail(EventPermissionRequiredMixin, OrderViewMixin, FormView):
'subject': mark_safe(_('Subject: {subject}').format(
subject=prefix_subject(order.event, escape(email_subject), highlight=True)
)),
- 'html': markdown_compile_email(email_content)
+ 'html': format_map(markdown_compile_email(email_content), email_context, mode=SafeFormatter.MODE_RICH_TO_HTML)
}
return self.get(self.request, *self.args, **self.kwargs)
else:
diff --git a/src/pretix/control/views/organizer.py b/src/pretix/control/views/organizer.py
index 92855c442..63c3a4ae7 100644
--- a/src/pretix/control/views/organizer.py
+++ b/src/pretix/control/views/organizer.py
@@ -122,7 +122,7 @@ from pretix.control.views.mailsetup import MailSettingsSetupView
from pretix.helpers import OF_SELF, GroupConcat
from pretix.helpers.compat import CompatDeleteView
from pretix.helpers.dicts import merge_dicts
-from pretix.helpers.format import format_map
+from pretix.helpers.format import SafeFormatter, format_map
from pretix.helpers.urls import build_absolute_uri as build_global_uri
from pretix.multidomain.urlreverse import build_absolute_uri
from pretix.presale.forms.customer import TokenGenerator
@@ -357,9 +357,10 @@ class MailSettingsPreview(OrganizerPermissionRequiredMixin, View):
highlight=True,
)
else:
- msgs[self.supported_locale[idx]] = markdown_compile_email(
- format_map(v, self.placeholders(preview_item))
- )
+ placeholders = self.placeholders(preview_item)
+ msgs[self.supported_locale[idx]] = format_map(markdown_compile_email(
+ format_map(v, placeholders)
+ ), placeholders, mode=SafeFormatter.MODE_RICH_TO_HTML)
return JsonResponse({
'item': preview_item,
diff --git a/src/pretix/control/views/vouchers.py b/src/pretix/control/views/vouchers.py
index 5d9774cac..60c9870cf 100644
--- a/src/pretix/control/views/vouchers.py
+++ b/src/pretix/control/views/vouchers.py
@@ -50,7 +50,7 @@ from django.http import (
from django.shortcuts import redirect, render
from django.urls import resolve, reverse
from django.utils.functional import cached_property
-from django.utils.html import escape, format_html
+from django.utils.html import format_html
from django.utils.safestring import mark_safe
from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _
@@ -59,12 +59,12 @@ from django.views.generic import (
)
from django_scopes import scopes_disabled
-from pretix.base.email import get_available_placeholders
from pretix.base.models import (
CartPosition, LogEntry, Voucher, WaitingListEntry,
)
from pretix.base.models.vouchers import generate_codes
from pretix.base.services.mail import prefix_subject
+from pretix.base.services.placeholders import get_sample_context
from pretix.base.services.vouchers import vouchers_send
from pretix.base.templatetags.rich_text import markdown_compile_email
from pretix.base.views.tasks import AsyncFormView
@@ -74,7 +74,7 @@ from pretix.control.permissions import EventPermissionRequiredMixin
from pretix.control.signals import voucher_form_class
from pretix.control.views import PaginationMixin
from pretix.helpers.compat import CompatDeleteView
-from pretix.helpers.format import format_map
+from pretix.helpers.format import SafeFormatter, format_map
from pretix.helpers.models import modelcopy
from pretix.multidomain.urlreverse import build_absolute_uri
@@ -549,22 +549,10 @@ class VoucherBulkMailPreview(EventPermissionRequiredMixin, View):
# get all supported placeholders with dummy values
def placeholders(self, item):
- ctx = {}
base_ctx = ['event', 'name']
if item == 'send_message':
base_ctx += ['voucher_list']
- for p in get_available_placeholders(self.request.event, base_ctx).values():
- s = str(p.render_sample(self.request.event))
- if s.strip().startswith('* ') or s.startswith(' '):
- ctx[p.identifier] = '{}
'.format(
- _('This value will be replaced based on dynamic parameters.'),
- markdown_compile_email(s)
- )
- else:
- ctx[p.identifier] = '{}'.format(
- _('This value will be replaced based on dynamic parameters.'),
- escape(s)
- )
+ ctx = get_sample_context(self.request.event, base_ctx)
return self.SafeDict(ctx)
def post(self, request, *args, **kwargs):
@@ -579,9 +567,10 @@ class VoucherBulkMailPreview(EventPermissionRequiredMixin, View):
highlight=True
)
else:
- msgs["all"] = markdown_compile_email(
- format_map(request.POST.get(preview_item), self.placeholders(preview_item))
- )
+ placeholders = self.placeholders(preview_item)
+ msgs["all"] = format_map(markdown_compile_email(
+ format_map(request.POST.get(preview_item), placeholders)
+ ), placeholders, mode=SafeFormatter.MODE_RICH_TO_HTML)
return JsonResponse({
'item': preview_item,
diff --git a/src/pretix/helpers/format.py b/src/pretix/helpers/format.py
index 7a31775d0..3ed2eac36 100644
--- a/src/pretix/helpers/format.py
+++ b/src/pretix/helpers/format.py
@@ -25,14 +25,29 @@ from string import Formatter
logger = logging.getLogger(__name__)
+class PlainHtmlAlternativeString:
+ def __init__(self, plain, html, is_block=False):
+ self.plain = plain
+ self.html = html
+ self.is_block = is_block
+
+ def __repr__(self):
+ return f"PlainHtmlAlternativeString('{self.plain}', '{self.html}')"
+
+
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.
"""
- def __init__(self, context, raise_on_missing=False):
+ 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):
self.context = context
self.raise_on_missing = raise_on_missing
+ self.mode = mode
def get_field(self, field_name, args, kwargs):
return self.get_value(field_name, args, kwargs), field_name
@@ -40,14 +55,22 @@ 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) + '}'
- return self.context[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
+ elif self.mode == self.MODE_RICH_TO_HTML:
+ return r.html
+ return r
def format_field(self, value, format_spec):
- # Ignore format _spec
+ # Ignore format_spec
return super().format_field(value, '')
-def format_map(template, context, raise_on_missing=False):
+def format_map(template, context, raise_on_missing=False, mode=SafeFormatter.MODE_IGNORE_RICH):
if not isinstance(template, str):
template = str(template)
- return SafeFormatter(context, raise_on_missing).format(template)
+ return SafeFormatter(context, raise_on_missing, mode=mode).format(template)
diff --git a/src/pretix/plugins/sendmail/forms.py b/src/pretix/plugins/sendmail/forms.py
index 7dd3c54a0..5d46c7a76 100644
--- a/src/pretix/plugins/sendmail/forms.py
+++ b/src/pretix/plugins/sendmail/forms.py
@@ -79,8 +79,8 @@ class BaseMailForm(FormPlaceholderMixin, forms.Form):
widget=I18nMarkdownTextarea, required=True,
locales=event.settings.get('locales'),
)
- self._set_field_placeholders('subject', context_parameters)
- self._set_field_placeholders('message', context_parameters)
+ self._set_field_placeholders('subject', context_parameters, rich=False)
+ self._set_field_placeholders('message', context_parameters, rich=True)
class WaitinglistMailForm(BaseMailForm):
@@ -382,7 +382,7 @@ class RuleForm(FormPlaceholderMixin, I18nModelForm):
)
self._set_field_placeholders('subject', ['event', 'order', 'event_or_subevent'])
- self._set_field_placeholders('template', ['event', 'order', 'event_or_subevent'])
+ self._set_field_placeholders('template', ['event', 'order', 'event_or_subevent'], rich=True)
choices = [(e, l) for e, l in Order.STATUS_CHOICE if e != 'n']
choices.insert(0, ('n__valid_if_pending', _('payment pending but already confirmed')))
diff --git a/src/pretix/plugins/sendmail/views.py b/src/pretix/plugins/sendmail/views.py
index c3d85c2c6..5b46cac2c 100644
--- a/src/pretix/plugins/sendmail/views.py
+++ b/src/pretix/plugins/sendmail/views.py
@@ -46,12 +46,10 @@ from django.shortcuts import get_object_or_404, redirect
from django.template.loader import get_template
from django.urls import reverse
from django.utils.functional import cached_property
-from django.utils.html import escape
from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _, ngettext
from django.views.generic import DeleteView, FormView, ListView, TemplateView
-from pretix.base.email import get_available_placeholders
from pretix.base.i18n import LazyI18nString, language
from pretix.base.models import Checkin, LogEntry, Order, OrderPosition
from pretix.base.models.event import SubEvent
@@ -63,7 +61,8 @@ from pretix.plugins.sendmail.tasks import (
)
from ...base.services.mail import prefix_subject
-from ...helpers.format import format_map
+from ...base.services.placeholders import get_sample_context
+from ...helpers.format import SafeFormatter, format_map
from ...helpers.models import modelcopy
from . import forms
from .models import Rule, ScheduledMail
@@ -191,17 +190,15 @@ class BaseSenderView(EventPermissionRequiredMixin, FormView):
if self.request.POST.get("action") != "send":
for l in self.request.event.settings.locales:
with language(l, self.request.event.settings.region):
- context_dict = {}
- for k, v in get_available_placeholders(self.request.event, self.context_parameters).items():
- context_dict[k] = '{}'.format(
- _('This value will be replaced based on dynamic parameters.'),
- escape(v.render_sample(self.request.event))
- )
-
+ context_dict = get_sample_context(self.request.event, self.context_parameters)
subject = bleach.clean(form.cleaned_data['subject'].localize(l), tags=set())
preview_subject = prefix_subject(self.request.event, format_map(subject, context_dict), highlight=True)
message = form.cleaned_data['message'].localize(l)
- preview_text = markdown_compile_email(format_map(message, context_dict))
+ preview_text = format_map(
+ markdown_compile_email(format_map(message, context_dict)),
+ context_dict,
+ mode=SafeFormatter.MODE_RICH_TO_HTML,
+ )
self.output[l] = {
'subject': _('Subject: {subject}').format(subject=preview_subject),
@@ -603,31 +600,6 @@ class CreateRule(EventPermissionRequiredMixin, CreateView):
return super().form_invalid(form)
def form_valid(self, form):
- self.output = {}
-
- if self.request.POST.get("action") == "preview":
- for l in self.request.event.settings.locales:
- with language(l, self.request.event.settings.region):
- context_dict = {}
- for k, v in get_available_placeholders(self.request.event, ['event', 'order',
- 'position_or_address']).items():
- context_dict[k] = '{}'.format(
- _('This value will be replaced based on dynamic parameters.'),
- escape(v.render_sample(self.request.event))
- )
-
- subject = bleach.clean(form.cleaned_data['subject'].localize(l), tags=set())
- preview_subject = prefix_subject(self.request.event, format_map(subject, context_dict), highlight=True)
- template = form.cleaned_data['template'].localize(l)
- preview_text = markdown_compile_email(format_map(template, context_dict))
-
- self.output[l] = {
- 'subject': _('Subject: {subject}').format(subject=preview_subject),
- 'html': preview_text,
- }
-
- return self.get(self.request, *self.args, **self.kwargs)
-
messages.success(self.request, _('Your rule has been created.'))
form.instance.event = self.request.event
@@ -685,17 +657,15 @@ class UpdateRule(EventPermissionRequiredMixin, UpdateView):
for lang in self.request.event.settings.locales:
with language(lang, self.request.event.settings.region):
- placeholders = {}
- for k, v in get_available_placeholders(self.request.event, ['event', 'order', 'position_or_address']).items():
- placeholders[k] = '{}'.format(
- _('This value will be replaced based on dynamic parameters.'),
- escape(v.render_sample(self.request.event))
- )
-
+ placeholders = get_sample_context(self.request.event, ['event', 'order', 'position_or_address'])
subject = bleach.clean(self.object.subject.localize(lang), tags=set())
preview_subject = prefix_subject(self.request.event, format_map(subject, placeholders), highlight=True)
template = self.object.template.localize(lang)
- preview_text = markdown_compile_email(format_map(template, placeholders))
+ preview_text = format_map(
+ markdown_compile_email(format_map(template, placeholders)),
+ placeholders,
+ mode=SafeFormatter.MODE_RICH_TO_HTML,
+ )
o[lang] = {
'subject': _('Subject: {subject}'.format(subject=preview_subject)),
diff --git a/src/pretix/static/pretixcontrol/scss/_forms.scss b/src/pretix/static/pretixcontrol/scss/_forms.scss
index fbe1e1de2..1843fbc9e 100644
--- a/src/pretix/static/pretixcontrol/scss/_forms.scss
+++ b/src/pretix/static/pretixcontrol/scss/_forms.scss
@@ -194,66 +194,6 @@ div[data-formset-body], div[data-formset-form], div[data-nested-formset-form], d
line-height: 30px;
}
-div.mail-preview {
- border: 1px solid #ccc;
- border-top-width: 1px;
- border-radius: 3px;
-
- .placeholder {
- background: var(--pretix-brand-warning-transparent-60);
- }
-}
-
-.mail-preview-group div[lang] {
- @include border-top-radius(0px);
- @include border-bottom-radius(0px);
- border-top-width: 0;
- margin-bottom: 0;
- padding-right: 15px;
- padding-bottom: 8px;
-
- &:first-child {
- @include border-top-radius($input-border-radius);
- border-top-width: 1px;
- }
- &:last-child {
- @include border-bottom-radius($input-border-radius);
- margin-bottom: 20px;
- }
- h2, h3 {
- margin-bottom: 20px;
- margin-top: 10px;
- }
- p {
- margin: 0 0 10px;
-
- /* These are technically the same, but use both */
- overflow-wrap: break-word;
- word-wrap: break-word;
-
- -ms-word-break: break-all;
- /* This is the dangerous one in WebKit, as it breaks things wherever */
- word-break: break-all;
- /* Instead use this non-standard one: */
- word-break: break-word;
-
- /* Adds a hyphen where the word breaks, if supported (No Blink) */
- -ms-hyphens: auto;
- -moz-hyphens: auto;
- -webkit-hyphens: auto;
- hyphens: auto;
- }
- p:last-child {
- margin-bottom: 0;
- }
- /* Reset styling from bootstrap that we don't actually have in emails */
- pre {
- background: none;
- border: none;
- padding: 0;
- }
-}
-
.search-line {
width: 100%;
margin-bottom: 20px;
diff --git a/src/pretix/static/pretixcontrol/scss/_mail_preview.scss b/src/pretix/static/pretixcontrol/scss/_mail_preview.scss
new file mode 100644
index 000000000..d97152cc0
--- /dev/null
+++ b/src/pretix/static/pretixcontrol/scss/_mail_preview.scss
@@ -0,0 +1,93 @@
+div.mail-preview {
+ border: 1px solid #ccc;
+ border-top-width: 1px;
+ border-radius: 3px;
+
+ .placeholder {
+ background: var(--pretix-brand-warning-transparent-60);
+ }
+ .placeholder-html {
+ background: none;
+ outline: 2px solid var(--pretix-brand-warning-transparent-60);
+ display: inline-block;
+ }
+}
+
+.mail-preview-group div[lang] {
+ @include border-top-radius(0px);
+ @include border-bottom-radius(0px);
+ border-top-width: 0;
+ margin-bottom: 0;
+ padding-right: 15px;
+ padding-bottom: 8px;
+
+ &:first-child {
+ @include border-top-radius($input-border-radius);
+ border-top-width: 1px;
+ }
+ &:last-child {
+ @include border-bottom-radius($input-border-radius);
+ margin-bottom: 20px;
+ }
+ h2, h3 {
+ margin-bottom: 20px;
+ margin-top: 10px;
+ }
+ p {
+ margin: 0 0 10px;
+
+ /* These are technically the same, but use both */
+ overflow-wrap: break-word;
+ word-wrap: break-word;
+
+ -ms-word-break: break-all;
+ /* This is the dangerous one in WebKit, as it breaks things wherever */
+ word-break: break-all;
+ /* Instead use this non-standard one: */
+ word-break: break-word;
+
+ /* Adds a hyphen where the word breaks, if supported (No Blink) */
+ -ms-hyphens: auto;
+ -moz-hyphens: auto;
+ -webkit-hyphens: auto;
+ hyphens: auto;
+ }
+ p:last-child {
+ margin-bottom: 0;
+ }
+ /* Reset styling from bootstrap that we don't actually have in emails */
+ pre {
+ background: none;
+ border: none;
+ padding: 0;
+ }
+
+ /* Add some basic styling similar to our default email renderers */
+ a.button {
+ display: inline-block;
+ padding: 10px 16px;
+ font-size: 14px;
+ line-height: 1.33333;
+ border: 1px solid #cccccc;
+ border-radius: 6px;
+ -webkit-border-radius: 6px;
+ -moz-border-radius: 6px;
+ margin: 5px;
+ text-decoration: none;
+ color: var(--pretix-brand-primary);
+ }
+
+ table {
+ width: 100%;
+ }
+
+ table td {
+ vertical-align: top;
+ text-align: left;
+ padding: 0;
+ }
+
+ .text-right, table td.text-right {
+ text-align: right;
+ }
+}
diff --git a/src/pretix/static/pretixcontrol/scss/main.scss b/src/pretix/static/pretixcontrol/scss/main.scss
index fb9814cff..f6f9112ac 100644
--- a/src/pretix/static/pretixcontrol/scss/main.scss
+++ b/src/pretix/static/pretixcontrol/scss/main.scss
@@ -12,6 +12,7 @@
@import "_flags.scss";
@import "_orders.scss";
@import "_dashboard.scss";
+@import "_mail_preview.scss";
@import "../../pretixbase/scss/webfont.scss";
@import "../../fileupload/jquery.fileupload.scss";
@import "../../leaflet/leaflet.scss";
diff --git a/src/tests/helpers/test_format.py b/src/tests/helpers/test_format.py
index e0f70627e..6165fe622 100644
--- a/src/tests/helpers/test_format.py
+++ b/src/tests/helpers/test_format.py
@@ -19,7 +19,9 @@
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# .
#
-from pretix.helpers.format import format_map
+from pretix.helpers.format import (
+ PlainHtmlAlternativeString, SafeFormatter, format_map,
+)
def test_format_map():
@@ -28,3 +30,16 @@ def test_format_map():
assert format_map("Foo {bar.__module__}", {"bar": 3}) == "Foo {bar.__module__}"
assert format_map("Foo {bar!s}", {"bar": 3}) == "Foo 3"
assert format_map("Foo {bar:<20}", {"bar": 3}) == "Foo 3"
+
+
+def test_format_alternatives():
+ ctx = {
+ "bar": PlainHtmlAlternativeString(
+ "plain text",
+ "HTML version",
+ )
+ }
+
+ 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"