Commit 6ba186ecc4 for woocommerce

commit 6ba186ecc488b9d97445b82596b8c9d818bd42b0
Author: Jan Lysý <lysyjan@users.noreply.github.com>
Date:   Mon Nov 3 11:13:42 2025 +0100

    Add product block collection variant for the block email editor (#61546)

    * Simplify block rendering by directly using `block_content` instead of processing `innerBlocks`

    Using the `render_block` function in wrapper blocks caused unintentional rerendering, which affected performance and caused a bug with empty content when the product collection block was used in MailPoet.

    * Fix used `$post` instead of `$email_post` in backup assignment for proper context

    * Introduce `get_inner_content` utility to extract inner content

    The block_content variable contains a wrapping div and the new method removes it when needed.

    * Add email-specific context support to rendering context

    Introduced an `email_context` property in `Rendering_Context` to provide email-specific data (e.g., user ID, recipient email, order ID).
    Updated block rendering to utilize the new context and added tests to validate its behavior.
    Added filter `woocommerce_email_editor_rendering_email_context` for extensibility. Updated documentation accordingly.

    * Add "Cart Contents" collection for email-specific product display

    Introduced new "Cart Contents" product collection for use in abandoned cart emails.
    Updated backend logic, email rendering, and registration to handle cart-based queries.
    Implemented email-specific collection registration and custom query handlers. Added fallback to sample products for preview.

    * Add email context support to BlockEmailRenderer

    * Add changelogs

    * Update cart collection icon to custom cart SVG

    * Fix empty core/post-content block rendering in batch email processing

    Replaced WordPress's static render callback with a stateless implementation to address issues in MailPoet batch email processing. Added integration tests to validate multiple renders per request. Updated block registration and initializer logic to override the default callback.

    * Replace custom cart SVG with WooCommerce icons library asset

    STOMAIL-7500

    * Refactor Rendering_Context to centralize email context access

    Replaced specific `get_order_id` method with the generic `get()` for retrieving email context values. Updated related tests and documentation to reflect changes. Enhanced flexibility for extensions by allowing custom context data access.

    * Remove unused `woo_email_type` and `order_id` context from BlockEmailRenderer

    STOMAIL-7500

    * Refactor to replace custom DB query with `wc_get_products()` for recent products

    * Fix cart collection empty state handling for emails and previews

    * Sanitize user input for `userId` and `userEmail`

diff --git a/packages/php/email-editor/README.md b/packages/php/email-editor/README.md
index 4e73998035..bb1b3522c3 100644
--- a/packages/php/email-editor/README.md
+++ b/packages/php/email-editor/README.md
@@ -98,11 +98,12 @@ We may add, update and delete any of them.
 | `woocommerce_email_editor_send_preview_email`                      | `Array` $post_data                                               | `boolean` Result of processing. Was email sent successfully? | Allows override of the send preview mail function. Folks may choose to use custom implementation                                                                       |
 | `woocommerce_email_editor_post_sent_status_args`                   | `Array` `sent` post status args                                  | `Array` register_post_status args                            | Allows update of the argument for the sent post status                                                                                                                 |
 | `woocommerce_email_blocks_renderer_parsed_blocks`                  | `Array` Parsed blocks data                                       | `Array` Parsed blocks data                                   | You can modify the parsed blocks before they are processed by email renderer.                                                                                          |
+| `woocommerce_email_editor_rendering_email_context`                 | `Array` $email_context                                           | `Array` $email_context                                       | Applied during email rendering to provide context data (e.g., `recipient_email`, `user_id`, `order_id`) to block renderers.                                            |
 | `woocommerce_email_editor_send_preview_email_rendered_data`        | `string` $data Rendered email                                    | `string` Rendered email                                      | Allows modifying the rendered email when displaying or sending it in preview                                                                                           |
 | `woocommerce_email_editor_send_preview_email_personalizer_context` | `string` $content_styles, `WP_Post` $post` $personalizer_context | `Array` Personalizer context data                            | Allows modifying the personalizer context data for the send preview email function                                                                                     |
 | `woocommerce_email_editor_synced_site_styles`                      | `Array` $synced_data, `Array` $site_data                         | `Array` Modified synced data                                 | Used to filter the synced site style data before applying to email theme.                                                                                              |
 | `woocommerce_email_editor_site_style_sync_enabled`                 | `bool` $enabled                                                  | `bool`                                                       | Use to control whether site style sync functionality is enabled or disabled. Returning `false` will disable site theme sync.                                           |
-| `woocommerce_email_editor_allowed_iframe_style_handles`            | `Array` $allowed_iframe_style_handles                            | `Array` $allowed_iframe_style_handles                        | Filter the list of allowed stylesheet handles in the editor iframe. |
+| `woocommerce_email_editor_allowed_iframe_style_handles`            | `Array` $allowed_iframe_style_handles                            | `Array` $allowed_iframe_style_handles                        | Filter the list of allowed stylesheet handles in the editor iframe.                                                                                                    |
 | `woocommerce_email_editor_script_localization_data`                | `Array` $localization_data                                       | `Array` $localization_data                                   | Use to modify inlined JavaScript variables used by Email Editor client.                                                                                                |

 ## Logging
diff --git a/packages/php/email-editor/changelog/fix-post-content-batch-rendering b/packages/php/email-editor/changelog/fix-post-content-batch-rendering
new file mode 100644
index 0000000000..4703a795f2
--- /dev/null
+++ b/packages/php/email-editor/changelog/fix-post-content-batch-rendering
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Fix core/post-content block rendering empty on second email in batch processing by overriding WordPress render callback with stateless version
diff --git a/packages/php/email-editor/changelog/stomail-7500-cart-contents-in-email b/packages/php/email-editor/changelog/stomail-7500-cart-contents-in-email
new file mode 100644
index 0000000000..dbca0617cd
--- /dev/null
+++ b/packages/php/email-editor/changelog/stomail-7500-cart-contents-in-email
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Extend Rendering_Context with email-specific context support (user_id, order_id, recipient_email), add woocommerce_email_editor_rendering_email_context filter
diff --git a/packages/php/email-editor/src/Engine/Renderer/ContentRenderer/class-content-renderer.php b/packages/php/email-editor/src/Engine/Renderer/ContentRenderer/class-content-renderer.php
index e323762933..2922e5c38f 100644
--- a/packages/php/email-editor/src/Engine/Renderer/ContentRenderer/class-content-renderer.php
+++ b/packages/php/email-editor/src/Engine/Renderer/ContentRenderer/class-content-renderer.php
@@ -169,7 +169,29 @@ class Content_Renderer {
 	 * @return string
 	 */
 	public function render_block( string $block_content, array $parsed_block ): string {
-		$context = new Rendering_Context( $this->theme_controller->get_theme() );
+		/**
+		 * Filter the email-specific context data passed to block renderers.
+		 *
+		 * This allows email sending systems to provide context data such as user ID,
+		 * email address, order information, etc., that can be used by blocks during rendering.
+		 *
+		 * Blocks that need cart product information can derive it from the user_id or recipient_email
+		 * using CartCheckoutUtils::get_cart_product_ids_for_user().
+		 *
+		 * @since 1.9.0
+		 *
+		 * @param array $email_context {
+		 *     Email-specific context data.
+		 *
+		 *     @type int    $user_id         The ID of the user receiving the email.
+		 *     @type string $recipient_email The recipient's email address.
+		 *     @type int    $order_id        The order ID (for order-related emails).
+		 *     @type string $email_type      The type of email being rendered.
+		 * }
+		 */
+		$email_context = apply_filters( 'woocommerce_email_editor_rendering_email_context', array() );
+
+		$context = new Rendering_Context( $this->theme_controller->get_theme(), $email_context );

 		$block_type = $this->block_type_registry->get_registered( $parsed_block['blockName'] );
 		try {
@@ -208,7 +230,7 @@ class Content_Renderer {
 		$this->backup_template_content = $_wp_current_template_content;
 		$this->backup_template_id      = $_wp_current_template_id;
 		$this->backup_query            = $wp_query;
-		$this->backup_post             = $email_post;
+		$this->backup_post             = $post;

 		$_wp_current_template_id      = $template->id;
 		$_wp_current_template_content = $template->content;
diff --git a/packages/php/email-editor/src/Engine/Renderer/ContentRenderer/class-rendering-context.php b/packages/php/email-editor/src/Engine/Renderer/ContentRenderer/class-rendering-context.php
index 0c264a0b4c..c1f78b0fcb 100644
--- a/packages/php/email-editor/src/Engine/Renderer/ContentRenderer/class-rendering-context.php
+++ b/packages/php/email-editor/src/Engine/Renderer/ContentRenderer/class-rendering-context.php
@@ -22,13 +22,28 @@ class Rendering_Context {
 	 */
 	private WP_Theme_JSON $theme_json;

+	/**
+	 * Email-specific context data.
+	 *
+	 * This array contains email-specific information that can be used during rendering,
+	 * such as:
+	 * - 'user_id': The ID of the user receiving the email
+	 * - 'recipient_email': The recipient's email address
+	 * - Additional context can be added by extensions using the generic get() method
+	 *
+	 * @var array<string, mixed>
+	 */
+	private array $email_context;
+
 	/**
 	 * Rendering_Context constructor.
 	 *
-	 * @param WP_Theme_JSON $theme_json Theme Json used in the email.
+	 * @param WP_Theme_JSON        $theme_json Theme Json used in the email.
+	 * @param array<string, mixed> $email_context Email-specific context data.
 	 */
-	public function __construct( WP_Theme_JSON $theme_json ) {
-		$this->theme_json = $theme_json;
+	public function __construct( WP_Theme_JSON $theme_json, array $email_context = array() ) {
+		$this->theme_json    = $theme_json;
+		$this->email_context = $email_context;
 	}

 	/**
@@ -106,4 +121,45 @@ class Rendering_Context {
 		}
 		return $color_slug;
 	}
+
+	/**
+	 * Get the email-specific context data.
+	 *
+	 * @return array<string, mixed>
+	 */
+	public function get_email_context(): array {
+		return $this->email_context;
+	}
+
+	/**
+	 * Get the user ID from the email context.
+	 *
+	 * @return int|null The user ID if available, null otherwise.
+	 */
+	public function get_user_id(): ?int {
+		return isset( $this->email_context['user_id'] ) && is_numeric( $this->email_context['user_id'] ) ? (int) $this->email_context['user_id'] : null;
+	}
+
+	/**
+	 * Get the recipient email address from the email context.
+	 *
+	 * @return string|null The email address if available, null otherwise.
+	 */
+	public function get_recipient_email(): ?string {
+		return isset( $this->email_context['recipient_email'] ) && is_string( $this->email_context['recipient_email'] ) ? $this->email_context['recipient_email'] : null;
+	}
+
+	/**
+	 * Get a specific value from the email context.
+	 *
+	 * This method allows extensions to access custom context data that may be
+	 * specific to their implementation (e.g., order IDs, email types, etc.).
+	 *
+	 * @param string $key The context key.
+	 * @param mixed  $default_value Default value if key is not found.
+	 * @return mixed The context value or default.
+	 */
+	public function get( string $key, $default_value = null ) {
+		return $this->email_context[ $key ] ?? $default_value;
+	}
 }
diff --git a/packages/php/email-editor/src/Engine/class-send-preview-email.php b/packages/php/email-editor/src/Engine/class-send-preview-email.php
index 730901fb0a..85f831f909 100644
--- a/packages/php/email-editor/src/Engine/class-send-preview-email.php
+++ b/packages/php/email-editor/src/Engine/class-send-preview-email.php
@@ -86,6 +86,9 @@ class Send_Preview_Email {
 		$subject  = $post->post_title;
 		$language = get_bloginfo( 'language' );

+		// Add filter to set preview context for block renderers.
+		add_filter( 'woocommerce_email_editor_rendering_email_context', array( $this, 'add_preview_context' ) );
+
 		$rendered_data = $this->renderer->render(
 			$post,
 			$subject,
@@ -93,11 +96,28 @@ class Send_Preview_Email {
 			$language
 		);

+		// Remove filter after rendering.
+		remove_filter( 'woocommerce_email_editor_rendering_email_context', array( $this, 'add_preview_context' ) );
+
 		$rendered_data = apply_filters( 'woocommerce_email_editor_send_preview_email_rendered_data', $rendered_data );

 		return $this->set_personalize_content( $rendered_data['html'] );
 	}

+	/**
+	 * Add preview context to email rendering.
+	 *
+	 * This filter callback adds the is_user_preview flag and current user information
+	 * to the rendering context, allowing block renderers to show appropriate preview content.
+	 *
+	 * @param array $email_context Email context data.
+	 * @return array Modified email context with preview flag.
+	 */
+	public function add_preview_context( $email_context ): array {
+		$email_context['is_user_preview'] = true;
+		return $email_context;
+	}
+
 	/**
 	 * Personalize the content.
 	 *
diff --git a/packages/php/email-editor/src/Integrations/Core/Renderer/Blocks/class-abstract-block-renderer.php b/packages/php/email-editor/src/Integrations/Core/Renderer/Blocks/class-abstract-block-renderer.php
index ff44ee5a9c..7f254fc38e 100644
--- a/packages/php/email-editor/src/Integrations/Core/Renderer/Blocks/class-abstract-block-renderer.php
+++ b/packages/php/email-editor/src/Integrations/Core/Renderer/Blocks/class-abstract-block-renderer.php
@@ -10,6 +10,7 @@ namespace Automattic\WooCommerce\EmailEditor\Integrations\Core\Renderer\Blocks;

 use Automattic\WooCommerce\EmailEditor\Engine\Renderer\ContentRenderer\Block_Renderer;
 use Automattic\WooCommerce\EmailEditor\Engine\Renderer\ContentRenderer\Rendering_Context;
+use Automattic\WooCommerce\EmailEditor\Integrations\Utils\Dom_Document_Helper;
 use Automattic\WooCommerce\EmailEditor\Integrations\Utils\Styles_Helper;
 use Automattic\WooCommerce\EmailEditor\Integrations\Utils\Table_Wrapper_Helper;
 use WP_Style_Engine;
@@ -39,6 +40,23 @@ abstract class Abstract_Block_Renderer implements Block_Renderer {
 		return WP_Style_Engine::compile_css( array_merge( ...$styles ), '' );
 	}

+	/**
+	 * Extract inner content from a wrapper element.
+	 *
+	 * Removes the outer wrapper element (e.g., div) and returns only the inner HTML content.
+	 * This is useful when you need to strip the wrapper and use only the inner content.
+	 *
+	 * @param string $block_content Block content with wrapper element.
+	 * @param string $tag_name      Tag name of the wrapper element (default: 'div').
+	 * @return string Inner content without the wrapper element, or original content if wrapper not found.
+	 */
+	protected function get_inner_content( string $block_content, string $tag_name = 'div' ): string {
+		$dom_helper = new Dom_Document_Helper( $block_content );
+		$element    = $dom_helper->find_element( $tag_name );
+
+		return $element ? $dom_helper->get_element_inner_html( $element ) : $block_content;
+	}
+
 	/**
 	 * Add a spacer around the block.
 	 *
diff --git a/packages/php/email-editor/src/Integrations/Core/Renderer/Blocks/class-column.php b/packages/php/email-editor/src/Integrations/Core/Renderer/Blocks/class-column.php
index bfa256c6ab..d572884235 100644
--- a/packages/php/email-editor/src/Integrations/Core/Renderer/Blocks/class-column.php
+++ b/packages/php/email-editor/src/Integrations/Core/Renderer/Blocks/class-column.php
@@ -37,14 +37,9 @@ class Column extends Abstract_Block_Renderer {
 	 * @return string
 	 */
 	protected function render_content( string $block_content, array $parsed_block, Rendering_Context $rendering_context ): string {
-		$content = '';
-		foreach ( $parsed_block['innerBlocks'] ?? array() as $block ) {
-			$content .= render_block( $block );
-		}
-
 		return str_replace(
 			'{column_content}',
-			$content,
+			$this->get_inner_content( $block_content ),
 			$this->get_block_wrapper( $block_content, $parsed_block, $rendering_context )
 		);
 	}
diff --git a/packages/php/email-editor/src/Integrations/Core/Renderer/Blocks/class-columns.php b/packages/php/email-editor/src/Integrations/Core/Renderer/Blocks/class-columns.php
index e20a318d48..1f8b1ed000 100644
--- a/packages/php/email-editor/src/Integrations/Core/Renderer/Blocks/class-columns.php
+++ b/packages/php/email-editor/src/Integrations/Core/Renderer/Blocks/class-columns.php
@@ -27,14 +27,9 @@ class Columns extends Abstract_Block_Renderer {
 	 * @return string
 	 */
 	protected function render_content( string $block_content, array $parsed_block, Rendering_Context $rendering_context ): string {
-		$content = '';
-		foreach ( $parsed_block['innerBlocks'] ?? array() as $block ) {
-			$content .= render_block( $block );
-		}
-
 		return str_replace(
 			'{columns_content}',
-			$content,
+			$this->get_inner_content( $block_content ),
 			$this->getBlockWrapper( $block_content, $parsed_block, $rendering_context )
 		);
 	}
diff --git a/packages/php/email-editor/src/Integrations/Core/Renderer/Blocks/class-group.php b/packages/php/email-editor/src/Integrations/Core/Renderer/Blocks/class-group.php
index 29d2d7b31d..83cc81211a 100644
--- a/packages/php/email-editor/src/Integrations/Core/Renderer/Blocks/class-group.php
+++ b/packages/php/email-editor/src/Integrations/Core/Renderer/Blocks/class-group.php
@@ -26,16 +26,9 @@ class Group extends Abstract_Block_Renderer {
 	 * @return string
 	 */
 	protected function render_content( string $block_content, array $parsed_block, Rendering_Context $rendering_context ): string {
-		$content      = '';
-		$inner_blocks = $parsed_block['innerBlocks'] ?? array();
-
-		foreach ( $inner_blocks as $block ) {
-			$content .= render_block( $block );
-		}
-
 		return str_replace(
 			'{group_content}',
-			$content,
+			$this->get_inner_content( $block_content ),
 			$this->get_block_wrapper( $block_content, $parsed_block, $rendering_context )
 		);
 	}
diff --git a/packages/php/email-editor/src/Integrations/Core/Renderer/Blocks/class-post-content.php b/packages/php/email-editor/src/Integrations/Core/Renderer/Blocks/class-post-content.php
new file mode 100644
index 0000000000..22963b97cd
--- /dev/null
+++ b/packages/php/email-editor/src/Integrations/Core/Renderer/Blocks/class-post-content.php
@@ -0,0 +1,86 @@
+<?php
+/**
+ * This file is part of the WooCommerce Email Editor package
+ *
+ * @package Automattic\WooCommerce\EmailEditor
+ */
+
+declare( strict_types = 1 );
+namespace Automattic\WooCommerce\EmailEditor\Integrations\Core\Renderer\Blocks;
+
+/**
+ * Stateless renderer for core/post-content block.
+ *
+ * This renderer replaces WordPress's default render_block_core_post_content()
+ * which uses a static $seen_ids array that causes issues when rendering multiple
+ * emails in a single request (e.g., MailPoet batch processing).
+ *
+ * Unlike other block renderers, this class does NOT extend Abstract_Block_Renderer
+ * because it needs to directly replace WordPress's render_callback with a method
+ * that matches the exact signature expected by WordPress.
+ */
+class Post_Content {
+	/**
+	 * Stateless render callback for core/post-content block.
+	 *
+	 * This implementation avoids using get_the_content() which relies on
+	 * global query state, and instead directly accesses post content
+	 * and applies the_content filter for processing.
+	 *
+	 * Key differences from WordPress's implementation:
+	 * - No static $seen_ids array (allows multiple renders in same request)
+	 * - Uses direct post content access instead of get_the_content()
+	 * - Properly backs up and restores global state
+	 *
+	 * @param array     $attributes Block attributes.
+	 * @param string    $content    Block content.
+	 * @param \WP_Block $block      Block instance.
+	 * @return string Rendered post content HTML.
+	 */
+	public function render_stateless( $attributes, $content, $block ): string {
+		$post_id = $block->context['postId'] ?? null;
+
+		if ( ! $post_id ) {
+			return '';
+		}
+
+		$email_post = get_post( $post_id );
+		if ( ! $email_post || empty( $email_post->post_content ) ) {
+			return '';
+		}
+
+		// Backup global state.
+		global $post, $wp_query;
+		$backup_post  = $post;
+		$backup_query = $wp_query;
+
+		// Set up global state for block rendering.
+		// This ensures that blocks which depend on global $post work correctly.
+		$post = $email_post; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
+		// Create a query specifically for this post to ensure proper context.
+		$wp_query = new \WP_Query( array( 'p' => $post_id ) ); // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
+
+		// Get raw post content and apply the_content filter.
+		// The the_content filter processes blocks, shortcodes, etc.
+		// We don't use get_the_content() to avoid issues with loop state.
+		$post_content = $email_post->post_content;
+
+		// Check for nextpage to display page links for paginated posts.
+		if ( has_block( 'core/nextpage', $email_post ) ) {
+			$post_content .= wp_link_pages( array( 'echo' => 0 ) );
+		}
+
+		// Apply the_content filter to process blocks.
+		$post_content = apply_filters( 'the_content', str_replace( ']]>', ']]&gt;', $post_content ) );
+
+		// Restore global state.
+		$post     = $backup_post; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
+		$wp_query = $backup_query; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
+
+		if ( empty( $post_content ) ) {
+			return '';
+		}
+
+		return $post_content;
+	}
+}
diff --git a/packages/php/email-editor/src/Integrations/Core/Renderer/Blocks/class-quote.php b/packages/php/email-editor/src/Integrations/Core/Renderer/Blocks/class-quote.php
index b5549f6000..d9396fce3f 100644
--- a/packages/php/email-editor/src/Integrations/Core/Renderer/Blocks/class-quote.php
+++ b/packages/php/email-editor/src/Integrations/Core/Renderer/Blocks/class-quote.php
@@ -26,7 +26,6 @@ class Quote extends Abstract_Block_Renderer {
 	 * @return string
 	 */
 	protected function render_content( string $block_content, array $parsed_block, Rendering_Context $rendering_context ): string {
-		$content    = '';
 		$dom_helper = new Dom_Document_Helper( $block_content );

 		// Extract citation if present.
@@ -40,15 +39,9 @@ class Quote extends Abstract_Block_Renderer {
 			);
 		}

-		// Process inner blocks for main content.
-		$inner_blocks = $parsed_block['innerBlocks'] ?? array();
-		foreach ( $inner_blocks as $block ) {
-			$content .= render_block( $block );
-		}
-
 		return str_replace(
 			array( '{quote_content}', '{citation_content}' ),
-			array( $content, $citation_content ),
+			array( $this->get_inner_content( $block_content ), $citation_content ),
 			$this->get_block_wrapper( $block_content, $parsed_block, $rendering_context )
 		);
 	}
diff --git a/packages/php/email-editor/src/Integrations/Core/class-initializer.php b/packages/php/email-editor/src/Integrations/Core/class-initializer.php
index 25656712ad..5e6232a95f 100644
--- a/packages/php/email-editor/src/Integrations/Core/class-initializer.php
+++ b/packages/php/email-editor/src/Integrations/Core/class-initializer.php
@@ -25,6 +25,7 @@ use Automattic\WooCommerce\EmailEditor\Integrations\Core\Renderer\Blocks\Image;
 use Automattic\WooCommerce\EmailEditor\Integrations\Core\Renderer\Blocks\List_Block;
 use Automattic\WooCommerce\EmailEditor\Integrations\Core\Renderer\Blocks\List_Item;
 use Automattic\WooCommerce\EmailEditor\Integrations\Core\Renderer\Blocks\Media_Text;
+use Automattic\WooCommerce\EmailEditor\Integrations\Core\Renderer\Blocks\Post_Content;
 use Automattic\WooCommerce\EmailEditor\Integrations\Core\Renderer\Blocks\Quote;
 use Automattic\WooCommerce\EmailEditor\Integrations\Core\Renderer\Blocks\Video;
 use Automattic\WooCommerce\EmailEditor\Integrations\Core\Renderer\Blocks\Social_Link;
@@ -129,9 +130,10 @@ class Initializer {
 	/**
 	 * Configure block settings for email editor support and rendering.
 	 *
-	 * This method handles two types of blocks:
+	 * This method handles three types of blocks:
 	 * 1. Editor-available blocks: Set supports.email = true and render_email_callback
 	 * 2. Render-only blocks: Only set render_email_callback (not available in editor)
+	 * 3. Special blocks: Custom handling (e.g., core/post-content stateless renderer)
 	 *
 	 * @param array $settings Block settings.
 	 * @return array Modified block settings.
@@ -148,6 +150,14 @@ class Initializer {
 			$settings['render_email_callback'] = array( $this, 'render_block' );
 		}

+		// Special handling for core/post-content to use stateless renderer.
+		// This prevents issues with WordPress's static $seen_ids array when rendering
+		// multiple emails in a single request (e.g., MailPoet batch processing).
+		if ( 'core/post-content' === $settings['name'] ) {
+			$post_content_renderer       = new Post_Content();
+			$settings['render_callback'] = array( $post_content_renderer, 'render_stateless' );
+		}
+
 		return $settings;
 	}

diff --git a/packages/php/email-editor/src/Integrations/WooCommerce/Renderer/Blocks/class-product-collection.php b/packages/php/email-editor/src/Integrations/WooCommerce/Renderer/Blocks/class-product-collection.php
index 87e726505d..70dc56c709 100644
--- a/packages/php/email-editor/src/Integrations/WooCommerce/Renderer/Blocks/class-product-collection.php
+++ b/packages/php/email-editor/src/Integrations/WooCommerce/Renderer/Blocks/class-product-collection.php
@@ -25,7 +25,7 @@ class Product_Collection extends Abstract_Product_Block_Renderer {
 	 */
 	protected function render_content( string $block_content, array $parsed_block, Rendering_Context $rendering_context ): string {
 		// Create a query for the Product Collection block.
-		$query = $this->prepare_and_execute_query( $parsed_block );
+		$query = $this->prepare_and_execute_query( $parsed_block, $rendering_context );

 		$content = '';

@@ -148,10 +148,11 @@ class Product_Collection extends Abstract_Product_Block_Renderer {
 	/**
 	 * Prepare and execute a query for the Product Collection block using the original QueryBuilder.
 	 *
-	 * @param array $parsed_block Parsed block data.
+	 * @param array             $parsed_block Parsed block data.
+	 * @param Rendering_Context $rendering_context Rendering context.
 	 * @return WP_Query
 	 */
-	private function prepare_and_execute_query( array $parsed_block ): WP_Query {
+	private function prepare_and_execute_query( array $parsed_block, Rendering_Context $rendering_context ): WP_Query {
 		$collection  = $parsed_block['attrs']['collection'] ?? '';
 		$query_attrs = $parsed_block['attrs']['query'] ?? array();

@@ -248,8 +249,8 @@ class Product_Collection extends Abstract_Product_Block_Renderer {
 			$query_args['tax_query'] = array_merge( $query_args['tax_query'], $attribute_queries ); // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query
 		}

-		// Handle special collections: upsells, cross-sells, related.
-		$product_ids_to_include = $this->get_collection_specific_product_ids( $collection, $parsed_block );
+		// Handle special collections: upsells, cross-sells, related, cart-contents.
+		$product_ids_to_include = $this->get_collection_specific_product_ids( $collection, $parsed_block, $rendering_context );
 		if ( ! empty( $product_ids_to_include ) ) {
 			$query_args['post__in'] = $product_ids_to_include;
 		}
@@ -342,13 +343,14 @@ class Product_Collection extends Abstract_Product_Block_Renderer {
 	}

 	/**
-	 * Get specific product IDs for collection types that need them (upsell, cross-sell, related).
+	 * Get specific product IDs for collection types that need them (upsell, cross-sell, related, cart-contents).
 	 *
-	 * @param string $collection Collection type.
-	 * @param array  $parsed_block Parsed block data.
+	 * @param string            $collection Collection type.
+	 * @param array             $parsed_block Parsed block data.
+	 * @param Rendering_Context $rendering_context Rendering context.
 	 * @return array Array of product IDs or empty array.
 	 */
-	private function get_collection_specific_product_ids( string $collection, array $parsed_block ): array {
+	private function get_collection_specific_product_ids( string $collection, array $parsed_block, Rendering_Context $rendering_context ): array {
 		switch ( $collection ) {
 			case 'woocommerce/product-collection/upsells':
 				return $this->get_upsell_product_ids( $parsed_block );
@@ -359,6 +361,9 @@ class Product_Collection extends Abstract_Product_Block_Renderer {
 			case 'woocommerce/product-collection/related':
 				return $this->get_related_product_ids( $parsed_block );

+			case 'woocommerce/product-collection/cart-contents':
+				return $this->get_cart_contents_product_ids( $parsed_block, $rendering_context );
+
 			default:
 				return array();
 		}
@@ -487,6 +492,107 @@ class Product_Collection extends Abstract_Product_Block_Renderer {
 		return $product_references;
 	}

+	/**
+	 * Get cart contents product IDs for email rendering.
+	 *
+	 * @param array             $parsed_block Parsed block data.
+	 * @param Rendering_Context $rendering_context Rendering context.
+	 * @return array Array of cart product IDs or sample products for preview.
+	 */
+	private function get_cart_contents_product_ids( array $parsed_block, Rendering_Context $rendering_context ): array {
+		// Try to get cart product IDs from the user's cart using user ID or email.
+		$cart_product_ids = $this->get_user_cart_product_ids_from_context( $rendering_context );
+
+		if ( ! empty( $cart_product_ids ) ) {
+			return $cart_product_ids;
+		}
+
+		// For preview emails, show sample products so users can see what the email will look like.
+		if ( $rendering_context->get( 'is_user_preview', false ) ) {
+			return $this->get_sample_product_ids_for_preview();
+		}
+
+		// For real emails with empty cart, return -1 to ensure no products are shown.
+		return array( -1 );
+	}
+
+	/**
+	 * Get cart product IDs from rendering context using user ID or email.
+	 *
+	 * @param Rendering_Context $rendering_context Rendering context.
+	 * @return array Array of product IDs from the user's cart.
+	 */
+	private function get_user_cart_product_ids_from_context( Rendering_Context $rendering_context ): array {
+		$user_id = $rendering_context->get_user_id();
+		$email   = $rendering_context->get_recipient_email();
+
+		// Use shared utility if available (WooCommerce 10.4+).
+		if ( class_exists( '\Automattic\WooCommerce\Blocks\Utils\CartCheckoutUtils' ) &&
+			method_exists( '\Automattic\WooCommerce\Blocks\Utils\CartCheckoutUtils', 'get_cart_product_ids_for_user' ) ) {
+			// @phpstan-ignore-next-line - Method exists in newer WooCommerce versions.
+			return \Automattic\WooCommerce\Blocks\Utils\CartCheckoutUtils::get_cart_product_ids_for_user( $user_id, $email );
+		}
+
+		// Fallback: Get user ID from email if needed, then fetch cart.
+		if ( ! $user_id && $email ) {
+			$user = get_user_by( 'email', $email );
+			if ( $user ) {
+				$user_id = $user->ID;
+			}
+		}
+
+		if ( ! $user_id ) {
+			return array();
+		}
+
+		// Fallback implementation for older WooCommerce versions.
+		$cart_data = get_user_meta( $user_id, '_woocommerce_persistent_cart_' . get_current_blog_id(), true );
+
+		if ( ! is_array( $cart_data ) || empty( $cart_data ) || ! isset( $cart_data['cart'] ) || ! is_array( $cart_data['cart'] ) ) {
+			return array();
+		}
+
+		$product_ids = array();
+
+		foreach ( $cart_data['cart'] as $cart_item ) {
+			if ( is_array( $cart_item ) && isset( $cart_item['product_id'] ) && is_numeric( $cart_item['product_id'] ) ) {
+				$product_ids[] = (int) $cart_item['product_id'];
+			}
+		}
+
+		return array_unique( $product_ids );
+	}
+
+	/**
+	 * Get sample product IDs for preview emails.
+	 * This ensures that preview emails show representative content even when the cart is empty.
+	 *
+	 * @return array Array of sample product IDs.
+	 */
+	private function get_sample_product_ids_for_preview(): array {
+		$query = new WP_Query(
+			array(
+				'post_type'      => 'product',
+				'post_status'    => 'publish',
+				'posts_per_page' => 3,
+				'orderby'        => 'date',
+				'order'          => 'DESC',
+				'fields'         => 'ids',
+			)
+		);
+
+		if ( ! empty( $query->posts ) && is_array( $query->posts ) ) {
+			return array_map(
+				static function ( $id ) {
+					return is_numeric( $id ) ? (int) $id : 0;
+				},
+				$query->posts
+			);
+		}
+
+		return array( -1 );
+	}
+
 	/**
 	 * Render a no results message.
 	 *
diff --git a/packages/php/email-editor/tests/integration/Integrations/Core/Renderer/Blocks/Column_Test.php b/packages/php/email-editor/tests/integration/Integrations/Core/Renderer/Blocks/Column_Test.php
index 69a1db2b06..2974a17c00 100644
--- a/packages/php/email-editor/tests/integration/Integrations/Core/Renderer/Blocks/Column_Test.php
+++ b/packages/php/email-editor/tests/integration/Integrations/Core/Renderer/Blocks/Column_Test.php
@@ -73,7 +73,7 @@ class Column_Test extends \Email_Editor_Integration_Test_Case {
 	 * Test it renders column content
 	 */
 	public function testItRendersColumnContent(): void {
-		$rendered = $this->column_renderer->render( '', $this->parsed_column, $this->rendering_context );
+		$rendered = $this->column_renderer->render( '<p>Column content</p>', $this->parsed_column, $this->rendering_context );
 		$this->checkValidHTML( $rendered );
 		$this->assertStringContainsString( 'Column content', $rendered );
 	}
diff --git a/packages/php/email-editor/tests/integration/Integrations/Core/Renderer/Blocks/Columns_Test.php b/packages/php/email-editor/tests/integration/Integrations/Core/Renderer/Blocks/Columns_Test.php
index 85916014b6..81079f85b1 100644
--- a/packages/php/email-editor/tests/integration/Integrations/Core/Renderer/Blocks/Columns_Test.php
+++ b/packages/php/email-editor/tests/integration/Integrations/Core/Renderer/Blocks/Columns_Test.php
@@ -81,7 +81,7 @@ class Columns_Test extends \Email_Editor_Integration_Test_Case {
 	 * Test it renders inner column
 	 */
 	public function testItRendersInnerColumn(): void {
-		$rendered = $this->columns_renderer->render( '', $this->parsed_columns, $this->rendering_context );
+		$rendered = $this->columns_renderer->render( '<p>Column 1</p>', $this->parsed_columns, $this->rendering_context );
 		$this->assertStringContainsString( 'Column 1', $rendered );
 	}

diff --git a/packages/php/email-editor/tests/integration/Integrations/Core/Renderer/Blocks/Post_Content_Test.php b/packages/php/email-editor/tests/integration/Integrations/Core/Renderer/Blocks/Post_Content_Test.php
new file mode 100644
index 0000000000..dd5e5da5d2
--- /dev/null
+++ b/packages/php/email-editor/tests/integration/Integrations/Core/Renderer/Blocks/Post_Content_Test.php
@@ -0,0 +1,266 @@
+<?php
+/**
+ * This file is part of the WooCommerce Email Editor package
+ *
+ * @package Automattic\WooCommerce\EmailEditor
+ */
+
+declare(strict_types = 1);
+namespace Automattic\WooCommerce\EmailEditor\Integrations\Core\Renderer\Blocks;
+
+use Automattic\WooCommerce\EmailEditor\Engine\Email_Editor;
+use Automattic\WooCommerce\EmailEditor\Integrations\Core\Initializer;
+
+/**
+ * Integration test for core/post-content block rendering
+ *
+ * This test verifies that the core/post-content block can be rendered multiple times
+ * in a single request without content becoming empty. This is critical for MailPoet's
+ * batch email processing which renders multiple emails in a single PHP request.
+ */
+class Post_Content_Test extends \Email_Editor_Integration_Test_Case {
+	/**
+	 * Core Initializer instance
+	 *
+	 * @var Initializer
+	 */
+	private $initializer;
+
+	/**
+	 * Set up before each test
+	 */
+	public function setUp(): void {
+		parent::setUp();
+		$this->di_container->get( Email_Editor::class )->initialize();
+		$this->initializer = $this->di_container->get( Initializer::class );
+		$this->initializer->initialize();
+
+		// Manually trigger block settings update for core/post-content
+		// This simulates what the block_type_metadata_settings filter does in Bootstrap.
+		$registry   = \WP_Block_Type_Registry::get_instance();
+		$block_type = $registry->get_registered( 'core/post-content' );
+		if ( $block_type ) {
+			$settings                    = (array) $block_type;
+			$settings['name']            = 'core/post-content';
+			$updated_settings            = $this->initializer->update_block_settings( $settings );
+			$block_type->render_callback = $updated_settings['render_callback'] ?? null;
+		}
+	}
+
+	/**
+	 * Test that core/post-content block can be rendered multiple times in a single request.
+	 *
+	 * This simulates the MailPoet scenario where multiple emails are rendered in a queue
+	 * within the same PHP request. WordPress's default render_block_core_post_content()
+	 * uses static $seen_ids which causes the second render to return empty content.
+	 * Our fix uses a stateless Post_Content renderer to avoid this issue.
+	 */
+	public function testItRendersPostContentMultipleTimesInSameRequest(): void {
+		// Create a test post with some content.
+		$post_id = wp_insert_post(
+			array(
+				'post_title'   => 'Test Email Content',
+				'post_content' => '<!-- wp:paragraph --><p>This is test email content.</p><!-- /wp:paragraph -->',
+				'post_status'  => 'publish',
+				'post_type'    => 'post',
+			)
+		);
+
+		$this->assertNotWPError( $post_id, 'Failed to create test post' );
+
+		// Create a block instance simulating core/post-content.
+		$block = new \WP_Block(
+			array(
+				'blockName' => 'core/post-content',
+				'attrs'     => array(),
+			),
+			array(
+				'postId' => $post_id,
+			)
+		);
+
+		// Get the overridden render callback.
+		$registry   = \WP_Block_Type_Registry::get_instance();
+		$block_type = $registry->get_registered( 'core/post-content' );
+
+		$this->assertNotNull( $block_type, 'core/post-content block type should be registered' );
+		$this->assertIsCallable( $block_type->render_callback, 'Render callback should be callable' );
+
+		// First render - should return content.
+		$first_render = call_user_func( $block_type->render_callback, array(), '', $block );
+
+		$this->assertNotEmpty( $first_render, 'First render should not be empty' );
+		$this->assertStringContainsString( 'This is test email content.', $first_render, 'First render should contain post content' );
+
+		// Second render - should also return content (this is the critical test).
+		// With WordPress's default implementation using static $seen_ids, this would return empty.
+		$second_render = call_user_func( $block_type->render_callback, array(), '', $block );
+
+		$this->assertNotEmpty( $second_render, 'Second render should not be empty' );
+		$this->assertStringContainsString( 'This is test email content.', $second_render, 'Second render should contain post content' );
+
+		// Both renders should produce the same content.
+		$this->assertEquals( $first_render, $second_render, 'First and second render should produce identical content' );
+
+		// Clean up.
+		wp_delete_post( $post_id, true );
+	}
+
+	/**
+	 * Test that the render callback is properly overridden with Post_Content renderer.
+	 */
+	public function testItOverridesPostContentBlockCallback(): void {
+		$registry   = \WP_Block_Type_Registry::get_instance();
+		$block_type = $registry->get_registered( 'core/post-content' );
+
+		$this->assertNotNull( $block_type, 'core/post-content block type should be registered' );
+		$this->assertIsArray( $block_type->render_callback, 'Render callback should be an array (object method)' );
+		$this->assertInstanceOf( Post_Content::class, $block_type->render_callback[0], 'Render callback should be from Post_Content class' );
+		$this->assertEquals( 'render_stateless', $block_type->render_callback[1], 'Render callback should be render_stateless method' );
+	}
+
+	/**
+	 * Test that render returns empty when post ID is missing from context.
+	 */
+	public function testItReturnsEmptyWhenPostIdMissing(): void {
+		$block = new \WP_Block(
+			array(
+				'blockName' => 'core/post-content',
+				'attrs'     => array(),
+			),
+			array() // No postId in context.
+		);
+
+		$registry   = \WP_Block_Type_Registry::get_instance();
+		$block_type = $registry->get_registered( 'core/post-content' );
+
+		$this->assertNotNull( $block_type, 'core/post-content block type should be registered' );
+		$this->assertIsCallable( $block_type->render_callback, 'Render callback should be callable' );
+
+		$render = call_user_func( $block_type->render_callback, array(), '', $block );
+
+		$this->assertEmpty( $render, 'Should return empty when postId is missing from context' );
+	}
+
+	/**
+	 * Test that render returns empty when post doesn't exist.
+	 */
+	public function testItReturnsEmptyWhenPostDoesNotExist(): void {
+		$block = new \WP_Block(
+			array(
+				'blockName' => 'core/post-content',
+				'attrs'     => array(),
+			),
+			array(
+				'postId' => 999999, // Non-existent post ID.
+			)
+		);
+
+		$registry   = \WP_Block_Type_Registry::get_instance();
+		$block_type = $registry->get_registered( 'core/post-content' );
+
+		$this->assertNotNull( $block_type, 'core/post-content block type should be registered' );
+		$this->assertIsCallable( $block_type->render_callback, 'Render callback should be callable' );
+
+		$render = call_user_func( $block_type->render_callback, array(), '', $block );
+
+		$this->assertEmpty( $render, 'Should return empty when post does not exist' );
+	}
+
+	/**
+	 * Test that render returns empty when post content is empty.
+	 */
+	public function testItReturnsEmptyWhenPostContentIsEmpty(): void {
+		$post_id = wp_insert_post(
+			array(
+				'post_title'   => 'Empty Post',
+				'post_content' => '', // Empty content.
+				'post_status'  => 'publish',
+				'post_type'    => 'post',
+			)
+		);
+
+		$this->assertNotWPError( $post_id, 'Failed to create test post' );
+
+		$block = new \WP_Block(
+			array(
+				'blockName' => 'core/post-content',
+				'attrs'     => array(),
+			),
+			array(
+				'postId' => $post_id,
+			)
+		);
+
+		$registry   = \WP_Block_Type_Registry::get_instance();
+		$block_type = $registry->get_registered( 'core/post-content' );
+
+		$this->assertNotNull( $block_type, 'core/post-content block type should be registered' );
+		$this->assertIsCallable( $block_type->render_callback, 'Render callback should be callable' );
+
+		$render = call_user_func( $block_type->render_callback, array(), '', $block );
+
+		$this->assertEmpty( $render, 'Should return empty when post content is empty' );
+
+		// Clean up.
+		wp_delete_post( $post_id, true );
+	}
+
+	/**
+	 * Test that global post is properly restored after rendering.
+	 */
+	public function testItRestoresGlobalPostAfterRendering(): void {
+		global $post;
+
+		// Create a test post.
+		$post_id = wp_insert_post(
+			array(
+				'post_title'   => 'Test Email Content',
+				'post_content' => '<!-- wp:paragraph --><p>Test content.</p><!-- /wp:paragraph -->',
+				'post_status'  => 'publish',
+				'post_type'    => 'post',
+			)
+		);
+
+		$this->assertNotWPError( $post_id, 'Failed to create test post' );
+
+		// Set a different post as global.
+		$original_post_id = wp_insert_post(
+			array(
+				'post_title'  => 'Original Post',
+				'post_status' => 'publish',
+				'post_type'   => 'post',
+			)
+		);
+		$post             = get_post( $original_post_id ); // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
+
+		$this->assertInstanceOf( \WP_Post::class, $post, 'Global post should be set' );
+		$this->assertEquals( $original_post_id, $post->ID, 'Global post ID should be the original' );
+
+		// Render the core/post-content block.
+		$block = new \WP_Block(
+			array(
+				'blockName' => 'core/post-content',
+				'attrs'     => array(),
+			),
+			array(
+				'postId' => $post_id,
+			)
+		);
+
+		$registry   = \WP_Block_Type_Registry::get_instance();
+		$block_type = $registry->get_registered( 'core/post-content' );
+
+		$this->assertNotNull( $block_type, 'core/post-content block type should be registered' );
+		$this->assertIsCallable( $block_type->render_callback, 'Render callback should be callable' );
+
+		call_user_func( $block_type->render_callback, array(), '', $block );
+
+		// Verify global post is restored.
+		$this->assertEquals( $original_post_id, $post->ID, 'Global post should be restored to original' );
+
+		// Clean up.
+		wp_delete_post( $post_id, true );
+		wp_delete_post( $original_post_id, true );
+	}
+}
diff --git a/packages/php/email-editor/tests/integration/Integrations/Core/Renderer/Blocks/Quote_Test.php b/packages/php/email-editor/tests/integration/Integrations/Core/Renderer/Blocks/Quote_Test.php
index 8437ba434a..eb30c6f4d9 100644
--- a/packages/php/email-editor/tests/integration/Integrations/Core/Renderer/Blocks/Quote_Test.php
+++ b/packages/php/email-editor/tests/integration/Integrations/Core/Renderer/Blocks/Quote_Test.php
@@ -71,7 +71,7 @@ class Quote_Test extends \Email_Editor_Integration_Test_Case {
 	 * Test it renders quote content
 	 */
 	public function testItRendersQuoteContent(): void {
-		$rendered = $this->quote_renderer->render( '', $this->parsed_quote, $this->rendering_context );
+		$rendered = $this->quote_renderer->render( '<p>Quote content</p>', $this->parsed_quote, $this->rendering_context );
 		$this->checkValidHTML( $rendered );
 		$this->assertStringContainsString( 'Quote content', $rendered );
 	}
diff --git a/packages/php/email-editor/tests/unit/Engine/Renderer/ContentRenderer/Rendering_Context_Test.php b/packages/php/email-editor/tests/unit/Engine/Renderer/ContentRenderer/Rendering_Context_Test.php
index bbdd5f39a1..bad6cb98bd 100644
--- a/packages/php/email-editor/tests/unit/Engine/Renderer/ContentRenderer/Rendering_Context_Test.php
+++ b/packages/php/email-editor/tests/unit/Engine/Renderer/ContentRenderer/Rendering_Context_Test.php
@@ -95,4 +95,52 @@ class Rendering_Context_Test extends \Email_Editor_Unit_Test {
 		$this->assertSame( '#00ff00', $context->translate_slug_to_color( 'secondary' ) );
 		$this->assertSame( 'unknown', $context->translate_slug_to_color( 'unknown' ) );
 	}
+
+	/**
+	 * Test it stores and retrieves email context data.
+	 */
+	public function testItStoresEmailContext(): void {
+		/**
+		 * WP_Theme_JSON mock for using in test.
+		 *
+		 * @var \WP_Theme_JSON&\PHPUnit\Framework\MockObject\MockObject $theme_json
+		 */
+		$theme_json = $this->createMock( \WP_Theme_JSON::class );
+
+		$email_context = array(
+			'user_id'         => 123,
+			'recipient_email' => 'user@example.com',
+			'order_id'        => 456,
+			'email_type'      => 'order_confirmation',
+		);
+
+		$context = new Rendering_Context( $theme_json, $email_context );
+
+		$this->assertSame( 123, $context->get_user_id() );
+		$this->assertSame( 'user@example.com', $context->get_recipient_email() );
+		$this->assertSame( 456, $context->get( 'order_id' ) );
+		$this->assertSame( 'order_confirmation', $context->get( 'email_type' ) );
+		$this->assertSame( $email_context, $context->get_email_context() );
+	}
+
+	/**
+	 * Test it returns null for missing email context data.
+	 */
+	public function testItReturnsNullForMissingEmailContext(): void {
+		/**
+		 * WP_Theme_JSON mock for using in test.
+		 *
+		 * @var \WP_Theme_JSON&\PHPUnit\Framework\MockObject\MockObject $theme_json
+		 */
+		$theme_json = $this->createMock( \WP_Theme_JSON::class );
+
+		$context = new Rendering_Context( $theme_json );
+
+		$this->assertNull( $context->get_user_id() );
+		$this->assertNull( $context->get_recipient_email() );
+		$this->assertNull( $context->get( 'order_id' ) );
+		$this->assertNull( $context->get( 'non_existent_key' ) );
+		$this->assertSame( 'default', $context->get( 'non_existent_key', 'default' ) );
+		$this->assertSame( array(), $context->get_email_context() );
+	}
 }
diff --git a/plugins/woocommerce/changelog/stomail-7500-cart-contents-in-email b/plugins/woocommerce/changelog/stomail-7500-cart-contents-in-email
new file mode 100644
index 0000000000..94b81e391c
--- /dev/null
+++ b/plugins/woocommerce/changelog/stomail-7500-cart-contents-in-email
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Add BlockEmailRenderer with email context support and Cart Contents product collection
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/collections/cart-contents.tsx b/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/collections/cart-contents.tsx
new file mode 100644
index 0000000000..cb3a49c496
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/collections/cart-contents.tsx
@@ -0,0 +1,79 @@
+/**
+ * External dependencies
+ */
+import type {
+	InnerBlockTemplate,
+	BlockVariationScope,
+} from '@wordpress/blocks';
+import { __ } from '@wordpress/i18n';
+import { Icon } from '@wordpress/icons';
+import { cart } from '@woocommerce/icons';
+
+/**
+ * Internal dependencies
+ */
+import { INNER_BLOCKS_PRODUCT_TEMPLATE } from '../constants';
+import { CoreCollectionNames, CoreFilterNames } from '../types';
+
+const collection = {
+	name: CoreCollectionNames.CART_CONTENTS,
+	title: __( 'Cart Contents', 'woocommerce' ),
+	icon: <Icon icon={ cart } />,
+	description: __(
+		'Display products from the customer cart for abandoned cart emails.',
+		'woocommerce'
+	),
+	keywords: [ 'cart', 'email', 'abandoned' ],
+	scope: [ 'inserter', 'block' ] as BlockVariationScope[],
+};
+
+const attributes = {
+	displayLayout: {
+		type: 'flex',
+		columns: 1, // Single column for email compatibility
+		shrinkColumns: true,
+	},
+	query: {
+		// This will need to be handled by a custom query filter on the backend
+		// to fetch products from the cart context
+		inherit: false,
+		perPage: 10, // Show up to 10 cart items
+		pages: 1,
+	},
+	hideControls: [
+		CoreFilterNames.ATTRIBUTES,
+		CoreFilterNames.KEYWORD,
+		CoreFilterNames.ORDER,
+		CoreFilterNames.DEFAULT_ORDER,
+		CoreFilterNames.FEATURED,
+		CoreFilterNames.ON_SALE,
+		CoreFilterNames.STOCK_STATUS,
+		CoreFilterNames.HAND_PICKED,
+		CoreFilterNames.TAXONOMY,
+		CoreFilterNames.FILTERABLE,
+		CoreFilterNames.CREATED,
+		CoreFilterNames.PRICE_RANGE,
+	],
+	queryContextIncludes: [ 'cart' ],
+};
+
+const heading: InnerBlockTemplate = [
+	'core/heading',
+	{
+		textAlign: 'center',
+		level: 2,
+		content: __( 'Your Cart', 'woocommerce' ),
+		style: { spacing: { margin: { bottom: '1rem' } } },
+	},
+];
+
+const innerBlocks: InnerBlockTemplate[] = [
+	heading,
+	INNER_BLOCKS_PRODUCT_TEMPLATE,
+];
+
+export default {
+	...collection,
+	attributes,
+	innerBlocks,
+};
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/collections/index.tsx b/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/collections/index.tsx
index 39ff4b0f6d..12532f81b1 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/collections/index.tsx
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/collections/index.tsx
@@ -26,6 +26,7 @@ import topRated from './top-rated';
 import upsells from './upsells';
 import byCategory from './by-category';
 import byTag from './by-tag';
+import cartContents from './cart-contents';

 // Order in here is reflected in the Collection Chooser in Editor.
 const collections: BlockVariation[] = [
@@ -43,12 +44,21 @@ const collections: BlockVariation[] = [
 	crossSells,
 ];

+// Email-only collections
+const emailCollections: BlockVariation[] = [ cartContents ];
+
 export const registerCollections = () => {
 	collections.forEach( ( collection ) =>
 		registerProductCollection( collection )
 	);
 };

+export const registerEmailCollections = () => {
+	emailCollections.forEach( ( collection ) =>
+		registerProductCollection( collection )
+	);
+};
+
 export const getCollectionByName = ( collectionName?: CollectionName ) => {
 	if ( ! collectionName ) {
 		return null;
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/edit/index.tsx b/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/edit/index.tsx
index 4a16cb29fe..701c9be955 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/edit/index.tsx
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/edit/index.tsx
@@ -21,12 +21,16 @@ import CollectionSelectionModal from './collection-selection-modal';
 import { useProductCollectionUIState } from '../utils';
 import ProductPicker from './ProductPicker';
 import { useTracksLocation } from '../tracks-utils';
+import { useRegisterEmailCollections } from '../hooks/use-register-email-collections';

 const Edit = ( props: ProductCollectionEditComponentProps ) => {
 	const { clientId, attributes, context } = props;
 	const location = useGetLocation( context, clientId );
 	const tracksLocation = useTracksLocation( context.templateSlug );

+	// Register email-only collections when in email editor
+	useRegisterEmailCollections();
+
 	const [ isSelectionModalOpen, setIsSelectionModalOpen ] = useState( false );
 	const hasInnerBlocks = useSelect(
 		( select ) =>
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/hooks/use-register-email-collections.ts b/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/hooks/use-register-email-collections.ts
new file mode 100644
index 0000000000..6a3772511e
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/hooks/use-register-email-collections.ts
@@ -0,0 +1,25 @@
+/**
+ * External dependencies
+ */
+import { useEffect } from '@wordpress/element';
+import { useIsEmailEditor } from '@woocommerce/email-editor';
+
+/**
+ * Internal dependencies
+ */
+import { registerEmailCollections } from '../collections';
+
+/**
+ * Custom hook to register email-only product collections when in the email editor.
+ * This ensures that email-specific collections like "Cart Contents" are only
+ * available in the email editor context.
+ */
+export const useRegisterEmailCollections = () => {
+	const isEmailEditor = useIsEmailEditor();
+
+	useEffect( () => {
+		if ( isEmailEditor ) {
+			registerEmailCollections();
+		}
+	}, [ isEmailEditor ] );
+};
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/types.ts b/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/types.ts
index 35106cc395..0fbdf87f90 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/types.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/types.ts
@@ -190,6 +190,7 @@ export enum CoreCollectionNames {
 	CROSS_SELLS = 'woocommerce/product-collection/cross-sells',
 	BY_CATEGORY = 'woocommerce/product-collection/by-category',
 	BY_TAG = 'woocommerce/product-collection/by-tag',
+	CART_CONTENTS = 'woocommerce/product-collection/cart-contents',
 }

 export enum CoreFilterNames {
diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/ProductCollection/HandlerRegistry.php b/plugins/woocommerce/src/Blocks/BlockTypes/ProductCollection/HandlerRegistry.php
index 7e053b6e26..92dc339aec 100644
--- a/plugins/woocommerce/src/Blocks/BlockTypes/ProductCollection/HandlerRegistry.php
+++ b/plugins/woocommerce/src/Blocks/BlockTypes/ProductCollection/HandlerRegistry.php
@@ -3,6 +3,7 @@ declare(strict_types=1);

 namespace Automattic\WooCommerce\Blocks\BlockTypes\ProductCollection;

+use Automattic\WooCommerce\Blocks\Utils\CartCheckoutUtils;
 use InvalidArgumentException;

 /**
@@ -330,6 +331,25 @@ class HandlerRegistry {
 				return $collection_args;
 			}
 		);
+
+		$this->register_collection_handlers(
+			'woocommerce/product-collection/cart-contents',
+			function ( $collection_args ) {
+				$cart_product_ids = $collection_args['cartProductIds'] ?? null;
+				if ( empty( $cart_product_ids ) ) {
+					return array( 'post__in' => array( -1 ) );
+				}
+				return array( 'post__in' => $cart_product_ids );
+			},
+			function ( $collection_args, $query ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed
+				$collection_args['cartProductIds'] = $this->get_cart_product_ids( $collection_args, null );
+				return $collection_args;
+			},
+			function ( $collection_args, $query, $request ) {
+				$collection_args['cartProductIds'] = $this->get_cart_product_ids( $collection_args, $request );
+				return $collection_args;
+			}
+		);
 		return $this->collection_handler_store;
 	}

@@ -378,4 +398,49 @@ class HandlerRegistry {
 		}
 		return $product_references;
 	}
+
+	/**
+	 * Get cart product IDs from various sources.
+	 * Handles loading cart products from location context or request params.
+	 *
+	 * @param array                 $collection_args Collection arguments with location context.
+	 * @param \WP_REST_Request|null $request         Optional REST request for editor context.
+	 * @return array<int> The product IDs from the cart. Returns recent products for preview in editor context only.
+	 */
+	private function get_cart_product_ids( $collection_args, $request = null ) {
+		$location = $collection_args['productCollectionLocation'] ?? array();
+
+		if ( $request ) {
+			$user_id    = $request->get_param( 'userId' ) ? absint( $request->get_param( 'userId' ) ) : null;
+			$user_email = $request->get_param( 'userEmail' ) ? sanitize_email( $request->get_param( 'userEmail' ) ) : null;
+			if ( $user_id || $user_email ) {
+				$cart_ids = CartCheckoutUtils::get_cart_product_ids_for_user( $user_id, $user_email );
+				if ( ! empty( $cart_ids ) ) {
+					return $cart_ids;
+				}
+			}
+			// In editor context (REST request), show sample products for preview when cart is empty.
+			$recent_product_ids = wc_get_products(
+				array(
+					'status'  => 'publish',
+					'orderby' => 'date',
+					'order'   => 'DESC',
+					'limit'   => 3,
+					'return'  => 'ids',
+				)
+			);
+			return ! empty( $recent_product_ids ) ? $recent_product_ids : array();
+		}
+
+		if ( isset( $location['type'] ) && 'cart' === $location['type'] ) {
+			$user_id    = isset( $location['sourceData']['userId'] ) ? absint( $location['sourceData']['userId'] ) : null;
+			$user_email = isset( $location['sourceData']['userEmail'] ) ? sanitize_email( $location['sourceData']['userEmail'] ) : null;
+			if ( $user_id || $user_email ) {
+				return CartCheckoutUtils::get_cart_product_ids_for_user( $user_id, $user_email );
+			}
+		}
+
+		// In frontend/email context, return empty array when no cart is found.
+		return array();
+	}
 }
diff --git a/plugins/woocommerce/src/Blocks/Utils/CartCheckoutUtils.php b/plugins/woocommerce/src/Blocks/Utils/CartCheckoutUtils.php
index 3f9187bf59..caeaa94b1d 100644
--- a/plugins/woocommerce/src/Blocks/Utils/CartCheckoutUtils.php
+++ b/plugins/woocommerce/src/Blocks/Utils/CartCheckoutUtils.php
@@ -504,4 +504,47 @@ class CartCheckoutUtils {
 	public static function has_cart_page() {
 		return wc_get_page_permalink( 'cart', -1 ) !== -1;
 	}
+
+	/**
+	 * Get product IDs from a user's persistent cart.
+	 *
+	 * This method retrieves product IDs stored in the user's persistent cart meta.
+	 * It can be used for abandoned cart emails, cart-based product collections,
+	 * and other scenarios where cart products need to be retrieved for a user.
+	 *
+	 * @param int|null    $user_id    The user ID. If not provided, will attempt to look up by email.
+	 * @param string|null $user_email The user email. Used to lookup user if ID not provided.
+	 * @return array<int> Array of product IDs from the user's cart, or empty array if none found.
+	 */
+	public static function get_cart_product_ids_for_user( ?int $user_id, ?string $user_email ) {
+		if ( empty( $user_id ) && ! empty( $user_email ) ) {
+			$user = get_user_by( 'email', $user_email );
+			if ( $user ) {
+				$user_id = $user->ID;
+			}
+		}
+
+		if ( empty( $user_id ) ) {
+			return array();
+		}
+
+		$cart_meta = get_user_meta( $user_id, '_woocommerce_persistent_cart_' . get_current_blog_id(), true );
+
+		if ( empty( $cart_meta ) || ! is_array( $cart_meta ) || empty( $cart_meta['cart'] ) ) {
+			return array();
+		}
+
+		return array_values(
+			array_unique(
+				array_filter(
+					array_map(
+						function ( $cart_item ) {
+							return isset( $cart_item['product_id'] ) ? intval( $cart_item['product_id'] ) : 0;
+						},
+						$cart_meta['cart']
+					)
+				)
+			)
+		);
+	}
 }
diff --git a/plugins/woocommerce/src/Internal/EmailEditor/BlockEmailRenderer.php b/plugins/woocommerce/src/Internal/EmailEditor/BlockEmailRenderer.php
index 47027110cd..b107025065 100644
--- a/plugins/woocommerce/src/Internal/EmailEditor/BlockEmailRenderer.php
+++ b/plugins/woocommerce/src/Internal/EmailEditor/BlockEmailRenderer.php
@@ -90,15 +90,29 @@ class BlockEmailRenderer {
 	 */
 	private function render_block_email( \WP_Post $email_post, string $woo_content, \WC_Email $wc_email ): ?string {
 		try {
+			// Set email context before rendering so blocks can access it.
+			$filter_callback = function ( $context = array() ) use ( $wc_email ) {
+				return array_merge( $context, $this->build_email_context( $wc_email ) );
+			};
+			add_filter( 'woocommerce_email_editor_rendering_email_context', $filter_callback, 10, 1 );
+
 			$subject             = $wc_email->get_subject(); // We will get subject from $email_post after we add it to the editor.
 			$preheader           = $wc_email->get_preheader();
 			$rendered_email_data = $this->renderer->render( $email_post, $subject, $preheader, 'en' );
 			$personalized_email  = $this->personalizer->personalize_content( $rendered_email_data['html'] );
 			$rendered_email      = str_replace( self::WOO_EMAIL_CONTENT_PLACEHOLDER, $woo_content, $personalized_email );
+
+			// Remove the filter after rendering to prevent context leakage.
+			remove_filter( 'woocommerce_email_editor_rendering_email_context', $filter_callback );
+
 			add_filter( 'woocommerce_email_styles', array( $this->woo_content_processor, 'prepare_css' ), 10, 2 );
 			return $rendered_email;
 		} catch ( \Exception $e ) {
 			wc_caught_exception( $e, __METHOD__, array( $email_post, $woo_content, $wc_email ) );
+			// Remove the filter in case of exception.
+			if ( isset( $filter_callback ) ) {
+				remove_filter( 'woocommerce_email_editor_rendering_email_context', $filter_callback );
+			}
 			return null;
 		}
 	}
@@ -112,4 +126,32 @@ class BlockEmailRenderer {
 	private function get_email_post_by_wc_email( \WC_Email $email ): ?\WP_Post {
 		return $this->template_manager->get_email_post( $email->id );
 	}
+
+	/**
+	 * Build email context from WC_Email object.
+	 *
+	 * Extracts relevant context data from the WC_Email object that can be used
+	 * by blocks during rendering, such as user ID, email address, order information, etc.
+	 *
+	 * Blocks that need cart product information can derive it from the user_id or email
+	 * using CartCheckoutUtils::get_cart_product_ids_for_user().
+	 *
+	 * @param \WC_Email $wc_email WooCommerce email object.
+	 * @return array Email context data.
+	 */
+	private function build_email_context( \WC_Email $wc_email ): array {
+		$recipient_raw = $wc_email->get_recipient();
+		$emails        = array_values( array_filter( array_map( 'sanitize_email', array_map( 'trim', explode( ',', $recipient_raw ) ) ) ) );
+		$context       = array(
+			'recipient_email' => $emails[0] ?? null,
+		);
+
+		// Extract order-related context if the email object is an order.
+		if ( isset( $wc_email->object ) && $wc_email->object instanceof \WC_Order ) {
+			$order              = $wc_email->object;
+			$context['user_id'] = $order->get_customer_id();
+		}
+
+		return $context;
+	}
 }