Commit 1a317e6e31a for woocommerce

commit 1a317e6e31a8db1fb0ab3f5d63d94171b78554e5
Author: Rostislav Wolný <1082140+costasovo@users.noreply.github.com>
Date:   Fri Jul 31 09:39:58 2026 +0200

    [Email Editor] Add context-aware rendering to personalization tags (#66937)

    * Pass a rendering context to personalization tag callbacks

    Personalization tag values land in different destinations — HTML body
    content, plain-text output such as the email subject and preheader, and
    link href attributes — but callbacks had no way to know which one they
    were producing a value for, and integrators asked to serve different
    content per destination from a single tag. The destination cannot be
    detected from the content itself because both HTML and plain-text runs
    carry tags as HTML comments.

    personalize_content() accepts an optional rendering context (html or
    text, defaulting to html for backward compatibility) and each
    replacement site now exposes its context — html, text, or href — to
    the tag callback under the reserved rendering_context key of the
    context array. The title tag recursion personalizes in the text
    context since a subject is effectively plain text, and both link
    replacement sites report the href context, where callbacks must return
    a raw unescaped URL. The reserved key is injected per replacement via
    array_replace, so consumer-set values under that key are overwritten
    and integer context keys are preserved.

    * Add a value type declaration to personalization tags

    Tag callbacks return either plain text (names, order numbers, dates) or
    deliberate HTML fragments (formatted prices, addresses), and the
    Personalizer cannot tell which — only the tag author knows. This adds
    an optional value_type to Personalization_Tag so tags can declare it:
    VALUE_TYPE_TEXT for callbacks that always return raw plain text, and
    VALUE_TYPE_HTML (the default, preserving current behavior) for
    callbacks that own their output format and escaping. Unknown values
    fall back to VALUE_TYPE_HTML. The declaration carries no behavior by
    itself; the Personalizer uses it in the next commit to escape
    text-typed values appropriately for the rendering context.

    * Escape text value type personalization tags in HTML content

    With the value type declared, the Personalizer can take over escaping
    for the common case: a tag registered with VALUE_TYPE_TEXT always
    returns raw plain text, and its value is now passed through esc_html()
    when it lands in HTML content, while staying untouched in the text
    rendering context and at href sites (where esc_url() is applied when
    the attribute is written). This closes the gap where customer-provided
    data such as names could carry markup characters into HTML output raw.

    esc_html() does not double-encode existing entities, so a defensively
    pre-escaped value is not corrupted; the documented contract is still to
    return genuinely raw text. Tags with the default VALUE_TYPE_HTML are
    never touched, so existing tags keep their exact behavior.

    * Harden personalization tag replacement in link URLs

    The plain-href replacement previously overwrote the entire attribute
    with the first tag's value. That worked only for the Button block
    whole-URL flow (the editor stores such links as http://[tag], and the
    whole-replace discarded the forced protocol prefix), and it broke every
    other shape: tags embedded in larger URLs produced dead links, appended
    query parameters were lost, and additional tokens in the same href were
    discarded. The regex-based replacement on the data-link-href path also
    interpreted dollar signs and backslashes in tag values as
    backreferences, corrupting URLs built from customer data.

    Link personalization now resolves all replaceable tokens into a map and
    applies them in a single strtr() pass, so inserted values are never
    re-scanned for other tokens and regex metacharacters stay literal. The
    replacement base is the original attribute value — preserving
    legitimate percent-encoding in the surrounding URL — falling back to
    the decoded form only when a replaced token exists solely there, and
    only tokens actually being replaced participate in that choice. The
    editor's forced protocol prefix is stripped case-insensitively, and
    only when the token directly after it is being replaced, keeping any
    appended suffix intact. A tag value of 0 is accepted; only an empty
    string means no value. The data-link-href path escapes dollar signs
    and backslashes before regex replacement.

    * Personalize transactional email subjects and preheaders as plain text

    Email subjects and preheaders are plain-text destinations, but they
    were personalized in the default HTML rendering context, so a tag
    opting into the text value type would have its value HTML-escaped
    where entities must never appear. personalize_transactional_content()
    accepts the rendering context and passes it through, and all subject
    and preheader call sites now declare the text context. With no core
    tag declaring a value type yet this is behavior-neutral today; it
    makes the plain-text paths correct the moment tags start opting in.

    * Extend regression coverage for raw-markup tags and metacharacters in links

    Integration feedback on the PR asked whether two behaviors are pinned
    by tests: tags registered without a value type keep emitting raw HTML
    untouched (tracking pixels), and URL values containing %, &, and
    literal $1/\1 substitute correctly across all link replacement paths.
    The default-type test now includes a raw tracking pixel inserted
    verbatim, and the regex metacharacter test covers backslash sequences
    and asserts both the data-link-href and the plain-href replacement
    sites.

    * Treat a "0" tag value as valid in data-link-href replacement

    A tag resolving to the string "0" failed the truthiness check at the
    data-link-href site, skipping the href write and shipping the editor-only
    data-link-href and contenteditable attributes in the sent email. The
    plain-href site already used a strict empty-string check, so this aligns
    the two. Also documents and pins the known decoded-base tradeoff in
    personalize_href_tokens: when a token exists only in URL-encoded form,
    the whole href switches to the decoded base and unrelated
    percent-encoding is decoded with it. Raised in PR review feedback.

diff --git a/packages/php/email-editor/changelog/add-context-aware-personalization b/packages/php/email-editor/changelog/add-context-aware-personalization
new file mode 100644
index 00000000000..c65c3e9d3cb
--- /dev/null
+++ b/packages/php/email-editor/changelog/add-context-aware-personalization
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Add rendering context awareness to the Personalizer: personalize_content() accepts a text/html rendering context which is exposed to tag callbacks, Personalization_Tag supports an optional text value type with automatic escaping in HTML content, and tags embedded inside larger link URLs are now replaced correctly.
diff --git a/packages/php/email-editor/docs/personalization-tags.md b/packages/php/email-editor/docs/personalization-tags.md
index 87b783608a7..2d38ee6c276 100644
--- a/packages/php/email-editor/docs/personalization-tags.md
+++ b/packages/php/email-editor/docs/personalization-tags.md
@@ -9,6 +9,7 @@
 -   [Editor UI](#editor-ui)
 -   [Format](#format)
 -   [Context](#context)
+-   [Rendering Context and Escaping](#rendering-context-and-escaping)
 -   [Core Components](#core-components)
 -   [Creating Custom Tags](#creating-custom-tags)
 -   [Usage with Renderer](#usage-with-renderer)
@@ -99,7 +100,7 @@ This would render as: "Hello John, your order #12345 was placed on January 15, 2

 ## Context

-Rendering context is a simple associative array passed to the Personalizer. It is the integrator's responsibility to build the context and set it to the Personalizer.
+The personalization context is a simple associative array passed to the Personalizer. It is the integrator's responsibility to build the context and set it to the Personalizer.
 The context is then passed to the `Personalization_Tag` callback function and can be used to derive the value.
 Note: This is still an early concept, and we may add actions/filters, as well as default context data.

@@ -113,6 +114,60 @@ $context = [
 ];
 ```

+One key is reserved: `rendering_context` — see [Rendering Context and Escaping](#rendering-context-and-escaping).
+
+## Rendering Context and Escaping
+
+Email content is rendered into different destinations, and the correct value (and its escaping) differs per destination. The caller of `personalize_content()` declares the rendering context of the whole content: `Personalizer::RENDERING_CONTEXT_HTML` (default) for HTML bodies, or `Personalizer::RENDERING_CONTEXT_TEXT` for plain-text content such as the email subject, preheader, or a plain-text body.
+
+The Personalizer passes the context of each replacement to the tag callback under the reserved `rendering_context` key (`Personalizer::RENDERING_CONTEXT_KEY`) of the context array. Any value stored under this key via `set_context()` is overwritten.
+
+| Rendering context | Where the value lands | What the callback must return |
+| --- | --- | --- |
+| `Personalizer::RENDERING_CONTEXT_HTML` | HTML body content | An HTML fragment. Escape any dynamic data yourself (e.g. `esc_html()`) — unless the tag declares `VALUE_TYPE_TEXT`, in which case return raw text and the Personalizer escapes it (see [Value Type](#value-type)). |
+| `Personalizer::RENDERING_CONTEXT_TEXT` | Plain-text output (subject, preheader, plain-text body, `<title>`) | Raw plain text. No HTML markup, no entities, no escaping. |
+| `Personalizer::RENDERING_CONTEXT_HREF` | A link URL (`href`) | A raw, unescaped URL — do not pre-escape; URL escaping is applied when the attribute is written. |
+
+If the value is a URL *component* rather than a whole URL (e.g. an email address embedded in a query string), `rawurlencode()` it in the callback — the Personalizer cannot tell the difference.
+
+Inspecting the rendering context is optional — a callback that ignores it simply returns the same value for every destination. A tag that serves different content per destination can switch on the key:
+
+```php
+use Automattic\WooCommerce\EmailEditor\Engine\Personalizer;
+
+function ( $context ) {
+    if ( Personalizer::RENDERING_CONTEXT_TEXT === ( $context[ Personalizer::RENDERING_CONTEXT_KEY ] ?? '' ) ) {
+        return 'Save 20% with code SAVE20';
+    }
+    return '<strong>Save 20%</strong> with code SAVE20';
+}
+```
+
+### Value Type
+
+Most tags return plain text: names, order numbers, dates. Declare those with `Personalization_Tag::VALUE_TYPE_TEXT`. The callback then always returns the raw, unescaped value, and the Personalizer escapes it to fit the rendering context: `esc_html()` in HTML content, untouched in plain-text output and in link URLs (where `esc_url()` is applied when the attribute is written). Declaring a value type is the expected practice for new tags.
+
+```php
+$registry->register(
+    new Personalization_Tag(
+        'First Name',
+        'customer/first-name',
+        'Customer',
+        function ( $context ) {
+            return $context['customer_first_name'] ?? '';
+        },
+        array(),
+        null,
+        array(),
+        Personalization_Tag::VALUE_TYPE_TEXT
+    )
+);
+```
+
+Note: the `esc_html()` call does not double-encode existing entities, so a raw text value that itself contains an entity-shaped sequence (e.g. a literal `&amp;`) renders as the decoded character (`&`). Always return genuinely raw text.
+
+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).
+
 ## Core Components

 ### Personalization_Tags_Registry
@@ -170,13 +225,14 @@ The main class representing individual tags.

 ```php
 new Personalization_Tag(
-    string $name,           // Display name in UI
-    string $token,          // Token used in content
-    string $category,       // Category for organization
-    callable $callback,     // Function to generate value
-    array $attributes = [], // Default attributes
-    ?string $value_to_insert = null // Custom insert value
-    array $post_types       // List of supported post types
+    string $name,                    // Display name in UI
+    string $token,                   // Token used in content
+    string $category,                // Category for organization
+    callable $callback,              // Function to generate value
+    array $attributes = [],          // Default attributes
+    ?string $value_to_insert = null, // Custom insert value
+    array $post_types = [],          // List of supported post types
+    string $value_type = Personalization_Tag::VALUE_TYPE_HTML // Type of value the callback returns (VALUE_TYPE_TEXT or VALUE_TYPE_HTML)
 );
 ```

@@ -190,6 +246,7 @@ new Personalization_Tag(
 -   `get_attributes()`: Get default attributes
 -   `get_value_to_insert()`: Get the value to insert in UI
 -   `get_post_types()`: Get the list of post types
+-   `get_value_type()`: Get the type of value the callback returns

 ### Personalizer

@@ -199,7 +256,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)`: Process and personalize content
+-   `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

 **Example Usage:**

@@ -324,7 +381,7 @@ $personalized_html = $personalizer->personalize_content( $rendered_email['html']

 // The personalized HTML is now ready for sending
 $email_html = $personalized_html;
-$email_text = $rendered_email['text']; // Note: text version would need separate personalization if needed
+$email_text = $personalizer->personalize_content( $rendered_email['text'], Personalizer::RENDERING_CONTEXT_TEXT );
 ```

 This workflow first renders the email template with blocks, then applies personalization tags to the rendered HTML content, creating a fully personalized email ready for delivery.
diff --git a/packages/php/email-editor/src/Engine/PersonalizationTags/class-personalization-tag.php b/packages/php/email-editor/src/Engine/PersonalizationTags/class-personalization-tag.php
index 52d5eee2131..a4e1904ed5d 100644
--- a/packages/php/email-editor/src/Engine/PersonalizationTags/class-personalization-tag.php
+++ b/packages/php/email-editor/src/Engine/PersonalizationTags/class-personalization-tag.php
@@ -14,6 +14,18 @@ namespace Automattic\WooCommerce\EmailEditor\Engine\PersonalizationTags;
  * for replacing the tag with its value and displaying it in the UI.
  */
 class Personalization_Tag {
+	/**
+	 * Value type for tags whose callback returns an HTML fragment appropriate to the rendering
+	 * context it receives. The Personalizer inserts the value untouched — the callback owns escaping.
+	 */
+	public const VALUE_TYPE_HTML = 'html';
+
+	/**
+	 * Value type for tags whose callback always returns raw plain text. The Personalizer
+	 * escapes the value as needed for the rendering context (e.g. esc_html() in HTML content).
+	 */
+	public const VALUE_TYPE_TEXT = 'text';
+
 	/**
 	 * The name of the tag displayed in the UI.
 	 *
@@ -56,6 +68,12 @@ class Personalization_Tag {
 	 * @var string[]
 	 */
 	private array $post_types;
+	/**
+	 * The type of value the callback returns — one of the VALUE_TYPE_* constants.
+	 *
+	 * @var string
+	 */
+	private string $value_type;

 	/**
 	 * Personalization_Tag constructor.
@@ -79,6 +97,7 @@ class Personalization_Tag {
 	 * @param array       $attributes The attributes which are used in the Personalization Tag UI.
 	 * @param string|null $value_to_insert The value that is inserted via the UI. When the value is null the token is generated based on $token attribute and $attributes.
 	 * @param string[]    $post_types The list of supported post types.
+	 * @param string      $value_type The type of value the callback returns — one of the VALUE_TYPE_* constants. Unknown values fall back to VALUE_TYPE_HTML.
 	 */
 	public function __construct(
 		string $name,
@@ -87,7 +106,8 @@ class Personalization_Tag {
 		callable $callback,
 		array $attributes = array(),
 		?string $value_to_insert = null,
-		array $post_types = array()
+		array $post_types = array(),
+		string $value_type = self::VALUE_TYPE_HTML
 	) {
 		$this->name = $name;
 		// Because Gutenberg does not wrap the token with square brackets, we need to add them here.
@@ -114,6 +134,7 @@ class Personalization_Tag {
 		}
 		$this->value_to_insert = $value_to_insert;
 		$this->post_types      = $post_types;
+		$this->value_type      = in_array( $value_type, array( self::VALUE_TYPE_HTML, self::VALUE_TYPE_TEXT ), true ) ? $value_type : self::VALUE_TYPE_HTML;
 	}

 	/**
@@ -181,6 +202,15 @@ class Personalization_Tag {
 		return $this->post_types;
 	}

+	/**
+	 * Returns the type of value the callback returns — one of the VALUE_TYPE_* constants.
+	 *
+	 * @return string
+	 */
+	public function get_value_type(): string {
+		return $this->value_type;
+	}
+
 	/**
 	 * Returns the callback function of the personalization tag.
 	 *
diff --git a/packages/php/email-editor/src/Engine/class-personalizer.php b/packages/php/email-editor/src/Engine/class-personalizer.php
index b7e9ef1e7b0..187994e0172 100644
--- a/packages/php/email-editor/src/Engine/class-personalizer.php
+++ b/packages/php/email-editor/src/Engine/class-personalizer.php
@@ -10,6 +10,7 @@ declare(strict_types = 1);
 namespace Automattic\WooCommerce\EmailEditor\Engine;

 use Automattic\WooCommerce\EmailEditor\Engine\PersonalizationTags\HTML_Tag_Processor;
+use Automattic\WooCommerce\EmailEditor\Engine\PersonalizationTags\Personalization_Tag;
 use Automattic\WooCommerce\EmailEditor\Engine\PersonalizationTags\Personalization_Tags_Registry;

 /**
@@ -23,6 +24,29 @@ class Personalizer {
 	 */
 	private const TAG_NAME_PATTERN = '[a-zA-Z0-9\-\/]+';

+	/**
+	 * Rendering context for HTML content. Tag values are rendered into HTML markup.
+	 */
+	public const RENDERING_CONTEXT_HTML = 'html';
+
+	/**
+	 * Rendering context for plain-text content (e.g., email subject, preheader, plain-text body).
+	 * Tag values must be raw text without HTML entities or markup.
+	 */
+	public const RENDERING_CONTEXT_TEXT = 'text';
+
+	/**
+	 * Rendering context for link destinations. Tag callbacks must return a raw, unescaped URL
+	 * (or URL component); URL escaping is applied when the attribute is written.
+	 */
+	public const RENDERING_CONTEXT_HREF = 'href';
+
+	/**
+	 * Reserved key under which the current rendering context is exposed in the context array
+	 * passed to tag callbacks. Any value set via set_context() under this key is overwritten.
+	 */
+	public const RENDERING_CONTEXT_KEY = 'rendering_context';
+
 	/**
 	 * Personalization tags registry.
 	 *
@@ -92,9 +116,15 @@ class Personalizer {
 	 * Personalize the content by replacing the personalization tags with their values.
 	 *
 	 * @param string $content The content to personalize.
+	 * @param string $rendering_context The rendering context of the content — one of the RENDERING_CONTEXT_HTML
+	 *                                  or RENDERING_CONTEXT_TEXT constants. Unknown values fall back to RENDERING_CONTEXT_HTML.
 	 * @return string The personalized content.
 	 */
-	public function personalize_content( string $content ): string {
+	public function personalize_content( string $content, string $rendering_context = self::RENDERING_CONTEXT_HTML ): string {
+		if ( ! in_array( $rendering_context, array( self::RENDERING_CONTEXT_HTML, self::RENDERING_CONTEXT_TEXT ), true ) ) {
+			$rendering_context = self::RENDERING_CONTEXT_HTML;
+		}
+
 		$content_processor = new HTML_Tag_Processor( $content );
 		while ( $content_processor->next_token() ) {
 			if ( $content_processor->get_token_type() === '#comment' ) {
@@ -105,13 +135,17 @@ class Personalizer {
 					continue;
 				}

-				$value = $tag->execute_callback( $this->context, $token['arguments'] );
+				$value = $tag->execute_callback( $this->get_callback_context( $rendering_context ), $token['arguments'] );
+				if ( self::RENDERING_CONTEXT_HTML === $rendering_context && Personalization_Tag::VALUE_TYPE_TEXT === $tag->get_value_type() ) {
+					$value = esc_html( $value );
+				}
 				$content_processor->replace_token( $value );

 			} elseif ( $content_processor->get_token_type() === '#tag' && $content_processor->get_tag() === 'TITLE' ) {
 				// The title tag contains the subject of the email which should be personalized. HTML_Tag_Processor does parse the header tags.
+				// The title content is effectively plain text, so it is personalized in the text rendering context.
 				$modifiable_text = $content_processor->get_modifiable_text();
-				$title           = $this->personalize_content( $modifiable_text );
+				$title           = $this->personalize_content( $modifiable_text, self::RENDERING_CONTEXT_TEXT );
 				$content_processor->set_modifiable_text( $title );

 			} elseif ( $content_processor->get_token_type() === '#tag' && $content_processor->get_tag() === 'A' && $content_processor->get_attribute( 'data-link-href' ) ) {
@@ -123,9 +157,9 @@ class Personalizer {
 					continue;
 				}

-				$value = $tag->execute_callback( $this->context, $token['arguments'] );
+				$value = $tag->execute_callback( $this->get_callback_context( self::RENDERING_CONTEXT_HREF ), $token['arguments'] );
 				$value = $this->replace_link_href( $href, $tag->get_token(), $value );
-				if ( $value ) {
+				if ( '' !== $value ) {
 					$content_processor->set_attribute( 'href', $value );
 					$content_processor->remove_attribute( 'data-link-href' );
 					$content_processor->remove_attribute( 'contenteditable' );
@@ -136,29 +170,87 @@ class Personalizer {
 					continue;
 				}

-				// 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( '/\[' . self::TAG_NAME_PATTERN . '(?:\s+[^\]]+)?\]/', $decoded_href, $matches ) ) {
-					continue;
+				$personalized_href = $this->personalize_href_tokens( $href );
+				if ( null !== $personalized_href ) {
+					$content_processor->set_attribute( 'href', $personalized_href );
 				}
+			}
+		}

-				$token = $this->parse_token( $matches[0] );
-				$tag   = $this->tags_registry->get_by_token( $token['token'] );
+		$content_processor->flush_updates();
+		return $content_processor->get_updated_html();
+	}

-				if ( ! $tag ) {
-					continue;
-				}
+	/**
+	 * Replace personalization tag tokens embedded in a link URL.
+	 *
+	 * @param string $href The href attribute value.
+	 * @return string|null The href with tokens replaced, or null when nothing was replaced.
+	 */
+	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 ) ) {
+			return null;
+		}

-				$value = $tag->execute_callback( $this->context, $token['arguments'] );
+		// Resolve every replaceable token first.
+		$replacements = array();
+		foreach ( array_unique( $matches[0] ) as $token_string ) {
+			$token = $this->parse_token( $token_string );
+			$tag   = $this->tags_registry->get_by_token( $token['token'] );
+			if ( ! $tag ) {
+				continue;
+			}

-				if ( $value ) {
-					$content_processor->set_attribute( 'href', $value );
-				}
+			$value = $tag->execute_callback( $this->get_callback_context( self::RENDERING_CONTEXT_HREF ), $token['arguments'] );
+			if ( '' !== $value ) {
+				$replacements[ $token_string ] = $value;
 			}
 		}

-		$content_processor->flush_updates();
-		return $content_processor->get_updated_html();
+		if ( ! $replacements ) {
+			return null;
+		}
+
+		// Prefer the original attribute value as the replacement base so legitimate
+		// percent-encoding in the surrounding URL is preserved; fall back to the decoded
+		// form when a replaced token occurrence exists only there (e.g. URL-encoded tokens).
+		// Only tokens that are actually replaced matter here — an unregistered bracket
+		// sequence that exists purely in the decoded form must not force the decoded base.
+		// Known tradeoff: the base is chosen for the whole href, so when the decoded form
+		// is used, unrelated percent-encoding elsewhere in the URL is decoded too.
+		$base = $href;
+		foreach ( array_keys( $replacements ) as $token_string ) {
+			if ( substr_count( $href, $token_string ) !== substr_count( $decoded_href, $token_string ) ) {
+				$base = $decoded_href;
+				break;
+			}
+		}
+
+		// The editor forces a protocol prefix when a tag is used as the whole URL
+		// ("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] ] ) ) {
+			$base = (string) preg_replace( '#^https?://#i', '', $base );
+		}
+
+		// Single-pass replacement — tag values are never re-scanned for other tokens,
+		// and a regex replacement would interpret `$` and `\` in them.
+		return strtr( $base, $replacements );
+	}
+
+	/**
+	 * Build the context array passed to a tag callback, exposing the rendering context
+	 * of the current replacement site under the reserved key.
+	 *
+	 * @param string $rendering_context One of the RENDERING_CONTEXT_* constants.
+	 * @return array<string, mixed> The callback context.
+	 */
+	private function get_callback_context( string $rendering_context ): array {
+		// array_replace() (unlike array_merge()) preserves integer keys in the consumer's context.
+		return array_replace( $this->context, array( self::RENDERING_CONTEXT_KEY => $rendering_context ) );
 	}

 	/**
@@ -224,6 +316,7 @@ class Personalizer {
 		// Create a regex pattern dynamically.
 		$pattern = '/\[' . $escaped_shortcode . '(?:\s+[^\]]+)?\]/';

-		return trim( (string) preg_replace( $pattern, $replacement, $content ) );
+		// 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 15f638cbba4..c8f90b0f593 100644
--- a/packages/php/email-editor/tests/integration/Engine/Personalizer_Test.php
+++ b/packages/php/email-editor/tests/integration/Engine/Personalizer_Test.php
@@ -251,6 +251,506 @@ class Personalizer_Test extends \Email_Editor_Integration_Test_Case {
 		$this->assertSame( $expected, $this->personalizer->personalize_content( $html_content ) );
 	}

+	/**
+	 * Test that a tag embedded inside a larger URL is replaced in place.
+	 */
+	public function testPersonalizeContentWithEmbeddedHrefTag(): void {
+		$this->tags_registry->register(
+			new Personalization_Tag(
+				'email',
+				'user/email',
+				'Subscriber Info',
+				function () {
+					return 'john@example.com';
+				}
+			)
+		);
+
+		$this->assertSame(
+			'<a href="http://example.com?test=john@example.com">Click here</a>',
+			$this->personalizer->personalize_content( '<a href="http://example.com?test=[user/email]">Click here</a>' ),
+			'The token should be replaced within the surrounding URL'
+		);
+		$this->assertSame(
+			'<a href="http://example.com/?next=%2Fshop%2F&#038;e=john@example.com">Click here</a>',
+			$this->personalizer->personalize_content( '<a href="http://example.com/?next=%2Fshop%2F&e=[user/email]">Click here</a>' ),
+			'Percent-encoding elsewhere in the URL should be preserved'
+		);
+	}
+
+	/**
+	 * Test how the editor-forced protocol prefix is handled for whole-URL tags.
+	 */
+	public function testWholeUrlHrefTagPrefixHandling(): void {
+		$this->tags_registry->register(
+			new Personalization_Tag(
+				'Store URL',
+				'woocommerce/store-url',
+				'Store',
+				function () {
+					return 'https://example.com';
+				}
+			)
+		);
+		$this->tags_registry->register(
+			new Personalization_Tag(
+				'Store domain',
+				'woocommerce/store-domain',
+				'Store',
+				function () {
+					return 'example.com';
+				}
+			)
+		);
+
+		$this->assertSame(
+			'<a href="https://example.com?utm_source=email">Click here</a>',
+			$this->personalizer->personalize_content( '<a href="http://[woocommerce/store-url]?utm_source=email">Click here</a>' ),
+			'An appended suffix should be kept while the forced prefix is discarded'
+		);
+		$this->assertSame(
+			'<a href="https://example.com">Click here</a>',
+			$this->personalizer->personalize_content( '<a href="HTTP://[woocommerce/store-url]">Click here</a>' ),
+			'The forced prefix should be discarded case-insensitively'
+		);
+		// esc_url() prepends the scheme when the value lacks one.
+		$this->assertSame(
+			'<a href="http://example.com">Click here</a>',
+			$this->personalizer->personalize_content( '<a href="http://[woocommerce/store-domain]">Click here</a>' ),
+			'A schemeless value should still produce a usable link'
+		);
+	}
+
+	/**
+	 * Test that multiple different tags in one href are all replaced.
+	 */
+	public function testMultipleTagsInOneHref(): void {
+		$this->tags_registry->register(
+			new Personalization_Tag(
+				'email',
+				'user/email',
+				'Subscriber Info',
+				function () {
+					return 'john@example.com';
+				}
+			)
+		);
+		$this->tags_registry->register(
+			new Personalization_Tag(
+				'first_name',
+				'user/firstname',
+				'Subscriber Info',
+				function () {
+					return 'John';
+				}
+			)
+		);
+
+		$html_content = '<a href="http://example.com/?e=[user/email]&n=[user/firstname]">Click here</a>';
+		$this->assertSame(
+			'<a href="http://example.com/?e=john@example.com&#038;n=John">Click here</a>',
+			$this->personalizer->personalize_content( $html_content )
+		);
+	}
+
+	/**
+	 * Test that the same tag with different arguments produces per-occurrence values.
+	 */
+	public function testSameTagWithDifferentArgumentsInOneHref(): void {
+		$this->tags_registry->register(
+			new Personalization_Tag(
+				'echo_arg',
+				'test/echo',
+				'Test',
+				function ( $context, $args ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundBeforeLastUsed -- The $context parameter is not used in this test.
+					return $args['value'] ?? '';
+				}
+			)
+		);
+
+		$html_content = '<a href="http://example.com/?a=[test/echo value=one]&b=[test/echo value=two]">Click here</a>';
+		$this->assertSame(
+			'<a href="http://example.com/?a=one&#038;b=two">Click here</a>',
+			$this->personalizer->personalize_content( $html_content )
+		);
+	}
+
+	/**
+	 * Test that an unreplaced leading bracket does not cost the URL its protocol.
+	 */
+	public function testUnregisteredLeadingBracketKeepsProtocol(): void {
+		$this->tags_registry->register(
+			new Personalization_Tag(
+				'coupon',
+				'test/coupon',
+				'Test',
+				function () {
+					return 'SAVE20';
+				}
+			)
+		);
+
+		$html_content = '<a href="http://[2024]/page?ref=[test/coupon]">Click here</a>';
+		$this->assertSame(
+			'<a href="http://[2024]/page?ref=SAVE20">Click here</a>',
+			$this->personalizer->personalize_content( $html_content )
+		);
+	}
+
+	/**
+	 * Test that a token appearing both plain and URL-encoded in one href is replaced in both places.
+	 */
+	public function testMixedPlainAndEncodedTokenOccurrences(): void {
+		$this->tags_registry->register(
+			new Personalization_Tag(
+				'coupon',
+				'test/coupon',
+				'Test',
+				function () {
+					return 'SAVE20';
+				}
+			)
+		);
+
+		$html_content = '<a href="http://example.com/?a=[test/coupon]&b=%5Btest/coupon%5D">Click here</a>';
+		$this->assertSame(
+			'<a href="http://example.com/?a=SAVE20&#038;b=SAVE20">Click here</a>',
+			$this->personalizer->personalize_content( $html_content )
+		);
+	}
+
+	/**
+	 * Test that a tag value containing another tag's token is not re-scanned for replacements.
+	 */
+	public function testTagValueContainingAnotherTokenStaysLiteral(): void {
+		$this->tags_registry->register(
+			new Personalization_Tag(
+				'name',
+				'test/name',
+				'Test',
+				function () {
+					return 'x[test/coupon]y';
+				}
+			)
+		);
+		$this->tags_registry->register(
+			new Personalization_Tag(
+				'coupon',
+				'test/coupon',
+				'Test',
+				function () {
+					return 'SAVE20';
+				}
+			)
+		);
+
+		$html_content = '<a href="http://example.com/?n=[test/name]&c=[test/coupon]">Click here</a>';
+		$this->assertSame(
+			'<a href="http://example.com/?n=x%5Btest/coupon%5Dy&#038;c=SAVE20">Click here</a>',
+			$this->personalizer->personalize_content( $html_content )
+		);
+	}
+
+	/**
+	 * Test that an unregistered URL-encoded bracket sequence does not cost the URL its encoding.
+	 */
+	public function testUnregisteredEncodedBracketKeepsSurroundingEncoding(): void {
+		$this->tags_registry->register(
+			new Personalization_Tag(
+				'coupon',
+				'test/coupon',
+				'Test',
+				function () {
+					return 'SAVE20';
+				}
+			)
+		);
+
+		// items%5B0%5D decodes to a token-shaped [0]; it must not force the decoded base.
+		$html_content = '<a href="https://shop.example/checkout?items%5B0%5D=2&note=a%26b&c=[test/coupon]">Click here</a>';
+		$this->assertSame(
+			'<a href="https://shop.example/checkout?items%5B0%5D=2&#038;note=a%26b&#038;c=SAVE20">Click here</a>',
+			$this->personalizer->personalize_content( $html_content )
+		);
+	}
+
+	/**
+	 * Test that a tag value of "0" is a valid replacement in links.
+	 */
+	public function testZeroStringTagValueIsReplacedInHref(): void {
+		$this->tags_registry->register(
+			new Personalization_Tag(
+				'items_count',
+				'test/count',
+				'Test',
+				function () {
+					return '0';
+				}
+			)
+		);
+
+		$html_content = '<a href="http://example.com/?count=[test/count]">Click here</a>';
+		$this->assertSame(
+			'<a href="http://example.com/?count=0">Click here</a>',
+			$this->personalizer->personalize_content( $html_content )
+		);
+
+		// The data-link-href site must also treat "0" as a valid value and clean up the editor-only attributes.
+		$this->assertSame(
+			'<a  href="http://0">Click here</a>',
+			$this->personalizer->personalize_content( '<a data-link-href="[test/count]" href="#">Click here</a>' )
+		);
+	}
+
+	/**
+	 * Test that a token present only in URL-encoded form switches the whole href to the decoded base.
+	 *
+	 * This pins a known tradeoff: the decoded base also decodes unrelated percent-encoding
+	 * elsewhere in the URL (the redirect parameter below loses its encoding).
+	 */
+	public function testEncodedOnlyTokenDecodesWholeHrefBase(): void {
+		$this->tags_registry->register(
+			new Personalization_Tag(
+				'coupon',
+				'test/coupon',
+				'Test',
+				function () {
+					return 'SAVE20';
+				}
+			)
+		);
+
+		$html_content = '<a href="https://example.com/?redirect=%2Fpath%3Fa%3D1%26b%3D2&tag=%5Btest/coupon%5D">Click here</a>';
+		$this->assertSame(
+			'<a href="https://example.com/?redirect=/path?a=1&#038;b=2&#038;tag=SAVE20">Click here</a>',
+			$this->personalizer->personalize_content( $html_content )
+		);
+	}
+
+	/**
+	 * Test that dollar signs and backslashes in tag values are inserted literally into links.
+	 */
+	public function testHrefTagValueWithRegexSpecialCharacters(): void {
+		$this->tags_registry->register(
+			new Personalization_Tag(
+				'Tracking URL',
+				'test/tracking-url',
+				'Test',
+				function () {
+					return 'https://example.com/?q=$0&r=${1}&b=a\\1b';
+				}
+			)
+		);
+
+		// The data-link-href site goes through the regex-based replacement.
+		// Note: esc_url() strips the curly braces from `${1}` and the backslash from `\1`;
+		// the important part is that `$0`, `$1`, and `\1` are not interpreted as regex backreferences.
+		$this->assertSame(
+			'<a  href="https://example.com/?q=$0&#038;r=$1&#038;b=a1b">Click here</a>',
+			$this->personalizer->personalize_content( '<a data-link-href="[test/tracking-url]" href="#">Click here</a>' ),
+			'The data-link-href site should insert regex metacharacters literally'
+		);
+		// The plain-href site uses exact string replacement.
+		$this->assertSame(
+			'<a href="https://r.example/go?to=https://example.com/?q=$0&#038;r=$1&#038;b=a1b&#038;p=1">Click here</a>',
+			$this->personalizer->personalize_content( '<a href="https://r.example/go?to=[test/tracking-url]&p=1">Click here</a>' ),
+			'The plain-href site should insert regex metacharacters literally'
+		);
+	}
+
+	/**
+	 * Test that the callback receives the rendering context passed to personalize_content.
+	 */
+	public function testCallbackReceivesRenderingContext(): void {
+		$this->tags_registry->register(
+			new Personalization_Tag(
+				'context_echo',
+				'test/context',
+				'Test',
+				function ( $context, $args ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed -- The $args parameter is not used in this test.
+					return $context[ Personalizer::RENDERING_CONTEXT_KEY ];
+				}
+			)
+		);
+
+		$content = '<p><!--[test/context]--></p>';
+		$this->assertSame( '<p>html</p>', $this->personalizer->personalize_content( $content ) );
+		$this->assertSame( '<p>text</p>', $this->personalizer->personalize_content( $content, Personalizer::RENDERING_CONTEXT_TEXT ) );
+		// Unknown rendering context falls back to html.
+		$this->assertSame( '<p>html</p>', $this->personalizer->personalize_content( $content, 'bogus' ) );
+	}
+
+	/**
+	 * Test that the title tag content is personalized in the text rendering context.
+	 */
+	public function testTitleReceivesTextRenderingContext(): void {
+		$this->tags_registry->register(
+			new Personalization_Tag(
+				'context_echo',
+				'test/context',
+				'Test',
+				function ( $context, $args ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed -- The $args parameter is not used in this test.
+					return $context[ Personalizer::RENDERING_CONTEXT_KEY ];
+				}
+			)
+		);
+
+		$content = '<html><head><title><!--[test/context]--></title></head><body><p><!--[test/context]--></p></body></html>';
+		$this->assertSame(
+			'<html><head><title>text</title></head><body><p>html</p></body></html>',
+			$this->personalizer->personalize_content( $content )
+		);
+	}
+
+	/**
+	 * Test that both href replacement sites pass the href rendering context to the callback.
+	 */
+	public function testHrefReceivesHrefRenderingContext(): void {
+		$captured = array();
+		$this->tags_registry->register(
+			new Personalization_Tag(
+				'Store URL',
+				'woocommerce/store-url',
+				'Store',
+				function ( $context, $args ) use ( &$captured ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed -- The $args parameter is not used in this test.
+					$captured[] = $context[ Personalizer::RENDERING_CONTEXT_KEY ];
+					return 'https://example.com';
+				}
+			)
+		);
+
+		$html_content = '<a data-link-href="[woocommerce/store-url]" href="#">First</a><a href="http://[woocommerce/store-url]">Second</a>';
+		$this->personalizer->personalize_content( $html_content );
+		$this->assertSame( array( Personalizer::RENDERING_CONTEXT_HREF, Personalizer::RENDERING_CONTEXT_HREF ), $captured );
+	}
+
+	/**
+	 * Test that a text value type tag is escaped in the html rendering context only.
+	 */
+	public function testTextValueTypeIsEscapedInHtmlContext(): void {
+		$this->tags_registry->register(
+			new Personalization_Tag(
+				'shop_name',
+				'test/shop-name',
+				'Test',
+				function ( $context, $args ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed -- The $args parameter is not used in this test.
+					return $context['shop_name'] ?? '';
+				},
+				array(),
+				null,
+				array(),
+				Personalization_Tag::VALUE_TYPE_TEXT
+			)
+		);
+
+		$this->personalizer->set_context( array( 'shop_name' => "Tom & Jerry's <shop>" ) );
+		$content = 'Welcome to <!--[test/shop-name]-->';
+		$this->assertSame(
+			'Welcome to Tom &amp; Jerry&#039;s &lt;shop&gt;',
+			$this->personalizer->personalize_content( $content, Personalizer::RENDERING_CONTEXT_HTML )
+		);
+		$this->assertSame(
+			"Welcome to Tom & Jerry's <shop>",
+			$this->personalizer->personalize_content( $content, Personalizer::RENDERING_CONTEXT_TEXT )
+		);
+	}
+
+	/**
+	 * Test that an already escaped value of a text value type tag is not double-encoded.
+	 */
+	public function testTextValueTypeIsNotDoubleEncoded(): void {
+		$this->tags_registry->register(
+			new Personalization_Tag(
+				'shop_name',
+				'test/shop-name',
+				'Test',
+				function () {
+					return 'Tom &amp; Jerry';
+				},
+				array(),
+				null,
+				array(),
+				Personalization_Tag::VALUE_TYPE_TEXT
+			)
+		);
+
+		$this->assertSame(
+			'<p>Tom &amp; Jerry</p>',
+			$this->personalizer->personalize_content( '<p><!--[test/shop-name]--></p>' )
+		);
+	}
+
+	/**
+	 * Test that an html value type tag (the default) is never touched by the Personalizer.
+	 */
+	public function testHtmlValueTypeIsNotEscaped(): void {
+		$this->tags_registry->register(
+			new Personalization_Tag(
+				'price',
+				'test/price',
+				'Test',
+				function () {
+					return '<span class="price">10 &euro;</span>';
+				}
+			)
+		);
+
+		$this->tags_registry->register(
+			new Personalization_Tag(
+				'tracking_pixel',
+				'test/tracking-pixel',
+				'Test',
+				function () {
+					return '<img src="https://track.example/open?id=42&amp;c=1" width="1" height="1" alt="" />';
+				}
+			)
+		);
+
+		$content = '<p><!--[test/price]--></p>';
+		$this->assertSame(
+			'<p><span class="price">10 &euro;</span></p>',
+			$this->personalizer->personalize_content( $content, Personalizer::RENDERING_CONTEXT_HTML )
+		);
+		$this->assertSame(
+			'<p><span class="price">10 &euro;</span></p>',
+			$this->personalizer->personalize_content( $content, Personalizer::RENDERING_CONTEXT_TEXT )
+		);
+		// A tag emitting raw markup, such as a tracking pixel, is inserted verbatim.
+		$this->assertSame(
+			'<div><img src="https://track.example/open?id=42&amp;c=1" width="1" height="1" alt="" /></div>',
+			$this->personalizer->personalize_content( '<div><!--[test/tracking-pixel]--></div>' )
+		);
+	}
+
+	/**
+	 * Test that the reserved rendering context key cannot be injected via set_context().
+	 */
+	public function testRenderingContextKeyIsReserved(): void {
+		$this->tags_registry->register(
+			new Personalization_Tag(
+				'context_echo',
+				'test/context',
+				'Test',
+				function ( $context, $args ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed -- The $args parameter is not used in this test.
+					return $context[ Personalizer::RENDERING_CONTEXT_KEY ] . '|' . ( $context[5] ?? 'missing' );
+				}
+			)
+		);
+
+		/**
+		 * The reserved key is overwritten while the rest of the context — including the
+		 * integer key, which is deliberately outside the documented contract — survives.
+		 *
+		 * @var array<string, mixed> $context
+		 */
+		$context = array(
+			Personalizer::RENDERING_CONTEXT_KEY => 'bogus',
+			5                                   => 'five',
+		);
+		$this->personalizer->set_context( $context );
+		$this->assertSame( '<p>html|five</p>', $this->personalizer->personalize_content( '<p><!--[test/context]--></p>' ) );
+	}
+
 	/**
 	 * Test parsing tokens with various formats.
 	 */
diff --git a/packages/php/email-editor/tests/unit/Engine/PersonalizationTags/Personalization_Tag_Test.php b/packages/php/email-editor/tests/unit/Engine/PersonalizationTags/Personalization_Tag_Test.php
index 5eb23fa60eb..6d93a016512 100644
--- a/packages/php/email-editor/tests/unit/Engine/PersonalizationTags/Personalization_Tag_Test.php
+++ b/packages/php/email-editor/tests/unit/Engine/PersonalizationTags/Personalization_Tag_Test.php
@@ -63,6 +63,24 @@ class Personalization_Tag_Test extends TestCase {
 		$this->assertSame( 'callback result: test', $result );
 	}

+	/**
+	 * Test the value type defaults to html, accepts text, and falls back to html for unknown values.
+	 */
+	public function testValueType(): void {
+		$callback = function () {
+			return 'test value';
+		};
+
+		$tag = new Personalization_Tag( 'Test Tag', 'test_token', 'Test Category', $callback );
+		$this->assertSame( Personalization_Tag::VALUE_TYPE_HTML, $tag->get_value_type() );
+
+		$tag = new Personalization_Tag( 'Test Tag', 'test_token', 'Test Category', $callback, array(), null, array(), Personalization_Tag::VALUE_TYPE_TEXT );
+		$this->assertSame( Personalization_Tag::VALUE_TYPE_TEXT, $tag->get_value_type() );
+
+		$tag = new Personalization_Tag( 'Test Tag', 'test_token', 'Test Category', $callback, array(), null, array(), 'unknown-type' );
+		$this->assertSame( Personalization_Tag::VALUE_TYPE_HTML, $tag->get_value_type() );
+	}
+
 	/**
 	 * Test that deserialization is prevented for security reasons.
 	 */
diff --git a/plugins/woocommerce/changelog/add-context-aware-personalization b/plugins/woocommerce/changelog/add-context-aware-personalization
new file mode 100644
index 00000000000..e62d3d11a3e
--- /dev/null
+++ b/plugins/woocommerce/changelog/add-context-aware-personalization
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Pass the plain-text rendering context when personalizing transactional email subjects and preheaders so personalization tag values are not HTML-escaped in plain-text output.
diff --git a/plugins/woocommerce/includes/emails/class-wc-email-customer-invoice.php b/plugins/woocommerce/includes/emails/class-wc-email-customer-invoice.php
index 919097fc628..1d5ef123326 100644
--- a/plugins/woocommerce/includes/emails/class-wc-email-customer-invoice.php
+++ b/plugins/woocommerce/includes/emails/class-wc-email-customer-invoice.php
@@ -5,6 +5,7 @@
  * @package WooCommerce\Emails
  */

+use Automattic\WooCommerce\EmailEditor\Engine\Personalizer;
 use Automattic\WooCommerce\Enums\OrderStatus;
 use Automattic\WooCommerce\Utilities\FeaturesUtil;

@@ -91,7 +92,7 @@ if ( ! class_exists( 'WC_Email_Customer_Invoice', false ) ) :
 				$subject = $this->get_option( 'subject_paid', $this->get_default_subject( true ) );

 				if ( $this->block_email_editor_enabled ) {
-					$subject = $this->personalizer->personalize_transactional_content( $subject, $this );
+					$subject = $this->personalizer->personalize_transactional_content( $subject, $this, Personalizer::RENDERING_CONTEXT_TEXT );
 				}

 				return apply_filters( 'woocommerce_email_subject_customer_invoice_paid', $this->format_string( $subject ), $this->object, $this );
@@ -100,7 +101,7 @@ if ( ! class_exists( 'WC_Email_Customer_Invoice', false ) ) :
 			$subject = $this->get_option( 'subject', $this->get_default_subject() );

 			if ( $this->block_email_editor_enabled ) {
-				$subject = $this->personalizer->personalize_transactional_content( $subject, $this );
+				$subject = $this->personalizer->personalize_transactional_content( $subject, $this, Personalizer::RENDERING_CONTEXT_TEXT );
 			}

 			return apply_filters( 'woocommerce_email_subject_customer_invoice', $this->format_string( $subject ), $this->object, $this );
diff --git a/plugins/woocommerce/includes/emails/class-wc-email-customer-partially-refunded-order.php b/plugins/woocommerce/includes/emails/class-wc-email-customer-partially-refunded-order.php
index 9fb91d82dee..d7733010d11 100644
--- a/plugins/woocommerce/includes/emails/class-wc-email-customer-partially-refunded-order.php
+++ b/plugins/woocommerce/includes/emails/class-wc-email-customer-partially-refunded-order.php
@@ -5,6 +5,8 @@
  * @package WooCommerce\Emails
  */

+use Automattic\WooCommerce\EmailEditor\Engine\Personalizer;
+
 if ( ! defined( 'ABSPATH' ) ) {
 	exit; // Exit if accessed directly.
 }
@@ -86,7 +88,7 @@ if ( ! class_exists( 'WC_Email_Customer_Partially_Refunded_Order', false ) ) :
 			 */
 			$subject = apply_filters( 'woocommerce_email_subject_customer_refunded_order', $this->format_string( $subject ), $this->object, $this );
 			if ( $this->block_email_editor_enabled ) {
-				$subject = $this->personalizer->personalize_transactional_content( $subject, $this );
+				$subject = $this->personalizer->personalize_transactional_content( $subject, $this, Personalizer::RENDERING_CONTEXT_TEXT );
 			}
 			return $subject;
 		}
diff --git a/plugins/woocommerce/includes/emails/class-wc-email-customer-refunded-order.php b/plugins/woocommerce/includes/emails/class-wc-email-customer-refunded-order.php
index 361bc916324..e70a1812e64 100644
--- a/plugins/woocommerce/includes/emails/class-wc-email-customer-refunded-order.php
+++ b/plugins/woocommerce/includes/emails/class-wc-email-customer-refunded-order.php
@@ -5,6 +5,7 @@
  * @package WooCommerce\Emails
  */

+use Automattic\WooCommerce\EmailEditor\Engine\Personalizer;
 use Automattic\WooCommerce\Utilities\FeaturesUtil;

 if ( ! defined( 'ABSPATH' ) ) {
@@ -127,7 +128,7 @@ if ( ! class_exists( 'WC_Email_Customer_Refunded_Order', false ) ) :
 			 */
 			$subject = apply_filters( 'woocommerce_email_subject_customer_refunded_order', $this->format_string( $subject ), $this->object, $this );
 			if ( $this->block_email_editor_enabled ) {
-				$subject = $this->personalizer->personalize_transactional_content( $subject, $this );
+				$subject = $this->personalizer->personalize_transactional_content( $subject, $this, Personalizer::RENDERING_CONTEXT_TEXT );
 			}
 			return $subject;
 		}
diff --git a/plugins/woocommerce/includes/emails/class-wc-email.php b/plugins/woocommerce/includes/emails/class-wc-email.php
index 475b4f450e7..c90bfbe3a05 100644
--- a/plugins/woocommerce/includes/emails/class-wc-email.php
+++ b/plugins/woocommerce/includes/emails/class-wc-email.php
@@ -5,6 +5,7 @@
  * @package WooCommerce\Emails
  */

+use Automattic\WooCommerce\EmailEditor\Engine\Personalizer;
 use Automattic\WooCommerce\Internal\EmailEditor\BlockEmailRenderer;
 use Automattic\WooCommerce\Internal\EmailEditor\TransactionalEmailPersonalizer;
 use Automattic\WooCommerce\Utilities\FeaturesUtil;
@@ -566,7 +567,7 @@ class WC_Email extends WC_Settings_API {
 		$subject = apply_filters( 'woocommerce_email_subject_' . $this->id, $this->format_string( $this->get_option_or_transient( 'subject', $this->get_default_subject() ) ), $this->object, $this );
 		if ( $this->block_email_editor_enabled ) {
 			// Because the new email editor uses rich-text component for subject editing, to be ensure that the subject is always in plain text, we need to strip all tags.
-			$subject = wp_strip_all_tags( $this->personalizer->personalize_transactional_content( $subject, $this ) );
+			$subject = wp_strip_all_tags( $this->personalizer->personalize_transactional_content( $subject, $this, Personalizer::RENDERING_CONTEXT_TEXT ) );
 		}
 		return $subject;
 	}
@@ -590,7 +591,7 @@ class WC_Email extends WC_Settings_API {
 		 */
 		$preheader = apply_filters( 'woocommerce_email_preheader' . $this->id, $this->format_string( $this->get_option_or_transient( 'preheader', '' ) ), $this->object, $this );
 		if ( $this->block_email_editor_enabled ) {
-			$preheader = $this->personalizer->personalize_transactional_content( $preheader, $this );
+			$preheader = $this->personalizer->personalize_transactional_content( $preheader, $this, Personalizer::RENDERING_CONTEXT_TEXT );
 		}
 		return $preheader;
 	}
diff --git a/plugins/woocommerce/src/Internal/EmailEditor/TransactionalEmailPersonalizer.php b/plugins/woocommerce/src/Internal/EmailEditor/TransactionalEmailPersonalizer.php
index b27e9f90572..cade5f07047 100644
--- a/plugins/woocommerce/src/Internal/EmailEditor/TransactionalEmailPersonalizer.php
+++ b/plugins/woocommerce/src/Internal/EmailEditor/TransactionalEmailPersonalizer.php
@@ -38,11 +38,12 @@ class TransactionalEmailPersonalizer {
 	 *
 	 * @param string    $content The content to personalize.
 	 * @param \WC_Email $email The WooCommerce email object.
+	 * @param string    $rendering_context The rendering context of the content — Personalizer::RENDERING_CONTEXT_HTML or Personalizer::RENDERING_CONTEXT_TEXT.
 	 * @return string The personalized content.
 	 */
-	public function personalize_transactional_content( string $content, \WC_Email $email ): string {
+	public function personalize_transactional_content( string $content, \WC_Email $email, string $rendering_context = Personalizer::RENDERING_CONTEXT_HTML ): string {
 		$this->configure_context_by_email( $email );
-		return $this->personalizer->personalize_content( $content );
+		return $this->personalizer->personalize_content( $content, $rendering_context );
 	}

 	/**
diff --git a/plugins/woocommerce/tests/php/src/Internal/EmailEditor/TransactionalEmailPersonalizerTest.php b/plugins/woocommerce/tests/php/src/Internal/EmailEditor/TransactionalEmailPersonalizerTest.php
index aeecb2af527..c4c5b99d496 100644
--- a/plugins/woocommerce/tests/php/src/Internal/EmailEditor/TransactionalEmailPersonalizerTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/EmailEditor/TransactionalEmailPersonalizerTest.php
@@ -3,6 +3,10 @@ declare( strict_types = 1 );

 namespace Automattic\WooCommerce\Tests\Internal\EmailEditor;

+use Automattic\WooCommerce\EmailEditor\Email_Editor_Container;
+use Automattic\WooCommerce\EmailEditor\Engine\Personalizer;
+use Automattic\WooCommerce\EmailEditor\Engine\PersonalizationTags\Personalization_Tag;
+use Automattic\WooCommerce\EmailEditor\Engine\PersonalizationTags\Personalization_Tags_Registry;
 use Automattic\WooCommerce\Internal\EmailEditor\TransactionalEmailPersonalizer;
 use WC_Unit_Test_Case;

@@ -31,9 +35,47 @@ class TransactionalEmailPersonalizerTest extends WC_Unit_Test_Case {
 	 */
 	public function tearDown(): void {
 		remove_all_filters( 'woocommerce_email_editor_integration_personalizer_context_data' );
+		Email_Editor_Container::container()->get( Personalization_Tags_Registry::class )->unregister( '[test/text-tag]' );
 		parent::tearDown();
 	}

+	/**
+	 * @testdox Should pass the rendering context through so text-typed tag values stay raw in text context and are escaped in the HTML default.
+	 */
+	public function test_rendering_context_is_passed_through(): void {
+		Email_Editor_Container::container()->get( Personalization_Tags_Registry::class )->register(
+			new Personalization_Tag(
+				'Text Tag',
+				'test/text-tag',
+				'Test',
+				function () {
+					return 'Tom & Jerry';
+				},
+				array(),
+				null,
+				array(),
+				Personalization_Tag::VALUE_TYPE_TEXT
+			)
+		);
+
+		$mock_email = $this->createMock( \WC_Email::class );
+		$mock_email->method( 'get_recipient' )->willReturn( 'test@example.com' );
+		$mock_email->object = new \stdClass();
+
+		$content = 'Hi <!--[test/text-tag]-->';
+
+		$this->assertSame(
+			'Hi Tom & Jerry',
+			$this->sut->personalize_transactional_content( $content, $mock_email, Personalizer::RENDERING_CONTEXT_TEXT ),
+			'Text context should keep the raw value'
+		);
+		$this->assertSame(
+			'Hi Tom &amp; Jerry',
+			$this->sut->personalize_transactional_content( $content, $mock_email ),
+			'HTML default should escape text-typed values'
+		);
+	}
+
 	/**
 	 * @testdox Should preserve wp_user set by the filter when core cannot derive it from the email object.
 	 */