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 &amp; 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 &#038; in URLs.
+		$this->assertSame(
+			'<a href="https://example.com/?a=ONE&#038;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 ) );
+	}
 }