Commit d852f6b9dea for woocommerce

commit d852f6b9dea5617e6a96eb038e5fbaa9951db860
Author: Jonathan Bossenger <jonathanbossenger@gmail.com>
Date:   Wed May 27 11:47:40 2026 +0200

    Link email events orders store actions (#65247)

diff --git a/plugins/woocommerce/changelog/65247 b/plugins/woocommerce/changelog/65247
new file mode 100644
index 00000000000..f0ba44c72c6
--- /dev/null
+++ b/plugins/woocommerce/changelog/65247
@@ -0,0 +1,4 @@
+Significance: minor
+Type: enhancement
+
+Email Logger: add a private order note for every transactional email send or failure tied to a WC_Order, making the event visible directly on the order admin page.
diff --git a/plugins/woocommerce/src/Internal/Email/EmailLogger.php b/plugins/woocommerce/src/Internal/Email/EmailLogger.php
index 0989eed029b..95b7363379d 100644
--- a/plugins/woocommerce/src/Internal/Email/EmailLogger.php
+++ b/plugins/woocommerce/src/Internal/Email/EmailLogger.php
@@ -3,6 +3,7 @@ declare( strict_types = 1 );

 namespace Automattic\WooCommerce\Internal\Email;

+use Automattic\WooCommerce\Internal\Orders\OrderNoteGroup;
 use Automattic\WooCommerce\Internal\RegisterHooksInterface;
 use WC_Email;
 use WC_Log_Levels;
@@ -131,6 +132,70 @@ class EmailLogger implements RegisterHooksInterface {

 		$level = $success ? WC_Log_Levels::INFO : WC_Log_Levels::WARNING;
 		wc_get_logger()->log( $level, $message, $context );
+
+		$this->maybe_add_order_note( $email->object, $email_id, $email, (bool) $success, $last_mail_error );
+	}
+
+	/**
+	 * Add a private order note when a transactional email is sent or fails for an order.
+	 *
+	 * Accepts mixed input because $email->object is loosely typed (any object the email subclass attaches),
+	 * and we narrow to WC_Order at the top of the method before doing anything with it.
+	 *
+	 * @param mixed       $wc_object    The email's related object, or false/null when none is set.
+	 * @param string      $email_id     The email type ID (e.g. `customer_processing_order`).
+	 * @param WC_Email    $email        The WC_Email instance.
+	 * @param bool        $success      Whether the email was sent successfully.
+	 * @param string|null $error_reason The error message from wp_mail_failed, or null.
+	 * @return void
+	 */
+	private function maybe_add_order_note( $wc_object, string $email_id, WC_Email $email, bool $success, ?string $error_reason ): void {
+		if ( ! $wc_object instanceof WC_Order ) {
+			return;
+		}
+
+		/**
+		 * Filter whether to add an order note for this transactional email attempt.
+		 *
+		 * Return false to suppress the order note for a particular email or globally,
+		 * while still allowing the WooCommerce logger entry to be written.
+		 *
+		 * @since 10.9.0
+		 *
+		 * @param bool     $enabled  Whether to add the order note.
+		 * @param string   $email_id The email type ID.
+		 * @param WC_Email $email    The WC_Email instance.
+		 * @param WC_Order $order    The order the note would be added to.
+		 */
+		if ( ! apply_filters( 'woocommerce_email_log_add_order_note', true, $email_id, $email, $wc_object ) ) {
+			return;
+		}
+
+		$email_title = $email->get_title();
+		$email_label = '' !== $email_title ? $email_title : $email_id;
+
+		if ( $success ) {
+			$note = sprintf(
+				/* translators: %s: Email title or type identifier */
+				__( 'Email "%s" sent.', 'woocommerce' ),
+				$email_label
+			);
+		} elseif ( $error_reason ) {
+			$note = sprintf(
+				/* translators: 1: Email title or type identifier, 2: Error reason */
+				__( 'Email "%1$s" failed to send: %2$s.', 'woocommerce' ),
+				$email_label,
+				$this->redact_emails( $error_reason )
+			);
+		} else {
+			$note = sprintf(
+				/* translators: %s: Email title or type identifier */
+				__( 'Email "%s" failed to send.', 'woocommerce' ),
+				$email_label
+			);
+		}
+
+		$wc_object->add_order_note( $note, 0, false, array( 'note_group' => OrderNoteGroup::EMAIL_NOTIFICATION ) );
 	}

 	/**
diff --git a/plugins/woocommerce/tests/e2e-pw/tests/order/create-order.spec.ts b/plugins/woocommerce/tests/e2e-pw/tests/order/create-order.spec.ts
index b2ddfec7df6..89085b9d5c2 100644
--- a/plugins/woocommerce/tests/e2e-pw/tests/order/create-order.spec.ts
+++ b/plugins/woocommerce/tests/e2e-pw/tests/order/create-order.spec.ts
@@ -510,9 +510,12 @@ test.describe(
 			await expect( page.locator( '#order_status' ) ).toHaveValue(
 				'wc-processing'
 			);
-			await expect( page.locator( 'div.note_content' ) ).toContainText(
-				'Order status changed from Pending payment to Processing.'
-			);
+			await expect(
+				page.locator( 'div.note_content' ).filter( {
+					hasText:
+						'Order status changed from Pending payment to Processing.',
+				} )
+			).toBeVisible();
 		} );

 		test( 'can create new complex order with multiple product types & tax classes', async ( {
diff --git a/plugins/woocommerce/tests/e2e-pw/tests/order/order-refund.spec.ts b/plugins/woocommerce/tests/e2e-pw/tests/order/order-refund.spec.ts
index e9bbabca02c..1f0a6181e2d 100644
--- a/plugins/woocommerce/tests/e2e-pw/tests/order/order-refund.spec.ts
+++ b/plugins/woocommerce/tests/e2e-pw/tests/order/order-refund.spec.ts
@@ -111,10 +111,10 @@ test.describe.serial(

 			// Verify system note was added
 			await expect(
-				page.locator( '.system-note >> nth=0' )
-			).toContainText(
-				'Order status changed from Completed to Refunded.'
-			);
+				page.locator( '.system-note' ).filter( {
+					hasText: 'Order status changed from Completed to Refunded.',
+				} )
+			).toBeVisible();
 		} );

 		// this test relies on the previous test, so should refactor
@@ -219,10 +219,11 @@ test.describe(

 			// Verify stock reduction system note was added
 			await expect(
-				page.locator( '.system-note >> nth=1' )
-			).toContainText(
-				/Stock levels reduced: Product with stock \(10→8\)/
-			);
+				page.locator( '.system-note' ).filter( {
+					hasText:
+						/Stock levels reduced: Product with stock \(10→8\)/,
+				} )
+			).toBeVisible();

 			// Click the Refund button
 			await page.locator( 'button.refund-items' ).click();
@@ -243,8 +244,10 @@ test.describe(

 			// Verify restock system note was added
 			await expect(
-				page.locator( '.system-note >> nth=0' )
-			).toContainText( /Item #\d+ stock increased from 8 to 10./ );
+				page.locator( '.system-note' ).filter( {
+					hasText: /Item #\d+ stock increased from 8 to 10./,
+				} )
+			).toBeVisible();
 		} );
 	}
 );
diff --git a/plugins/woocommerce/tests/php/src/Internal/Email/EmailLoggerTest.php b/plugins/woocommerce/tests/php/src/Internal/Email/EmailLoggerTest.php
index 599c48e5e2f..702a3ce10d7 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Email/EmailLoggerTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Email/EmailLoggerTest.php
@@ -4,6 +4,7 @@ declare( strict_types = 1 );
 namespace Automattic\WooCommerce\Tests\Internal\Email;

 use Automattic\WooCommerce\Internal\Email\EmailLogger;
+use Automattic\WooCommerce\Internal\Orders\OrderNoteGroup;
 use Automattic\WooCommerce\RestApi\UnitTests\LoggerSpyTrait;
 use WC_Unit_Test_Case;

@@ -37,6 +38,7 @@ class EmailLoggerTest extends WC_Unit_Test_Case {
 	public function tearDown(): void {
 		remove_all_filters( 'woocommerce_email_log_enabled' );
 		remove_all_filters( 'woocommerce_email_log_context' );
+		remove_all_filters( 'woocommerce_email_log_add_order_note' );
 		remove_all_actions( 'woocommerce_email_disabled' );
 		remove_all_actions( 'woocommerce_email_skipped' );
 		remove_action( 'woocommerce_email_sent', array( $this->sut, 'handle_woocommerce_email_sent' ) );
@@ -367,6 +369,140 @@ class EmailLoggerTest extends WC_Unit_Test_Case {
 		$this->assertLogged( 'info', 'new_order', array( 'custom_key' => 'custom_value' ) );
 	}

+	/**
+	 * @testdox An order note is added when an email is sent successfully for an order.
+	 */
+	public function test_order_note_added_on_successful_send_for_order(): void {
+		$order = $this->createMock( \WC_Order::class );
+		$order->method( 'get_id' )->willReturn( 42 );
+		$order->expects( $this->once() )
+			->method( 'add_order_note' )
+			->with(
+				$this->stringContains( 'sent' ),
+				0,
+				false,
+				$this->callback( fn( $meta ) => isset( $meta['note_group'] ) && OrderNoteGroup::EMAIL_NOTIFICATION === $meta['note_group'] )
+			);
+
+		$email = $this->create_mock_email( 'customer_processing_order', 'customer@example.com', $order );
+		$this->sut->handle_woocommerce_email_sent( true, 'customer_processing_order', $email );
+	}
+
+	/**
+	 * @testdox An order note is added when an email fails to send for an order (no error reason).
+	 */
+	public function test_order_note_added_on_failed_send_for_order(): void {
+		$order = $this->createMock( \WC_Order::class );
+		$order->method( 'get_id' )->willReturn( 42 );
+		$order->expects( $this->once() )
+			->method( 'add_order_note' )
+			->with(
+				$this->logicalAnd(
+					$this->stringContains( 'failed to send.' ),
+					$this->logicalNot( $this->stringContains( 'failed to send:' ) )
+				),
+				0,
+				false,
+				$this->callback( fn( $meta ) => isset( $meta['note_group'] ) && OrderNoteGroup::EMAIL_NOTIFICATION === $meta['note_group'] )
+			);
+
+		$email = $this->create_mock_email( 'customer_processing_order', 'customer@example.com', $order );
+		$this->sut->handle_woocommerce_email_sent( false, 'customer_processing_order', $email );
+	}
+
+	/**
+	 * @testdox The order note for a failed send includes the error reason in colon-form.
+	 */
+	public function test_order_note_failure_includes_error_reason(): void {
+		$order = $this->createMock( \WC_Order::class );
+		$order->method( 'get_id' )->willReturn( 42 );
+		$order->expects( $this->once() )
+			->method( 'add_order_note' )
+			->with(
+				$this->logicalAnd(
+					$this->stringContains( 'failed to send:' ),
+					$this->stringContains( 'SMTP connect() failed' )
+				)
+			);
+
+		$error = new \WP_Error( 'wp_mail_failed', 'SMTP connect() failed' );
+		$this->sut->capture_mail_error( $error );
+
+		$email = $this->create_mock_email( 'customer_processing_order', 'customer@example.com', $order );
+		$this->sut->handle_woocommerce_email_sent( false, 'customer_processing_order', $email );
+	}
+
+	/**
+	 * @testdox The order note redacts email addresses embedded in the failure reason.
+	 */
+	public function test_order_note_failure_redacts_email_addresses_in_reason(): void {
+		$order = $this->createMock( \WC_Order::class );
+		$order->method( 'get_id' )->willReturn( 42 );
+		$order->expects( $this->once() )
+			->method( 'add_order_note' )
+			->with(
+				$this->logicalAnd(
+					$this->stringContains( '[redacted_email]' ),
+					$this->logicalNot( $this->stringContains( 'customer@example.com' ) )
+				)
+			);
+
+		$error = new \WP_Error(
+			'wp_mail_failed',
+			'SMTP Error: Could not send to customer@example.com (rejected by server.example.org).'
+		);
+		$this->sut->capture_mail_error( $error );
+
+		$email = $this->create_mock_email( 'customer_processing_order', 'customer@example.com', $order );
+		$this->sut->handle_woocommerce_email_sent( false, 'customer_processing_order', $email );
+	}
+
+	/**
+	 * @testdox No order note is added when the email is not associated with an order.
+	 */
+	public function test_no_order_note_for_non_order_object(): void {
+		$product = $this->createMock( \WC_Product::class );
+		$product->method( 'get_id' )->willReturn( 10 );
+
+		$email = $this->create_mock_email( 'some_product_email', 'admin@example.com', $product );
+
+		// Should complete without throwing – product objects do not get order notes.
+		$this->sut->handle_woocommerce_email_sent( true, 'some_product_email', $email );
+
+		$this->assertLogged( 'info', 'some_product_email' );
+	}
+
+	/**
+	 * @testdox No order note is added when logging is disabled by the filter.
+	 */
+	public function test_no_order_note_when_logging_disabled_by_filter(): void {
+		add_filter( 'woocommerce_email_log_enabled', '__return_false' );
+
+		$order = $this->createMock( \WC_Order::class );
+		$order->method( 'get_id' )->willReturn( 42 );
+		$order->expects( $this->never() )->method( 'add_order_note' );
+
+		$email = $this->create_mock_email( 'customer_processing_order', 'customer@example.com', $order );
+		$this->sut->handle_woocommerce_email_sent( true, 'customer_processing_order', $email );
+	}
+
+	/**
+	 * @testdox woocommerce_email_log_add_order_note filter can suppress the order note independently of logging.
+	 */
+	public function test_order_note_suppressed_by_add_order_note_filter(): void {
+		add_filter( 'woocommerce_email_log_add_order_note', '__return_false' );
+
+		$order = $this->createMock( \WC_Order::class );
+		$order->method( 'get_id' )->willReturn( 42 );
+		$order->expects( $this->never() )->method( 'add_order_note' );
+
+		$email = $this->create_mock_email( 'customer_processing_order', 'customer@example.com', $order );
+		$this->sut->handle_woocommerce_email_sent( true, 'customer_processing_order', $email );
+
+		// Logger entry should still be written even though the note is suppressed.
+		$this->assertLogged( 'info', 'customer_processing_order' );
+	}
+
 	/**
 	 * @testdox Logs a notice entry when email is disabled.
 	 */
@@ -733,6 +869,7 @@ class EmailLoggerTest extends WC_Unit_Test_Case {
 		$email->id     = $email_id;
 		$email->object = $wc_object;
 		$email->expects( $this->any() )->method( 'get_recipient' )->willReturn( $recipient );
+		$email->expects( $this->any() )->method( 'get_title' )->willReturn( $email_id );

 		return $email;
 	}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Orders/OrderActionsRestControllerTest.php b/plugins/woocommerce/tests/php/src/Internal/Orders/OrderActionsRestControllerTest.php
index 100fe201b67..ecc4475621e 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Orders/OrderActionsRestControllerTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Orders/OrderActionsRestControllerTest.php
@@ -694,6 +694,24 @@ class OrderActionsRestControllerTest extends WC_REST_Unit_Test_Case {
 		remove_filter( 'woocommerce_rest_order_actions_email_preferred_template_ids', $override_preferred_ids );
 	}

+	/**
+	 * Remove notes added by EmailLogger so endpoint-specific assertions are not affected by the
+	 * private "Email ... sent." notes the logger adds whenever a transactional email fires.
+	 *
+	 * @param array $notes Order notes to filter.
+	 * @return array Notes with EmailLogger-added entries removed (re-indexed).
+	 */
+	private function filter_out_email_logger_notes( array $notes ): array {
+		return array_values(
+			array_filter(
+				$notes,
+				static function ( $note ) {
+					return 0 !== strpos( $note->content, 'Email "' );
+				}
+			)
+		);
+	}
+
 	/**
 	 * Test sending order details email.
 	 */
@@ -715,9 +733,10 @@ class OrderActionsRestControllerTest extends WC_REST_Unit_Test_Case {
 		$this->assertArrayHasKey( 'message', $data );
 		$this->assertEquals( 'Order details sent to customer@email.com.', $data['message'] );

-		$notes = wc_get_order_notes( array( 'order_id' => $order->get_id() ) );
-		$this->assertCount( 1, $notes );
-		$this->assertEquals( 'Order details sent to customer@email.com.', $notes[0]->content );
+		$notes          = wc_get_order_notes( array( 'order_id' => $order->get_id() ) );
+		$endpoint_notes = $this->filter_out_email_logger_notes( $notes );
+		$this->assertCount( 1, $endpoint_notes );
+		$this->assertEquals( 'Order details sent to customer@email.com.', $endpoint_notes[0]->content );
 	}

 	/**
@@ -796,10 +815,11 @@ class OrderActionsRestControllerTest extends WC_REST_Unit_Test_Case {
 		$this->assertArrayHasKey( 'message', $data );
 		$this->assertEquals( 'Billing email updated to another@email.com. Order details sent to another@email.com.', $data['message'] );

-		$notes = wc_get_order_notes( array( 'order_id' => $order->get_id() ) );
-		$this->assertCount( 2, $notes );
+		$notes          = wc_get_order_notes( array( 'order_id' => $order->get_id() ) );
+		$endpoint_notes = $this->filter_out_email_logger_notes( $notes );
+		$this->assertCount( 2, $endpoint_notes );

-		$notes_content = wp_list_pluck( $notes, 'content' );
+		$notes_content = wp_list_pluck( $endpoint_notes, 'content' );
 		$this->assertContainsEquals( 'Billing email updated to another@email.com.', $notes_content );
 		$this->assertContainsEquals( 'Order details sent to another@email.com.', $notes_content );
 	}