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 );
}