Commit a48e24d9abd for woocommerce

commit a48e24d9abde11cc12733da9831cb52213c90df5
Author: Pavel Dohnal <pavel.dohnal@automattic.com>
Date:   Thu Nov 12 08:12:07 2026 +0100

    Decode personalization tag arguments and keep brackets in quoted values (#70818)

    * Decode tag argument entities and keep "]" in quoted values in links

    Tag arguments were passed to callbacks with HTML entities still in them,
    so a default such as O'Brien, which esc_attr() stores as O&#039;Brien,
    reached plain-text emails and subjects encoded. Values are now decoded
    when the token is parsed, which covers comments and both link sites.

    The link and plain-text placeholder patterns stopped a token at the
    first "]", so a quoted value such as "a [2026]" left a stray "]" in a
    data-link-href URL and dropped the tag from the plain-text body. They
    now read a value quoted right after "=" whole; an apostrophe in an
    unquoted value, as esc_url() leaves it, is still a plain character.

    * Share the tag arguments pattern and note decoded argument values

    The Renderer kept its own copy of the tag arguments pattern, so a later
    fix could land in one place only; it now uses the Personalizer's
    constant. The changelog and docs now say that callbacks receive decoded
    argument values and must escape any they output as HTML.

    * Test a whole-URL link tag with "]" in a quoted argument

    The prefix-strip regex had no test: reverting it left the forced
    http:// in front of the tag value with every test still passing.

    * Test a tag argument with a quote that is never closed

    An unclosed quote must not swallow the rest of the URL: it is read as
    part of an unquoted value and the token still ends at the first "]".

    * Close a quoted tag argument only before a space or "]"

    An unclosed quote in a link, as in note="abc]&d=[tag note="x"], ran to
    the next quote and swallowed the second tag, which trunk still replaced.
    A quote now closes a value only when a space or "]" follows it, so the
    broken value falls back to the unquoted reading.

    * Register the Order Date tag as a text tag so its value is escaped

    The tag returned wc_format_datetime() untouched with the default HTML
    value type, so a date format that produced markup reached the email as
    markup now that tag arguments are decoded. As a text tag, the
    Personalizer escapes the value in HTML; the default date reads the same.

    * Use a fixed order date in the Order Date tag test

    The expected year came from date_i18n(), the same formatting code the
    tag uses, so a shared formatting error could pass. The test now sets
    the order date and asserts the literal year.

    * Read a whole quoted argument in plain-text link targets

    The plain-text link pass from #70819 found a tag target with \[[^\]]+\],
    so [plugin/url note='a]b'] ended at the first "]" and the raw tag stayed
    in the email. It now uses the same tag name and argument patterns as
    the rest of the Personalizer.

diff --git a/packages/php/email-editor/changelog/fix-wooplug-8121-tag-attribute-entities b/packages/php/email-editor/changelog/fix-wooplug-8121-tag-attribute-entities
new file mode 100644
index 00000000000..e80d7a53895
--- /dev/null
+++ b/packages/php/email-editor/changelog/fix-wooplug-8121-tag-attribute-entities
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Pass tag attribute values with quotes, ampersands or brackets correctly; tag callbacks now get them decoded and must escape them.
diff --git a/packages/php/email-editor/docs/personalization-tags.md b/packages/php/email-editor/docs/personalization-tags.md
index b90ecd3033f..c0b34939d83 100644
--- a/packages/php/email-editor/docs/personalization-tags.md
+++ b/packages/php/email-editor/docs/personalization-tags.md
@@ -81,7 +81,7 @@ When selected from a modal, the tag is placed into a text block displayed in a s

 ## Format

-Personalization Tags use HTML comment syntax with a specific format: `<!--[token-name attributes]-->`. The token name follows a namespaced pattern (e.g., `my-plugin/tag-name`) and can include attributes for configuration. This format ensures safe fallback behavior in browsers and provides reliable parsing capabilities.
+Personalization Tags use HTML comment syntax with a specific format: `<!--[token-name attributes]-->`. The token name follows a namespaced pattern (e.g., `my-plugin/tag-name`) and can include attributes for configuration. Attribute values may be quoted with double or single quotes and may contain `]`. HTML entities in values (such as `&#039;`, which `esc_attr()` adds) are decoded before the callback receives them, so a callback that outputs an argument in HTML must escape it. This format ensures safe fallback behavior in browsers and provides reliable parsing capabilities.

 **Example usage in a paragraph block:**

diff --git a/packages/php/email-editor/src/Engine/Renderer/class-renderer.php b/packages/php/email-editor/src/Engine/Renderer/class-renderer.php
index 2f0e5e539ca..5b30f153b85 100644
--- a/packages/php/email-editor/src/Engine/Renderer/class-renderer.php
+++ b/packages/php/email-editor/src/Engine/Renderer/class-renderer.php
@@ -15,6 +15,7 @@ use Automattic\WooCommerce\EmailEditor\Engine\Renderer\ContentRenderer\Rendering
 use Automattic\WooCommerce\EmailEditor\Engine\Templates\Templates;
 use Automattic\WooCommerce\EmailEditor\Engine\Theme_Controller;
 use Automattic\WooCommerce\EmailEditor\Engine\PersonalizationTags\Personalization_Tags_Registry;
+use Automattic\WooCommerce\EmailEditor\Engine\Personalizer;
 use WP_Style_Engine;

 /**
@@ -290,7 +291,7 @@ class Renderer {
 		// Match every comment that looks like a personalization tag, registered or not: a plugin may
 		// register its tags only before personalizing, and Html2Text would drop the comment.
 		$template = preg_replace_callback(
-			'/<!--\[[a-zA-Z0-9\-\/]+(?:\s+[^\]]*)?\]-->/',
+			'/<!--\[[a-zA-Z0-9\-\/]+' . Personalizer::TAG_ARGUMENTS_PATTERN . '\]-->/',
 			function ( $matches ) use ( &$counter ) {
 				$placeholder = 'PERSONALIZATION_TAG_PLACEHOLDER_' . $counter;
 				$this->personalization_tag_placeholders[ $placeholder ] = $matches[0];
diff --git a/packages/php/email-editor/src/Engine/class-personalizer.php b/packages/php/email-editor/src/Engine/class-personalizer.php
index 3b7d373eb8f..1fb16d62dde 100644
--- a/packages/php/email-editor/src/Engine/class-personalizer.php
+++ b/packages/php/email-editor/src/Engine/class-personalizer.php
@@ -25,6 +25,17 @@ class Personalizer {
 	 */
 	private const TAG_NAME_PATTERN = '[a-zA-Z0-9\-\/]+';

+	/**
+	 * Regex pattern for the optional arguments part of a tag token inside a link, e.g. ` default="a [2026]"`.
+	 * A value quoted right after "=" and closed before a space or "]" is consumed whole, so a "]" inside it
+	 * does not end the token, while an apostrophe inside an unquoted value (O'Brien) and a quote that is
+	 * never closed are plain characters. The possessive quantifier stops
+	 * backtracking, which keeps the scan linear.
+	 *
+	 * @internal Shared with the Renderer, which protects tag tokens in the plain-text version; not a public API.
+	 */
+	public const TAG_ARGUMENTS_PATTERN = '(?:\s(?:=(?:"[^"]*"(?=[\s\]])|\'[^\']*\'(?=[\s\]]))|[^\]])*+)?';
+
 	/**
 	 * Rendering context for HTML content. Tag values are rendered into HTML markup.
 	 */
@@ -297,7 +308,7 @@ class Personalizer {
 	 */
 	private function personalize_text_link_targets( string $content ): string {
 		return (string) preg_replace_callback(
-			'/\]\((\[[^\]]+\]|(?:[^()\s]++|\([^()\s]*\))++)\)/',
+			'/\]\((\[' . self::TAG_NAME_PATTERN . self::TAG_ARGUMENTS_PATTERN . '\]|(?:[^()\s]++|\([^()\s]*\))++)\)/',
 			function ( array $matches ): string {
 				$url = $this->personalize_href_tokens( $matches[1] );
 				if ( null !== $url ) {
@@ -318,7 +329,7 @@ class Personalizer {
 	private function personalize_href_tokens( string $href ): ?string {
 		// Decode both URL encoding (%XX) and HTML entities (&#039;) 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 ) ) {
+		if ( ! preg_match_all( '/\[' . self::TAG_NAME_PATTERN . self::TAG_ARGUMENTS_PATTERN . '\]/', $decoded_href, $matches ) ) {
 			return null;
 		}

@@ -361,7 +372,7 @@ class Personalizer {
 		// ("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] ] ) ) {
+		if ( preg_match( '#^https?://(\[' . self::TAG_NAME_PATTERN . self::TAG_ARGUMENTS_PATTERN . '\])#i', $base, $prefix_match ) && isset( $replacements[ $prefix_match[1] ] ) ) {
 			$base = (string) preg_replace( '#^https?://#i', '', $base );
 		}

@@ -416,12 +427,14 @@ class Personalizer {
 					$unquoted_value      = $attribute[4] ?? '';

 					if ( '' !== $double_quoted_value ) {
-						$result['arguments'][ $attribute[1] ] = $double_quoted_value;
+						$value = $double_quoted_value;
 					} elseif ( '' !== $single_quoted_value ) {
-						$result['arguments'][ $attribute[1] ] = $single_quoted_value;
+						$value = $single_quoted_value;
 					} else {
-						$result['arguments'][ $attribute[1] ] = $unquoted_value;
+						$value = $unquoted_value;
 					}
+					// Tokens live in comments and attributes, so values can carry entities such as &#039; from esc_attr().
+					$result['arguments'][ $attribute[1] ] = html_entity_decode( $value, ENT_QUOTES | ENT_HTML5, 'UTF-8' );
 				}
 			}
 		}
@@ -443,7 +456,7 @@ class Personalizer {
 		$escaped_shortcode = preg_quote( substr( $token, 1, strlen( $token ) - 2 ), '/' );

 		// Create a regex pattern dynamically.
-		$pattern = '/\[' . $escaped_shortcode . '(?:\s+[^\]]+)?\]/';
+		$pattern = '/\[' . $escaped_shortcode . self::TAG_ARGUMENTS_PATTERN . '\]/';

 		// 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 b6617f61bd1..67f5ab0a160 100644
--- a/packages/php/email-editor/tests/integration/Engine/Personalizer_Test.php
+++ b/packages/php/email-editor/tests/integration/Engine/Personalizer_Test.php
@@ -802,6 +802,107 @@ class Personalizer_Test extends \Email_Editor_Integration_Test_Case {
 		);
 	}

+	/**
+	 * Content with an argument holding the entities &#039;, &quot; and &amp; at each replacement site.
+	 * Tokens in attributes are HTML-encoded once more, as they are serialized in the block markup.
+	 *
+	 * @return array<string, array{string}>
+	 */
+	public function encodedArgumentSitesProvider(): array {
+		return array(
+			'comment token'  => array( '<p><!--[test/args note="O&#039;Brien &quot;Bob&quot; &amp; co"]--></p>' ),
+			'data-link-href' => array( '<a data-link-href="[test/args note=&quot;O&amp;#039;Brien &amp;quot;Bob&amp;quot; &amp;amp; co&quot;]" href="#">Link</a>' ),
+			// A saved href has been through esc_url(), which drops the quotes around the value.
+			'plain href'     => array( '<a href="https://example.com/?r=%5Btest/args%20note=O&#039;Brien%20&quot;Bob&quot;%20&#038;%20co%5D">Link</a>' ),
+		);
+	}
+
+	/**
+	 * Test that HTML entities in tag argument values reach the callback decoded at every replacement site.
+	 *
+	 * @dataProvider encodedArgumentSitesProvider
+	 * @param string $content The content to personalize.
+	 */
+	public function testArgumentEntitiesAreDecoded( string $content ): void {
+		$received = array();
+		$this->tags_registry->register(
+			new Personalization_Tag(
+				'args',
+				'test/args',
+				'Test',
+				function ( $context, $args ) use ( &$received ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundBeforeLastUsed -- The $context parameter is not used in this test.
+					$received[] = $args['note'] ?? '';
+					return 'https://example.com/value';
+				}
+			)
+		);
+
+		$this->personalizer->personalize_content( $content );
+		$this->assertSame( array( 'O\'Brien "Bob" & co' ), $received );
+	}
+
+	/**
+	 * Test that a "]" inside a quoted argument value does not end a tag inside a link early.
+	 */
+	public function testClosingBracketInQuotedArgumentInLinks(): void {
+		$received = array();
+		$this->tags_registry->register(
+			new Personalization_Tag(
+				'campaign',
+				'test/campaign',
+				'Test',
+				function ( $context, $args ) use ( &$received ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundBeforeLastUsed -- The $context parameter is not used in this test.
+					$received[] = $args['note'] ?? '';
+					return 'spring';
+				}
+			)
+		);
+
+		$this->assertSame(
+			'<a  href="http://spring">Link</a>',
+			$this->personalizer->personalize_content( '<a data-link-href="[test/campaign note=&quot;a [2026]&quot;]" href="#">Link</a>' )
+		);
+		$this->assertSame(
+			'<a href="https://example.com/?c=spring&#038;x=1">Link</a>',
+			$this->personalizer->personalize_content( '<a href="https://example.com/?c=[test/campaign note=&quot;a [2026]&quot;]&amp;x=1">Link</a>' )
+		);
+		// An apostrophe in an unquoted value (esc_url() drops the quotes) must not run into the next tag.
+		$this->assertSame(
+			'<a href="https://example.com/?c=spring&#038;d=spring">Link</a>',
+			$this->personalizer->personalize_content( '<a href="https://example.com/?c=[test/campaign note=O&#039;Brien]&amp;d=[test/campaign note=\'x\']">Link</a>' )
+		);
+		$this->assertSame( array( 'a [2026]', 'a [2026]', 'O\'Brien', 'x' ), $received );
+
+		// A quote that is never closed is read as part of an unquoted value, and the token still ends at "]".
+		$this->assertSame(
+			'<a href="https://example.com/?c=spring&#038;x=1">Link</a>',
+			$this->personalizer->personalize_content( '<a href="https://example.com/?c=[test/campaign note=&quot;abc]&amp;x=1">Link</a>' )
+		);
+		$this->assertSame( '"abc', end( $received ) );
+
+		// An unclosed quote must not swallow a second tag in the same link.
+		$this->assertSame(
+			'<a href="https://example.com/?c=spring&#038;d=spring">Link</a>',
+			$this->personalizer->personalize_content( '<a href="https://example.com/?c=[test/campaign note=&quot;abc]&amp;d=[test/campaign note=&quot;x&quot;]">Link</a>' )
+		);
+
+		// A tag used as the whole URL drops the protocol the editor forces in front of it.
+		$this->tags_registry->register(
+			new Personalization_Tag(
+				'campaign_url',
+				'test/campaign-url',
+				'Test',
+				function () {
+					return 'https://shop.example/spring';
+				}
+			)
+		);
+		$this->assertSame(
+			'<a href="https://shop.example/spring">Link</a>',
+			$this->personalizer->personalize_content( '<a href="http://[test/campaign-url note=&quot;a [2026]&quot;]">Link</a>' )
+		);
+	}
+
 	/**
 	 * Test that the reserved rendering context key cannot be injected via set_context().
 	 */
diff --git a/packages/php/email-editor/tests/integration/Engine/Renderer/Renderer_Test.php b/packages/php/email-editor/tests/integration/Engine/Renderer/Renderer_Test.php
index 509f82a9245..68c54aff8cc 100644
--- a/packages/php/email-editor/tests/integration/Engine/Renderer/Renderer_Test.php
+++ b/packages/php/email-editor/tests/integration/Engine/Renderer/Renderer_Test.php
@@ -437,7 +437,7 @@ class Renderer_Test extends \Email_Editor_Integration_Test_Case {
 	 * Test that rendering preserves personalization tags.
 	 */
 	public function testItPreservesPersonalizationTags(): void {
-		$this->email_post->post_content = '<!-- wp:paragraph --><p><!--[woocommerce/customer-username]--><!--[woocommerce/customer-username default="john"]--></p><!-- /wp:paragraph -->';
+		$this->email_post->post_content = '<!-- wp:paragraph --><p><!--[woocommerce/customer-username]--><!--[woocommerce/customer-username default="john"]--><!--[woocommerce/customer-username default="a [2026]"]--></p><!-- /wp:paragraph -->';
 		wp_update_post(
 			array(
 				'ID'           => $this->email_post->ID,
@@ -454,6 +454,8 @@ class Renderer_Test extends \Email_Editor_Integration_Test_Case {
 		$this->assertStringContainsString( '<!--[woocommerce/customer-username default="john"]-->', $rendered['html'] );
 		$this->assertStringContainsString( '<!--[woocommerce/customer-username]-->', $rendered['text'] );
 		$this->assertStringContainsString( '<!--[woocommerce/customer-username default="john"]-->', $rendered['text'] );
+		// A "]" inside a quoted attribute value must not end the tag early.
+		$this->assertStringContainsString( '<!--[woocommerce/customer-username default="a [2026]"]-->', $rendered['text'] );
 	}

 	/**
@@ -797,6 +799,15 @@ class Renderer_Test extends \Email_Editor_Integration_Test_Case {
 			$personalized
 		);
 		$this->assertStringNotContainsString( '[other-plugin/url]', $personalized );
+
+		// A "]" inside a quoted argument does not end the link target early.
+		list( $registry, $text ) = $this->render_with_link_tag(
+			'<!-- wp:paragraph --><p>F <a data-link-href="[renderer-test/account-url note=\'a]b\']">Account</a>.</p><!-- /wp:paragraph -->'
+		);
+		$this->assertStringContainsString(
+			'F [Account](https://shop.example/account?a=1&b=2).',
+			( new Personalizer( $registry ) )->personalize_content( $text, Personalizer::RENDERING_CONTEXT_TEXT )
+		);
 	}

 	/**
diff --git a/plugins/woocommerce/changelog/fix-wooplug-8121-order-date-tag-text b/plugins/woocommerce/changelog/fix-wooplug-8121-order-date-tag-text
new file mode 100644
index 00000000000..5d8561a7609
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-wooplug-8121-order-date-tag-text
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Show the Order Date personalization tag in emails as plain text, also when its date format contains markup.
diff --git a/plugins/woocommerce/src/Internal/EmailEditor/PersonalizationTags/OrderTagsProvider.php b/plugins/woocommerce/src/Internal/EmailEditor/PersonalizationTags/OrderTagsProvider.php
index 5b59531d748..bce12ac0137 100644
--- a/plugins/woocommerce/src/Internal/EmailEditor/PersonalizationTags/OrderTagsProvider.php
+++ b/plugins/woocommerce/src/Internal/EmailEditor/PersonalizationTags/OrderTagsProvider.php
@@ -59,6 +59,7 @@ class OrderTagsProvider extends AbstractTagProvider {
 				),
 				null,
 				array( Integration::EMAIL_POST_TYPE ),
+				Personalization_Tag::VALUE_TYPE_TEXT,
 			)
 		);

diff --git a/plugins/woocommerce/tests/php/src/Internal/EmailEditor/PersonalizationTags/OrderTagsProviderTest.php b/plugins/woocommerce/tests/php/src/Internal/EmailEditor/PersonalizationTags/OrderTagsProviderTest.php
index 8008d756b50..df02d4a5ea7 100644
--- a/plugins/woocommerce/tests/php/src/Internal/EmailEditor/PersonalizationTags/OrderTagsProviderTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/EmailEditor/PersonalizationTags/OrderTagsProviderTest.php
@@ -5,6 +5,7 @@ namespace Automattic\WooCommerce\Tests\Internal\EmailEditor\PersonalizationTags;

 use Automattic\WooCommerce\EmailEditor\Engine\Logger\Email_Editor_Logger;
 use Automattic\WooCommerce\EmailEditor\Engine\PersonalizationTags\Personalization_Tags_Registry;
+use Automattic\WooCommerce\EmailEditor\Engine\Personalizer;
 use Automattic\WooCommerce\Internal\EmailEditor\PersonalizationTags\OrderTagsProvider;
 use WC_Helper_Order;
 use WC_Unit_Test_Case;
@@ -70,4 +71,19 @@ class OrderTagsProviderTest extends WC_Unit_Test_Case {

 		$this->assertSame( '', $tag->execute_callback( array() ) );
 	}
+
+	/**
+	 * @testdox The order date tag escapes markup that its format argument produces in HTML.
+	 */
+	public function test_order_date_tag_escapes_markup_from_format(): void {
+		$order = WC_Helper_Order::create_order();
+		$order->set_date_created( '2024-03-15 10:00:00' );
+		$order->save();
+		$personalizer = new Personalizer( $this->registry );
+		$personalizer->set_context( array( 'order' => $order ) );
+
+		$html = $personalizer->personalize_content( '<p><!--[woocommerce/order-date format="&lt;\\m\\a\\r\\k&gt;Y&lt;/\\m\\a\\r\\k&gt;"]--></p>' );
+
+		$this->assertSame( '<p>&lt;mark&gt;2024&lt;/mark&gt;</p>', $html );
+	}
 }