Commit 1a317e6e31a for woocommerce
commit 1a317e6e31a8db1fb0ab3f5d63d94171b78554e5
Author: Rostislav Wolný <1082140+costasovo@users.noreply.github.com>
Date: Fri Jul 31 09:39:58 2026 +0200
[Email Editor] Add context-aware rendering to personalization tags (#66937)
* Pass a rendering context to personalization tag callbacks
Personalization tag values land in different destinations — HTML body
content, plain-text output such as the email subject and preheader, and
link href attributes — but callbacks had no way to know which one they
were producing a value for, and integrators asked to serve different
content per destination from a single tag. The destination cannot be
detected from the content itself because both HTML and plain-text runs
carry tags as HTML comments.
personalize_content() accepts an optional rendering context (html or
text, defaulting to html for backward compatibility) and each
replacement site now exposes its context — html, text, or href — to
the tag callback under the reserved rendering_context key of the
context array. The title tag recursion personalizes in the text
context since a subject is effectively plain text, and both link
replacement sites report the href context, where callbacks must return
a raw unescaped URL. The reserved key is injected per replacement via
array_replace, so consumer-set values under that key are overwritten
and integer context keys are preserved.
* Add a value type declaration to personalization tags
Tag callbacks return either plain text (names, order numbers, dates) or
deliberate HTML fragments (formatted prices, addresses), and the
Personalizer cannot tell which — only the tag author knows. This adds
an optional value_type to Personalization_Tag so tags can declare it:
VALUE_TYPE_TEXT for callbacks that always return raw plain text, and
VALUE_TYPE_HTML (the default, preserving current behavior) for
callbacks that own their output format and escaping. Unknown values
fall back to VALUE_TYPE_HTML. The declaration carries no behavior by
itself; the Personalizer uses it in the next commit to escape
text-typed values appropriately for the rendering context.
* Escape text value type personalization tags in HTML content
With the value type declared, the Personalizer can take over escaping
for the common case: a tag registered with VALUE_TYPE_TEXT always
returns raw plain text, and its value is now passed through esc_html()
when it lands in HTML content, while staying untouched in the text
rendering context and at href sites (where esc_url() is applied when
the attribute is written). This closes the gap where customer-provided
data such as names could carry markup characters into HTML output raw.
esc_html() does not double-encode existing entities, so a defensively
pre-escaped value is not corrupted; the documented contract is still to
return genuinely raw text. Tags with the default VALUE_TYPE_HTML are
never touched, so existing tags keep their exact behavior.
* Harden personalization tag replacement in link URLs
The plain-href replacement previously overwrote the entire attribute
with the first tag's value. That worked only for the Button block
whole-URL flow (the editor stores such links as http://[tag], and the
whole-replace discarded the forced protocol prefix), and it broke every
other shape: tags embedded in larger URLs produced dead links, appended
query parameters were lost, and additional tokens in the same href were
discarded. The regex-based replacement on the data-link-href path also
interpreted dollar signs and backslashes in tag values as
backreferences, corrupting URLs built from customer data.
Link personalization now resolves all replaceable tokens into a map and
applies them in a single strtr() pass, so inserted values are never
re-scanned for other tokens and regex metacharacters stay literal. The
replacement base is the original attribute value — preserving
legitimate percent-encoding in the surrounding URL — falling back to
the decoded form only when a replaced token exists solely there, and
only tokens actually being replaced participate in that choice. The
editor's forced protocol prefix is stripped case-insensitively, and
only when the token directly after it is being replaced, keeping any
appended suffix intact. A tag value of 0 is accepted; only an empty
string means no value. The data-link-href path escapes dollar signs
and backslashes before regex replacement.
* Personalize transactional email subjects and preheaders as plain text
Email subjects and preheaders are plain-text destinations, but they
were personalized in the default HTML rendering context, so a tag
opting into the text value type would have its value HTML-escaped
where entities must never appear. personalize_transactional_content()
accepts the rendering context and passes it through, and all subject
and preheader call sites now declare the text context. With no core
tag declaring a value type yet this is behavior-neutral today; it
makes the plain-text paths correct the moment tags start opting in.
* Extend regression coverage for raw-markup tags and metacharacters in links
Integration feedback on the PR asked whether two behaviors are pinned
by tests: tags registered without a value type keep emitting raw HTML
untouched (tracking pixels), and URL values containing %, &, and
literal $1/\1 substitute correctly across all link replacement paths.
The default-type test now includes a raw tracking pixel inserted
verbatim, and the regex metacharacter test covers backslash sequences
and asserts both the data-link-href and the plain-href replacement
sites.
* Treat a "0" tag value as valid in data-link-href replacement
A tag resolving to the string "0" failed the truthiness check at the
data-link-href site, skipping the href write and shipping the editor-only
data-link-href and contenteditable attributes in the sent email. The
plain-href site already used a strict empty-string check, so this aligns
the two. Also documents and pins the known decoded-base tradeoff in
personalize_href_tokens: when a token exists only in URL-encoded form,
the whole href switches to the decoded base and unrelated
percent-encoding is decoded with it. Raised in PR review feedback.
diff --git a/packages/php/email-editor/changelog/add-context-aware-personalization b/packages/php/email-editor/changelog/add-context-aware-personalization
new file mode 100644
index 00000000000..c65c3e9d3cb
--- /dev/null
+++ b/packages/php/email-editor/changelog/add-context-aware-personalization
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Add rendering context awareness to the Personalizer: personalize_content() accepts a text/html rendering context which is exposed to tag callbacks, Personalization_Tag supports an optional text value type with automatic escaping in HTML content, and tags embedded inside larger link URLs are now replaced correctly.
diff --git a/packages/php/email-editor/docs/personalization-tags.md b/packages/php/email-editor/docs/personalization-tags.md
index 87b783608a7..2d38ee6c276 100644
--- a/packages/php/email-editor/docs/personalization-tags.md
+++ b/packages/php/email-editor/docs/personalization-tags.md
@@ -9,6 +9,7 @@
- [Editor UI](#editor-ui)
- [Format](#format)
- [Context](#context)
+- [Rendering Context and Escaping](#rendering-context-and-escaping)
- [Core Components](#core-components)
- [Creating Custom Tags](#creating-custom-tags)
- [Usage with Renderer](#usage-with-renderer)
@@ -99,7 +100,7 @@ This would render as: "Hello John, your order #12345 was placed on January 15, 2
## Context
-Rendering context is a simple associative array passed to the Personalizer. It is the integrator's responsibility to build the context and set it to the Personalizer.
+The personalization context is a simple associative array passed to the Personalizer. It is the integrator's responsibility to build the context and set it to the Personalizer.
The context is then passed to the `Personalization_Tag` callback function and can be used to derive the value.
Note: This is still an early concept, and we may add actions/filters, as well as default context data.
@@ -113,6 +114,60 @@ $context = [
];
```
+One key is reserved: `rendering_context` — see [Rendering Context and Escaping](#rendering-context-and-escaping).
+
+## Rendering Context and Escaping
+
+Email content is rendered into different destinations, and the correct value (and its escaping) differs per destination. The caller of `personalize_content()` declares the rendering context of the whole content: `Personalizer::RENDERING_CONTEXT_HTML` (default) for HTML bodies, or `Personalizer::RENDERING_CONTEXT_TEXT` for plain-text content such as the email subject, preheader, or a plain-text body.
+
+The Personalizer passes the context of each replacement to the tag callback under the reserved `rendering_context` key (`Personalizer::RENDERING_CONTEXT_KEY`) of the context array. Any value stored under this key via `set_context()` is overwritten.
+
+| Rendering context | Where the value lands | What the callback must return |
+| --- | --- | --- |
+| `Personalizer::RENDERING_CONTEXT_HTML` | HTML body content | An HTML fragment. Escape any dynamic data yourself (e.g. `esc_html()`) — unless the tag declares `VALUE_TYPE_TEXT`, in which case return raw text and the Personalizer escapes it (see [Value Type](#value-type)). |
+| `Personalizer::RENDERING_CONTEXT_TEXT` | Plain-text output (subject, preheader, plain-text body, `<title>`) | Raw plain text. No HTML markup, no entities, no escaping. |
+| `Personalizer::RENDERING_CONTEXT_HREF` | A link URL (`href`) | A raw, unescaped URL — do not pre-escape; URL escaping is applied when the attribute is written. |
+
+If the value is a URL *component* rather than a whole URL (e.g. an email address embedded in a query string), `rawurlencode()` it in the callback — the Personalizer cannot tell the difference.
+
+Inspecting the rendering context is optional — a callback that ignores it simply returns the same value for every destination. A tag that serves different content per destination can switch on the key:
+
+```php
+use Automattic\WooCommerce\EmailEditor\Engine\Personalizer;
+
+function ( $context ) {
+ if ( Personalizer::RENDERING_CONTEXT_TEXT === ( $context[ Personalizer::RENDERING_CONTEXT_KEY ] ?? '' ) ) {
+ return 'Save 20% with code SAVE20';
+ }
+ return '<strong>Save 20%</strong> with code SAVE20';
+}
+```
+
+### Value Type
+
+Most tags return plain text: names, order numbers, dates. Declare those with `Personalization_Tag::VALUE_TYPE_TEXT`. The callback then always returns the raw, unescaped value, and the Personalizer escapes it to fit the rendering context: `esc_html()` in HTML content, untouched in plain-text output and in link URLs (where `esc_url()` is applied when the attribute is written). Declaring a value type is the expected practice for new tags.
+
+```php
+$registry->register(
+ new Personalization_Tag(
+ 'First Name',
+ 'customer/first-name',
+ 'Customer',
+ function ( $context ) {
+ return $context['customer_first_name'] ?? '';
+ },
+ array(),
+ null,
+ array(),
+ Personalization_Tag::VALUE_TYPE_TEXT
+ )
+);
+```
+
+Note: the `esc_html()` call does not double-encode existing entities, so a raw text value that itself contains an entity-shaped sequence (e.g. a literal `&`) renders as the decoded character (`&`). Always return genuinely raw text.
+
+The default, `Personalization_Tag::VALUE_TYPE_HTML`, inserts the value untouched in every rendering context — including plain-text output; the callback owns escaping and any per-context differences (in the `text` rendering context it must return raw plain text without markup or entities).
+
## Core Components
### Personalization_Tags_Registry
@@ -170,13 +225,14 @@ The main class representing individual tags.
```php
new Personalization_Tag(
- string $name, // Display name in UI
- string $token, // Token used in content
- string $category, // Category for organization
- callable $callback, // Function to generate value
- array $attributes = [], // Default attributes
- ?string $value_to_insert = null // Custom insert value
- array $post_types // List of supported post types
+ string $name, // Display name in UI
+ string $token, // Token used in content
+ string $category, // Category for organization
+ callable $callback, // Function to generate value
+ array $attributes = [], // Default attributes
+ ?string $value_to_insert = null, // Custom insert value
+ array $post_types = [], // List of supported post types
+ string $value_type = Personalization_Tag::VALUE_TYPE_HTML // Type of value the callback returns (VALUE_TYPE_TEXT or VALUE_TYPE_HTML)
);
```
@@ -190,6 +246,7 @@ new Personalization_Tag(
- `get_attributes()`: Get default attributes
- `get_value_to_insert()`: Get the value to insert in UI
- `get_post_types()`: Get the list of post types
+- `get_value_type()`: Get the type of value the callback returns
### Personalizer
@@ -199,7 +256,7 @@ Main engine for replacing tags with values in email content.
- `set_context(array $context)`: Set the personalization context
- `get_context()`: Get the current context
-- `personalize_content(string $content)`: Process and personalize content
+- `personalize_content(string $content, string $rendering_context = Personalizer::RENDERING_CONTEXT_HTML)`: Process and personalize content; pass `Personalizer::RENDERING_CONTEXT_TEXT` for plain-text content such as subjects or plain-text bodies
**Example Usage:**
@@ -324,7 +381,7 @@ $personalized_html = $personalizer->personalize_content( $rendered_email['html']
// The personalized HTML is now ready for sending
$email_html = $personalized_html;
-$email_text = $rendered_email['text']; // Note: text version would need separate personalization if needed
+$email_text = $personalizer->personalize_content( $rendered_email['text'], Personalizer::RENDERING_CONTEXT_TEXT );
```
This workflow first renders the email template with blocks, then applies personalization tags to the rendered HTML content, creating a fully personalized email ready for delivery.
diff --git a/packages/php/email-editor/src/Engine/PersonalizationTags/class-personalization-tag.php b/packages/php/email-editor/src/Engine/PersonalizationTags/class-personalization-tag.php
index 52d5eee2131..a4e1904ed5d 100644
--- a/packages/php/email-editor/src/Engine/PersonalizationTags/class-personalization-tag.php
+++ b/packages/php/email-editor/src/Engine/PersonalizationTags/class-personalization-tag.php
@@ -14,6 +14,18 @@ namespace Automattic\WooCommerce\EmailEditor\Engine\PersonalizationTags;
* for replacing the tag with its value and displaying it in the UI.
*/
class Personalization_Tag {
+ /**
+ * Value type for tags whose callback returns an HTML fragment appropriate to the rendering
+ * context it receives. The Personalizer inserts the value untouched — the callback owns escaping.
+ */
+ public const VALUE_TYPE_HTML = 'html';
+
+ /**
+ * Value type for tags whose callback always returns raw plain text. The Personalizer
+ * escapes the value as needed for the rendering context (e.g. esc_html() in HTML content).
+ */
+ public const VALUE_TYPE_TEXT = 'text';
+
/**
* The name of the tag displayed in the UI.
*
@@ -56,6 +68,12 @@ class Personalization_Tag {
* @var string[]
*/
private array $post_types;
+ /**
+ * The type of value the callback returns — one of the VALUE_TYPE_* constants.
+ *
+ * @var string
+ */
+ private string $value_type;
/**
* Personalization_Tag constructor.
@@ -79,6 +97,7 @@ class Personalization_Tag {
* @param array $attributes The attributes which are used in the Personalization Tag UI.
* @param string|null $value_to_insert The value that is inserted via the UI. When the value is null the token is generated based on $token attribute and $attributes.
* @param string[] $post_types The list of supported post types.
+ * @param string $value_type The type of value the callback returns — one of the VALUE_TYPE_* constants. Unknown values fall back to VALUE_TYPE_HTML.
*/
public function __construct(
string $name,
@@ -87,7 +106,8 @@ class Personalization_Tag {
callable $callback,
array $attributes = array(),
?string $value_to_insert = null,
- array $post_types = array()
+ array $post_types = array(),
+ string $value_type = self::VALUE_TYPE_HTML
) {
$this->name = $name;
// Because Gutenberg does not wrap the token with square brackets, we need to add them here.
@@ -114,6 +134,7 @@ class Personalization_Tag {
}
$this->value_to_insert = $value_to_insert;
$this->post_types = $post_types;
+ $this->value_type = in_array( $value_type, array( self::VALUE_TYPE_HTML, self::VALUE_TYPE_TEXT ), true ) ? $value_type : self::VALUE_TYPE_HTML;
}
/**
@@ -181,6 +202,15 @@ class Personalization_Tag {
return $this->post_types;
}
+ /**
+ * Returns the type of value the callback returns — one of the VALUE_TYPE_* constants.
+ *
+ * @return string
+ */
+ public function get_value_type(): string {
+ return $this->value_type;
+ }
+
/**
* Returns the callback function of the personalization tag.
*
diff --git a/packages/php/email-editor/src/Engine/class-personalizer.php b/packages/php/email-editor/src/Engine/class-personalizer.php
index b7e9ef1e7b0..187994e0172 100644
--- a/packages/php/email-editor/src/Engine/class-personalizer.php
+++ b/packages/php/email-editor/src/Engine/class-personalizer.php
@@ -10,6 +10,7 @@ declare(strict_types = 1);
namespace Automattic\WooCommerce\EmailEditor\Engine;
use Automattic\WooCommerce\EmailEditor\Engine\PersonalizationTags\HTML_Tag_Processor;
+use Automattic\WooCommerce\EmailEditor\Engine\PersonalizationTags\Personalization_Tag;
use Automattic\WooCommerce\EmailEditor\Engine\PersonalizationTags\Personalization_Tags_Registry;
/**
@@ -23,6 +24,29 @@ class Personalizer {
*/
private const TAG_NAME_PATTERN = '[a-zA-Z0-9\-\/]+';
+ /**
+ * Rendering context for HTML content. Tag values are rendered into HTML markup.
+ */
+ public const RENDERING_CONTEXT_HTML = 'html';
+
+ /**
+ * Rendering context for plain-text content (e.g., email subject, preheader, plain-text body).
+ * Tag values must be raw text without HTML entities or markup.
+ */
+ public const RENDERING_CONTEXT_TEXT = 'text';
+
+ /**
+ * Rendering context for link destinations. Tag callbacks must return a raw, unescaped URL
+ * (or URL component); URL escaping is applied when the attribute is written.
+ */
+ public const RENDERING_CONTEXT_HREF = 'href';
+
+ /**
+ * Reserved key under which the current rendering context is exposed in the context array
+ * passed to tag callbacks. Any value set via set_context() under this key is overwritten.
+ */
+ public const RENDERING_CONTEXT_KEY = 'rendering_context';
+
/**
* Personalization tags registry.
*
@@ -92,9 +116,15 @@ class Personalizer {
* Personalize the content by replacing the personalization tags with their values.
*
* @param string $content The content to personalize.
+ * @param string $rendering_context The rendering context of the content — one of the RENDERING_CONTEXT_HTML
+ * or RENDERING_CONTEXT_TEXT constants. Unknown values fall back to RENDERING_CONTEXT_HTML.
* @return string The personalized content.
*/
- public function personalize_content( string $content ): string {
+ public function personalize_content( string $content, string $rendering_context = self::RENDERING_CONTEXT_HTML ): string {
+ if ( ! in_array( $rendering_context, array( self::RENDERING_CONTEXT_HTML, self::RENDERING_CONTEXT_TEXT ), true ) ) {
+ $rendering_context = self::RENDERING_CONTEXT_HTML;
+ }
+
$content_processor = new HTML_Tag_Processor( $content );
while ( $content_processor->next_token() ) {
if ( $content_processor->get_token_type() === '#comment' ) {
@@ -105,13 +135,17 @@ class Personalizer {
continue;
}
- $value = $tag->execute_callback( $this->context, $token['arguments'] );
+ $value = $tag->execute_callback( $this->get_callback_context( $rendering_context ), $token['arguments'] );
+ if ( self::RENDERING_CONTEXT_HTML === $rendering_context && Personalization_Tag::VALUE_TYPE_TEXT === $tag->get_value_type() ) {
+ $value = esc_html( $value );
+ }
$content_processor->replace_token( $value );
} elseif ( $content_processor->get_token_type() === '#tag' && $content_processor->get_tag() === 'TITLE' ) {
// The title tag contains the subject of the email which should be personalized. HTML_Tag_Processor does parse the header tags.
+ // The title content is effectively plain text, so it is personalized in the text rendering context.
$modifiable_text = $content_processor->get_modifiable_text();
- $title = $this->personalize_content( $modifiable_text );
+ $title = $this->personalize_content( $modifiable_text, self::RENDERING_CONTEXT_TEXT );
$content_processor->set_modifiable_text( $title );
} elseif ( $content_processor->get_token_type() === '#tag' && $content_processor->get_tag() === 'A' && $content_processor->get_attribute( 'data-link-href' ) ) {
@@ -123,9 +157,9 @@ class Personalizer {
continue;
}
- $value = $tag->execute_callback( $this->context, $token['arguments'] );
+ $value = $tag->execute_callback( $this->get_callback_context( self::RENDERING_CONTEXT_HREF ), $token['arguments'] );
$value = $this->replace_link_href( $href, $tag->get_token(), $value );
- if ( $value ) {
+ if ( '' !== $value ) {
$content_processor->set_attribute( 'href', $value );
$content_processor->remove_attribute( 'data-link-href' );
$content_processor->remove_attribute( 'contenteditable' );
@@ -136,29 +170,87 @@ class Personalizer {
continue;
}
- // Decode both URL encoding (%XX) and HTML entities (') to handle various encoding scenarios.
- $decoded_href = html_entity_decode( urldecode( $href ), ENT_QUOTES, 'UTF-8' );
- if ( ! preg_match( '/\[' . self::TAG_NAME_PATTERN . '(?:\s+[^\]]+)?\]/', $decoded_href, $matches ) ) {
- continue;
+ $personalized_href = $this->personalize_href_tokens( $href );
+ if ( null !== $personalized_href ) {
+ $content_processor->set_attribute( 'href', $personalized_href );
}
+ }
+ }
- $token = $this->parse_token( $matches[0] );
- $tag = $this->tags_registry->get_by_token( $token['token'] );
+ $content_processor->flush_updates();
+ return $content_processor->get_updated_html();
+ }
- if ( ! $tag ) {
- continue;
- }
+ /**
+ * Replace personalization tag tokens embedded in a link URL.
+ *
+ * @param string $href The href attribute value.
+ * @return string|null The href with tokens replaced, or null when nothing was replaced.
+ */
+ private function personalize_href_tokens( string $href ): ?string {
+ // Decode both URL encoding (%XX) and HTML entities (') to handle various encoding scenarios.
+ $decoded_href = html_entity_decode( urldecode( $href ), ENT_QUOTES, 'UTF-8' );
+ if ( ! preg_match_all( '/\[' . self::TAG_NAME_PATTERN . '(?:\s+[^\]]+)?\]/', $decoded_href, $matches ) ) {
+ return null;
+ }
- $value = $tag->execute_callback( $this->context, $token['arguments'] );
+ // Resolve every replaceable token first.
+ $replacements = array();
+ foreach ( array_unique( $matches[0] ) as $token_string ) {
+ $token = $this->parse_token( $token_string );
+ $tag = $this->tags_registry->get_by_token( $token['token'] );
+ if ( ! $tag ) {
+ continue;
+ }
- if ( $value ) {
- $content_processor->set_attribute( 'href', $value );
- }
+ $value = $tag->execute_callback( $this->get_callback_context( self::RENDERING_CONTEXT_HREF ), $token['arguments'] );
+ if ( '' !== $value ) {
+ $replacements[ $token_string ] = $value;
}
}
- $content_processor->flush_updates();
- return $content_processor->get_updated_html();
+ if ( ! $replacements ) {
+ return null;
+ }
+
+ // Prefer the original attribute value as the replacement base so legitimate
+ // percent-encoding in the surrounding URL is preserved; fall back to the decoded
+ // form when a replaced token occurrence exists only there (e.g. URL-encoded tokens).
+ // Only tokens that are actually replaced matter here — an unregistered bracket
+ // sequence that exists purely in the decoded form must not force the decoded base.
+ // Known tradeoff: the base is chosen for the whole href, so when the decoded form
+ // is used, unrelated percent-encoding elsewhere in the URL is decoded too.
+ $base = $href;
+ foreach ( array_keys( $replacements ) as $token_string ) {
+ if ( substr_count( $href, $token_string ) !== substr_count( $decoded_href, $token_string ) ) {
+ $base = $decoded_href;
+ break;
+ }
+ }
+
+ // The editor forces a protocol prefix when a tag is used as the whole URL
+ // ("http://[tag]"). Strip it only when the token directly after it is being
+ // replaced, so the tag value is used as-is while any suffix (e.g. appended
+ // query parameters) is kept.
+ if ( preg_match( '#^https?://(\[' . self::TAG_NAME_PATTERN . '(?:\s+[^\]]+)?\])#i', $base, $prefix_match ) && isset( $replacements[ $prefix_match[1] ] ) ) {
+ $base = (string) preg_replace( '#^https?://#i', '', $base );
+ }
+
+ // Single-pass replacement — tag values are never re-scanned for other tokens,
+ // and a regex replacement would interpret `$` and `\` in them.
+ return strtr( $base, $replacements );
+ }
+
+ /**
+ * Build the context array passed to a tag callback, exposing the rendering context
+ * of the current replacement site under the reserved key.
+ *
+ * @param string $rendering_context One of the RENDERING_CONTEXT_* constants.
+ * @return array<string, mixed> The callback context.
+ */
+ private function get_callback_context( string $rendering_context ): array {
+ // array_replace() (unlike array_merge()) preserves integer keys in the consumer's context.
+ return array_replace( $this->context, array( self::RENDERING_CONTEXT_KEY => $rendering_context ) );
}
/**
@@ -224,6 +316,7 @@ class Personalizer {
// Create a regex pattern dynamically.
$pattern = '/\[' . $escaped_shortcode . '(?:\s+[^\]]+)?\]/';
- return trim( (string) preg_replace( $pattern, $replacement, $content ) );
+ // Escape `$` and `\` so they are inserted literally instead of being interpreted as backreferences.
+ return trim( (string) preg_replace( $pattern, addcslashes( $replacement, '\\$' ), $content ) );
}
}
diff --git a/packages/php/email-editor/tests/integration/Engine/Personalizer_Test.php b/packages/php/email-editor/tests/integration/Engine/Personalizer_Test.php
index 15f638cbba4..c8f90b0f593 100644
--- a/packages/php/email-editor/tests/integration/Engine/Personalizer_Test.php
+++ b/packages/php/email-editor/tests/integration/Engine/Personalizer_Test.php
@@ -251,6 +251,506 @@ class Personalizer_Test extends \Email_Editor_Integration_Test_Case {
$this->assertSame( $expected, $this->personalizer->personalize_content( $html_content ) );
}
+ /**
+ * Test that a tag embedded inside a larger URL is replaced in place.
+ */
+ public function testPersonalizeContentWithEmbeddedHrefTag(): void {
+ $this->tags_registry->register(
+ new Personalization_Tag(
+ 'email',
+ 'user/email',
+ 'Subscriber Info',
+ function () {
+ return 'john@example.com';
+ }
+ )
+ );
+
+ $this->assertSame(
+ '<a href="http://example.com?test=john@example.com">Click here</a>',
+ $this->personalizer->personalize_content( '<a href="http://example.com?test=[user/email]">Click here</a>' ),
+ 'The token should be replaced within the surrounding URL'
+ );
+ $this->assertSame(
+ '<a href="http://example.com/?next=%2Fshop%2F&e=john@example.com">Click here</a>',
+ $this->personalizer->personalize_content( '<a href="http://example.com/?next=%2Fshop%2F&e=[user/email]">Click here</a>' ),
+ 'Percent-encoding elsewhere in the URL should be preserved'
+ );
+ }
+
+ /**
+ * Test how the editor-forced protocol prefix is handled for whole-URL tags.
+ */
+ public function testWholeUrlHrefTagPrefixHandling(): void {
+ $this->tags_registry->register(
+ new Personalization_Tag(
+ 'Store URL',
+ 'woocommerce/store-url',
+ 'Store',
+ function () {
+ return 'https://example.com';
+ }
+ )
+ );
+ $this->tags_registry->register(
+ new Personalization_Tag(
+ 'Store domain',
+ 'woocommerce/store-domain',
+ 'Store',
+ function () {
+ return 'example.com';
+ }
+ )
+ );
+
+ $this->assertSame(
+ '<a href="https://example.com?utm_source=email">Click here</a>',
+ $this->personalizer->personalize_content( '<a href="http://[woocommerce/store-url]?utm_source=email">Click here</a>' ),
+ 'An appended suffix should be kept while the forced prefix is discarded'
+ );
+ $this->assertSame(
+ '<a href="https://example.com">Click here</a>',
+ $this->personalizer->personalize_content( '<a href="HTTP://[woocommerce/store-url]">Click here</a>' ),
+ 'The forced prefix should be discarded case-insensitively'
+ );
+ // esc_url() prepends the scheme when the value lacks one.
+ $this->assertSame(
+ '<a href="http://example.com">Click here</a>',
+ $this->personalizer->personalize_content( '<a href="http://[woocommerce/store-domain]">Click here</a>' ),
+ 'A schemeless value should still produce a usable link'
+ );
+ }
+
+ /**
+ * Test that multiple different tags in one href are all replaced.
+ */
+ public function testMultipleTagsInOneHref(): void {
+ $this->tags_registry->register(
+ new Personalization_Tag(
+ 'email',
+ 'user/email',
+ 'Subscriber Info',
+ function () {
+ return 'john@example.com';
+ }
+ )
+ );
+ $this->tags_registry->register(
+ new Personalization_Tag(
+ 'first_name',
+ 'user/firstname',
+ 'Subscriber Info',
+ function () {
+ return 'John';
+ }
+ )
+ );
+
+ $html_content = '<a href="http://example.com/?e=[user/email]&n=[user/firstname]">Click here</a>';
+ $this->assertSame(
+ '<a href="http://example.com/?e=john@example.com&n=John">Click here</a>',
+ $this->personalizer->personalize_content( $html_content )
+ );
+ }
+
+ /**
+ * Test that the same tag with different arguments produces per-occurrence values.
+ */
+ public function testSameTagWithDifferentArgumentsInOneHref(): void {
+ $this->tags_registry->register(
+ new Personalization_Tag(
+ 'echo_arg',
+ 'test/echo',
+ 'Test',
+ function ( $context, $args ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundBeforeLastUsed -- The $context parameter is not used in this test.
+ return $args['value'] ?? '';
+ }
+ )
+ );
+
+ $html_content = '<a href="http://example.com/?a=[test/echo value=one]&b=[test/echo value=two]">Click here</a>';
+ $this->assertSame(
+ '<a href="http://example.com/?a=one&b=two">Click here</a>',
+ $this->personalizer->personalize_content( $html_content )
+ );
+ }
+
+ /**
+ * Test that an unreplaced leading bracket does not cost the URL its protocol.
+ */
+ public function testUnregisteredLeadingBracketKeepsProtocol(): void {
+ $this->tags_registry->register(
+ new Personalization_Tag(
+ 'coupon',
+ 'test/coupon',
+ 'Test',
+ function () {
+ return 'SAVE20';
+ }
+ )
+ );
+
+ $html_content = '<a href="http://[2024]/page?ref=[test/coupon]">Click here</a>';
+ $this->assertSame(
+ '<a href="http://[2024]/page?ref=SAVE20">Click here</a>',
+ $this->personalizer->personalize_content( $html_content )
+ );
+ }
+
+ /**
+ * Test that a token appearing both plain and URL-encoded in one href is replaced in both places.
+ */
+ public function testMixedPlainAndEncodedTokenOccurrences(): void {
+ $this->tags_registry->register(
+ new Personalization_Tag(
+ 'coupon',
+ 'test/coupon',
+ 'Test',
+ function () {
+ return 'SAVE20';
+ }
+ )
+ );
+
+ $html_content = '<a href="http://example.com/?a=[test/coupon]&b=%5Btest/coupon%5D">Click here</a>';
+ $this->assertSame(
+ '<a href="http://example.com/?a=SAVE20&b=SAVE20">Click here</a>',
+ $this->personalizer->personalize_content( $html_content )
+ );
+ }
+
+ /**
+ * Test that a tag value containing another tag's token is not re-scanned for replacements.
+ */
+ public function testTagValueContainingAnotherTokenStaysLiteral(): void {
+ $this->tags_registry->register(
+ new Personalization_Tag(
+ 'name',
+ 'test/name',
+ 'Test',
+ function () {
+ return 'x[test/coupon]y';
+ }
+ )
+ );
+ $this->tags_registry->register(
+ new Personalization_Tag(
+ 'coupon',
+ 'test/coupon',
+ 'Test',
+ function () {
+ return 'SAVE20';
+ }
+ )
+ );
+
+ $html_content = '<a href="http://example.com/?n=[test/name]&c=[test/coupon]">Click here</a>';
+ $this->assertSame(
+ '<a href="http://example.com/?n=x%5Btest/coupon%5Dy&c=SAVE20">Click here</a>',
+ $this->personalizer->personalize_content( $html_content )
+ );
+ }
+
+ /**
+ * Test that an unregistered URL-encoded bracket sequence does not cost the URL its encoding.
+ */
+ public function testUnregisteredEncodedBracketKeepsSurroundingEncoding(): void {
+ $this->tags_registry->register(
+ new Personalization_Tag(
+ 'coupon',
+ 'test/coupon',
+ 'Test',
+ function () {
+ return 'SAVE20';
+ }
+ )
+ );
+
+ // items%5B0%5D decodes to a token-shaped [0]; it must not force the decoded base.
+ $html_content = '<a href="https://shop.example/checkout?items%5B0%5D=2¬e=a%26b&c=[test/coupon]">Click here</a>';
+ $this->assertSame(
+ '<a href="https://shop.example/checkout?items%5B0%5D=2&note=a%26b&c=SAVE20">Click here</a>',
+ $this->personalizer->personalize_content( $html_content )
+ );
+ }
+
+ /**
+ * Test that a tag value of "0" is a valid replacement in links.
+ */
+ public function testZeroStringTagValueIsReplacedInHref(): void {
+ $this->tags_registry->register(
+ new Personalization_Tag(
+ 'items_count',
+ 'test/count',
+ 'Test',
+ function () {
+ return '0';
+ }
+ )
+ );
+
+ $html_content = '<a href="http://example.com/?count=[test/count]">Click here</a>';
+ $this->assertSame(
+ '<a href="http://example.com/?count=0">Click here</a>',
+ $this->personalizer->personalize_content( $html_content )
+ );
+
+ // The data-link-href site must also treat "0" as a valid value and clean up the editor-only attributes.
+ $this->assertSame(
+ '<a href="http://0">Click here</a>',
+ $this->personalizer->personalize_content( '<a data-link-href="[test/count]" href="#">Click here</a>' )
+ );
+ }
+
+ /**
+ * Test that a token present only in URL-encoded form switches the whole href to the decoded base.
+ *
+ * This pins a known tradeoff: the decoded base also decodes unrelated percent-encoding
+ * elsewhere in the URL (the redirect parameter below loses its encoding).
+ */
+ public function testEncodedOnlyTokenDecodesWholeHrefBase(): void {
+ $this->tags_registry->register(
+ new Personalization_Tag(
+ 'coupon',
+ 'test/coupon',
+ 'Test',
+ function () {
+ return 'SAVE20';
+ }
+ )
+ );
+
+ $html_content = '<a href="https://example.com/?redirect=%2Fpath%3Fa%3D1%26b%3D2&tag=%5Btest/coupon%5D">Click here</a>';
+ $this->assertSame(
+ '<a href="https://example.com/?redirect=/path?a=1&b=2&tag=SAVE20">Click here</a>',
+ $this->personalizer->personalize_content( $html_content )
+ );
+ }
+
+ /**
+ * Test that dollar signs and backslashes in tag values are inserted literally into links.
+ */
+ public function testHrefTagValueWithRegexSpecialCharacters(): void {
+ $this->tags_registry->register(
+ new Personalization_Tag(
+ 'Tracking URL',
+ 'test/tracking-url',
+ 'Test',
+ function () {
+ return 'https://example.com/?q=$0&r=${1}&b=a\\1b';
+ }
+ )
+ );
+
+ // The data-link-href site goes through the regex-based replacement.
+ // Note: esc_url() strips the curly braces from `${1}` and the backslash from `\1`;
+ // the important part is that `$0`, `$1`, and `\1` are not interpreted as regex backreferences.
+ $this->assertSame(
+ '<a href="https://example.com/?q=$0&r=$1&b=a1b">Click here</a>',
+ $this->personalizer->personalize_content( '<a data-link-href="[test/tracking-url]" href="#">Click here</a>' ),
+ 'The data-link-href site should insert regex metacharacters literally'
+ );
+ // The plain-href site uses exact string replacement.
+ $this->assertSame(
+ '<a href="https://r.example/go?to=https://example.com/?q=$0&r=$1&b=a1b&p=1">Click here</a>',
+ $this->personalizer->personalize_content( '<a href="https://r.example/go?to=[test/tracking-url]&p=1">Click here</a>' ),
+ 'The plain-href site should insert regex metacharacters literally'
+ );
+ }
+
+ /**
+ * Test that the callback receives the rendering context passed to personalize_content.
+ */
+ public function testCallbackReceivesRenderingContext(): void {
+ $this->tags_registry->register(
+ new Personalization_Tag(
+ 'context_echo',
+ 'test/context',
+ 'Test',
+ function ( $context, $args ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed -- The $args parameter is not used in this test.
+ return $context[ Personalizer::RENDERING_CONTEXT_KEY ];
+ }
+ )
+ );
+
+ $content = '<p><!--[test/context]--></p>';
+ $this->assertSame( '<p>html</p>', $this->personalizer->personalize_content( $content ) );
+ $this->assertSame( '<p>text</p>', $this->personalizer->personalize_content( $content, Personalizer::RENDERING_CONTEXT_TEXT ) );
+ // Unknown rendering context falls back to html.
+ $this->assertSame( '<p>html</p>', $this->personalizer->personalize_content( $content, 'bogus' ) );
+ }
+
+ /**
+ * Test that the title tag content is personalized in the text rendering context.
+ */
+ public function testTitleReceivesTextRenderingContext(): void {
+ $this->tags_registry->register(
+ new Personalization_Tag(
+ 'context_echo',
+ 'test/context',
+ 'Test',
+ function ( $context, $args ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed -- The $args parameter is not used in this test.
+ return $context[ Personalizer::RENDERING_CONTEXT_KEY ];
+ }
+ )
+ );
+
+ $content = '<html><head><title><!--[test/context]--></title></head><body><p><!--[test/context]--></p></body></html>';
+ $this->assertSame(
+ '<html><head><title>text</title></head><body><p>html</p></body></html>',
+ $this->personalizer->personalize_content( $content )
+ );
+ }
+
+ /**
+ * Test that both href replacement sites pass the href rendering context to the callback.
+ */
+ public function testHrefReceivesHrefRenderingContext(): void {
+ $captured = array();
+ $this->tags_registry->register(
+ new Personalization_Tag(
+ 'Store URL',
+ 'woocommerce/store-url',
+ 'Store',
+ function ( $context, $args ) use ( &$captured ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed -- The $args parameter is not used in this test.
+ $captured[] = $context[ Personalizer::RENDERING_CONTEXT_KEY ];
+ return 'https://example.com';
+ }
+ )
+ );
+
+ $html_content = '<a data-link-href="[woocommerce/store-url]" href="#">First</a><a href="http://[woocommerce/store-url]">Second</a>';
+ $this->personalizer->personalize_content( $html_content );
+ $this->assertSame( array( Personalizer::RENDERING_CONTEXT_HREF, Personalizer::RENDERING_CONTEXT_HREF ), $captured );
+ }
+
+ /**
+ * Test that a text value type tag is escaped in the html rendering context only.
+ */
+ public function testTextValueTypeIsEscapedInHtmlContext(): void {
+ $this->tags_registry->register(
+ new Personalization_Tag(
+ 'shop_name',
+ 'test/shop-name',
+ 'Test',
+ function ( $context, $args ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed -- The $args parameter is not used in this test.
+ return $context['shop_name'] ?? '';
+ },
+ array(),
+ null,
+ array(),
+ Personalization_Tag::VALUE_TYPE_TEXT
+ )
+ );
+
+ $this->personalizer->set_context( array( 'shop_name' => "Tom & Jerry's <shop>" ) );
+ $content = 'Welcome to <!--[test/shop-name]-->';
+ $this->assertSame(
+ 'Welcome to Tom & Jerry's <shop>',
+ $this->personalizer->personalize_content( $content, Personalizer::RENDERING_CONTEXT_HTML )
+ );
+ $this->assertSame(
+ "Welcome to Tom & Jerry's <shop>",
+ $this->personalizer->personalize_content( $content, Personalizer::RENDERING_CONTEXT_TEXT )
+ );
+ }
+
+ /**
+ * Test that an already escaped value of a text value type tag is not double-encoded.
+ */
+ public function testTextValueTypeIsNotDoubleEncoded(): void {
+ $this->tags_registry->register(
+ new Personalization_Tag(
+ 'shop_name',
+ 'test/shop-name',
+ 'Test',
+ function () {
+ return 'Tom & Jerry';
+ },
+ array(),
+ null,
+ array(),
+ Personalization_Tag::VALUE_TYPE_TEXT
+ )
+ );
+
+ $this->assertSame(
+ '<p>Tom & Jerry</p>',
+ $this->personalizer->personalize_content( '<p><!--[test/shop-name]--></p>' )
+ );
+ }
+
+ /**
+ * Test that an html value type tag (the default) is never touched by the Personalizer.
+ */
+ public function testHtmlValueTypeIsNotEscaped(): void {
+ $this->tags_registry->register(
+ new Personalization_Tag(
+ 'price',
+ 'test/price',
+ 'Test',
+ function () {
+ return '<span class="price">10 €</span>';
+ }
+ )
+ );
+
+ $this->tags_registry->register(
+ new Personalization_Tag(
+ 'tracking_pixel',
+ 'test/tracking-pixel',
+ 'Test',
+ function () {
+ return '<img src="https://track.example/open?id=42&c=1" width="1" height="1" alt="" />';
+ }
+ )
+ );
+
+ $content = '<p><!--[test/price]--></p>';
+ $this->assertSame(
+ '<p><span class="price">10 €</span></p>',
+ $this->personalizer->personalize_content( $content, Personalizer::RENDERING_CONTEXT_HTML )
+ );
+ $this->assertSame(
+ '<p><span class="price">10 €</span></p>',
+ $this->personalizer->personalize_content( $content, Personalizer::RENDERING_CONTEXT_TEXT )
+ );
+ // A tag emitting raw markup, such as a tracking pixel, is inserted verbatim.
+ $this->assertSame(
+ '<div><img src="https://track.example/open?id=42&c=1" width="1" height="1" alt="" /></div>',
+ $this->personalizer->personalize_content( '<div><!--[test/tracking-pixel]--></div>' )
+ );
+ }
+
+ /**
+ * Test that the reserved rendering context key cannot be injected via set_context().
+ */
+ public function testRenderingContextKeyIsReserved(): void {
+ $this->tags_registry->register(
+ new Personalization_Tag(
+ 'context_echo',
+ 'test/context',
+ 'Test',
+ function ( $context, $args ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed -- The $args parameter is not used in this test.
+ return $context[ Personalizer::RENDERING_CONTEXT_KEY ] . '|' . ( $context[5] ?? 'missing' );
+ }
+ )
+ );
+
+ /**
+ * The reserved key is overwritten while the rest of the context — including the
+ * integer key, which is deliberately outside the documented contract — survives.
+ *
+ * @var array<string, mixed> $context
+ */
+ $context = array(
+ Personalizer::RENDERING_CONTEXT_KEY => 'bogus',
+ 5 => 'five',
+ );
+ $this->personalizer->set_context( $context );
+ $this->assertSame( '<p>html|five</p>', $this->personalizer->personalize_content( '<p><!--[test/context]--></p>' ) );
+ }
+
/**
* Test parsing tokens with various formats.
*/
diff --git a/packages/php/email-editor/tests/unit/Engine/PersonalizationTags/Personalization_Tag_Test.php b/packages/php/email-editor/tests/unit/Engine/PersonalizationTags/Personalization_Tag_Test.php
index 5eb23fa60eb..6d93a016512 100644
--- a/packages/php/email-editor/tests/unit/Engine/PersonalizationTags/Personalization_Tag_Test.php
+++ b/packages/php/email-editor/tests/unit/Engine/PersonalizationTags/Personalization_Tag_Test.php
@@ -63,6 +63,24 @@ class Personalization_Tag_Test extends TestCase {
$this->assertSame( 'callback result: test', $result );
}
+ /**
+ * Test the value type defaults to html, accepts text, and falls back to html for unknown values.
+ */
+ public function testValueType(): void {
+ $callback = function () {
+ return 'test value';
+ };
+
+ $tag = new Personalization_Tag( 'Test Tag', 'test_token', 'Test Category', $callback );
+ $this->assertSame( Personalization_Tag::VALUE_TYPE_HTML, $tag->get_value_type() );
+
+ $tag = new Personalization_Tag( 'Test Tag', 'test_token', 'Test Category', $callback, array(), null, array(), Personalization_Tag::VALUE_TYPE_TEXT );
+ $this->assertSame( Personalization_Tag::VALUE_TYPE_TEXT, $tag->get_value_type() );
+
+ $tag = new Personalization_Tag( 'Test Tag', 'test_token', 'Test Category', $callback, array(), null, array(), 'unknown-type' );
+ $this->assertSame( Personalization_Tag::VALUE_TYPE_HTML, $tag->get_value_type() );
+ }
+
/**
* Test that deserialization is prevented for security reasons.
*/
diff --git a/plugins/woocommerce/changelog/add-context-aware-personalization b/plugins/woocommerce/changelog/add-context-aware-personalization
new file mode 100644
index 00000000000..e62d3d11a3e
--- /dev/null
+++ b/plugins/woocommerce/changelog/add-context-aware-personalization
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Pass the plain-text rendering context when personalizing transactional email subjects and preheaders so personalization tag values are not HTML-escaped in plain-text output.
diff --git a/plugins/woocommerce/includes/emails/class-wc-email-customer-invoice.php b/plugins/woocommerce/includes/emails/class-wc-email-customer-invoice.php
index 919097fc628..1d5ef123326 100644
--- a/plugins/woocommerce/includes/emails/class-wc-email-customer-invoice.php
+++ b/plugins/woocommerce/includes/emails/class-wc-email-customer-invoice.php
@@ -5,6 +5,7 @@
* @package WooCommerce\Emails
*/
+use Automattic\WooCommerce\EmailEditor\Engine\Personalizer;
use Automattic\WooCommerce\Enums\OrderStatus;
use Automattic\WooCommerce\Utilities\FeaturesUtil;
@@ -91,7 +92,7 @@ if ( ! class_exists( 'WC_Email_Customer_Invoice', false ) ) :
$subject = $this->get_option( 'subject_paid', $this->get_default_subject( true ) );
if ( $this->block_email_editor_enabled ) {
- $subject = $this->personalizer->personalize_transactional_content( $subject, $this );
+ $subject = $this->personalizer->personalize_transactional_content( $subject, $this, Personalizer::RENDERING_CONTEXT_TEXT );
}
return apply_filters( 'woocommerce_email_subject_customer_invoice_paid', $this->format_string( $subject ), $this->object, $this );
@@ -100,7 +101,7 @@ if ( ! class_exists( 'WC_Email_Customer_Invoice', false ) ) :
$subject = $this->get_option( 'subject', $this->get_default_subject() );
if ( $this->block_email_editor_enabled ) {
- $subject = $this->personalizer->personalize_transactional_content( $subject, $this );
+ $subject = $this->personalizer->personalize_transactional_content( $subject, $this, Personalizer::RENDERING_CONTEXT_TEXT );
}
return apply_filters( 'woocommerce_email_subject_customer_invoice', $this->format_string( $subject ), $this->object, $this );
diff --git a/plugins/woocommerce/includes/emails/class-wc-email-customer-partially-refunded-order.php b/plugins/woocommerce/includes/emails/class-wc-email-customer-partially-refunded-order.php
index 9fb91d82dee..d7733010d11 100644
--- a/plugins/woocommerce/includes/emails/class-wc-email-customer-partially-refunded-order.php
+++ b/plugins/woocommerce/includes/emails/class-wc-email-customer-partially-refunded-order.php
@@ -5,6 +5,8 @@
* @package WooCommerce\Emails
*/
+use Automattic\WooCommerce\EmailEditor\Engine\Personalizer;
+
if ( ! defined( 'ABSPATH' ) ) {
exit; // Exit if accessed directly.
}
@@ -86,7 +88,7 @@ if ( ! class_exists( 'WC_Email_Customer_Partially_Refunded_Order', false ) ) :
*/
$subject = apply_filters( 'woocommerce_email_subject_customer_refunded_order', $this->format_string( $subject ), $this->object, $this );
if ( $this->block_email_editor_enabled ) {
- $subject = $this->personalizer->personalize_transactional_content( $subject, $this );
+ $subject = $this->personalizer->personalize_transactional_content( $subject, $this, Personalizer::RENDERING_CONTEXT_TEXT );
}
return $subject;
}
diff --git a/plugins/woocommerce/includes/emails/class-wc-email-customer-refunded-order.php b/plugins/woocommerce/includes/emails/class-wc-email-customer-refunded-order.php
index 361bc916324..e70a1812e64 100644
--- a/plugins/woocommerce/includes/emails/class-wc-email-customer-refunded-order.php
+++ b/plugins/woocommerce/includes/emails/class-wc-email-customer-refunded-order.php
@@ -5,6 +5,7 @@
* @package WooCommerce\Emails
*/
+use Automattic\WooCommerce\EmailEditor\Engine\Personalizer;
use Automattic\WooCommerce\Utilities\FeaturesUtil;
if ( ! defined( 'ABSPATH' ) ) {
@@ -127,7 +128,7 @@ if ( ! class_exists( 'WC_Email_Customer_Refunded_Order', false ) ) :
*/
$subject = apply_filters( 'woocommerce_email_subject_customer_refunded_order', $this->format_string( $subject ), $this->object, $this );
if ( $this->block_email_editor_enabled ) {
- $subject = $this->personalizer->personalize_transactional_content( $subject, $this );
+ $subject = $this->personalizer->personalize_transactional_content( $subject, $this, Personalizer::RENDERING_CONTEXT_TEXT );
}
return $subject;
}
diff --git a/plugins/woocommerce/includes/emails/class-wc-email.php b/plugins/woocommerce/includes/emails/class-wc-email.php
index 475b4f450e7..c90bfbe3a05 100644
--- a/plugins/woocommerce/includes/emails/class-wc-email.php
+++ b/plugins/woocommerce/includes/emails/class-wc-email.php
@@ -5,6 +5,7 @@
* @package WooCommerce\Emails
*/
+use Automattic\WooCommerce\EmailEditor\Engine\Personalizer;
use Automattic\WooCommerce\Internal\EmailEditor\BlockEmailRenderer;
use Automattic\WooCommerce\Internal\EmailEditor\TransactionalEmailPersonalizer;
use Automattic\WooCommerce\Utilities\FeaturesUtil;
@@ -566,7 +567,7 @@ class WC_Email extends WC_Settings_API {
$subject = apply_filters( 'woocommerce_email_subject_' . $this->id, $this->format_string( $this->get_option_or_transient( 'subject', $this->get_default_subject() ) ), $this->object, $this );
if ( $this->block_email_editor_enabled ) {
// Because the new email editor uses rich-text component for subject editing, to be ensure that the subject is always in plain text, we need to strip all tags.
- $subject = wp_strip_all_tags( $this->personalizer->personalize_transactional_content( $subject, $this ) );
+ $subject = wp_strip_all_tags( $this->personalizer->personalize_transactional_content( $subject, $this, Personalizer::RENDERING_CONTEXT_TEXT ) );
}
return $subject;
}
@@ -590,7 +591,7 @@ class WC_Email extends WC_Settings_API {
*/
$preheader = apply_filters( 'woocommerce_email_preheader' . $this->id, $this->format_string( $this->get_option_or_transient( 'preheader', '' ) ), $this->object, $this );
if ( $this->block_email_editor_enabled ) {
- $preheader = $this->personalizer->personalize_transactional_content( $preheader, $this );
+ $preheader = $this->personalizer->personalize_transactional_content( $preheader, $this, Personalizer::RENDERING_CONTEXT_TEXT );
}
return $preheader;
}
diff --git a/plugins/woocommerce/src/Internal/EmailEditor/TransactionalEmailPersonalizer.php b/plugins/woocommerce/src/Internal/EmailEditor/TransactionalEmailPersonalizer.php
index b27e9f90572..cade5f07047 100644
--- a/plugins/woocommerce/src/Internal/EmailEditor/TransactionalEmailPersonalizer.php
+++ b/plugins/woocommerce/src/Internal/EmailEditor/TransactionalEmailPersonalizer.php
@@ -38,11 +38,12 @@ class TransactionalEmailPersonalizer {
*
* @param string $content The content to personalize.
* @param \WC_Email $email The WooCommerce email object.
+ * @param string $rendering_context The rendering context of the content — Personalizer::RENDERING_CONTEXT_HTML or Personalizer::RENDERING_CONTEXT_TEXT.
* @return string The personalized content.
*/
- public function personalize_transactional_content( string $content, \WC_Email $email ): string {
+ public function personalize_transactional_content( string $content, \WC_Email $email, string $rendering_context = Personalizer::RENDERING_CONTEXT_HTML ): string {
$this->configure_context_by_email( $email );
- return $this->personalizer->personalize_content( $content );
+ return $this->personalizer->personalize_content( $content, $rendering_context );
}
/**
diff --git a/plugins/woocommerce/tests/php/src/Internal/EmailEditor/TransactionalEmailPersonalizerTest.php b/plugins/woocommerce/tests/php/src/Internal/EmailEditor/TransactionalEmailPersonalizerTest.php
index aeecb2af527..c4c5b99d496 100644
--- a/plugins/woocommerce/tests/php/src/Internal/EmailEditor/TransactionalEmailPersonalizerTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/EmailEditor/TransactionalEmailPersonalizerTest.php
@@ -3,6 +3,10 @@ declare( strict_types = 1 );
namespace Automattic\WooCommerce\Tests\Internal\EmailEditor;
+use Automattic\WooCommerce\EmailEditor\Email_Editor_Container;
+use Automattic\WooCommerce\EmailEditor\Engine\Personalizer;
+use Automattic\WooCommerce\EmailEditor\Engine\PersonalizationTags\Personalization_Tag;
+use Automattic\WooCommerce\EmailEditor\Engine\PersonalizationTags\Personalization_Tags_Registry;
use Automattic\WooCommerce\Internal\EmailEditor\TransactionalEmailPersonalizer;
use WC_Unit_Test_Case;
@@ -31,9 +35,47 @@ class TransactionalEmailPersonalizerTest extends WC_Unit_Test_Case {
*/
public function tearDown(): void {
remove_all_filters( 'woocommerce_email_editor_integration_personalizer_context_data' );
+ Email_Editor_Container::container()->get( Personalization_Tags_Registry::class )->unregister( '[test/text-tag]' );
parent::tearDown();
}
+ /**
+ * @testdox Should pass the rendering context through so text-typed tag values stay raw in text context and are escaped in the HTML default.
+ */
+ public function test_rendering_context_is_passed_through(): void {
+ Email_Editor_Container::container()->get( Personalization_Tags_Registry::class )->register(
+ new Personalization_Tag(
+ 'Text Tag',
+ 'test/text-tag',
+ 'Test',
+ function () {
+ return 'Tom & Jerry';
+ },
+ array(),
+ null,
+ array(),
+ Personalization_Tag::VALUE_TYPE_TEXT
+ )
+ );
+
+ $mock_email = $this->createMock( \WC_Email::class );
+ $mock_email->method( 'get_recipient' )->willReturn( 'test@example.com' );
+ $mock_email->object = new \stdClass();
+
+ $content = 'Hi <!--[test/text-tag]-->';
+
+ $this->assertSame(
+ 'Hi Tom & Jerry',
+ $this->sut->personalize_transactional_content( $content, $mock_email, Personalizer::RENDERING_CONTEXT_TEXT ),
+ 'Text context should keep the raw value'
+ );
+ $this->assertSame(
+ 'Hi Tom & Jerry',
+ $this->sut->personalize_transactional_content( $content, $mock_email ),
+ 'HTML default should escape text-typed values'
+ );
+ }
+
/**
* @testdox Should preserve wp_user set by the filter when core cannot derive it from the email object.
*/