Commit 2c9bdae7d0e for woocommerce

commit 2c9bdae7d0ea9afd77b38794db35dbed92fd9984
Author: Rostislav Wolný <1082140+costasovo@users.noreply.github.com>
Date:   Tue Jul 28 09:41:03 2026 +0200

    Enable the embed block in the email editor (#66968)

    * Enable core/embed block in the email editor

    The email renderer already supported embeds; the block was render-only
    and could not be inserted. Move it to ALLOWED_BLOCK_TYPES so it becomes
    insertable, which sets supports.email and surfaces it in the editor's
    inserter.

    * Add the embed block enhancer to the email editor

    Adapt core/embed to the constraints of email. Only the video providers
    the renderer can show as clickable thumbnails (plus the WordPress
    embed, rendered as a rich link card) are offered in the inserter; audio
    providers are left out because they render as a bare link button that
    users can build better with core blocks. The responsive attribute is
    dropped since responsive iframe sizing has no effect in email, and any
    embed that would be sent as a link shows a warning so the editor
    reflects the delivered result.

    * Render embed captions and support more YouTube URL formats in emails

    Captions authored on an embed were shown in the editor but dropped from
    the sent email; they now render centered below the embed output, with a
    matching editor-canvas rule so the editor mirrors the result. The
    shared caption sanitization helper is fixed to reliably restrict
    captions to the allowed set of tags and attributes. YouTube Shorts,
    live, and uppercase-host URLs previewed fine but degraded to plain
    links because the thumbnail regex only matched watch?v= and youtu.be;
    it now covers those forms, and other shapes such as playlists fall back
    to the cached oEmbed thumbnail lookup used by the other video
    providers.

diff --git a/packages/js/email-editor/changelog/wooplug-6181-enable-embed-block b/packages/js/email-editor/changelog/wooplug-6181-enable-embed-block
new file mode 100644
index 00000000000..d3a08486fe1
--- /dev/null
+++ b/packages/js/email-editor/changelog/wooplug-6181-enable-embed-block
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Enable the embed block for supported video providers in the email editor and improve embed rendering in emails.
diff --git a/packages/js/email-editor/src/blocks/core/embed.tsx b/packages/js/email-editor/src/blocks/core/embed.tsx
new file mode 100644
index 00000000000..27d77887e58
--- /dev/null
+++ b/packages/js/email-editor/src/blocks/core/embed.tsx
@@ -0,0 +1,154 @@
+/**
+ * External dependencies
+ */
+import { Notice } from '@wordpress/components';
+import { createHigherOrderComponent } from '@wordpress/compose';
+import { useDispatch } from '@wordpress/data';
+import { useEffect } from '@wordpress/element';
+import type { BlockConfiguration, BlockEditProps } from '@wordpress/blocks';
+import { __ } from '@wordpress/i18n';
+
+/**
+ * Internal dependencies
+ */
+import { updateBlockSettings } from '../../config-tools/block-config';
+import { addFilterForEmail } from '../../config-tools/filters';
+
+// Providers the email renderer can render as a clickable video thumbnail.
+// Keep in sync with VIDEO_PROVIDERS in
+// packages/php/email-editor/src/Integrations/Core/Renderer/Blocks/class-embed.php.
+// The renderer's AUDIO_PROVIDERS are deliberately not offered in the editor:
+// they render as a bare "Listen on …" link button, which users can build
+// better themselves with core blocks. The renderer keeps supporting them for
+// stored content, so audio URLs entered manually get the warning notice.
+const supportedProviderDomains: Record< string, string[] > = {
+	youtube: [ 'youtube.com', 'youtu.be' ],
+	videopress: [ 'videopress.com', 'video.wordpress.com' ],
+	vimeo: [ 'vimeo.com', 'player.vimeo.com' ],
+	tiktok: [ 'tiktok.com' ],
+	dailymotion: [ 'dailymotion.com', 'dai.ly' ],
+};
+
+export function isSupportedProviderUrl( url: string ): boolean {
+	let hostname = '';
+	try {
+		hostname = new URL( url ).hostname;
+	} catch {
+		return false;
+	}
+	return Object.values( supportedProviderDomains ).some( ( domains ) =>
+		domains.some(
+			( domain ) =>
+				hostname === domain || hostname.endsWith( `.${ domain }` )
+		)
+	);
+}
+
+interface EmbedAttributes {
+	url?: string;
+	type?: string;
+	responsive?: boolean;
+	[ key: string ]: unknown;
+}
+
+const withEmailEmbedAdjustments = createHigherOrderComponent(
+	( BlockEdit ) =>
+		function EmailEmbedAdjustments(
+			props: BlockEditProps< EmbedAttributes > & { name: string }
+		) {
+			const isEmbed = props.name === 'core/embed';
+			const { responsive } = props.attributes;
+			const { setAttributes } = props;
+			const blockEditorDispatch = useDispatch( 'core/block-editor' );
+			// Reset the responsive attribute on embeds coming from stored or
+			// pasted content (see removeUnsupportedVariations). The change is
+			// marked non-persistent so it neither dirties a freshly opened
+			// email nor creates an undo step the user gets stuck on.
+			useEffect( () => {
+				if ( isEmbed && responsive ) {
+					// @ts-expect-error Unstable action is missing from the dispatch types.
+					blockEditorDispatch.__unstableMarkNextChangeAsNotPersistent();
+					setAttributes( { responsive: false } );
+				}
+			}, [ isEmbed, responsive, setAttributes, blockEditorDispatch ] );
+
+			if ( ! isEmbed ) {
+				return <BlockEdit { ...props } />;
+			}
+			const { url, type } = props.attributes;
+			// WordPress embeds are rendered as a rich link card in the email, so no warning is needed.
+			if (
+				! url ||
+				type === 'wp-embed' ||
+				isSupportedProviderUrl( url )
+			) {
+				return <BlockEdit { ...props } />;
+			}
+			return (
+				<>
+					<Notice status="warning" isDismissible={ false }>
+						{ __(
+							'This embed is not supported in emails. It will be sent as a link.',
+							__i18n_text_domain__
+						) }
+					</Notice>
+					<BlockEdit { ...props } />
+				</>
+			);
+		},
+	'withEmailEmbedAdjustments'
+);
+
+// The `wordpress` variation is kept although it has no domain entry: WordPress
+// embeds are detected via the `wp-embed` type attribute and rendered as a rich
+// link card in the email.
+const keptVariations = [
+	...Object.keys( supportedProviderDomains ),
+	'wordpress',
+];
+
+function removeUnsupportedVariations() {
+	updateBlockSettings( 'core/embed', ( current ) => ( {
+		...current,
+		variations:
+			// @ts-expect-error Type BlockConfiguration is missing variations.
+			( ( current as BlockConfiguration ).variations || [] )
+				.filter( ( variation: { name: string } ) =>
+					keptVariations.includes( variation.name )
+				)
+				// Drop the responsive attribute: responsive iframe sizing has
+				// no effect in emails, and a truthy value would only surface a
+				// "Media settings" panel with a resize toggle that does nothing.
+				.map(
+					( variation: {
+						attributes?: Record< string, unknown >;
+					} ) => {
+						const { responsive, ...attributes } =
+							variation.attributes || {};
+						return { ...variation, attributes };
+					}
+				),
+	} ) );
+}
+
+function addEmailEmbedAdjustments() {
+	addFilterForEmail(
+		'editor.BlockEdit',
+		'woocommerce-email-editor/embed-adjustments',
+		withEmailEmbedAdjustments
+	);
+}
+
+/**
+ * Enhances the embed block for the email editor: only the video providers the
+ * email renderer can render as clickable thumbnails (plus the WordPress embed,
+ * rendered as a rich link card) are offered in the inserter, without the
+ * responsive behavior that has no effect in emails; embeds that would be sent
+ * as a link show a warning in the editor.
+ */
+function enhanceEmbedBlock() {
+	removeUnsupportedVariations();
+	addEmailEmbedAdjustments();
+}
+
+export { enhanceEmbedBlock };
diff --git a/packages/js/email-editor/src/blocks/core/test/embed.spec.tsx b/packages/js/email-editor/src/blocks/core/test/embed.spec.tsx
new file mode 100644
index 00000000000..27fc8b722d4
--- /dev/null
+++ b/packages/js/email-editor/src/blocks/core/test/embed.spec.tsx
@@ -0,0 +1,212 @@
+/**
+ * External dependencies
+ */
+import { render, screen } from '@testing-library/react';
+import { applyFilters } from '@wordpress/hooks';
+import {
+	registerBlockType,
+	unregisterBlockType,
+	getBlockType,
+} from '@wordpress/blocks';
+
+/**
+ * Internal dependencies
+ */
+import { enhanceEmbedBlock, isSupportedProviderUrl } from '../embed';
+import { clearAllEmailHooks } from '../../../config-tools/filters';
+import { restoreAllModifiedBlockSettings } from '../../../config-tools/block-config';
+
+jest.mock( '@wordpress/components', () => ( {
+	Notice: ( { children }: { children: React.ReactNode } ) => (
+		<div role="alert">{ children }</div>
+	),
+} ) );
+
+jest.mock( '@wordpress/data', () => ( {
+	...jest.requireActual( '@wordpress/data' ),
+	useDispatch: jest.fn( () => ( {
+		__unstableMarkNextChangeAsNotPersistent: jest.fn(),
+	} ) ),
+} ) );
+
+describe( 'isSupportedProviderUrl', () => {
+	it( 'accepts URLs from providers supported by the email renderer', () => {
+		expect(
+			isSupportedProviderUrl( 'https://www.youtube.com/watch?v=abc' )
+		).toBe( true );
+		expect( isSupportedProviderUrl( 'https://youtu.be/abc' ) ).toBe( true );
+		expect( isSupportedProviderUrl( 'https://vimeo.com/123' ) ).toBe(
+			true
+		);
+		expect( isSupportedProviderUrl( 'https://videopress.com/v/abc' ) ).toBe(
+			true
+		);
+	} );
+
+	it( 'rejects audio provider URLs (rendered as a plain link button)', () => {
+		expect(
+			isSupportedProviderUrl( 'https://open.spotify.com/track/abc' )
+		).toBe( false );
+		expect(
+			isSupportedProviderUrl( 'https://soundcloud.com/forss/flickermood' )
+		).toBe( false );
+	} );
+
+	it( 'rejects URLs from unsupported providers', () => {
+		expect(
+			isSupportedProviderUrl( 'https://twitter.com/user/status/1' )
+		).toBe( false );
+		expect(
+			isSupportedProviderUrl( 'https://example.com/youtube.com' )
+		).toBe( false );
+		expect( isSupportedProviderUrl( 'https://notyoutube.com/watch' ) ).toBe(
+			false
+		);
+		expect( isSupportedProviderUrl( 'not-a-url' ) ).toBe( false );
+	} );
+} );
+
+describe( 'enhanceEmbedBlock', () => {
+	beforeEach( () => {
+		registerBlockType( 'core/embed', {
+			apiVersion: 3,
+			title: 'Embed',
+			category: 'embed',
+			attributes: {},
+			variations: [
+				{
+					name: 'youtube',
+					title: 'YouTube',
+					attributes: {
+						providerNameSlug: 'youtube',
+						responsive: true,
+					},
+				},
+				{ name: 'spotify', title: 'Spotify' },
+				{ name: 'twitter', title: 'Twitter' },
+				{ name: 'facebook', title: 'Facebook' },
+				{ name: 'wordpress', title: 'WordPress' },
+			],
+		} );
+		enhanceEmbedBlock();
+	} );
+
+	afterEach( () => {
+		clearAllEmailHooks();
+		restoreAllModifiedBlockSettings();
+		unregisterBlockType( 'core/embed' );
+	} );
+
+	it( 'removes all variations except video providers and wordpress', () => {
+		const variations = getBlockType( 'core/embed' )?.variations ?? [];
+		expect( variations.map( ( v ) => v.name ) ).toEqual( [
+			'youtube',
+			'wordpress',
+		] );
+	} );
+
+	const OriginalBlockEdit = () => <div>original edit</div>;
+	const renderFiltered = ( name: string, attributes: object ) => {
+		const FilteredBlockEdit = applyFilters(
+			'editor.BlockEdit',
+			OriginalBlockEdit
+		) as React.ElementType;
+		render(
+			<FilteredBlockEdit
+				name={ name }
+				attributes={ attributes }
+				setAttributes={ jest.fn() }
+			/>
+		);
+	};
+
+	it( 'shows a warning for embeds from unsupported providers', () => {
+		renderFiltered( 'core/embed', {
+			url: 'https://twitter.com/user/status/1',
+		} );
+		expect( screen.getByRole( 'alert' ) ).toHaveTextContent(
+			'This embed is not supported in emails. It will be sent as a link.'
+		);
+		expect( screen.getByText( 'original edit' ) ).toBeInTheDocument();
+	} );
+
+	it( 'warns for audio provider embeds', () => {
+		renderFiltered( 'core/embed', {
+			url: 'https://open.spotify.com/track/abc',
+		} );
+		expect( screen.getByRole( 'alert' ) ).toBeInTheDocument();
+	} );
+
+	it( 'does not warn for supported providers', () => {
+		renderFiltered( 'core/embed', {
+			url: 'https://vimeo.com/123',
+		} );
+		expect( screen.queryByRole( 'alert' ) ).not.toBeInTheDocument();
+	} );
+
+	it( 'does not warn for WordPress embeds', () => {
+		renderFiltered( 'core/embed', {
+			url: 'https://wordpress.org/news/post',
+			type: 'wp-embed',
+		} );
+		expect( screen.queryByRole( 'alert' ) ).not.toBeInTheDocument();
+	} );
+
+	it( 'does not warn for embeds without a URL', () => {
+		renderFiltered( 'core/embed', {} );
+		expect( screen.queryByRole( 'alert' ) ).not.toBeInTheDocument();
+	} );
+
+	it( 'removes the responsive attribute from kept variations', () => {
+		const variations = getBlockType( 'core/embed' )?.variations ?? [];
+		const youtube = variations.find( ( v ) => v.name === 'youtube' );
+		expect( youtube?.attributes ).toEqual( {
+			providerNameSlug: 'youtube',
+		} );
+	} );
+
+	it( 'resets the responsive attribute on embeds from stored content', () => {
+		const setAttributes = jest.fn();
+		const FilteredBlockEdit = applyFilters(
+			'editor.BlockEdit',
+			OriginalBlockEdit
+		) as React.ElementType;
+		render(
+			<FilteredBlockEdit
+				name="core/embed"
+				attributes={ {
+					url: 'https://vimeo.com/123',
+					responsive: true,
+				} }
+				setAttributes={ setAttributes }
+			/>
+		);
+		expect( setAttributes ).toHaveBeenCalledWith( { responsive: false } );
+	} );
+
+	it( 'does not reset the responsive attribute when it is already false', () => {
+		const setAttributes = jest.fn();
+		const FilteredBlockEdit = applyFilters(
+			'editor.BlockEdit',
+			OriginalBlockEdit
+		) as React.ElementType;
+		render(
+			<FilteredBlockEdit
+				name="core/embed"
+				attributes={ {
+					url: 'https://vimeo.com/123',
+					responsive: false,
+				} }
+				setAttributes={ setAttributes }
+			/>
+		);
+		expect( setAttributes ).not.toHaveBeenCalled();
+	} );
+
+	it( 'does not affect other blocks', () => {
+		renderFiltered( 'core/paragraph', {
+			url: 'https://twitter.com/user/status/1',
+		} );
+		expect( screen.queryByRole( 'alert' ) ).not.toBeInTheDocument();
+	} );
+} );
diff --git a/packages/js/email-editor/src/blocks/index.ts b/packages/js/email-editor/src/blocks/index.ts
index 3f5fb00f3ee..a51acdf64fb 100644
--- a/packages/js/email-editor/src/blocks/index.ts
+++ b/packages/js/email-editor/src/blocks/index.ts
@@ -20,6 +20,7 @@ import {
 	activatePersonalizationTagsReplacing,
 } from './core/rich-text';
 import { enhanceButtonsBlock } from './core/buttons';
+import { enhanceEmbedBlock } from './core/embed';
 import {
 	alterSupportConfiguration,
 	removeBlockStylesFromAllBlocks,
@@ -47,6 +48,7 @@ export function initBlocks() {
 	disableColumnsLayoutAndEnhanceColumnsBlock();
 	disableGroupVariations();
 	enhanceButtonsBlock();
+	enhanceEmbedBlock();
 	enhancePostContentBlock();
 	enhanceQuoteBlock();
 	extendRichTextFormats();
diff --git a/packages/php/email-editor/changelog/wooplug-6181-enable-embed-block b/packages/php/email-editor/changelog/wooplug-6181-enable-embed-block
new file mode 100644
index 00000000000..d3a08486fe1
--- /dev/null
+++ b/packages/php/email-editor/changelog/wooplug-6181-enable-embed-block
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Enable the embed block for supported video providers in the email editor and improve embed rendering in emails.
diff --git a/packages/php/email-editor/src/Engine/content-editor.css b/packages/php/email-editor/src/Engine/content-editor.css
index 852181eb3cf..de8999eff7a 100644
--- a/packages/php/email-editor/src/Engine/content-editor.css
+++ b/packages/php/email-editor/src/Engine/content-editor.css
@@ -89,6 +89,14 @@
 	font-weight: bold;
 }

+/*
+ * Embed block enhancements
+ */
+.wp-block-embed figcaption {
+	/* Center-align embed captions like core WordPress, matching the email renderer and the table block */
+	text-align: center;
+}
+
 .wp-block-image.alignleft,
 .wp-block-image.alignright {
 	margin-inline: 0 0;
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 bd5500d3a01..76252f96045 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
@@ -186,6 +186,23 @@ class Embed extends Abstract_Block_Renderer {
 			return '';
 		}

+		$rendered = $this->render_embed( $block_content, $parsed_block, $rendering_context );
+		if ( '' === $rendered ) {
+			return '';
+		}
+
+		return $rendered . $this->render_caption( $block_content );
+	}
+
+	/**
+	 * Renders the embed itself (player, thumbnail, card or link fallback) without the caption.
+	 *
+	 * @param string            $block_content Block content.
+	 * @param array             $parsed_block Parsed block.
+	 * @param Rendering_Context $rendering_context Rendering context.
+	 * @return string
+	 */
+	private function render_embed( string $block_content, array $parsed_block, Rendering_Context $rendering_context ): string {
 		$attr = $parsed_block['attrs'];

 		// Check if this is a supported audio or video provider embed and has a valid URL.
@@ -217,6 +234,25 @@ class Embed extends Abstract_Block_Renderer {
 		return $this->render_content( $block_content, $parsed_block, $rendering_context );
 	}

+	/**
+	 * Render the block's figcaption (if any) as a centered block appended below the embed output.
+	 *
+	 * @param string $block_content Block content.
+	 * @return string Caption HTML or empty string.
+	 */
+	private function render_caption( string $block_content ): string {
+		if ( ! preg_match( '/<figcaption[^>]*>(.*?)<\/figcaption>/is', $block_content, $matches ) ) {
+			return '';
+		}
+
+		$caption = trim( $matches[1] );
+		if ( '' === $caption ) {
+			return '';
+		}
+
+		return '<div style="text-align: center; margin-top: 8px;">' . Html_Processing_Helper::sanitize_caption_html( $caption ) . '</div>';
+	}
+
 	/**
 	 * Renders the embed block content.
 	 *
@@ -595,7 +631,12 @@ class Embed extends Abstract_Block_Renderer {
 	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 );
+			$thumbnail = $this->get_youtube_thumbnail( $url );
+			if ( '' !== $thumbnail ) {
+				return $thumbnail;
+			}
+			// YouTube URLs without an extractable video ID (e.g. playlists)
+			// fall through to the oEmbed thumbnail lookup below.
 		}

 		// All other supported video providers (VideoPress, Vimeo, TikTok, Dailymotion)
@@ -611,10 +652,12 @@ class Embed extends Abstract_Block_Renderer {
 	 * @return string Thumbnail URL or empty string.
 	 */
 	private function get_youtube_thumbnail( string $url ): string {
-		// Extract video ID from various YouTube URL formats.
+		// Extract video ID from various YouTube URL formats. The `videoseries`
+		// token (used by playlist embed URLs like /embed/videoseries?list=…) is
+		// not a real video ID, so it is excluded to fall through to oEmbed.
 		$video_id = '';

-		if ( preg_match( '/(?:youtube\.com\/watch\?v=|youtu\.be\/)([a-zA-Z0-9_-]+)/', $url, $matches ) ) {
+		if ( preg_match( '/(?:youtube\.com\/(?:watch\?v=|shorts\/|live\/|embed\/)|youtu\.be\/)(?!videoseries\b)([a-zA-Z0-9_-]+)/i', $url, $matches ) ) {
 			$video_id = $matches[1];
 		}

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 a99bc41be24..020c44d42c3 100644
--- a/packages/php/email-editor/src/Integrations/Core/class-initializer.php
+++ b/packages/php/email-editor/src/Integrations/Core/class-initializer.php
@@ -46,6 +46,7 @@ class Initializer {
 		'core/buttons',
 		'core/column',
 		'core/columns',
+		'core/embed',
 		'core/group',
 		'core/heading',
 		'core/image',
@@ -73,7 +74,6 @@ class Initializer {
 		'core/gallery',
 		'core/media-text',
 		'core/audio',
-		'core/embed',
 		'core/cover',
 		'core/video',
 		'core/post-title',
diff --git a/packages/php/email-editor/src/Integrations/Utils/class-html-processing-helper.php b/packages/php/email-editor/src/Integrations/Utils/class-html-processing-helper.php
index bb75fb5732c..a1c83311f7c 100644
--- a/packages/php/email-editor/src/Integrations/Utils/class-html-processing-helper.php
+++ b/packages/php/email-editor/src/Integrations/Utils/class-html-processing-helper.php
@@ -452,7 +452,8 @@ class Html_Processing_Helper {

 		// First pass: Process attributes for allowed tags only.
 		while ( $html->next_tag() ) {
-			$tag_name = $html->get_tag();
+			// get_tag() returns the tag name uppercased, so normalize before comparing.
+			$tag_name = strtolower( (string) $html->get_tag() );

 			// Skip processing for disallowed tags.
 			if ( ! in_array( $tag_name, $allowed_tags, true ) ) {
@@ -469,29 +470,25 @@ class Html_Processing_Helper {
 			}
 		}

-		// Second pass: Remove disallowed tags using a simple regex approach.
+		// Second pass: strip every tag that is not in the allow-list, keeping its
+		// inner text. This covers opening, closing, self-closing and void tags
+		// (e.g. <img>, <svg>) in one pass.
 		$final_html = $html->get_updated_html();

-		// Create a regex pattern to match disallowed tags.
-		$allowed_tags_pattern = implode( '|', array_map( 'preg_quote', $allowed_tags ) );
+		$allowed_tags_pattern = implode( '|', array_map( static fn( $tag ) => preg_quote( $tag, '/' ), $allowed_tags ) );

-		// Remove disallowed opening and closing tags, keeping only their content.
-		$result = preg_replace( '/<(?!(?:' . $allowed_tags_pattern . ')\b)[^>]*>(.*?)<\/(?!(?:' . $allowed_tags_pattern . ')\b)[^>]*>/s', '$1', $final_html );
-		if ( null === $result ) {
-			$final_html = '';
-		} else {
-			$final_html = $result;
-		}
-
-		// Remove disallowed self-closing tags.
-		$result = preg_replace( '/<(?!(?:' . $allowed_tags_pattern . ')\b)[^>]*\/>/s', '', $final_html );
-		if ( null === $result ) {
-			$final_html = '';
-		} else {
-			$final_html = $result;
-		}
+		/*
+		 * Match a tag whose name is not allowed and remove it:
+		 *   <\/?                       an opening or closing tag.
+		 *   (?! allowed (?=[\s/>]|$) ) skip allowed names, but only when the name
+		 *                              ends at a real tag-name terminator, so a
+		 *                              custom element like "a-b" is not treated
+		 *                              as the allowed "a".
+		 *   [a-z][^>]*>                the tag name and the rest of the tag.
+		 */
+		$result = preg_replace( '/<\/?(?!(?:' . $allowed_tags_pattern . ')(?=[\s\/>]|$))[a-z][^>]*>/is', '', $final_html );

-		return $final_html;
+		return null === $result ? '' : $result;
 	}

 	/**
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 4706425f304..58d852b4759 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
@@ -534,6 +534,172 @@ class Embed_Test extends \Email_Editor_Integration_Test_Case {
 		$this->assertStringContainsString( 'play2x.png', $rendered );
 	}

+	/**
+	 * Test that YouTube embed handles Shorts and live URLs
+	 */
+	public function test_youtube_embed_handles_shorts_and_live_urls(): void {
+		foreach ( array( 'https://www.youtube.com/shorts/dQw4w9WgXcQ', 'https://www.youtube.com/live/dQw4w9WgXcQ', 'https://YouTube.com/watch?v=dQw4w9WgXcQ' ) as $url ) {
+			$parsed_youtube_embed = array(
+				'blockName' => 'core/embed',
+				'attrs'     => array(
+					'url'              => $url,
+					'type'             => 'video',
+					'providerNameSlug' => 'youtube',
+					'responsive'       => true,
+				),
+				'innerHTML' => '<figure class="wp-block-embed is-type-video is-provider-youtube wp-block-embed-youtube"><div class="wp-block-embed__wrapper">' . $url . '</div></figure>',
+			);
+
+			$rendered = $this->embed_renderer->render( $parsed_youtube_embed['innerHTML'], $parsed_youtube_embed, $this->rendering_context );
+
+			$this->assertStringContainsString( 'https://img.youtube.com/vi/dQw4w9WgXcQ/0.jpg', $rendered, "Thumbnail missing for {$url}" );
+			$this->assertStringContainsString( 'play2x.png', $rendered, "Play button missing for {$url}" );
+		}
+	}
+
+	/**
+	 * Test that YouTube URLs without an extractable video ID fall back to the oEmbed thumbnail lookup
+	 */
+	public function test_youtube_embed_falls_back_to_oembed_thumbnail_for_playlists(): void {
+		$mock_thumbnail_url   = 'https://i.ytimg.com/vi/abc/hqdefault.jpg';
+		$mock_oembed_response = wp_json_encode(
+			array(
+				'type'          => 'video',
+				'thumbnail_url' => $mock_thumbnail_url,
+				'title'         => 'Test Playlist',
+			)
+		);
+
+		// Use pre_http_request filter to intercept oEmbed HTTP calls.
+		$filter_callback = function ( $preempt, $args, $url ) use ( $mock_oembed_response ) {
+			if ( strpos( $url, 'youtube.com/oembed' ) !== false ) {
+				return array(
+					'response' => array( 'code' => 200 ),
+					'body'     => $mock_oembed_response,
+				);
+			}
+			return $preempt;
+		};
+
+		add_filter( 'pre_http_request', $filter_callback, 10, 3 );
+
+		$playlist_url         = 'https://www.youtube.com/playlist?list=PL590L5WQmH8dpP0RyH5pCfIaDEdt9nk7r';
+		$cache_key            = 'wc_email_vp_thumb_' . md5( $playlist_url );
+		$parsed_youtube_embed = array(
+			'blockName' => 'core/embed',
+			'attrs'     => array(
+				'url'              => $playlist_url,
+				'type'             => 'video',
+				'providerNameSlug' => 'youtube',
+				'responsive'       => true,
+			),
+			'innerHTML' => '<figure class="wp-block-embed is-type-video is-provider-youtube wp-block-embed-youtube"><div class="wp-block-embed__wrapper">' . $playlist_url . '</div></figure>',
+		);
+
+		// Clear any cached thumbnail so the mocked oEmbed response is exercised.
+		delete_transient( $cache_key );
+
+		try {
+			$rendered = $this->embed_renderer->render( $parsed_youtube_embed['innerHTML'], $parsed_youtube_embed, $this->rendering_context );
+
+			$this->assertStringContainsString( $mock_thumbnail_url, $rendered );
+			$this->assertStringContainsString( 'play2x.png', $rendered );
+		} finally {
+			remove_filter( 'pre_http_request', $filter_callback, 10 );
+			delete_transient( $cache_key );
+		}
+	}
+
+	/**
+	 * Test that a /embed/videoseries playlist URL does not build a broken thumbnail
+	 *
+	 * The "videoseries" token would otherwise be captured as a video ID, producing a
+	 * broken img.youtube.com/vi/videoseries thumbnail. It is excluded so the URL falls
+	 * through to the oEmbed lookup; WP core has no provider pattern for this form, so
+	 * it degrades to a graceful link fallback.
+	 */
+	public function test_youtube_playlist_embed_url_does_not_build_broken_thumbnail(): void {
+		// Block the outbound oEmbed discovery request so the test stays offline-deterministic.
+		$filter_callback = function ( $preempt, $args, $url ) {
+			if ( strpos( $url, 'youtube.com' ) !== false ) {
+				return new \WP_Error( 'blocked', 'Blocked in test.' );
+			}
+			return $preempt;
+		};
+		add_filter( 'pre_http_request', $filter_callback, 10, 3 );
+
+		$playlist_embed_url   = 'https://www.youtube.com/embed/videoseries?list=PL590L5WQmH8dpP0RyH5pCfIaDEdt9nk7r';
+		$parsed_youtube_embed = array(
+			'blockName' => 'core/embed',
+			'attrs'     => array(
+				'url'              => $playlist_embed_url,
+				'type'             => 'video',
+				'providerNameSlug' => 'youtube',
+			),
+			'innerHTML' => '<figure class="wp-block-embed is-type-video is-provider-youtube wp-block-embed-youtube"><div class="wp-block-embed__wrapper">' . $playlist_embed_url . '</div></figure>',
+		);
+
+		try {
+			$rendered = $this->embed_renderer->render( $parsed_youtube_embed['innerHTML'], $parsed_youtube_embed, $this->rendering_context );
+		} finally {
+			remove_filter( 'pre_http_request', $filter_callback, 10 );
+		}
+
+		// No broken thumbnail, and the URL degrades to a link instead.
+		$this->assertStringNotContainsString( 'img.youtube.com/vi/videoseries', $rendered );
+		$this->assertStringContainsString( '<a href="' . $playlist_embed_url . '"', $rendered );
+	}
+
+	/**
+	 * Test that embed captions are rendered below the embed output
+	 */
+	public function test_renders_caption_below_embed(): void {
+		$parsed_youtube_embed = array(
+			'blockName' => 'core/embed',
+			'attrs'     => array(
+				'url'              => 'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
+				'type'             => 'video',
+				'providerNameSlug' => 'youtube',
+				'responsive'       => true,
+			),
+			'innerHTML' => '<figure class="wp-block-embed is-type-video is-provider-youtube wp-block-embed-youtube"><div class="wp-block-embed__wrapper">https://www.youtube.com/watch?v=dQw4w9WgXcQ</div><figcaption class="wp-element-caption">My <strong>video</strong> caption</figcaption></figure>',
+		);
+
+		$rendered = $this->embed_renderer->render( $parsed_youtube_embed['innerHTML'], $parsed_youtube_embed, $this->rendering_context );
+
+		// The caption is wrapped in a centered div (matching content-editor.css),
+		// anchored to the caption text so the assertion cannot pass on the video
+		// renderer's own centered play-button wrapper.
+		$this->assertStringContainsString( '<div style="text-align: center; margin-top: 8px;">My <strong>video</strong> caption</div>', $rendered );
+		// The caption is appended after the embed output.
+		$thumbnail_pos = strpos( $rendered, 'img.youtube.com' );
+		$caption_pos   = strpos( $rendered, 'My <strong>video</strong> caption' );
+		$this->assertNotFalse( $thumbnail_pos, 'Expected the YouTube thumbnail in the output.' );
+		$this->assertNotFalse( $caption_pos, 'Expected the caption in the output.' );
+		$this->assertGreaterThan( $thumbnail_pos, $caption_pos );
+	}
+
+	/**
+	 * Test that captions are rendered for link fallbacks and sanitized
+	 */
+	public function test_renders_sanitized_caption_with_link_fallback(): void {
+		$parsed_unsupported_embed = array(
+			'blockName' => 'core/embed',
+			'attrs'     => array(
+				'url'              => 'https://example.com/some-content',
+				'providerNameSlug' => 'unsupported',
+			),
+			'innerHTML' => '<figure class="wp-block-embed"><div class="wp-block-embed__wrapper">https://example.com/some-content</div><figcaption class="wp-element-caption">Caption<script>alert(1)</script></figcaption></figure>',
+		);
+
+		$rendered = $this->embed_renderer->render( $parsed_unsupported_embed['innerHTML'], $parsed_unsupported_embed, $this->rendering_context );
+
+		// Link fallback with the caption appended, dangerous markup removed.
+		$this->assertStringContainsString( 'https://example.com/some-content', $rendered );
+		$this->assertStringContainsString( 'Caption', $rendered );
+		$this->assertStringNotContainsString( '<script>', $rendered );
+	}
+
 	/**
 	 * Test that VideoPress embed is detected and renders as video player, including handling URLs with query parameters.
 	 */
diff --git a/packages/php/email-editor/tests/integration/Integrations/Utils/Html_Processing_Helper_Test.php b/packages/php/email-editor/tests/integration/Integrations/Utils/Html_Processing_Helper_Test.php
new file mode 100644
index 00000000000..5bd1cdcbeb6
--- /dev/null
+++ b/packages/php/email-editor/tests/integration/Integrations/Utils/Html_Processing_Helper_Test.php
@@ -0,0 +1,68 @@
+<?php
+/**
+ * This file is part of the WooCommerce Email Editor package
+ *
+ * @package Automattic\WooCommerce\EmailEditor
+ */
+
+declare( strict_types = 1 );
+namespace Automattic\WooCommerce\EmailEditor\Tests\Integration\Integrations\Utils;
+
+use Automattic\WooCommerce\EmailEditor\Integrations\Utils\Html_Processing_Helper;
+
+/**
+ * Integration test for Html_Processing_Helper.
+ *
+ * These run against the real WordPress WP_HTML_Tag_Processor, which the unit
+ * tests only stub, so caption sanitization is exercised end to end.
+ */
+class Html_Processing_Helper_Test extends \Email_Editor_Integration_Test_Case {
+	/**
+	 * Dangerous caption markup must be neutralized against the real HTML API.
+	 *
+	 * @dataProvider caption_xss_provider
+	 * @param string $input        Raw caption HTML.
+	 * @param string $must_not_contain Substring that must not survive.
+	 */
+	public function test_sanitize_caption_html_strips_dangerous_markup( string $input, string $must_not_contain ): void {
+		$result = Html_Processing_Helper::sanitize_caption_html( $input );
+		$this->assertStringNotContainsStringIgnoringCase( $must_not_contain, $result );
+	}
+
+	/**
+	 * Data provider for dangerous caption markup.
+	 *
+	 * @return array<string, array{string, string}>
+	 */
+	public function caption_xss_provider(): array {
+		return array(
+			'javascript href'       => array( '<a href="javascript:alert(1)">x</a>', 'javascript:' ),
+			'event handler on link' => array( '<a href="https://x.test" onmouseover="alert(1)">x</a>', 'onmouseover' ),
+			'event handler on span' => array( '<span onclick="alert(1)">x</span>', 'onclick' ),
+			'void img with onerror' => array( '<img src=x onerror=alert(1)>', 'onerror' ),
+			'void svg with onload'  => array( '<svg onload=alert(1)>', 'onload' ),
+			'script element'        => array( '<script>alert(1)</script>caption', 'alert' ),
+			// Custom-element names that start with an allowed tag name must not
+			// slip through the tag-strip pass (e.g. "a-b" masquerading as "a").
+			'hyphenated custom tag' => array( '<a-b onmouseover="alert(1)">x</a-b>', 'onmouseover' ),
+			'colon custom tag'      => array( '<a:b onmouseover="alert(1)">x</a:b>', 'onmouseover' ),
+			'span-prefixed tag'     => array( '<span-x onclick="alert(1)">x</span-x>', 'onclick' ),
+		);
+	}
+
+	/**
+	 * Safe caption markup must be preserved.
+	 */
+	public function test_sanitize_caption_html_preserves_safe_markup(): void {
+		$this->assertSame(
+			'My <strong>video</strong> caption',
+			Html_Processing_Helper::sanitize_caption_html( 'My <strong>video</strong> caption' )
+		);
+
+		// A valid link is preserved; target="_blank" gains rel="noopener noreferrer".
+		$linked = Html_Processing_Helper::sanitize_caption_html( '<a href="https://example.com" target="_blank">link</a>' );
+		$this->assertStringContainsString( 'href="https://example.com"', $linked );
+		$this->assertStringContainsString( 'target="_blank"', $linked );
+		$this->assertStringContainsString( 'noopener', $linked );
+	}
+}