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." );
+ }
+ }
}