Commit d7fba8f43ee for woocommerce
commit d7fba8f43ee3382688270fee7de2401646676b58
Author: Rostislav Wolný <1082140+costasovo@users.noreply.github.com>
Date: Thu Aug 20 16:51:33 2026 +0200
Add value-interception extension point to email editor Personalizer (#66874)
* Add value-interceptor extension point to Personalizer
Allows integrators to substitute placeholders for
resolved personalization tag values or/and recording the values externally.
The interceptor receives the resolved
value, the raw source token, and the rendering context of the replacement
site (reusing the RENDERING_CONTEXT_* constants introduced with context-aware
rendering), and its return value is written instead. It runs after the
context-driven esc_html() step so whatever it returns lands in the content
verbatim. It persists until cleared with null, mirroring set_context().
diff --git a/packages/php/email-editor/changelog/personalizer-value-interception b/packages/php/email-editor/changelog/personalizer-value-interception
new file mode 100644
index 00000000000..ddd927a7214
--- /dev/null
+++ b/packages/php/email-editor/changelog/personalizer-value-interception
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Add a value-interception extension point to the Personalizer.
diff --git a/packages/php/email-editor/docs/personalization-tags.md b/packages/php/email-editor/docs/personalization-tags.md
index 2d38ee6c276..e012acacaee 100644
--- a/packages/php/email-editor/docs/personalization-tags.md
+++ b/packages/php/email-editor/docs/personalization-tags.md
@@ -10,6 +10,7 @@
- [Format](#format)
- [Context](#context)
- [Rendering Context and Escaping](#rendering-context-and-escaping)
+- [Value Interceptor](#value-interceptor)
- [Core Components](#core-components)
- [Creating Custom Tags](#creating-custom-tags)
- [Usage with Renderer](#usage-with-renderer)
@@ -168,6 +169,29 @@ Note: the `esc_html()` call does not double-encode existing entities, so a raw t
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).
+## Value Interceptor
+
+The Personalizer offers an advanced extension point that intercepts every resolved tag value just before it is written into the content:
+
+```php
+$previous = $personalizer->set_value_interceptor(
+ function ( string $value, string $source, string $rendering_context ): string {
+ // Record the value externally, substitute a placeholder, etc.
+ return $value;
+ }
+);
+```
+
+The interceptor receives:
+
+- `$value` — the resolved value, after any context-driven escaping (e.g. `esc_html()` for `VALUE_TYPE_TEXT` tags in HTML content).
+- `$source` — the raw source text being replaced: the trimmed tag token including arguments, the raw `data-link-href` attribute value, or the tag token found inside a plain `href`.
+- `$rendering_context` — the `RENDERING_CONTEXT_*` constant of the replacement site.
+
+Its return value is written instead of the resolved value and must be a string — anything else throws a `TypeError`. In the `html` and `text` rendering contexts it is written verbatim. In the `href` rendering context it ends up in the `href` attribute, which WordPress escapes with `esc_url()`: characters not allowed in URLs (such as curly braces) are stripped and a missing scheme is prepended, so a `{{placeholder}}` return comes out as `http://placeholder`. An empty return in the `href` rendering context means no replacement — the link is left untouched, just like an empty resolved value.
+
+The interceptor persists until cleared by passing `null`, and the Personalizer instance obtained from the DI container is shared — WooCommerce core's transactional emails personalize through the same instance. Clear the interceptor (or restore the previous one returned by `set_value_interceptor()`) in a `finally` block, so an exception cannot leave it registered for unrelated emails.
+
## Core Components
### Personalization_Tags_Registry
@@ -257,6 +281,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, 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
+- `set_value_interceptor(?callable $interceptor)`: Register a callback intercepting each resolved value before it is written, or clear it with `null`; returns the previously registered interceptor (see [Value Interceptor](#value-interceptor))
**Example Usage:**
diff --git a/packages/php/email-editor/src/Engine/class-personalizer.php b/packages/php/email-editor/src/Engine/class-personalizer.php
index 187994e0172..eab1adfcf99 100644
--- a/packages/php/email-editor/src/Engine/class-personalizer.php
+++ b/packages/php/email-editor/src/Engine/class-personalizer.php
@@ -71,6 +71,14 @@ class Personalizer {
*/
private array $context;
+ /**
+ * Optional callback intercepting each resolved personalization tag value before
+ * it is written into the content.
+ *
+ * @var (callable(string, string, string): string)|null
+ */
+ private $value_interceptor = null;
+
/**
* Class constructor with required dependencies.
*
@@ -112,6 +120,53 @@ class Personalizer {
return $this->context;
}
+ /**
+ * Set a callback intercepting each resolved personalization tag value before it is written.
+ *
+ * The interceptor receives the resolved value (after any context-driven escaping),
+ * the raw source text being replaced (the trimmed tag token including arguments,
+ * the raw data-link-href attribute value, or the tag token found inside a plain href),
+ * and the rendering context of the replacement site — one of the RENDERING_CONTEXT_HTML,
+ * RENDERING_CONTEXT_TEXT, or RENDERING_CONTEXT_HREF constants. Its return value is
+ * written instead of the resolved value — verbatim in the html and text rendering
+ * contexts. In the href rendering context the return value ends up in the href
+ * attribute, which WordPress escapes with esc_url(): characters not allowed in URLs
+ * (such as curly braces) are stripped and a missing scheme is prepended, so a
+ * "{{placeholder}}" return comes out as "http://placeholder". An empty return value
+ * in the href rendering context means no replacement — the link is left untouched,
+ * just like an empty resolved value. This allows consumers (e.g. bulk-sending integrations)
+ * to substitute placeholders for values while recording the values externally.
+ *
+ * The interceptor persists until cleared by passing null, and the Personalizer
+ * instance may be shared with other consumers, so clear or restore the interceptor
+ * (e.g. in a finally block) as soon as the work it was registered for is done.
+ * The previously registered interceptor is returned to support restoring it.
+ *
+ * @param callable|null $interceptor The interceptor callback or null to clear. The callback receives
+ * the resolved value, the raw source token, and a RENDERING_CONTEXT_* constant.
+ * @return callable|null The previously registered interceptor, or null when none was set.
+ */
+ public function set_value_interceptor( ?callable $interceptor ): ?callable {
+ $previous = $this->value_interceptor;
+ $this->value_interceptor = $interceptor;
+ return $previous;
+ }
+
+ /**
+ * Run a resolved value through the registered interceptor, if any.
+ *
+ * @param string $value The resolved personalization tag value about to be written.
+ * @param string $source The raw source text being replaced.
+ * @param string $rendering_context One of the RENDERING_CONTEXT_* constants.
+ * @return string The value to write.
+ */
+ private function intercept_value( string $value, string $source, string $rendering_context ): string {
+ if ( null === $this->value_interceptor ) {
+ return $value;
+ }
+ return ( $this->value_interceptor )( $value, $source, $rendering_context );
+ }
+
/**
* Personalize the content by replacing the personalization tags with their values.
*
@@ -139,6 +194,7 @@ class Personalizer {
if ( self::RENDERING_CONTEXT_HTML === $rendering_context && Personalization_Tag::VALUE_TYPE_TEXT === $tag->get_value_type() ) {
$value = esc_html( $value );
}
+ $value = $this->intercept_value( (string) $value, trim( $modifiable_text ), $rendering_context );
$content_processor->replace_token( $value );
} elseif ( $content_processor->get_token_type() === '#tag' && $content_processor->get_tag() === 'TITLE' ) {
@@ -159,6 +215,7 @@ class Personalizer {
$value = $tag->execute_callback( $this->get_callback_context( self::RENDERING_CONTEXT_HREF ), $token['arguments'] );
$value = $this->replace_link_href( $href, $tag->get_token(), $value );
+ $value = $this->intercept_value( $value, $href, self::RENDERING_CONTEXT_HREF );
if ( '' !== $value ) {
$content_processor->set_attribute( 'href', $value );
$content_processor->remove_attribute( 'data-link-href' );
@@ -204,6 +261,7 @@ class Personalizer {
}
$value = $tag->execute_callback( $this->get_callback_context( self::RENDERING_CONTEXT_HREF ), $token['arguments'] );
+ $value = $this->intercept_value( $value, $token_string, self::RENDERING_CONTEXT_HREF );
if ( '' !== $value ) {
$replacements[ $token_string ] = $value;
}
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 c8f90b0f593..a62237b4854 100644
--- a/packages/php/email-editor/tests/integration/Engine/Personalizer_Test.php
+++ b/packages/php/email-editor/tests/integration/Engine/Personalizer_Test.php
@@ -933,4 +933,326 @@ class Personalizer_Test extends \Email_Editor_Integration_Test_Case {
$this->assertSame( '', $result['token'] );
$this->assertEmpty( $result['arguments'] );
}
+
+ /**
+ * Test that a registered value interceptor receives the resolved value, the raw
+ * source token, and the html rendering context for a content tag, and that its
+ * return value is written instead of the resolved value.
+ */
+ public function testValueInterceptorForContentTag(): void {
+ $this->tags_registry->register(
+ new Personalization_Tag(
+ 'first_name',
+ 'user-firstname',
+ 'User',
+ function ( $context, $args ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed -- The $args parameter is not used in this test.
+ return $context['subscriber_name'] ?? 'Default Name';
+ }
+ )
+ );
+
+ $calls = array();
+ $this->personalizer->set_value_interceptor(
+ function ( string $value, string $source, string $rendering_context ) use ( &$calls ): string {
+ $calls[] = array( $value, $source, $rendering_context );
+ return '{placeholder-1}';
+ }
+ );
+
+ $this->personalizer->set_context( array( 'subscriber_name' => 'John' ) );
+ $html_content = '<p>Hello, <!--[user-firstname default="Guest"]-->!</p>';
+ $this->assertSame( '<p>Hello, {placeholder-1}!</p>', $this->personalizer->personalize_content( $html_content ) );
+ $this->assertSame(
+ array(
+ array( 'John', '[user-firstname default="Guest"]', Personalizer::RENDERING_CONTEXT_HTML ),
+ ),
+ $calls
+ );
+ }
+
+ /**
+ * Test that clearing the value interceptor with null restores default behavior.
+ */
+ public function testValueInterceptorCanBeCleared(): void {
+ $this->tags_registry->register(
+ new Personalization_Tag(
+ 'first_name',
+ 'user-firstname',
+ 'User',
+ function ( $context, $args ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed -- The $context parameter is not used in this test.
+ return 'John';
+ }
+ )
+ );
+
+ $this->personalizer->set_value_interceptor(
+ function ( string $value, string $source, string $rendering_context ): string { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed -- Parameters unused in this test.
+ return '{placeholder-1}';
+ }
+ );
+ $html_content = '<p>Hello, <!--[user-firstname]-->!</p>';
+ $this->assertSame( '<p>Hello, {placeholder-1}!</p>', $this->personalizer->personalize_content( $html_content ) );
+ // The second call verifies that the interceptor persists across personalize_content() calls.
+ $this->assertSame( '<p>Hello, {placeholder-1}!</p>', $this->personalizer->personalize_content( $html_content ) );
+
+ $this->personalizer->set_value_interceptor( null );
+ $this->assertSame( '<p>Hello, John!</p>', $this->personalizer->personalize_content( $html_content ) );
+ }
+
+ /**
+ * Test that the interceptor is not called for tokens without a registered tag.
+ */
+ public function testValueInterceptorNotCalledForUnknownTag(): void {
+ $calls = 0;
+ $this->personalizer->set_value_interceptor(
+ function ( string $value, string $source, string $rendering_context ) use ( &$calls ): string { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed -- Parameters unused in this test.
+ ++$calls;
+ return $value;
+ }
+ );
+
+ $html_content = '<p>Hello, <!--[mailpoet/unknown-tag]-->!</p>';
+ $this->assertSame( $html_content, $this->personalizer->personalize_content( $html_content ) );
+ $this->assertSame( 0, $calls );
+ }
+
+ /**
+ * Test that the interceptor receives the text rendering context for tags inside
+ * <title> and the html rendering context for tags in the body.
+ */
+ public function testValueInterceptorReceivesTextContextInTitle(): void {
+ $this->tags_registry->register(
+ new Personalization_Tag(
+ 'first_name',
+ 'user-firstname',
+ 'User',
+ function ( $context, $args ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed -- Parameters unused in this test.
+ return 'John';
+ }
+ )
+ );
+
+ $contexts = array();
+ $this->personalizer->set_value_interceptor(
+ function ( string $value, string $source, string $rendering_context ) use ( &$contexts ): string { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed -- The $source parameter is not used in this test.
+ $contexts[] = $rendering_context;
+ return Personalizer::RENDERING_CONTEXT_TEXT === $rendering_context ? '{title-placeholder}' : $value;
+ }
+ );
+
+ $html_content = '<html><head><title>Hi <!--[user-firstname]-->!</title></head><body><p>Hi <!--[user-firstname]-->!</p></body></html>';
+ $result = $this->personalizer->personalize_content( $html_content );
+ $this->assertSame( '<html><head><title>Hi {title-placeholder}!</title></head><body><p>Hi John!</p></body></html>', $result );
+ $this->assertSame( array( Personalizer::RENDERING_CONTEXT_TEXT, Personalizer::RENDERING_CONTEXT_HTML ), $contexts );
+ }
+
+ /**
+ * Test that the interceptor receives the escaped value of a text value type tag
+ * in the html rendering context and that its return value is written verbatim.
+ */
+ public function testValueInterceptorReceivesEscapedTextValue(): 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
+ )
+ );
+
+ $calls = array();
+ $this->personalizer->set_value_interceptor(
+ function ( string $value, string $source, string $rendering_context ) use ( &$calls ): string { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundBeforeLastUsed -- The $source parameter is not used in this test.
+ $calls[] = array( $value, $rendering_context );
+ return '{shop-placeholder}';
+ }
+ );
+
+ $this->assertSame( '<p>{shop-placeholder}</p>', $this->personalizer->personalize_content( '<p><!--[test/shop-name]--></p>' ) );
+ $this->assertSame(
+ array(
+ array( 'Tom & Jerry', Personalizer::RENDERING_CONTEXT_HTML ),
+ ),
+ $calls
+ );
+ }
+
+ /**
+ * Test that the interceptor receives the fully resolved href, the raw
+ * data-link-href source, and the href rendering context, and that its return
+ * value is written as the href.
+ */
+ public function testValueInterceptorForDataLinkHref(): void {
+ $this->tags_registry->register(
+ new Personalization_Tag(
+ 'Store URL',
+ 'woocommerce/store-url',
+ 'Store',
+ function ( $context, $args ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed -- Parameters unused in this test.
+ return 'https://example.com/store';
+ }
+ )
+ );
+
+ $calls = array();
+ $this->personalizer->set_value_interceptor(
+ function ( string $value, string $source, string $rendering_context ) use ( &$calls ): string {
+ $calls[] = array( $value, $source, $rendering_context );
+ return 'https://intercepted.example.com';
+ }
+ );
+
+ $html_content = '<a data-link-href="[woocommerce/store-url]" href="#" contenteditable="true">Click here</a>';
+ $result = $this->personalizer->personalize_content( $html_content );
+ $this->assertStringContainsString( 'href="https://intercepted.example.com"', $result );
+ $this->assertStringNotContainsString( 'data-link-href', $result );
+ $this->assertStringNotContainsString( 'contenteditable', $result );
+ $this->assertSame(
+ array(
+ array( 'https://example.com/store', '[woocommerce/store-url]', Personalizer::RENDERING_CONTEXT_HREF ),
+ ),
+ $calls
+ );
+ }
+
+ /**
+ * Test that the interceptor receives the resolved value, the tag token found in
+ * the href, and the href rendering context for a plain anchor with an embedded
+ * tag, and that its return value is written as the href.
+ */
+ public function testValueInterceptorForPlainHrefTag(): void {
+ $this->tags_registry->register(
+ new Personalization_Tag(
+ 'Store URL',
+ 'woocommerce/store-url',
+ 'Store',
+ function ( $context, $args ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed -- Parameters unused in this test.
+ return 'https://example.com/store';
+ }
+ )
+ );
+
+ $calls = array();
+ $this->personalizer->set_value_interceptor(
+ function ( string $value, string $source, string $rendering_context ) use ( &$calls ): string {
+ $calls[] = array( $value, $source, $rendering_context );
+ return 'https://intercepted.example.com';
+ }
+ );
+
+ $html_content = '<a href="http://[woocommerce/store-url]">Click here</a>';
+ $this->assertSame( '<a href="https://intercepted.example.com">Click here</a>', $this->personalizer->personalize_content( $html_content ) );
+ $this->assertSame(
+ array(
+ array( 'https://example.com/store', '[woocommerce/store-url]', Personalizer::RENDERING_CONTEXT_HREF ),
+ ),
+ $calls
+ );
+ }
+
+ /**
+ * Test that the interceptor is called once per tag token embedded in a plain
+ * href and that each return value replaces its token in the URL.
+ */
+ public function testValueInterceptorForMultipleHrefTokens(): void {
+ $this->tags_registry->register(
+ new Personalization_Tag(
+ 'One',
+ 'test/one',
+ 'Test',
+ function () {
+ return 'first-value';
+ }
+ )
+ );
+ $this->tags_registry->register(
+ new Personalization_Tag(
+ 'Two',
+ 'test/two',
+ 'Test',
+ function () {
+ return 'second-value';
+ }
+ )
+ );
+
+ $calls = array();
+ $this->personalizer->set_value_interceptor(
+ function ( string $value, string $source, string $rendering_context ) use ( &$calls ): string {
+ $calls[] = array( $value, $source, $rendering_context );
+ return '[test/one]' === $source ? 'ONE' : 'TWO';
+ }
+ );
+
+ // Note: WordPress encodes & as & in URLs.
+ $this->assertSame(
+ '<a href="https://example.com/?a=ONE&b=TWO">Click</a>',
+ $this->personalizer->personalize_content( '<a href="https://example.com/?a=[test/one]&b=[test/two]">Click</a>' )
+ );
+ $this->assertSame(
+ array(
+ array( 'first-value', '[test/one]', Personalizer::RENDERING_CONTEXT_HREF ),
+ array( 'second-value', '[test/two]', Personalizer::RENDERING_CONTEXT_HREF ),
+ ),
+ $calls
+ );
+ }
+
+ /**
+ * Test that an interceptor return value at the href sites is escaped as a URL when
+ * written into the href attribute: esc_url() strips characters not allowed in URLs
+ * (such as curly braces) and prepends a missing scheme.
+ */
+ public function testValueInterceptorHrefReturnIsEscapedAsUrl(): void {
+ $this->tags_registry->register(
+ new Personalization_Tag(
+ 'Store URL',
+ 'test/store-url',
+ 'Test',
+ function () {
+ return 'https://example.com/store';
+ }
+ )
+ );
+
+ $this->personalizer->set_value_interceptor(
+ function (): string {
+ return '{{placeholder-1}}';
+ }
+ );
+
+ $this->assertSame(
+ '<a href="http://placeholder-1">Click here</a>',
+ $this->personalizer->personalize_content( '<a data-link-href="[test/store-url]" href="#">Click here</a>' ),
+ 'The data-link-href site should write the interceptor return through esc_url()'
+ );
+ $this->assertSame(
+ '<a href="https://example.com/?ref=placeholder-1">Click here</a>',
+ $this->personalizer->personalize_content( '<a href="https://example.com/?ref=[test/store-url]">Click here</a>' ),
+ 'The plain-href site should write the interceptor return through esc_url()'
+ );
+ }
+
+ /**
+ * Test that set_value_interceptor() returns the previously registered interceptor
+ * so a consumer replacing it temporarily can restore it.
+ */
+ public function testValueInterceptorSetterReturnsPrevious(): void {
+ $first = function (): string {
+ return 'first';
+ };
+ $second = function (): string {
+ return 'second';
+ };
+
+ $this->assertNull( $this->personalizer->set_value_interceptor( $first ) );
+ $this->assertSame( $first, $this->personalizer->set_value_interceptor( $second ) );
+ $this->assertSame( $second, $this->personalizer->set_value_interceptor( null ) );
+ }
}