Commit ca3e2817254 for woocommerce

commit ca3e2817254d71e4a5c553d5c185191ebb2b68c1
Author: Tony Arcangelini <33258733+arcangelini@users.noreply.github.com>
Date:   Sat Jun 13 18:32:36 2026 +0200

    Email Editor: Add Vimeo, TikTok, and Dailymotion support to the embed renderer (#65502)

    * Add Vimeo support to email embed renderer

    * Add changelog entry for Vimeo email embeds

    * Add TikTok and Dailymotion support to email embed renderer

    * Update changelog entry to cover all new video providers

    * Refactor provider label switch to lookup map

    * Size video embed thumbnails using oEmbed aspect ratio

    * Consolidate video provider embed tests

    * Bump video thumbnail cache key to avoid stale cache shape

    * Back out aspect-ratio thumbnail sizing

    * Restore label switch and legacy thumbnail cache key

    * Consolidate video embed tests

    * Count oEmbed thumbnail fetches toward embed fetch cap

    * Check fetch cap after embed page cache lookup

diff --git a/packages/php/email-editor/changelog/add-email-rendering-for-more-video-embeds b/packages/php/email-editor/changelog/add-email-rendering-for-more-video-embeds
new file mode 100644
index 00000000000..a0bbe018e29
--- /dev/null
+++ b/packages/php/email-editor/changelog/add-email-rendering-for-more-video-embeds
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Add support for Vimeo, TikTok, and Dailymotion embeds in the Email Editor package.
diff --git a/packages/php/email-editor/src/Integrations/Core/Renderer/Blocks/class-embed.php b/packages/php/email-editor/src/Integrations/Core/Renderer/Blocks/class-embed.php
index 4c8ee64ffb7..bd5500d3a01 100644
--- a/packages/php/email-editor/src/Integrations/Core/Renderer/Blocks/class-embed.php
+++ b/packages/php/email-editor/src/Integrations/Core/Renderer/Blocks/class-embed.php
@@ -20,23 +20,39 @@ use Automattic\WooCommerce\EmailEditor\Integrations\Utils\Table_Wrapper_Helper;
  * This renderer handles core/embed blocks, detecting audio and video provider embeds and rendering them appropriately.
  *
  * Audio providers: Spotify, SoundCloud, Pocket Casts, Mixcloud, ReverbNation - rendered as audio players.
- * Video providers: YouTube - rendered as video thumbnails with play buttons.
+ * Video providers: YouTube, VideoPress, Vimeo, TikTok, Dailymotion - rendered as video thumbnails with play buttons.
  */
 class Embed extends Abstract_Block_Renderer {
 	/**
-	 * Maximum number of embed page fetch attempts per email render.
-	 * Beyond this limit, embeds render as compact link cards (no HTTP fetch).
+	 * Maximum number of embed HTTP fetch attempts per email render.
+	 * Covers both embed page fetches (rich cards) and oEmbed thumbnail lookups.
+	 * Beyond this limit, embeds render without fetching (compact link cards or link fallbacks).
 	 * Counts attempts, not successes, to cap outbound HTTP requests.
+	 * Cached results render normally and do not count toward the limit.
 	 */
 	private const MAX_EMBED_FETCHES = 5;

 	/**
-	 * Number of embed page fetch attempts so far by this instance.
+	 * Number of embed HTTP fetch attempts so far by this instance.
 	 *
 	 * @var int
 	 */
 	private int $embed_fetch_count = 0;

+	/**
+	 * Check whether an embed HTTP fetch may be performed and count the attempt.
+	 * Enforces the MAX_EMBED_FETCHES cap shared by embed page fetches and oEmbed thumbnail lookups.
+	 *
+	 * @return bool True if the fetch is within the limit and may proceed.
+	 */
+	private function may_attempt_embed_fetch(): bool {
+		if ( $this->embed_fetch_count >= self::MAX_EMBED_FETCHES ) {
+			return false;
+		}
+		++$this->embed_fetch_count;
+		return true;
+	}
+
 	/**
 	 * Supported audio providers with their configuration.
 	 *
@@ -71,14 +87,26 @@ class Embed extends Abstract_Block_Renderer {
 	 * @var array
 	 */
 	private const VIDEO_PROVIDERS = array(
-		'youtube'    => array(
+		'youtube'     => array(
 			'domains'  => array( 'youtube.com', 'youtu.be' ),
 			'base_url' => 'https://www.youtube.com/',
 		),
-		'videopress' => array(
+		'videopress'  => array(
 			'domains'  => array( 'videopress.com', 'video.wordpress.com' ),
 			'base_url' => 'https://videopress.com/',
 		),
+		'vimeo'       => array(
+			'domains'  => array( 'vimeo.com', 'player.vimeo.com' ),
+			'base_url' => 'https://vimeo.com/',
+		),
+		'tiktok'      => array(
+			'domains'  => array( 'tiktok.com' ),
+			'base_url' => 'https://www.tiktok.com/',
+		),
+		'dailymotion' => array(
+			'domains'  => array( 'dailymotion.com', 'dai.ly' ),
+			'base_url' => 'https://www.dailymotion.com/',
+		),
 	);

 	/**
@@ -169,15 +197,11 @@ class Embed extends Abstract_Block_Renderer {
 			$url         = $this->extract_provider_url( $attr, $block_content );
 			$is_wp_embed = isset( $attr['type'] ) && 'wp-embed' === $attr['type'];
 			if ( ! empty( $url ) && $is_wp_embed ) {
-				if ( $this->embed_fetch_count >= self::MAX_EMBED_FETCHES ) {
-					return $this->render_compact_link_card( $url, $parsed_block, $rendering_context );
-				}
-				++$this->embed_fetch_count;
 				$card_result = $this->render_link_embed_card( $url, $parsed_block, $rendering_context );
 				if ( ! empty( $card_result ) ) {
 					return $card_result;
 				}
-				// Fetch failed — render as compact link card instead of plain link.
+				// Fetch failed or over the fetch cap — render as compact link card instead of plain link.
 				return $this->render_compact_link_card( $url, $parsed_block, $rendering_context );
 			}
 			return $this->render_link_fallback( $attr, $block_content, $parsed_block, $rendering_context );
@@ -359,6 +383,12 @@ class Embed extends Abstract_Block_Renderer {
 				return __( 'Watch on YouTube', 'woocommerce' );
 			case 'videopress':
 				return __( 'Watch on VideoPress', 'woocommerce' );
+			case 'vimeo':
+				return __( 'Watch on Vimeo', 'woocommerce' );
+			case 'tiktok':
+				return __( 'Watch on TikTok', 'woocommerce' );
+			case 'dailymotion':
+				return __( 'Watch on Dailymotion', 'woocommerce' );
 			default:
 				return __( 'Listen to the audio', 'woocommerce' );
 		}
@@ -563,17 +593,15 @@ class Embed extends Abstract_Block_Renderer {
 	 * @return string Thumbnail URL or empty string.
 	 */
 	private function get_video_thumbnail_url( string $url, string $provider ): string {
+		// YouTube thumbnails follow a predictable URL pattern, so no HTTP request is needed.
 		if ( 'youtube' === $provider ) {
 			return $this->get_youtube_thumbnail( $url );
 		}

-		if ( 'videopress' === $provider ) {
-			return $this->get_videopress_thumbnail( $url );
-		}
-
-		// For other providers, we don't have thumbnail extraction implemented.
-		// Return empty to trigger link fallback.
-		return '';
+		// All other supported video providers (VideoPress, Vimeo, TikTok, Dailymotion)
+		// expose their thumbnails through the WordPress oEmbed API.
+		// Returns empty string on failure, which triggers the link fallback.
+		return $this->get_oembed_thumbnail( $url );
 	}

 	/**
@@ -600,18 +628,19 @@ class Embed extends Abstract_Block_Renderer {
 	}

 	/**
-	 * Extract VideoPress video thumbnail URL.
-	 * Uses WordPress oEmbed API to get thumbnail_url from the provider response.
+	 * Extract a video thumbnail URL via the WordPress oEmbed API.
+	 * Used by providers that expose thumbnails through oEmbed (e.g. VideoPress, Vimeo).
 	 * Results are cached using transients to avoid repeated HTTP requests.
 	 *
-	 * Note: URL validation against VideoPress domains is done in render_video_embed()
+	 * Note: URL validation against the provider's domains is done in render_video_embed()
 	 * via url_matches_provider() before this method is called.
 	 *
-	 * @param string $url VideoPress video URL (pre-validated by caller).
+	 * @param string $url Video URL (pre-validated by caller).
 	 * @return string Thumbnail URL or empty string.
 	 */
-	private function get_videopress_thumbnail( string $url ): string {
+	private function get_oembed_thumbnail( string $url ): string {
 		// Generate a cache key based on the URL.
+		// The legacy "vp" prefix is kept so existing VideoPress caches survive; the key is used for all oEmbed video thumbnails.
 		$cache_key = 'wc_email_vp_thumb_' . md5( $url );

 		// Check for cached thumbnail URL.
@@ -621,9 +650,15 @@ class Embed extends Abstract_Block_Renderer {
 			return is_string( $cached_thumbnail ) ? $cached_thumbnail : '';
 		}

+		// Enforce the per-render fetch cap before making an HTTP request.
+		// The result is intentionally not cached here, so the thumbnail can still be fetched on a later render.
+		if ( ! $this->may_attempt_embed_fetch() ) {
+			return '';
+		}
+
 		// Use WP_oEmbed::get_data() to fetch thumbnail from oEmbed endpoint.
 		// URL is pre-validated by render_video_embed() via url_matches_provider(),
-		// ensuring only VideoPress domains reach this point (SSRF mitigation).
+		// ensuring only the matched provider's domains reach this point (SSRF mitigation).
 		$oembed      = new \WP_oEmbed();
 		$oembed_data = $oembed->get_data( $url );

@@ -661,7 +696,7 @@ class Embed extends Abstract_Block_Renderer {
 		$thumbnail_url = $oembed_data->thumbnail_url;

 		// Validate the thumbnail URL.
-		if ( ! empty( $thumbnail_url ) && $this->is_valid_url( $thumbnail_url ) ) {
+		if ( is_string( $thumbnail_url ) && '' !== $thumbnail_url && $this->is_valid_url( $thumbnail_url ) ) {
 			// Cache the valid thumbnail URL.
 			set_transient( $cache_key, $thumbnail_url, $cache_ttl );
 			return $thumbnail_url;
@@ -727,6 +762,12 @@ class Embed extends Abstract_Block_Renderer {
 			return $empty_result;
 		}

+		// Enforce the per-render fetch cap before making an HTTP request.
+		// The result is intentionally not cached, so the page can still be fetched on a later render.
+		if ( ! $this->may_attempt_embed_fetch() ) {
+			return $empty_result;
+		}
+
 		/** This filter is documented in packages/php/email-editor/src/Integrations/Core/Renderer/Blocks/class-embed.php */
 		$cache_ttl = (int) apply_filters( 'oembed_ttl', DAY_IN_SECONDS, $url, array(), '' );

diff --git a/packages/php/email-editor/tests/integration/Integrations/Core/Renderer/Blocks/Embed_Test.php b/packages/php/email-editor/tests/integration/Integrations/Core/Renderer/Blocks/Embed_Test.php
index 3cfd6c718c3..4706425f304 100644
--- a/packages/php/email-editor/tests/integration/Integrations/Core/Renderer/Blocks/Embed_Test.php
+++ b/packages/php/email-editor/tests/integration/Integrations/Core/Renderer/Blocks/Embed_Test.php
@@ -725,6 +725,165 @@ class Embed_Test extends \Email_Editor_Integration_Test_Case {
 		$this->assertStringContainsString( 'background-image', $rendered, 'VideoPress embed should have background image' );
 	}

+	/**
+	 * Helper to mock the oEmbed HTTP response for video thumbnail lookups.
+	 *
+	 * @param string $endpoint_fragment Substring identifying the provider's oEmbed endpoint URL.
+	 * @param string $thumbnail_url Thumbnail URL to return in the mocked response.
+	 * @return callable The HTTP filter callback (for removal in cleanup).
+	 */
+	private function mock_oembed_thumbnail_response( string $endpoint_fragment, string $thumbnail_url ): callable {
+		$mock_oembed_response = wp_json_encode(
+			array(
+				'type'          => 'video',
+				'thumbnail_url' => $thumbnail_url,
+				'title'         => 'Test Video',
+			)
+		);
+
+		// Use pre_http_request filter to intercept oEmbed HTTP calls.
+		$filter_callback = function ( $preempt, $args, $url ) use ( $endpoint_fragment, $mock_oembed_response ) {
+			if ( strpos( $url, $endpoint_fragment ) !== false ) {
+				return array(
+					'response' => array( 'code' => 200 ),
+					'body'     => $mock_oembed_response,
+				);
+			}
+			return $preempt;
+		};
+
+		add_filter( 'pre_http_request', $filter_callback, 10, 3 );
+
+		return $filter_callback;
+	}
+
+	/**
+	 * Data provider for video provider oEmbed thumbnail rendering tests.
+	 *
+	 * @return array<string, array{array, string, string, array<string>}>
+	 */
+	public function video_embed_provider(): array {
+		return array(
+			// Vimeo URLs carry query parameters to verify they survive rendering (as &amp; in HTML).
+			'vimeo'              => array(
+				array(
+					'url'              => 'https://vimeo.com/123456789?w=500&h=281',
+					'providerNameSlug' => 'vimeo',
+				),
+				'vimeo.com/api/oembed',
+				'https://i.vimeocdn.com/video/123456789.jpg?w=500&h=281',
+				array( 'w=500', 'h=281' ),
+			),
+			'tiktok'             => array(
+				array(
+					'url'              => 'https://www.tiktok.com/@wordpress/video/7228005059881544986',
+					'providerNameSlug' => 'tiktok',
+				),
+				'tiktok.com/oembed',
+				'https://p16-sign.tiktokcdn-us.com/obj/tos-useast5-p-0068-tx/thumbnail.jpg',
+				array(),
+			),
+			// No providerNameSlug, so the dai.ly short URL exercises domain-based detection.
+			'dailymotion dai.ly' => array(
+				array( 'url' => 'https://dai.ly/x8x9abc' ),
+				'dailymotion.com/services/oembed',
+				'https://s1.dmcdn.net/v/X8x9abc/x720-thumbnail.jpg',
+				array(),
+			),
+		);
+	}
+
+	/**
+	 * Test that supported video provider embeds render as a video thumbnail with play button via the oEmbed API.
+	 *
+	 * @dataProvider video_embed_provider
+	 * @param array         $attrs Block attributes.
+	 * @param string        $endpoint_fragment Substring identifying the provider's oEmbed endpoint URL.
+	 * @param string        $thumbnail_url Thumbnail URL returned by the mocked oEmbed response.
+	 * @param array<string> $extra_expected_strings Additional strings expected in the rendered output.
+	 */
+	public function test_renders_video_provider_embed_via_oembed( array $attrs, string $endpoint_fragment, string $thumbnail_url, array $extra_expected_strings ): void {
+		// Mock the oEmbed HTTP response to avoid external calls in CI.
+		$filter_callback = $this->mock_oembed_thumbnail_response( $endpoint_fragment, $thumbnail_url );
+
+		$parsed_embed = array(
+			'blockName' => 'core/embed',
+			'attrs'     => $attrs,
+			'innerHTML' => '<figure class="wp-block-embed is-type-video"><div class="wp-block-embed__wrapper">' . $attrs['url'] . '</div></figure>',
+		);
+
+		try {
+			$rendered = $this->embed_renderer->render( $parsed_embed['innerHTML'], $parsed_embed, $this->rendering_context );
+		} finally {
+			remove_filter( 'pre_http_request', $filter_callback, 10 );
+		}
+
+		// Should detect the provider and render as video with play button and thumbnail.
+		$this->assertNotEmpty( $rendered );
+		$this->assertStringContainsString( 'play2x.png', $rendered, 'Embed should render with play button' );
+		// Verify background-image is present (not stripped by WP_Style_Engine).
+		$this->assertStringContainsString( 'background-image', $rendered, 'Background image should be present in CSS' );
+		foreach ( $extra_expected_strings as $expected_string ) {
+			$this->assertStringContainsString( $expected_string, $rendered, 'Expected string missing from rendered embed' );
+		}
+	}
+
+	/**
+	 * Test that oEmbed thumbnail lookups count toward the per-render HTTP fetch cap.
+	 */
+	public function test_caps_oembed_thumbnail_fetches_at_five_per_render(): void {
+		$request_count   = 0;
+		$filter_callback = function ( $preempt, $args, $url ) use ( &$request_count ) {
+			if ( strpos( $url, 'vimeo.com/api/oembed' ) !== false ) {
+				++$request_count;
+				return array(
+					'response' => array( 'code' => 200 ),
+					'body'     => wp_json_encode(
+						array(
+							'type'          => 'video',
+							'thumbnail_url' => 'https://i.vimeocdn.com/video/123456789.jpg',
+						)
+					),
+				);
+			}
+			return $preempt;
+		};
+		add_filter( 'pre_http_request', $filter_callback, 10, 3 );
+
+		$urls = array();
+		try {
+			for ( $i = 1; $i <= 6; $i++ ) {
+				$url    = 'https://vimeo.com/10000000' . $i;
+				$urls[] = $url;
+
+				$parsed_block = array(
+					'blockName' => 'core/embed',
+					'attrs'     => array(
+						'url'              => $url,
+						'providerNameSlug' => 'vimeo',
+					),
+					'innerHTML' => '<figure class="wp-block-embed is-type-video"><div class="wp-block-embed__wrapper">' . $url . '</div></figure>',
+				);
+
+				$rendered = $this->embed_renderer->render( $parsed_block['innerHTML'], $parsed_block, $this->rendering_context );
+
+				if ( $i <= 5 ) {
+					$this->assertStringContainsString( 'background-image', $rendered, "Embed #{$i} should render with a thumbnail" );
+				} else {
+					$this->assertStringNotContainsString( 'background-image', $rendered, 'Embed #6 should NOT render with a thumbnail' );
+					$this->assertStringContainsString( '<a href="https://vimeo.com/100000006"', $rendered, 'Embed #6 should render as a link fallback' );
+				}
+			}
+		} finally {
+			remove_filter( 'pre_http_request', $filter_callback, 10 );
+			foreach ( $urls as $url ) {
+				delete_transient( 'wc_email_vp_thumb_' . md5( $url ) );
+			}
+		}
+
+		$this->assertSame( 5, $request_count, 'Only five oEmbed thumbnail fetches should be made per render' );
+	}
+
 	/**
 	 * Helper to mock the embed page HTTP response for example.com URLs.
 	 *
@@ -1141,6 +1300,73 @@ class Embed_Test extends \Email_Editor_Integration_Test_Case {
 		}
 	}

+	/**
+	 * Test that cached rich cards render even after the per-render fetch cap is exhausted.
+	 */
+	public function test_renders_cached_rich_card_after_fetch_cap_exhausted(): void {
+		// Pre-warm the embed page cache for one URL to simulate a previous render.
+		$cached_url = 'https://example.com/cached-post';
+		set_transient(
+			'wc_email_embed_pg_' . md5( $cached_url ),
+			array(
+				'title'         => 'Cached Card Title',
+				'thumbnail_url' => '',
+				'provider_name' => 'Example Blog',
+				'provider_url'  => 'https://example.com',
+				'excerpt'       => '',
+				'site_icon_url' => '',
+			),
+			DAY_IN_SECONDS
+		);
+
+		$embed_page_html = '<html><body><div class="wp-embed">'
+			. '<p class="wp-embed-heading"><a href="https://example.com/post" target="_top">Fetched Card Title</a></p>'
+			. '<div class="wp-embed-site-title"><a href="https://example.com" target="_top">'
+			. '<span>Example Blog</span></a></div>'
+			. '</div></body></html>';
+		$filter_callback = $this->mock_embed_page_for_example_com( $embed_page_html );
+
+		$urls = array();
+		try {
+			// Exhaust the five fetch slots with uncached embeds.
+			for ( $i = 1; $i <= 5; $i++ ) {
+				$url    = 'https://example.com/uncached-' . $i;
+				$urls[] = $url;
+
+				$parsed_block = array(
+					'blockName' => 'core/embed',
+					'attrs'     => array(
+						'url'              => $url,
+						'type'             => 'wp-embed',
+						'providerNameSlug' => 'example-blog',
+					),
+					'innerHTML' => '<figure class="wp-block-embed is-type-wp-embed"><div class="wp-block-embed__wrapper">' . $url . '</div></figure>',
+				);
+				$this->embed_renderer->render( $parsed_block['innerHTML'], $parsed_block, $this->rendering_context );
+			}
+
+			// The cached embed should still render as a rich card without an HTTP fetch.
+			$parsed_cached_block = array(
+				'blockName' => 'core/embed',
+				'attrs'     => array(
+					'url'              => $cached_url,
+					'type'             => 'wp-embed',
+					'providerNameSlug' => 'example-blog',
+				),
+				'innerHTML' => '<figure class="wp-block-embed is-type-wp-embed"><div class="wp-block-embed__wrapper">' . $cached_url . '</div></figure>',
+			);
+			$rendered            = $this->embed_renderer->render( $parsed_cached_block['innerHTML'], $parsed_cached_block, $this->rendering_context );
+		} finally {
+			remove_filter( 'pre_http_request', $filter_callback, 10 );
+			delete_transient( 'wc_email_embed_pg_' . md5( $cached_url ) );
+			foreach ( $urls as $url ) {
+				delete_transient( 'wc_email_embed_pg_' . md5( $url ) );
+			}
+		}
+
+		$this->assertStringContainsString( 'Cached Card Title', $rendered, 'Cached embed should render as a rich card after the fetch cap is exhausted' );
+	}
+
 	/**
 	 * Test that compact link card shows URL in a styled card with theme link color
 	 */