From 2d2663f15fcb5a51bfb7b276da6111da412d7994 Mon Sep 17 00:00:00 2001 From: Raphael Michel Date: Fri, 13 Feb 2026 10:35:41 +0100 Subject: [PATCH] Mark strings as formatted to prevent double-formatting --- src/pretix/base/models/orders.py | 8 +++++--- src/pretix/base/services/mail.py | 8 ++++++-- src/pretix/helpers/format.py | 20 ++++++++++++++++++-- 3 files changed, 29 insertions(+), 7 deletions(-) diff --git a/src/pretix/base/models/orders.py b/src/pretix/base/models/orders.py index 1b2f459d2..a2b48ed98 100644 --- a/src/pretix/base/models/orders.py +++ b/src/pretix/base/models/orders.py @@ -87,7 +87,7 @@ from pretix.base.timemachine import time_machine_now from ...helpers import OF_SELF from ...helpers.countries import CachedCountries, FastCountryField -from ...helpers.format import format_map +from ...helpers.format import FormattedString, format_map from ...helpers.names import build_name from ...testutils.middleware import debugflags_var from ._transactions import ( @@ -1178,7 +1178,8 @@ class Order(LockModel, LoggedModel): recipient = position.attendee_email email_content = render_mail(template, context) - subject = format_map(subject, context) + if not isinstance(subject, FormattedString): + subject = format_map(subject, context) mail( recipient, subject, template, context, self.event, self.locale, self, headers=headers, sender=sender, @@ -2907,7 +2908,8 @@ class OrderPosition(AbstractPosition): with language(self.order.locale, self.order.event.settings.region): recipient = self.attendee_email email_content = render_mail(template, context) - subject = format_map(subject, context) + if not isinstance(subject, FormattedString): + subject = format_map(subject, context) mail( recipient, subject, template, context, self.event, self.order.locale, order=self.order, headers=headers, sender=sender, diff --git a/src/pretix/base/services/mail.py b/src/pretix/base/services/mail.py index 8159f8fe1..131621bc4 100644 --- a/src/pretix/base/services/mail.py +++ b/src/pretix/base/services/mail.py @@ -81,7 +81,7 @@ from pretix.base.signals import ( ) from pretix.celery_app import app from pretix.helpers import OF_SELF -from pretix.helpers.format import SafeFormatter, format_map +from pretix.helpers.format import FormattedString, 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 @@ -218,6 +218,9 @@ def mail(email: Union[str, Sequence[str]], subject: str, template: Union[str, La if email == INVALID_ADDRESS: return + if isinstance(template, FormattedString): + raise TypeError("Cannot pass an already formatted body template") + if no_order_links and not plain_text_only: raise ValueError('If you set no_order_links, you also need to set plain_text_only.') @@ -267,7 +270,8 @@ def mail(email: Union[str, Sequence[str]], subject: str, template: Union[str, La body_plain = format_map(body_plain, context, mode=SafeFormatter.MODE_RICH_TO_PLAIN) # Build subject - subject = format_map(subject, context) + if not isinstance(subject, FormattedString): + subject = format_map(subject, context) subject = raw_subject = subject.replace('\n', ' ').replace('\r', '')[:900] if settings_holder: diff --git a/src/pretix/helpers/format.py b/src/pretix/helpers/format.py index a79b869e5..04253b5b7 100644 --- a/src/pretix/helpers/format.py +++ b/src/pretix/helpers/format.py @@ -22,6 +22,7 @@ import logging from string import Formatter +from django.core.exceptions import SuspiciousOperation from django.utils.html import conditional_escape logger = logging.getLogger(__name__) @@ -37,6 +38,17 @@ class PlainHtmlAlternativeString: return f"PlainHtmlAlternativeString('{self.plain}', '{self.html}')" +class FormattedString(str): + """ + A str subclass that has been specifically marked as "already formatted" for email rendering + purposes to avoid duplicate formatting. + """ + __slots__ = () + + def __str__(self): + return self + + class SafeFormatter(Formatter): """ Customized version of ``str.format`` that (a) behaves just like ``str.format_map`` and @@ -78,7 +90,11 @@ class SafeFormatter(Formatter): return super().format_field(self._prepare_value(value), '') -def format_map(template, context, raise_on_missing=False, mode=SafeFormatter.MODE_RICH_TO_PLAIN, linkifier=None): +def format_map(template, context, raise_on_missing=False, mode=SafeFormatter.MODE_RICH_TO_PLAIN, linkifier=None) -> FormattedString: + if isinstance(template, FormattedString): + raise SuspiciousOperation("Calling format_map() on an already formatted string is likely unsafe.") if not isinstance(template, str): template = str(template) - return SafeFormatter(context, raise_on_missing, mode=mode, linkifier=linkifier).format(template) + return FormattedString( + SafeFormatter(context, raise_on_missing, mode=mode, linkifier=linkifier).format(template) + )