Commit cd46f6d913c for woocommerce

commit cd46f6d913c25517104197d86647c1560a0c2e79
Author: Anthony <bubel@users.noreply.github.com>
Date:   Wed Oct 7 02:23:09 2026 -0400

    Fix plain-text version corrupting personalization tags after the tenth (#69006)

    * Fix plain-text version corrupting personalization tags after the tenth

    To build an email's plain-text version, Renderer swaps each personalization tag
    for PERSONALIZATION_TAG_PLACEHOLDER_{n}, runs html2text, then restores the tags.
    Restoring them used str_replace() in a loop over the placeholders in ascending
    numeric order, and _1 is a prefix of _10, _11 and so on. Replacing _1 first
    rewrote every placeholder from the eleventh onward into tag #1 followed by a
    stray digit, so any email with more than ten personalization tags shipped a
    corrupted plain-text body. The HTML version was unaffected.

    Restore with strtr() instead: it matches the longest key at each position and
    never re-scans text it has already replaced, so prefix collisions cannot occur
    regardless of how many tags an email uses.

    The regression test renders more than ten tags through render_from_content() and
    asserts every tag comes back. Each tag is rendered alongside literal text,
    because the text renderer drops blocks whose content is only a personalization
    tag -- a token-only paragraph would make the test pass or fail for an unrelated
    reason.

diff --git a/packages/php/email-editor/changelog/fix-text-version-personalization-tags-after-tenth b/packages/php/email-editor/changelog/fix-text-version-personalization-tags-after-tenth
new file mode 100644
index 00000000000..253905c52d4
--- /dev/null
+++ b/packages/php/email-editor/changelog/fix-text-version-personalization-tags-after-tenth
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Fix the plain-text version of emails corrupting every personalization tag after the tenth.
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 9e1bab33466..b37551d8465 100644
--- a/packages/php/email-editor/src/Engine/Renderer/class-renderer.php
+++ b/packages/php/email-editor/src/Engine/Renderer/class-renderer.php
@@ -331,9 +331,9 @@ class Renderer {
 		if ( empty( $this->personalization_tag_placeholders ) ) {
 			return $text;
 		}
-		foreach ( $this->personalization_tag_placeholders as $placeholder => $html_comment ) {
-			$text = str_replace( $placeholder, $html_comment, $text );
-		}
-		return $text;
+		// Placeholders are numbered, so PERSONALIZATION_TAG_PLACEHOLDER_1 is a prefix of _10, _11 and
+		// so on. strtr() matches the longest key at each position and never re-scans text it has
+		// already replaced, so a short placeholder cannot match inside a longer one.
+		return strtr( $text, $this->personalization_tag_placeholders );
 	}
 }
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 4fec0971906..71da836df64 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
@@ -10,6 +10,8 @@ namespace Automattic\WooCommerce\EmailEditor\Engine\Renderer;

 use Automattic\WooCommerce\EmailEditor\Engine\Email_Editor;
 use Automattic\WooCommerce\EmailEditor\Engine\Templates\Utils;
+use Automattic\WooCommerce\EmailEditor\Engine\PersonalizationTags\Personalization_Tag;
+use Automattic\WooCommerce\EmailEditor\Engine\PersonalizationTags\Personalization_Tags_Registry;
 use Automattic\WooCommerce\EmailEditor\Engine\Theme_Controller;

 /**
@@ -682,4 +684,72 @@ class Renderer_Test extends \Email_Editor_Integration_Test_Case {
 		}
 		return null;
 	}
+
+	/**
+	 * Placeholders are numbered, so PERSONALIZATION_TAG_PLACEHOLDER_1 is a prefix of _10, _11 and so
+	 * on. Restoring them one at a time in ascending order rewrote every tag from the eleventh onward
+	 * into tag #1 followed by a stray digit, corrupting the plain-text body of any email that uses
+	 * more than ten personalization tags.
+	 *
+	 * Each tag is rendered alongside literal text because the text renderer drops blocks whose
+	 * content is only a personalization tag -- a token-only paragraph would make this test pass or
+	 * fail for an unrelated reason.
+	 */
+	public function testItRestoresMoreThanTenPersonalizationTagsInTextVersion(): void {
+		$registry = new Personalization_Tags_Registry(
+			$this->di_container->get( \Automattic\WooCommerce\EmailEditor\Engine\Logger\Email_Editor_Logger::class )
+		);
+
+		$paragraphs = array();
+		$expected   = array();
+		for ( $i = 0; $i < 14; $i++ ) {
+			$token = 'renderer-test/tag-' . $i;
+			$registry->register(
+				new Personalization_Tag(
+					'Renderer Test Tag ' . $i,
+					'[' . $token . ']',
+					'Renderer Test',
+					function () use ( $i ) {
+						return 'value-' . $i;
+					}
+				)
+			);
+			$paragraphs[] = '<!-- wp:paragraph --><p>Field ' . $i . ': <!--[' . $token . ']--></p><!-- /wp:paragraph -->';
+			$expected[]   = '<!--[' . $token . ']-->';
+		}
+
+		// The renderer built in setUp() uses a registry mock with no tags, so it preserves nothing.
+		$this->renderer = $this->getServiceWithOverrides(
+			Renderer::class,
+			array(
+				'personalization_tags_registry' => $registry,
+			)
+		);
+
+		// @phpstan-ignore-next-line PHPStan is not aware of the register_block_template function's side effects.
+		register_block_template(
+			'renderer-tests//test-email-template-personalization-tags',
+			array(
+				'title'       => 'Test Email Template',
+				'description' => 'A test email template.',
+				'content'     => '<!-- wp:group --><div class="wp-block-group"><!-- wp:post-content /--></div><!-- /wp:group -->',
+			)
+		);
+
+		$rendered = $this->renderer->render_from_content(
+			implode( '', $paragraphs ),
+			'test-email-template-personalization-tags',
+			'Subject',
+			'Preheader content'
+		);
+
+		// A tag followed by a stray digit is the signature of the collision. Asserting it before the
+		// per-tag checks names the defect instead of leaving a reader to spot one stray character.
+		$this->assertDoesNotMatchRegularExpression( '/\]-->\d/', $rendered['text'] );
+
+		foreach ( $expected as $index => $token ) {
+			$this->assertStringContainsString( $token, $rendered['text'], "Tag {$index} was not restored in the text version." );
+			$this->assertStringContainsString( $token, $rendered['html'], "Tag {$index} was not restored in the HTML version." );
+		}
+	}
 }