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( ']]>', ']]>', $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;
+ }
}