Commit 56e40f76a11 for woocommerce

commit 56e40f76a11d4107db08aac34893a5f40ec40440
Author: Jonathan Bossenger <jonathanbossenger@gmail.com>
Date:   Tue May 19 10:10:10 2026 +0200

    Make Woo's transactional email logging part of WooCommerce core (#64491)

    * Create woocommerce-core-dev agent documentation
    * Revise woocommerce-core-dev agent description and examples

    Updated the description for the woocommerce-core-dev agent to provide clearer guidance on its use and examples of context for user queries. Removed the Agent Memory section to streamline the document.

    * Update description formatting in woocommerce-core-dev.md
    * Initial plan
    * feat: add EmailLogger class for transactional email logging

    Agent-Logs-Url: https://github.com/jonathanbossenger/woocommerce/sessions/7aa95d05-2ba2-4c49-94e7-a9a1904aec90

    Co-authored-by: jonathanbossenger <180629+jonathanbossenger@users.noreply.github.com>

    * test: use explicit expects() in EmailLoggerTest mock setup

    Agent-Logs-Url: https://github.com/jonathanbossenger/woocommerce/sessions/7aa95d05-2ba2-4c49-94e7-a9a1904aec90

    Co-authored-by: jonathanbossenger <180629+jonathanbossenger@users.noreply.github.com>

    * test: clean up WC_Order after test to prevent database pollution

    Agent-Logs-Url: https://github.com/jonathanbossenger/woocommerce/sessions/7aa95d05-2ba2-4c49-94e7-a9a1904aec90

    Co-authored-by: jonathanbossenger <180629+jonathanbossenger@users.noreply.github.com>

    * refactor: address review feedback on EmailLogger and EmailLoggerTest

    - EmailLogger: add woocommerce_email_log_enabled and woocommerce_email_log_context
      filters; normalize object_type via instanceof; use wp_hash() instead of md5();
      drop trigger_time; add :void to register(); remove duplicate @internal; switch
      to logger->log() for LoggerSpyTrait compatibility; simplify array_merge
    - EmailLoggerTest: swap hand-rolled fake logger for LoggerSpyTrait; add @covers;
      move hook teardown to tearDown(); replace real WC_Order with createMock();
      add tests for empty recipient, normalized object types, and both new filters

    Agent-Logs-Url: https://github.com/jonathanbossenger/woocommerce/sessions/1be4692b-e694-4131-8c3f-48a2cd24492f

    Co-authored-by: jonathanbossenger <180629+jonathanbossenger@users.noreply.github.com>

    * fix: update changelog, align log source to transactional-emails, note untranslated message

    - Update changelog entry: remove stale "trigger time" reference, update source name
    - Change LOG_SOURCE from 'email-log' to 'transactional-emails' to align with
      existing convention in class-wc-emails.php:225
    - Add comment on intentionally untranslated log message (consistent with class-wc-emails.php)
    - Update test assertion source value to match new constant

    Agent-Logs-Url: https://github.com/jonathanbossenger/woocommerce/sessions/3822e0af-859f-4ea4-8b39-c17d5a04567f

    Co-authored-by: jonathanbossenger <180629+jonathanbossenger@users.noreply.github.com>

    * fix: resolve PHPCS violations in EmailLogger and EmailLoggerTest

    - Fix Squiz.Commenting.FunctionComment.SpacingAfterParamType in all @param
      docblocks (both files): reduce type+spaces to 9 chars total (5/3/1 pattern
      for bool/string/WC_Email; 4/3/1 for array/string/WC_Email; 1/1/2 for
      string/string/mixed)
    - Move intentional-non-translation comment above $status/$message pair so
      both assignments are in the same MultipleStatementAlignment block; double
      space in '$status  =' is now correct alignment
    - Rename $object parameter to $wc_object in get_object_context() and
      create_mock_email() to clear
      Universal.NamingConventions.NoReservedKeywordParameterNames.objectFound

    Agent-Logs-Url: https://github.com/jonathanbossenger/woocommerce/sessions/c8c6dfd8-5bfc-4f28-aadc-d5a6c0207fcd

    Co-authored-by: jonathanbossenger <180629+jonathanbossenger@users.noreply.github.com>

    * improve EmailLogger output and fix test-email send context

    - Message now reads naturally: `Email "new_order" for order #29 sent`
    - Failure messages include the PHPMailer reason via wp_mail_failed hook
    - Recipient stored as plain address instead of hashed value
    - Object context collapsed to a single typed key: `order: 29`, `user: 3`
    - `email_id` context key renamed to `email_type` for clarity
    - Fix EmailPreviewRestController to send via the actual WC_Email instance
      so email_type and recipient are populated correctly in test-email logs

    Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

    * fix: align EmailLoggerTest to new schema and update changelog

    - EmailLoggerTest.php: update all assertions to match a9aa887a schema
      - email_id -> email_type context key
      - recipient_hash -> plain recipient (test_recipient_is_hashed removed,
        test_recipient_is_stored_plain + test_empty_recipient_produces_empty_string added)
      - object_type/object_id -> single typed key (e.g. 'order' => 42)
      - test_object_context_omitted_when_no_object checks for order/product/user keys
      - test_register_adds_hook now also asserts wp_mail_failed hook
      - tearDown removes wp_mail_failed hook to prevent bleed
      - Add test_failure_message_includes_error_reason for wp_mail_failed capture path
      - Add test_success_message_has_no_error_reason
    - Changelog: drop "hashed recipient", note failure-reason capture from wp_mail_failed
    - EmailLogger.php: add code comment on wp_mail_failed cross-contamination window
    - EmailPreviewRestController.php: add comment explaining $email->recipient mutation

    Agent-Logs-Url: https://github.com/jonathanbossenger/woocommerce/sessions/9e16277a-cb44-4552-a753-0bb2f5793362

    Co-authored-by: jonathanbossenger <180629+jonathanbossenger@users.noreply.github.com>

    * refactor: resolve recipient to username or guest instead of plain email

    Avoids storing plain email addresses in the transactional email log while
    still giving support teams a useful identifier. Registered users are logged
    by WordPress username; addresses with no associated account log as 'guest'.
    Multi-recipient strings (comma-separated) are resolved address by address.

    Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

    * PR fixes

    * Add changefile(s) from automation for the following project(s): woocommerce

    * Clarify recipient wording in email log changelog entry

    Update the release-note phrasing to match the implemented privacy
    behavior: the log records a WordPress username for registered users or
    "guest" rather than a raw email address.

    Addresses CodeRabbit review comment on PR #64491.

    * Harden EmailLogger get_id() invocation against incompatible signatures

    Extension-provided objects can implement get_id() with required
    parameters or that throws on call. Use ReflectionMethod to verify the
    method takes no required parameters and wrap the call in a try/catch so
    logging falls back to the ID property (or returns a type-only context)
    instead of raising a runtime error.

    Add unit tests covering both the parameter-required and throwing cases.

    Addresses CodeRabbit review comment on PR #64491.

    * Vary checklist stems in woocommerce-core-dev agent doc

    Mix interrogative, imperative, and noun-fronted phrasing in the QA
    checklist and Escalation & Limits bullets so repeated stems don't
    flatten the lists.

    Addresses CodeRabbit nitpick on PR #64491.

    * Use get_object_vars() to access ID fallback safely

    property_exists() returns true for protected and private properties,
    which would make the direct $wc_object->ID access throw an Error in
    PHP 7.4+ for extension objects that don't expose ID publicly.
    get_object_vars() called from outside the class returns only public
    properties, so this guards the fallback against incompatible visibility.

    Addresses CodeRabbit review comment on PR #64491.

    * Wrap registered-user recipient test in try/finally

    If an assertion fails before wp_delete_user() runs, the test currently
    leaves the created user behind and pollutes later recipient-lookup
    cases. Move the cleanup into a finally block so the user is removed
    regardless of assertion outcome.

    Addresses CodeRabbit review comment on PR #64491.

    * EmailLogger: use @since 10.9.0 to match trunk

    Trunk is on 10.9.0-dev per class-woocommerce.php; align the class
    docblock and the woocommerce_email_log_enabled / woocommerce_email_log_context
    filter docblocks accordingly.

    Addresses review comment on PR #64491.

    * Remove duplicate changelog entry

    64491-woo-email-log and rsm-352-add-woo-native-email-log describe the
    same change and would produce a duplicate line in changelog.txt.
    Keeping 64491-woo-email-log as it carries the fuller user-facing detail
    (WooCommerce → Status → Logs path, transactional-emails source,
    wp_mail_failed reason).

    Addresses review comment on PR #64491.

    * Add EmailPreviewRestController send-path test

    The send-preview endpoint now resolves the WC_Email instance via
    EmailPreview::get_email() (instead of constructing a generic
    WC_Emails) and forwards that instance's headers and attachments to
    send(). This is what lets EmailLogger and other woocommerce_email_sent
    listeners see the right email type for previews — but it is a
    behaviour change for a separate feature, so add a regression test.

    The test hooks woocommerce_email_sent and verifies:
      - the captured WC_Email is the requested subclass (not generic);
      - the email id is set so listeners can identify the email type;
      - the recipient is set on the instance before send().

    Addresses review comment on PR #64491.

    * Clone resolved email so preview send does not mutate the cached singleton

    EmailPreview::get_email() returns the cached WC_Email by reference and the
    EmailPreview service is registered as a DI-container singleton, so assigning
    $email->recipient on the returned instance persisted the change on the
    singleton itself.

    Under long-lived PHP runtimes (Roadrunner / FrankenPHP / Bedrock) the
    EmailPreview instance survives across requests, so a subsequent read of
    get_email()->get_recipient() — by any code path or hook re-using the
    cached email — would surface the previous tester's address. That re-introduces
    the PII the recipient identifier was scrubbed of.

    Clone the resolved email before mutating, and add a regression test asserting
    the cached instance's recipient is not the test address after send-preview.

    Addresses prettyboymp's review on PR #64491.

    * Redact email addresses from PHPMailer error reasons in failure log message

    The recipient identifier on the log context is intentionally resolved to
    WordPress username or 'guest' to keep raw email addresses out of the log,
    but $this->last_mail_error was concatenated verbatim into the message.
    PHPMailer/SMTP errors commonly embed the recipient address (for example
    "SMTP Error: Could not send to foo@example.com"), which re-introduced the
    PII the recipient field was scrubbed of.

    Under the database log handler this is especially exposed: the message lands
    in {prefix}woocommerce_log.message, becomes visible in WC > Status > Logs to
    anyone with manage_woocommerce, is searchable, persists for the configured
    retention window, and surfaces in DB exports.

    Add a private redact_emails() helper using the same regex as
    RemoteLogger::redact_user_data() so the privacy posture stays consistent
    across loggers, and apply it to last_mail_error before concatenation. Cover
    with a regression test asserting addresses are replaced with [redacted_email]
    in the failure message.

    Addresses prettyboymp's review on PR #64491.

    * Skip ReflectionMethod for known typed objects in get_object_context

    WC_Order, WC_Product and WP_User have a stable zero-arg get_id() signature,
    so the ReflectionMethod round-trip on every successful send is unnecessary
    overhead. Keep the reflection guard for the unknown-class fallback (where
    extension subclasses with non-standard get_id() signatures could otherwise
    throw), and short-circuit the three known branches.

    Addresses prettyboymp's review on PR #64491.

    * Fix WP_User ID resolution and soften wp_mail_failed docblock

    get_object_context() called get_id() on WP_User via the in_array fast-path.
    WP_User has no real get_id() — __call() returns false, which casts to 0.
    Since $id was 0 (not null), the ->ID property fallback never ran, logging
    'user => 0' for every new-account email. Fix by branching WP_User explicitly
    to read ->ID directly, matching the reviewer's suggested approach.

    Also updates the capture_mail_error docblock to accurately describe the
    correlation window: the trailing edge is bounded (cleared after each send)
    but the leading edge is unbounded — a non-WooCommerce wp_mail_failed before
    a WooCommerce send failure will be attributed to that WooCommerce send.

    Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

    * Fix PHPCS linting errors in EmailLoggerTest

    Add missing @var docblocks to anonymous class properties and correct
    the return type of a always-throwing get_id() stub to `never`.

    Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

    ---------

    Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
    Co-authored-by: jonathanbossenger <180629+jonathanbossenger@users.noreply.github.com>
    Co-authored-by: Job <8783673+jobthomas@users.noreply.github.com>
    Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
    Co-authored-by: woocommercebot <woocommercebot@users.noreply.github.com>
    Co-authored-by: Brandon Kraft <public@brandonkraft.com>
    Co-authored-by: Michael Pretty <prettyboymp@users.noreply.github.com>

diff --git a/.github/agents/woocommerce-core-dev.md b/.github/agents/woocommerce-core-dev.md
new file mode 100644
index 00000000000..8b4ab32b847
--- /dev/null
+++ b/.github/agents/woocommerce-core-dev.md
@@ -0,0 +1,70 @@
+---
+# Fill in the fields below to create a basic custom agent for your repository.
+# The Copilot CLI can be used for local testing: https://gh.io/customagents/cli
+# To make this agent available, merge this file into the default repository branch.
+# For format details, see: https://gh.io/customagents/config
+
+name: "woocommerce-core-dev"
+description: "Use this agent when you need expert guidance on WooCommerce Core development, including writing or reviewing code that extends, integrates with, or modifies WooCommerce; implementing custom payment gateways, shipping methods, or product types; working with WooCommerce hooks, filters, and APIs; debugging WooCommerce-specific issues; optimizing WooCommerce performance; following WooCommerce coding standards and architectural patterns; or migrating/upgrading WooCommerce installations."
+---
+
+# WooCommerce Core Dev
+
+You are a Senior WooCommerce Core Developer with over a decade of experience contributing to and building on top of WooCommerce. You have deep expertise in the WooCommerce codebase architecture, its evolution from WooThemes through Automattic, and the full ecosystem of WordPress plugin development. You are intimately familiar with WooCommerce's internal APIs, data structures, class hierarchies, and both legacy and modern patterns (including the High-Performance Order Storage / HPOS, Cart and Checkout Blocks, the Store API, and the React-based admin).
+
+## Core Expertise
+
+You have mastery of:
+- **WooCommerce Architecture**: CRUD layer, data stores, object hierarchies (WC_Product, WC_Order, WC_Customer, WC_Cart, WC_Session), and the abstraction patterns that enable HPOS.
+- **Extension Points**: Actions, filters, template overrides, custom product types, payment gateways (WC_Payment_Gateway), shipping methods (WC_Shipping_Method), and tax integrations.
+- **Modern WooCommerce**: Blocks (Cart, Checkout, Mini Cart, product blocks), Store API (REST endpoints under /wc/store), the WooCommerce Admin (React/wp-data stores), Remote Inbox Notifications, and the Feature Plugin pattern.
+- **Data Layer**: Custom tables, HPOS (wc_orders, wc_order_addresses, wc_order_operational_data, wc_order_meta), legacy post-type storage, order data stores, and safe data migration patterns.
+- **WordPress Integration**: Hooks lifecycle, capability system, WP-Cron, REST API, WP_Query, transients, and internationalization (using woocommerce or your plugin's text domain appropriately).
+- **Coding Standards**: WooCommerce Coding Standards (a superset of WordPress Coding Standards), PHPCS rulesets, PHP 7.4+ features safely usable in WooCommerce, and backward compatibility policies.
+- **Testing**: PHPUnit for WooCommerce, wp-env/wp-cli, E2E testing with Playwright, and the WooCommerce test helper utilities.
+
+## Operational Approach
+
+When given a task, you will:
+
+1. **Clarify Context**: Identify the WooCommerce version(s) being targeted, whether HPOS is enabled, whether Blocks-based checkout is in use, and any relevant environment constraints. Ask focused questions only when a decision genuinely hinges on the answer.
+
+2. **Favor Official APIs**: Always prefer WooCommerce's CRUD methods (wc_get_order(), $order->get_items(), $product->save(), etc.) over direct database queries or post meta access. Never write code that assumes post-type storage unless explicitly wrapping legacy behavior behind compatibility layers.
+
+3. **HPOS Compatibility**: All order-related code you write must be HPOS-compatible. Declare compatibility via FeaturesUtil::declare_compatibility() when appropriate, use $order->get_meta()/update_meta_data()/save() rather than get_post_meta()/update_post_meta() on order IDs, and avoid WP_Query for orders—use wc_get_orders() instead.
+
+4. **Cart & Checkout Blocks Compatibility**: When touching checkout logic, account for both the shortcode checkout and Blocks checkout. Use ExtendSchema for Store API extension, register checkout block integrations via IntegrationInterface, and avoid hooks that only fire in the legacy checkout without providing a Blocks equivalent.
+
+5. **Follow Coding Standards**: Produce code that passes WooCommerce-Sniffs. Use Yoda conditions, proper escaping (esc_html, esc_attr, esc_url, wp_kses_post), sanitization (wc_clean, sanitize_text_field), nonces for all state-changing actions, and proper capability checks (manage_woocommerce for admin actions).
+
+6. **Backward Compatibility**: Respect WooCommerce's deprecation policy. Use wc_deprecated_function()/wc_deprecated_hook() when deprecating. Never remove public APIs without a proper deprecation cycle. Support at least the current and previous two minor versions of WooCommerce unless told otherwise.
+
+7. **Performance Consciousness**: Avoid N+1 queries on orders/products. Use batch APIs, prime caches with _prime_post_caches() or equivalents, leverage object caching, and be mindful of the action scheduler for long-running tasks.
+
+8. **Internationalization**: All user-facing strings use translation functions with the correct text domain. Escape after translation, not before.
+
+9. **Provide Context with Code**: When producing code, briefly explain the key WooCommerce-specific decisions (e.g., "Using wc_get_orders() here because it transparently supports both HPOS and legacy storage"). Point out hooks being used and why.
+
+10. **Review Mode**: When reviewing code, check for: HPOS compatibility issues, missing nonces/capability checks, direct DB access that should use CRUD, improper escaping, deprecated function usage, Blocks checkout gaps, and incorrect hook priorities or timing.
+
+## Quality Assurance
+
+Before finalizing any answer or code, verify:
+- Confirm it works under HPOS as well as legacy CPT storage.
+- Check Blocks checkout compatibility alongside shortcode checkout (if relevant).
+- Are all inputs sanitized and outputs escaped?
+- Capability and nonce checks in place for every mutation?
+- Ensure user-facing strings are translatable.
+- Follow WooCommerce coding standards.
+- Prefer CRUD APIs over direct post/meta access.
+- Will this survive a WooCommerce update within the supported version range?
+
+If any of these fail, fix before delivering.
+
+## Escalation & Limits
+
+- Warn explicitly and propose a safer path whenever a request would bypass WooCommerce's data integrity guarantees (e.g., directly manipulating order totals without recalculation).
+- State the version requirement clearly when a feature needs a WooCommerce version newer than what the user has indicated.
+- For tasks that stray into pure WordPress-core territory with no WooCommerce specifics, handle them competently but note when a more general WordPress resource might be more appropriate.
+
+You are decisive, precise, and grounded in the realities of production WooCommerce stores. Deliver expert-level guidance that respects the platform's conventions while helping the user accomplish their goal effectively.
diff --git a/plugins/woocommerce/changelog/64491-woo-email-log b/plugins/woocommerce/changelog/64491-woo-email-log
new file mode 100644
index 00000000000..48b364ac54d
--- /dev/null
+++ b/plugins/woocommerce/changelog/64491-woo-email-log
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Add a native transactional email log. Each send attempt is recorded under WooCommerce → Status → Logs (source: transactional-emails), capturing the type, related order or object, send status, and recipient (WordPress username for registered users, "guest" for unrecognised addresses). Failed sends include the error reason from wp_mail_failed.
\ No newline at end of file
diff --git a/plugins/woocommerce/includes/class-woocommerce.php b/plugins/woocommerce/includes/class-woocommerce.php
index c631625f155..0e2ecc4db2c 100644
--- a/plugins/woocommerce/includes/class-woocommerce.php
+++ b/plugins/woocommerce/includes/class-woocommerce.php
@@ -30,6 +30,7 @@ use Automattic\WooCommerce\Internal\Utilities\LegacyRestApiStub;
 use Automattic\WooCommerce\Internal\Utilities\WebhookUtil;
 use Automattic\WooCommerce\Internal\Admin\EmailImprovements\EmailImprovements;
 use Automattic\WooCommerce\Internal\Email\DeferredEmailQueue;
+use Automattic\WooCommerce\Internal\Email\EmailLogger;
 use Automattic\WooCommerce\Internal\Admin\Marketplace;
 use Automattic\WooCommerce\Proxies\LegacyProxy;
 use Automattic\WooCommerce\Utilities\{LoggingUtil, TimeUtil};
@@ -396,6 +397,7 @@ final class WooCommerce {
 		$container->get( Automattic\WooCommerce\Internal\Utilities\LegacyRestApiStub::class )->register();
 		$container->get( Automattic\WooCommerce\Internal\VariationGallery\Telemetry::class )->register();
 		$container->get( Automattic\WooCommerce\Internal\Email\EmailStyleSync::class )->register();
+		$container->get( EmailLogger::class )->register();
 		$container->get( Automattic\WooCommerce\Admin\Features\Fulfillments\FulfillmentsController::class )->register();
 		$container->get( Automattic\WooCommerce\Internal\Admin\Agentic\AgenticController::class )->register();
 		$container->get( Automattic\WooCommerce\Internal\ProductFeed\ProductFeed::class )->register();
diff --git a/plugins/woocommerce/src/Internal/Admin/EmailPreview/EmailPreviewRestController.php b/plugins/woocommerce/src/Internal/Admin/EmailPreview/EmailPreviewRestController.php
index d6948b12aa6..50a41657f2f 100644
--- a/plugins/woocommerce/src/Internal/Admin/EmailPreview/EmailPreviewRestController.php
+++ b/plugins/woocommerce/src/Internal/Admin/EmailPreview/EmailPreviewRestController.php
@@ -290,8 +290,15 @@ class EmailPreviewRestController extends RestApiControllerBase {
 		}
 		ob_end_clean();
 		$email_subject = $this->email_preview->get_subject();
-		$email         = new \WC_Emails();
-		$sent          = $email->send( $email_address, $email_subject, $email_content );
+		// Clone so the recipient mutation below does not persist on the EmailPreview
+		// singleton: under long-lived runtimes (Roadrunner/FrankenPHP) the cached
+		// instance is reused across requests, which would leak the test admin's
+		// address into a later send for a different recipient.
+		$email = clone $this->email_preview->get_email();
+		// Set the recipient on the WC_Email instance so it is available to hooks
+		// (e.g. woocommerce_email_sent) and log entries when the test email is sent.
+		$email->recipient = $email_address;
+		$sent             = $email->send( $email_address, $email_subject, $email_content, $email->get_headers(), $email->get_attachments() );

 		if ( $sent ) {
 			return array(
diff --git a/plugins/woocommerce/src/Internal/Email/EmailLogger.php b/plugins/woocommerce/src/Internal/Email/EmailLogger.php
new file mode 100644
index 00000000000..f27de85fa16
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Email/EmailLogger.php
@@ -0,0 +1,237 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Internal\Email;
+
+use Automattic\WooCommerce\Internal\RegisterHooksInterface;
+use WC_Email;
+use WC_Log_Levels;
+use WC_Order;
+use WC_Product;
+use WP_Error;
+use WP_User;
+
+/**
+ * Logs transactional email send attempts so store owners can inspect what WooCommerce attempted locally.
+ *
+ * Records are written to the WooCommerce logger under the `transactional-emails` source and include the email type,
+ * related object, recipient identifier, and the local send state. The recipient is logged as the WordPress username
+ * when the address is linked to an account, or as 'guest' for unrecognised addresses. Failure reasons are captured
+ * from wp_mail_failed.
+ *
+ * @since 10.9.0
+ * @internal
+ */
+class EmailLogger implements RegisterHooksInterface {
+
+	/**
+	 * Logger source used for all email log entries.
+	 */
+	private const LOG_SOURCE = 'transactional-emails';
+
+	/**
+	 * Holds the PHPMailer error message from the most recent failed wp_mail() call.
+	 *
+	 * @var string|null
+	 */
+	private ?string $last_mail_error = null;
+
+	/**
+	 * Register hooks.
+	 *
+	 * @return void
+	 */
+	public function register(): void {
+		add_action( 'wp_mail_failed', array( $this, 'capture_mail_error' ), 10, 1 );
+		add_action( 'woocommerce_email_sent', array( $this, 'handle_woocommerce_email_sent' ), 10, 3 );
+	}
+
+	/**
+	 * Capture the PHPMailer error from a failed wp_mail() call so it can be included in the log entry.
+	 *
+	 * Error attribution is best-effort: wp_mail_failed is a global hook, so any plugin's failed
+	 * wp_mail() call will set $last_mail_error. The trailing edge is controlled — $last_mail_error
+	 * is cleared immediately after each WooCommerce send — but the leading edge is unbounded: a
+	 * non-WooCommerce wp_mail_failed fired before a WooCommerce send failure will be attributed
+	 * to that WooCommerce send. This may produce misleading error reasons in stores where other
+	 * plugins also call wp_mail().
+	 *
+	 * @param WP_Error $error The error returned by wp_mail.
+	 * @return void
+	 */
+	public function capture_mail_error( WP_Error $error ): void {
+		$this->last_mail_error = $error->get_error_message();
+	}
+
+	/**
+	 * Handle the woocommerce_email_sent action.
+	 *
+	 * @param bool     $success  Whether the email was sent successfully.
+	 * @param string   $email_id The email type ID (e.g. `customer_processing_order`).
+	 * @param WC_Email $email    The WC_Email instance.
+	 * @return void
+	 */
+	public function handle_woocommerce_email_sent( $success, string $email_id, WC_Email $email ): void {
+		/**
+		 * Filter whether to log this transactional email attempt.
+		 *
+		 * Return false to skip logging for a particular email or globally.
+		 *
+		 * @since 10.9.0
+		 *
+		 * @param bool     $enabled  Whether logging is enabled.
+		 * @param string   $email_id The email type ID.
+		 * @param WC_Email $email    The WC_Email instance.
+		 */
+		if ( ! apply_filters( 'woocommerce_email_log_enabled', true, $email_id, $email ) ) {
+			$this->last_mail_error = null;
+			return;
+		}
+
+		$object_context = $this->get_object_context( $email->object );
+		$object_label   = isset( $object_context['type'], $object_context['id'] )
+			? sprintf( ' for %s #%d', $object_context['type'], $object_context['id'] )
+			: '';
+
+		if ( $success ) {
+			$message = sprintf( 'Email "%s"%s sent', $email_id, $object_label );
+		} else {
+			$reason  = $this->last_mail_error ? ': ' . $this->redact_emails( $this->last_mail_error ) : '';
+			$message = sprintf( 'Email "%s"%s failed to send%s', $email_id, $object_label, $reason );
+		}
+
+		$this->last_mail_error = null;
+
+		$context = array(
+			'source'     => self::LOG_SOURCE,
+			'email_type' => $email_id,
+			'status'     => $success ? 'sent' : 'failed',
+			'recipient'  => $this->resolve_recipient( $email->get_recipient() ),
+		);
+
+		if ( ! empty( $object_context ) ) {
+			$context[ $object_context['type'] ] = $object_context['id'] ?? null;
+		}
+
+		/**
+		 * Filter the context array logged for each transactional email attempt.
+		 *
+		 * @since 10.9.0
+		 *
+		 * @param array    $context  The context array to be logged.
+		 * @param string   $email_id The email type ID.
+		 * @param WC_Email $email    The WC_Email instance.
+		 */
+		$context = (array) apply_filters( 'woocommerce_email_log_context', $context, $email_id, $email );
+
+		$level = $success ? WC_Log_Levels::INFO : WC_Log_Levels::WARNING;
+		wc_get_logger()->log( $level, $message, $context );
+	}
+
+	/**
+	 * Resolve a recipient email string to an identifier safe for logging.
+	 *
+	 * Each address is mapped to the corresponding WordPress username when an account
+	 * exists, or to the string 'guest' for addresses with no associated account.
+	 * This avoids storing plain email addresses in logs while still giving support
+	 * teams a useful identifier for troubleshooting.
+	 *
+	 * @param string $recipient Comma-separated recipient email string from WC_Email::get_recipient().
+	 * @return string Comma-separated usernames or 'guest' labels.
+	 */
+	private function resolve_recipient( string $recipient ): string {
+		if ( '' === $recipient ) {
+			return 'guest';
+		}
+
+		$labels = array_map(
+			function ( string $email ): string {
+				$user = get_user_by( 'email', trim( $email ) );
+				return $user instanceof WP_User ? $user->user_login : 'guest';
+			},
+			explode( ',', $recipient )
+		);
+
+		return implode( ', ', $labels );
+	}
+
+	/**
+	 * Replace any email addresses in a log message fragment with `[redacted_email]`.
+	 *
+	 * PHPMailer / SMTP error strings frequently embed the recipient address
+	 * (e.g. "SMTP Error: Could not send to foo@example.com"). Without redaction,
+	 * the address would be written into the log message and — when the database
+	 * log handler is active — surface in WC > Status > Logs to anyone with
+	 * `manage_woocommerce`, defeating the username/`guest` resolution applied
+	 * to the `recipient` context field.
+	 *
+	 * Mirrors the regex used by RemoteLogger::redact_user_data() so the privacy
+	 * posture stays consistent across loggers.
+	 *
+	 * @param string $message The message fragment to scrub.
+	 * @return string The fragment with any email addresses replaced.
+	 */
+	private function redact_emails( string $message ): string {
+		return (string) preg_replace( '/[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/', '[redacted_email]', $message );
+	}
+
+	/**
+	 * Extract loggable context from the WooCommerce object attached to the email.
+	 *
+	 * Returns a stable short type identifier rather than the raw class name so that log aggregation
+	 * is not brittle across subclasses (e.g. WC_Order_Refund still returns type 'order').
+	 *
+	 * @param mixed $wc_object The email's related object (WC_Order, WC_Product, WP_User, etc.) or false/null.
+	 * @return array{type: string, id?: int}|array{} Type and (when resolvable) ID of the object, or empty when no object is set.
+	 */
+	private function get_object_context( $wc_object ): array {
+		if ( ! is_object( $wc_object ) ) {
+			return array();
+		}
+
+		if ( $wc_object instanceof WC_Order ) {
+			$type = 'order';
+		} elseif ( $wc_object instanceof WC_Product ) {
+			$type = 'product';
+		} elseif ( $wc_object instanceof WP_User ) {
+			$type = 'user';
+		} else {
+			$type = get_class( $wc_object );
+		}
+
+		$id = null;
+		if ( $wc_object instanceof WC_Order || $wc_object instanceof WC_Product ) {
+			// Both have an explicit get_id() — safe to call directly.
+			$id = (int) $wc_object->get_id();
+		} elseif ( $wc_object instanceof WP_User ) {
+			// WP_User has no get_id() method; __call() returns false for unknown methods,
+			// which casts to 0 and bypasses the ID-property fallback below.
+			$id = (int) $wc_object->ID;
+		} elseif ( method_exists( $wc_object, 'get_id' ) ) {
+			try {
+				$method = new \ReflectionMethod( $wc_object, 'get_id' );
+				if ( 0 === $method->getNumberOfRequiredParameters() ) {
+					$id = (int) $wc_object->get_id();
+				}
+			} catch ( \Throwable $e ) {
+				$id = null;
+			}
+		}
+
+		if ( null === $id ) {
+			$public_props = get_object_vars( $wc_object );
+			if ( array_key_exists( 'ID', $public_props ) ) {
+				$id = (int) $public_props['ID'];
+			}
+		}
+
+		if ( null === $id ) {
+			return array( 'type' => $type );
+		}
+
+		return array(
+			'type' => $type,
+			'id'   => $id,
+		);
+	}
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Admin/EmailPreview/EmailPreviewRestControllerTest.php b/plugins/woocommerce/tests/php/src/Internal/Admin/EmailPreview/EmailPreviewRestControllerTest.php
index 89a51b08b4b..c6f415bbaed 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Admin/EmailPreview/EmailPreviewRestControllerTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Admin/EmailPreview/EmailPreviewRestControllerTest.php
@@ -146,6 +146,58 @@ class EmailPreviewRestControllerTest extends WC_REST_Unit_Test_Case {
 		remove_filter( 'woocommerce_mail_callback', array( $this, 'simulate_failed_sending' ), 10 );
 	}

+	/**
+	 * Test that the preview send path uses the WC_Email instance resolved by
+	 * EmailPreview (rather than a generic WC_Emails instance), so the email
+	 * type, recipient, headers and attachments flow through to the
+	 * woocommerce_email_sent hook — which is what EmailLogger and any
+	 * third-party listeners depend on.
+	 */
+	public function test_send_preview_uses_resolved_email_instance() {
+		$captured = array();
+		$capture  = function ( $success, $email_id, $email ) use ( &$captured ) {
+			$captured[] = array(
+				'success'   => $success,
+				'email_id'  => $email_id,
+				'class'     => get_class( $email ),
+				'recipient' => $email->get_recipient(),
+			);
+		};
+		add_action( 'woocommerce_email_sent', $capture, 10, 3 );
+
+		try {
+			$request  = $this->get_email_preview_request( EmailPreview::DEFAULT_EMAIL_TYPE, self::EMAIL );
+			$response = $this->server->dispatch( $request );
+
+			$this->assertEquals( 200, $response->get_status() );
+			$this->assertNotEmpty( $captured, 'woocommerce_email_sent should fire for the preview send path.' );
+
+			$last = end( $captured );
+			$this->assertSame( EmailPreview::DEFAULT_EMAIL_TYPE, $last['class'], 'Send path should use the resolved WC_Email subclass, not a generic WC_Emails instance.' );
+			$this->assertNotEmpty( $last['email_id'], 'Email id should be set so listeners can identify the email type.' );
+			$this->assertSame( self::EMAIL, $last['recipient'], 'Recipient should be set on the resolved instance before send.' );
+		} finally {
+			remove_action( 'woocommerce_email_sent', $capture, 10 );
+		}
+	}
+
+	/**
+	 * Test that the preview send path does not mutate the recipient on the
+	 * EmailPreview singleton. The controller clones before assigning, so a
+	 * subsequent read of get_email()->get_recipient() must not return the
+	 * test address — under long-lived runtimes this would otherwise leak the
+	 * previous tester's address into a later send for a different customer.
+	 */
+	public function test_send_preview_does_not_persist_recipient_on_cached_email() {
+		$email_preview = wc_get_container()->get( EmailPreview::class );
+
+		$request  = $this->get_email_preview_request( EmailPreview::DEFAULT_EMAIL_TYPE, self::EMAIL );
+		$response = $this->server->dispatch( $request );
+
+		$this->assertEquals( 200, $response->get_status() );
+		$this->assertNotSame( self::EMAIL, $email_preview->get_email()->get_recipient(), 'Recipient must not persist on the cached EmailPreview instance after a preview send.' );
+	}
+
 	/**
 	 * Helper method to simulate a failed email sending.
 	 *
diff --git a/plugins/woocommerce/tests/php/src/Internal/Email/EmailLoggerTest.php b/plugins/woocommerce/tests/php/src/Internal/Email/EmailLoggerTest.php
new file mode 100644
index 00000000000..c509f0cd8d7
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Email/EmailLoggerTest.php
@@ -0,0 +1,374 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\Email;
+
+use Automattic\WooCommerce\Internal\Email\EmailLogger;
+use Automattic\WooCommerce\RestApi\UnitTests\LoggerSpyTrait;
+use WC_Unit_Test_Case;
+
+/**
+ * Tests for the EmailLogger class.
+ *
+ * @covers \Automattic\WooCommerce\Internal\Email\EmailLogger
+ */
+class EmailLoggerTest extends WC_Unit_Test_Case {
+
+	use LoggerSpyTrait;
+
+	/**
+	 * The System Under Test.
+	 *
+	 * @var EmailLogger
+	 */
+	private $sut;
+
+	/**
+	 * Set up test fixtures.
+	 */
+	public function setUp(): void {
+		parent::setUp();
+		$this->sut = new EmailLogger();
+	}
+
+	/**
+	 * Tear down test fixtures.
+	 */
+	public function tearDown(): void {
+		remove_all_filters( 'woocommerce_email_log_enabled' );
+		remove_all_filters( 'woocommerce_email_log_context' );
+		remove_action( 'woocommerce_email_sent', array( $this->sut, 'handle_woocommerce_email_sent' ) );
+		remove_action( 'wp_mail_failed', array( $this->sut, 'capture_mail_error' ) );
+		parent::tearDown();
+	}
+
+	/**
+	 * @testdox Register method adds hooks for woocommerce_email_sent and wp_mail_failed.
+	 */
+	public function test_register_adds_hook(): void {
+		$this->sut->register();
+
+		$this->assertNotFalse(
+			has_action( 'woocommerce_email_sent', array( $this->sut, 'handle_woocommerce_email_sent' ) ),
+			'Expected hook to be registered for woocommerce_email_sent'
+		);
+		$this->assertNotFalse(
+			has_action( 'wp_mail_failed', array( $this->sut, 'capture_mail_error' ) ),
+			'Expected hook to be registered for wp_mail_failed'
+		);
+	}
+
+	/**
+	 * @testdox Logs an info entry when email is sent successfully.
+	 */
+	public function test_logs_info_on_success(): void {
+		$email = $this->create_mock_email( 'customer_processing_order', 'customer@example.com' );
+
+		$this->sut->handle_woocommerce_email_sent( true, 'customer_processing_order', $email );
+
+		$this->assertLogged( 'info', 'customer_processing_order' );
+	}
+
+	/**
+	 * @testdox Logs a warning entry when email fails to send.
+	 */
+	public function test_logs_warning_on_failure(): void {
+		$email = $this->create_mock_email( 'customer_processing_order', 'customer@example.com' );
+
+		$this->sut->handle_woocommerce_email_sent( false, 'customer_processing_order', $email );
+
+		$this->assertLogged( 'warning', 'customer_processing_order' );
+	}
+
+	/**
+	 * @testdox Log context contains email_type, status, and recipient.
+	 */
+	public function test_log_context_contains_required_fields(): void {
+		$email = $this->create_mock_email( 'new_order', 'admin@example.com' );
+
+		$this->sut->handle_woocommerce_email_sent( true, 'new_order', $email );
+
+		$this->assertLogged(
+			'info',
+			'new_order',
+			array(
+				'source'     => 'transactional-emails',
+				'email_type' => 'new_order',
+				'status'     => 'sent',
+			)
+		);
+	}
+
+	/**
+	 * @testdox Status is "failed" when email send was unsuccessful.
+	 */
+	public function test_status_is_failed_on_unsuccessful_send(): void {
+		$email = $this->create_mock_email( 'customer_processing_order', 'customer@example.com' );
+
+		$this->sut->handle_woocommerce_email_sent( false, 'customer_processing_order', $email );
+
+		$this->assertLogged( 'warning', 'customer_processing_order', array( 'status' => 'failed' ) );
+	}
+
+	/**
+	 * @testdox Recipient is logged as the WordPress username for a registered user.
+	 */
+	public function test_recipient_is_username_for_registered_user(): void {
+		$user  = self::factory()->user->create_and_get( array( 'user_email' => 'registered@example.com' ) );
+		$email = $this->create_mock_email( 'customer_processing_order', 'registered@example.com' );
+
+		try {
+			$this->sut->handle_woocommerce_email_sent( true, 'customer_processing_order', $email );
+
+			$context = $this->captured_logs[0]['context'];
+
+			$this->assertArrayHasKey( 'recipient', $context );
+			$this->assertSame( $user->user_login, $context['recipient'], 'Recipient should be the WordPress username for a registered user' );
+			$this->assertStringNotContainsString( 'registered@example.com', $context['recipient'], 'Raw email address should not appear in the log context' );
+		} finally {
+			wp_delete_user( $user->ID );
+		}
+	}
+
+	/**
+	 * @testdox Recipient is logged as "guest" for an email address not linked to any user account.
+	 */
+	public function test_recipient_is_guest_for_unregistered_email(): void {
+		$email = $this->create_mock_email( 'customer_processing_order', 'guest@example.com' );
+
+		$this->sut->handle_woocommerce_email_sent( true, 'customer_processing_order', $email );
+
+		$context = $this->captured_logs[0]['context'];
+
+		$this->assertSame( 'guest', $context['recipient'], 'Recipient should be "guest" when the email is not linked to a user account' );
+	}
+
+	/**
+	 * @testdox Empty recipient is logged as "guest".
+	 */
+	public function test_empty_recipient_is_guest(): void {
+		$email = $this->create_mock_email( 'new_order', '' );
+
+		$this->sut->handle_woocommerce_email_sent( true, 'new_order', $email );
+
+		$context = $this->captured_logs[0]['context'];
+
+		$this->assertSame( 'guest', $context['recipient'], 'Empty recipient should yield "guest"' );
+	}
+
+	/**
+	 * @testdox Failure message includes the error reason captured from wp_mail_failed.
+	 */
+	public function test_failure_message_includes_error_reason(): void {
+		$error = new \WP_Error( 'wp_mail_failed', 'SMTP connect() failed' );
+		$this->sut->capture_mail_error( $error );
+
+		$email = $this->create_mock_email( 'new_order', 'admin@example.com' );
+		$this->sut->handle_woocommerce_email_sent( false, 'new_order', $email );
+
+		$this->assertLogged( 'warning', 'SMTP connect() failed' );
+	}
+
+	/**
+	 * @testdox Failure message redacts email addresses embedded in the captured wp_mail_failed reason.
+	 */
+	public function test_failure_message_redacts_email_addresses_in_reason(): void {
+		$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( 'new_order', 'admin@example.com' );
+		$this->sut->handle_woocommerce_email_sent( false, 'new_order', $email );
+
+		$log = $this->captured_logs[0];
+		$this->assertStringNotContainsString( 'customer@example.com', $log['message'], 'Raw recipient address must not appear in the logged message.' );
+		$this->assertStringNotContainsString( 'server.example.org', $log['message'], 'Domain-only host names should be left intact (only address-shaped tokens are redacted).' );
+		$this->assertStringContainsString( '[redacted_email]', $log['message'], 'Redacted addresses should be replaced with the [redacted_email] marker.' );
+	}
+
+	/**
+	 * @testdox Success message does not include an error reason.
+	 */
+	public function test_success_message_has_no_error_reason(): void {
+		$email = $this->create_mock_email( 'new_order', 'admin@example.com' );
+		$this->sut->handle_woocommerce_email_sent( true, 'new_order', $email );
+
+		$log = $this->captured_logs[0];
+
+		$this->assertStringContainsString( 'sent', $log['message'] );
+		$this->assertStringNotContainsString( 'failed', $log['message'] );
+	}
+
+	/**
+	 * @testdox Object type is normalized to a stable short identifier for WC_Order.
+	 */
+	public function test_object_type_normalized_for_order(): void {
+		$order = $this->createMock( \WC_Order::class );
+		$order->method( 'get_id' )->willReturn( 42 );
+		$email = $this->create_mock_email( 'customer_processing_order', 'customer@example.com', $order );
+
+		$this->sut->handle_woocommerce_email_sent( true, 'customer_processing_order', $email );
+
+		$this->assertLogged(
+			'info',
+			'customer_processing_order',
+			array( 'order' => 42 )
+		);
+	}
+
+	/**
+	 * @testdox Object type is normalized to a stable short identifier for WC_Product.
+	 */
+	public function test_object_type_normalized_for_product(): void {
+		$product = $this->createMock( \WC_Product::class );
+		$product->method( 'get_id' )->willReturn( 10 );
+		$email = $this->create_mock_email( 'some_product_email', 'customer@example.com', $product );
+
+		$this->sut->handle_woocommerce_email_sent( true, 'some_product_email', $email );
+
+		$this->assertLogged( 'info', 'some_product_email', array( 'product' => 10 ) );
+	}
+
+	/**
+	 * @testdox Object type is normalized to a stable short identifier for WP_User.
+	 */
+	public function test_object_type_normalized_for_user(): void {
+		$user     = new \WP_User();
+		$user->ID = 5;
+		$email    = $this->create_mock_email( 'customer_new_account', 'customer@example.com', $user );
+
+		$this->sut->handle_woocommerce_email_sent( true, 'customer_new_account', $email );
+
+		$this->assertLogged(
+			'info',
+			'customer_new_account',
+			array( 'user' => 5 )
+		);
+	}
+
+	/**
+	 * @testdox Object with a get_id() requiring parameters falls back to the ID property.
+	 */
+	public function test_object_with_required_get_id_parameters_falls_back_to_id_property(): void {
+		$wc_object     = new class() {
+			/** @var int Mirrors WP_Post::$ID. */
+			public int $ID = 0; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- Mirrors WP_Post::$ID.
+
+			/**
+			 * get_id with a required parameter, which the logger guard should refuse to call.
+			 *
+			 * @param int $context Required parameter.
+			 * @return int
+			 */
+			public function get_id( int $context ): int {
+				return $context;
+			}
+		};
+		$wc_object->ID = 7;
+		$class_name    = get_class( $wc_object );
+		$email         = $this->create_mock_email( 'custom_email', 'customer@example.com', $wc_object );
+
+		$this->sut->handle_woocommerce_email_sent( true, 'custom_email', $email );
+
+		$context = $this->captured_logs[0]['context'];
+
+		$this->assertArrayHasKey( $class_name, $context );
+		$this->assertSame( 7, $context[ $class_name ] );
+	}
+
+	/**
+	 * @testdox Object whose get_id() throws does not break logging.
+	 */
+	public function test_object_with_throwing_get_id_does_not_break_logging(): void {
+		$wc_object     = new class() {
+			/** @var int Mirrors WP_Post::$ID. */
+			public int $ID = 0; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- Mirrors WP_Post::$ID.
+
+			/**
+			 * get_id that always throws to simulate a misbehaving extension object.
+			 *
+			 * @return never
+			 * @throws \RuntimeException Always.
+			 */
+			public function get_id(): never {
+				throw new \RuntimeException( 'broken get_id' );
+			}
+		};
+		$wc_object->ID = 11;
+		$class_name    = get_class( $wc_object );
+		$email         = $this->create_mock_email( 'custom_email', 'customer@example.com', $wc_object );
+
+		$this->sut->handle_woocommerce_email_sent( true, 'custom_email', $email );
+
+		$context = $this->captured_logs[0]['context'];
+
+		$this->assertArrayHasKey( $class_name, $context );
+		$this->assertSame( 11, $context[ $class_name ] );
+	}
+
+	/**
+	 * @testdox Object context is omitted when the email has no related object.
+	 */
+	public function test_object_context_omitted_when_no_object(): void {
+		$email = $this->create_mock_email( 'customer_new_account', 'customer@example.com', false );
+
+		$this->sut->handle_woocommerce_email_sent( true, 'customer_new_account', $email );
+
+		$context = $this->captured_logs[0]['context'];
+
+		$this->assertArrayNotHasKey( 'order', $context, 'Context should not contain order key when no object is set' );
+		$this->assertArrayNotHasKey( 'product', $context, 'Context should not contain product key when no object is set' );
+		$this->assertArrayNotHasKey( 'user', $context, 'Context should not contain user key when no object is set' );
+	}
+
+	/**
+	 * @testdox woocommerce_email_log_enabled filter can disable logging entirely.
+	 */
+	public function test_log_enabled_filter_can_disable_logging(): void {
+		add_filter( 'woocommerce_email_log_enabled', '__return_false' );
+
+		$email = $this->create_mock_email( 'customer_processing_order', 'customer@example.com' );
+		$this->sut->handle_woocommerce_email_sent( true, 'customer_processing_order', $email );
+
+		$this->assertEmpty( $this->captured_logs, 'No log entry should be written when the enabled filter returns false' );
+	}
+
+	/**
+	 * @testdox woocommerce_email_log_context filter can modify context before logging.
+	 */
+	public function test_log_context_filter_can_modify_context(): void {
+		add_filter(
+			'woocommerce_email_log_context',
+			function ( array $context ) {
+				$context['custom_key'] = 'custom_value';
+				return $context;
+			}
+		);
+
+		$email = $this->create_mock_email( 'new_order', 'admin@example.com' );
+		$this->sut->handle_woocommerce_email_sent( true, 'new_order', $email );
+
+		$this->assertLogged( 'info', 'new_order', array( 'custom_key' => 'custom_value' ) );
+	}
+
+	/**
+	 * Create a mock WC_Email object for testing.
+	 *
+	 * @param string $email_id  Email type ID.
+	 * @param string $recipient Recipient email address.
+	 * @param mixed  $wc_object Related WooCommerce object or false.
+	 * @return \WC_Email
+	 */
+	private function create_mock_email( string $email_id, string $recipient, $wc_object = false ): \WC_Email {
+		$email         = $this->getMockBuilder( \WC_Email::class )
+			->disableOriginalConstructor()
+			->getMock();
+		$email->id     = $email_id;
+		$email->object = $wc_object;
+		$email->expects( $this->any() )->method( 'get_recipient' )->willReturn( $recipient );
+
+		return $email;
+	}
+}