Commit 54627373c30 for woocommerce
commit 54627373c30c99ab2fd978f2596f84030c2581c8
Author: Pavel Dohnal <pavel.dohnal@automattic.com>
Date: Tue Nov 10 13:42:54 2026 +0100
Remove web-only image attributes from emails (#70774)
* Keep only email-safe attributes on Image block images
The Image renderer removed srcset and class by name, so loading,
decoding and sizes reached the email. Apple Mail does not load images
marked loading=lazy.
Use a list of allowed attributes instead. The inline style is left as
saved because the shared image sanitizer would drop object-fit and
aspect-ratio, which distorts cropped images.
* Stop WordPress adding web image attributes in emails
The Post Content block applies the_content, which adds loading,
decoding, srcset and sizes after the block renderers have run.
Turn those additions off between the email render start and end hooks
so web pages built later in the same request are not affected.
* Stop lazy loading product images in order emails
Order and fulfillment item templates run outside the email editor
render, so its filters do not reach them, and wp_get_attachment_image
marks each thumbnail loading=lazy. Apple Mail does not load those.
* Also stop srcset on attachment images in emails
The earlier filter only applied to images found in post content.
Images built by wp_get_attachment_image, such as the site logo, still
got srcset and sizes during the email render. Emptying the image
metadata used for srcset covers both paths.
* Test that order email images are not lazy loaded
* Remove web-only image attributes saved in post content
The render filters only stop WordPress adding these attributes. Images
in pasted HTML or Custom HTML blocks can already have loading=lazy
saved, and those still reached the email.
* Remove image loading attributes in the WC_Email guard
Passing loading=false through the email templates put a boolean into
the array that public image filters receive, printed loading="" on
placeholder images, and missed stores that override the templates.
The existing attachment filter in WC_Email runs for every email image
while sending, so remove the attributes there and restore the templates.
* Use an own callback for the email image filters
Removing the shared __return_empty_array callback after the render
would also remove the same callback registered by another plugin.
* Tighten tests and remove fetchpriority from email images
The Post Content test shared a filter with the production code, so it
could not notice that filter going missing. Order item output is run
through wp_kses_post, which hides srcset, so test the WC_Email guard
directly. Block emails already removed fetchpriority; now classic ones
do too.
diff --git a/packages/php/email-editor/changelog/fix-wooplug-8115-web-only-image-attributes b/packages/php/email-editor/changelog/fix-wooplug-8115-web-only-image-attributes
new file mode 100644
index 00000000000..29cd5eefec8
--- /dev/null
+++ b/packages/php/email-editor/changelog/fix-wooplug-8115-web-only-image-attributes
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Remove web-only image attributes such as lazy loading from emails, so images load in Apple Mail.
diff --git a/packages/php/email-editor/src/Integrations/Core/Renderer/Blocks/class-image.php b/packages/php/email-editor/src/Integrations/Core/Renderer/Blocks/class-image.php
index 5a906349b30..66390e55790 100644
--- a/packages/php/email-editor/src/Integrations/Core/Renderer/Blocks/class-image.php
+++ b/packages/php/email-editor/src/Integrations/Core/Renderer/Blocks/class-image.php
@@ -595,15 +595,20 @@ class Image extends Abstract_Block_Renderer {
}
/**
- * Cleanup image HTML.
+ * Keep only the image attributes that are useful in an email.
+ *
+ * The inline style stays as saved, because it carries the block's crop (aspect-ratio and object-fit).
*
* @param string $content_html Content HTML.
*/
private function cleanup_image_html( string $content_html ): string {
$html = new \WP_HTML_Tag_Processor( $content_html );
if ( $html->next_tag( array( 'tag_name' => 'img' ) ) ) {
- $html->remove_attribute( 'srcset' );
- $html->remove_attribute( 'class' );
+ foreach ( $html->get_attribute_names_with_prefix( '' ) ?? array() as $attribute_name ) {
+ if ( ! in_array( $attribute_name, array( 'src', 'alt', 'title', 'width', 'height', 'style' ), true ) ) {
+ $html->remove_attribute( $attribute_name );
+ }
+ }
}
return $html->get_updated_html();
}
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
index 2bedcbe6fb1..cddd62b1edd 100644
--- 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
@@ -103,6 +103,22 @@ class Post_Content {
return '';
}
- return $post_content;
+ return $this->remove_web_only_image_attributes( $post_content );
+ }
+
+ /**
+ * Remove web-only attributes that were saved in the post content, such as in pasted HTML.
+ *
+ * @param string $content Rendered post content.
+ * @return string Content without the web-only image attributes.
+ */
+ private function remove_web_only_image_attributes( string $content ): string {
+ $html = new \WP_HTML_Tag_Processor( $content );
+ while ( $html->next_tag( array( 'tag_name' => 'img' ) ) ) {
+ foreach ( array( 'loading', 'decoding', 'fetchpriority', 'srcset', 'sizes' ) as $attribute_name ) {
+ $html->remove_attribute( $attribute_name );
+ }
+ }
+ return $html->get_updated_html();
}
}
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 c3e5467255b..2d66b0735e1 100644
--- a/packages/php/email-editor/src/Integrations/Core/class-initializer.php
+++ b/packages/php/email-editor/src/Integrations/Core/class-initializer.php
@@ -108,6 +108,8 @@ class Initializer {
add_filter( 'woocommerce_email_editor_theme_json', array( $this, 'adjust_theme_json' ), 10, 1 );
add_filter( 'safe_style_css', array( $this, 'allow_styles' ) );
add_action( 'woocommerce_email_editor_render_start', array( $this, 'reset_renderers' ) );
+ add_action( 'woocommerce_email_editor_render_start', array( $this, 'disable_web_image_attributes' ) );
+ add_action( 'woocommerce_email_editor_render_end', array( $this, 'restore_web_image_attributes' ) );
}
/**
@@ -117,6 +119,33 @@ class Initializer {
$this->renderers = array();
}
+ /**
+ * Stop WordPress from adding loading, decoding, srcset and sizes to images in the email.
+ *
+ * Apple Mail never loads images marked loading="lazy".
+ */
+ public function disable_web_image_attributes(): void {
+ add_filter( 'wp_get_loading_optimization_attributes', array( $this, 'get_no_image_attributes' ), PHP_INT_MAX );
+ add_filter( 'wp_calculate_image_srcset_meta', array( $this, 'get_no_image_attributes' ), PHP_INT_MAX );
+ }
+
+ /**
+ * Filter callback that gives WordPress no image attributes or srcset data to work with.
+ *
+ * @return array Empty array.
+ */
+ public function get_no_image_attributes(): array {
+ return array();
+ }
+
+ /**
+ * Let WordPress add its image attributes again once the email has rendered.
+ */
+ public function restore_web_image_attributes(): void {
+ remove_filter( 'wp_get_loading_optimization_attributes', array( $this, 'get_no_image_attributes' ), PHP_INT_MAX );
+ remove_filter( 'wp_calculate_image_srcset_meta', array( $this, 'get_no_image_attributes' ), PHP_INT_MAX );
+ }
+
/**
* Adjusts the editor's theme to add blocks specific settings for core blocks.
*
diff --git a/packages/php/email-editor/tests/integration/Integrations/Core/Renderer/Blocks/Image_Test.php b/packages/php/email-editor/tests/integration/Integrations/Core/Renderer/Blocks/Image_Test.php
index 9850cf85c5c..f37ab164b3d 100644
--- a/packages/php/email-editor/tests/integration/Integrations/Core/Renderer/Blocks/Image_Test.php
+++ b/packages/php/email-editor/tests/integration/Integrations/Core/Renderer/Blocks/Image_Test.php
@@ -495,4 +495,23 @@ class Image_Test extends \Email_Editor_Integration_Test_Case {
// Note: esc_attr() encodes single quotes as '.
$this->assertStringContainsString( 'data-link-href="[trackable-link url='test']"', $rendered );
}
+
+ /**
+ * Test that only attributes useful in an email stay on the image.
+ */
+ public function testItRemovesWebOnlyAttributesFromImage(): void {
+ $image_content = '<figure class="wp-block-image"><img class="wp-image-1" src="https://test.com/image.jpg?a=1&b=2" alt="Alt" title="Title" width="640" height="320" loading="lazy" decoding="async" fetchpriority="low" srcset="https://test.com/image.jpg 1000w" sizes="(max-width: 640px) 100vw, 640px" data-id="1" style="aspect-ratio:2;object-fit:cover"/></figure>';
+
+ $rendered = $this->image_renderer->render( $image_content, $this->parsed_image, $this->rendering_context );
+
+ $html = new \WP_HTML_Tag_Processor( $rendered );
+ $this->assertTrue( $html->next_tag( array( 'tag_name' => 'img' ) ) );
+ $this->assertEqualsCanonicalizing(
+ array( 'src', 'alt', 'title', 'width', 'height', 'style' ),
+ $html->get_attribute_names_with_prefix( '' )
+ );
+ $this->assertSame( 'https://test.com/image.jpg?a=1&b=2', $html->get_attribute( 'src' ) );
+ $this->assertStringContainsString( 'object-fit:cover', (string) $html->get_attribute( 'style' ) );
+ $this->assertStringContainsString( 'aspect-ratio:2', (string) $html->get_attribute( 'style' ) );
+ }
}
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
index b460b73013e..29e62d40e7a 100644
--- 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
@@ -262,4 +262,100 @@ class Post_Content_Test extends \Email_Editor_Integration_Test_Case {
wp_delete_post( $post_id, true );
wp_delete_post( $original_post_id, true );
}
+
+ /**
+ * Test that WordPress adds no web-only image attributes to post content while an email renders.
+ */
+ public function testItAddsNoWebOnlyImageAttributesDuringEmailRender(): void {
+ $attachment_id = self::factory()->attachment->create_object(
+ array(
+ 'file' => '2026/11/image.jpg',
+ 'post_mime_type' => 'image/jpeg',
+ )
+ );
+ $this->assertIsInt( $attachment_id );
+ wp_update_attachment_metadata(
+ $attachment_id,
+ array(
+ 'width' => 1200,
+ 'height' => 600,
+ 'file' => '2026/11/image.jpg',
+ 'sizes' => array(
+ 'medium' => array(
+ 'file' => 'image-300x150.jpg',
+ 'width' => 300,
+ 'height' => 150,
+ 'mime-type' => 'image/jpeg',
+ ),
+ ),
+ )
+ );
+ $image_url = (string) wp_get_attachment_url( $attachment_id );
+ $image = sprintf( '<p><img class="wp-image-%d" src="%s" width="1200" height="600" alt=""></p>', $attachment_id, $image_url );
+ $post_id = self::factory()->post->create( array( 'post_content' => str_repeat( $image, 4 ) ) );
+ $block = new \WP_Block(
+ array(
+ 'blockName' => 'core/post-content',
+ 'attrs' => array(),
+ ),
+ array( 'postId' => $post_id )
+ );
+
+ do_action( 'woocommerce_email_editor_render_start' );
+ $rendered = ( new Post_Content() )->render_stateless( array(), '', $block );
+ $attachment_image = wp_get_attachment_image( $attachment_id, 'full' );
+ do_action( 'woocommerce_email_editor_render_end' );
+
+ $this->assertSame( 4, substr_count( $rendered, '<img' ) );
+ $this->assertStringContainsString( '<img', $attachment_image );
+ $this->assertStringNotContainsString( 'srcset=', $attachment_image );
+ $this->assertStringNotContainsString( 'sizes=', $attachment_image );
+ $this->assertStringNotContainsString( 'loading=', $attachment_image );
+ $this->assertStringNotContainsString( 'loading=', $rendered );
+ $this->assertStringNotContainsString( 'decoding=', $rendered );
+ $this->assertStringNotContainsString( 'srcset=', $rendered );
+ $this->assertStringNotContainsString( 'sizes=', $rendered );
+
+ $after_render = wp_filter_content_tags( sprintf( '<img class="wp-image-%d" src="%s" width="1200" height="600" alt="">', $attachment_id, $image_url ) );
+ $this->assertStringContainsString( 'srcset=', $after_render, 'Web pages rendered after the email keep responsive images.' );
+ $this->assertStringContainsString( 'decoding=', $after_render, 'Web pages rendered after the email keep loading attributes.' );
+ }
+
+ /**
+ * Test that web-only attributes already saved in the post content are removed from every image.
+ */
+ public function testItRemovesSavedWebOnlyImageAttributes(): void {
+ $image = '<img src="https://test.com/image.jpg" alt="Alt" width="600" height="300" loading="lazy" decoding="async" fetchpriority="low" srcset="https://test.com/image.jpg 600w" sizes="100vw">';
+ $post_id = self::factory()->post->create( array( 'post_content' => $image . $image ) );
+ $block = new \WP_Block(
+ array(
+ 'blockName' => 'core/post-content',
+ 'attrs' => array(),
+ ),
+ array( 'postId' => $post_id )
+ );
+
+ $rendered = ( new Post_Content() )->render_stateless( array(), '', $block );
+
+ $this->assertSame( 2, substr_count( $rendered, '<img' ) );
+ foreach ( array( 'loading=', 'decoding=', 'fetchpriority=', 'srcset=', 'sizes=' ) as $attribute ) {
+ $this->assertStringNotContainsString( $attribute, $rendered );
+ }
+ $this->assertStringContainsString( 'src="https://test.com/image.jpg"', $rendered );
+ $this->assertStringContainsString( 'width="600"', $rendered );
+ }
+
+ /**
+ * Test that ending the render keeps a filter another plugin registered with a shared WordPress callback.
+ */
+ public function testItKeepsAnotherPluginsImageFilterAfterRender(): void {
+ add_filter( 'wp_calculate_image_srcset_meta', '__return_empty_array', PHP_INT_MAX );
+
+ do_action( 'woocommerce_email_editor_render_start' );
+ do_action( 'woocommerce_email_editor_render_end' );
+ $priority = has_filter( 'wp_calculate_image_srcset_meta', '__return_empty_array' );
+ remove_filter( 'wp_calculate_image_srcset_meta', '__return_empty_array', PHP_INT_MAX );
+
+ $this->assertSame( PHP_INT_MAX, $priority );
+ }
}
diff --git a/plugins/woocommerce/changelog/fix-wooplug-8115-email-product-image-loading b/plugins/woocommerce/changelog/fix-wooplug-8115-email-product-image-loading
new file mode 100644
index 00000000000..459acf70893
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-wooplug-8115-email-product-image-loading
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Load product images in order emails right away, so they show in Apple Mail.
diff --git a/plugins/woocommerce/includes/emails/class-wc-email.php b/plugins/woocommerce/includes/emails/class-wc-email.php
index a3a7e349aa1..17f7a5b2fdf 100644
--- a/plugins/woocommerce/includes/emails/class-wc-email.php
+++ b/plugins/woocommerce/includes/emails/class-wc-email.php
@@ -364,7 +364,7 @@ class WC_Email extends WC_Settings_API {
add_action( 'phpmailer_init', array( $this, 'handle_multipart' ) );
add_action( 'woocommerce_update_options_email_' . $this->id, array( $this, 'process_admin_options' ) );
- // Use priority 1 to ensure our skip classes are added before lazy loading plugins process the images.
+ // Use priority 1 to ensure our attribute changes are applied before lazy loading plugins process the images.
add_filter( 'wp_get_attachment_image_attributes', array( $this, 'prevent_lazy_loading_on_attachment' ), 1, 1 );
}
@@ -1861,7 +1861,7 @@ class WC_Email extends WC_Settings_API {
}
/**
- * Prevent lazy loading on attachment images in email context by adding skip classes.
+ * Prevent lazy loading on attachment images in email context by removing web-only attributes and adding skip classes.
* This is hooked into the wp_get_attachment_image_attributes filter.
*
* @param array $attributes The image attributes array.
@@ -1873,6 +1873,9 @@ class WC_Email extends WC_Settings_API {
return $attributes;
}
+ // Apple Mail does not load images marked loading="lazy", and the others do nothing in email.
+ unset( $attributes['loading'], $attributes['decoding'], $attributes['fetchpriority'], $attributes['srcset'], $attributes['sizes'] );
+
// Skip classes to prevent lazy loading plugins from applying lazy loading.
// These are the most common skip classes used by popular lazy loading plugins.
$skip_classes = array( 'skip-lazy', 'no-lazyload', 'lazyload-disabled', 'no-lazy', 'skip-lazyload' );
diff --git a/plugins/woocommerce/tests/php/includes/emails/class-wc-email-order-items-test.php b/plugins/woocommerce/tests/php/includes/emails/class-wc-email-order-items-test.php
index 1d2d518c143..ce701ac6edb 100644
--- a/plugins/woocommerce/tests/php/includes/emails/class-wc-email-order-items-test.php
+++ b/plugins/woocommerce/tests/php/includes/emails/class-wc-email-order-items-test.php
@@ -51,4 +51,63 @@ class WC_Email_Order_Items_Test extends \WC_Unit_Test_Case {
'Thumbnail column should reserve the image width plus the email improvements image gap.'
);
}
+
+ /**
+ * @testdox Order item images are not lazy loaded while an email is sending.
+ */
+ public function test_order_item_image_is_not_lazy_loaded_while_sending(): void {
+ $order = WC_Helper_Order::create_order();
+ $this->order_ids[] = $order->get_id();
+
+ $attachment_id = self::factory()->attachment->create_object(
+ array(
+ 'file' => '2026/11/image.jpg',
+ 'post_mime_type' => 'image/jpeg',
+ )
+ );
+ wp_update_attachment_metadata(
+ $attachment_id,
+ array(
+ 'width' => 48,
+ 'height' => 48,
+ 'file' => '2026/11/image.jpg',
+ )
+ );
+ foreach ( $order->get_items() as $item ) {
+ $product = $item->get_product();
+ $product->set_image_id( $attachment_id );
+ $product->save();
+ }
+
+ $email = new WC_Email();
+ $email->sending = true;
+ $content = wc_get_email_order_items( $order, array( 'show_image' => true ) );
+ $email->sending = false;
+
+ $this->assertStringContainsString( '<img', $content );
+ $this->assertStringNotContainsString( 'loading=', $content );
+ }
+
+ /**
+ * @testdox Web-only attributes are removed from attachment images while an email is sending.
+ */
+ public function test_web_only_attributes_are_removed_while_sending(): void {
+ $email = new WC_Email();
+ $email->sending = true;
+
+ $attributes = $email->prevent_lazy_loading_on_attachment(
+ array(
+ 'src' => 'https://example.com/image.jpg',
+ 'loading' => 'lazy',
+ 'decoding' => 'async',
+ 'fetchpriority' => 'high',
+ 'srcset' => 'https://example.com/image.jpg 300w',
+ 'sizes' => '100vw',
+ )
+ );
+ $email->sending = false;
+
+ $this->assertSame( 'https://example.com/image.jpg', $attributes['src'] );
+ $this->assertEmpty( array_intersect_key( $attributes, array_flip( array( 'loading', 'decoding', 'fetchpriority', 'srcset', 'sizes' ) ) ) );
+ }
}