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}'.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"