Commit cf1492b801e for woocommerce

commit cf1492b801ee389dc0ae4949ce511ace22d05468
Author: Jonathan Bossenger <jonathanbossenger@gmail.com>
Date:   Wed May 20 12:49:10 2026 +0200

    Capture email attempt outcomes (#64859)

    * 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>

    * Initial plan

    * feat: track email disabled/skipped outcomes and add EmailLogger handlers

    Add send_notification() protected method to WC_Email base class that
    fires woocommerce_email_disabled when is_enabled() returns false, and
    woocommerce_email_skipped (with reason) when no recipient is available.

    Update all 16 standard trigger() methods to call send_notification()
    instead of the inline is_enabled()/send() pattern. Add the already_sent
    skip action to WC_Email_New_Order's duplicate-send guard.

    Update EmailLogger to register and handle the two new action hooks,
    logging INFO entries for disabled and skipped outcomes with the same
    source, email_type, recipient, and object context as the existing
    woocommerce_email_sent handler.

    Add 12 new tests covering disabled/skipped log entries, status fields,
    reason fields, object context, and the log_enabled filter suppression."

    Agent-Logs-Url: https://github.com/jonathanbossenger/woocommerce/sessions/149fe54c-1e70-4459-a560-da1f88ffe27c

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

    * PR fixes

    * fix: rename changelog to match upstream PR number 64477

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

    * refactor: address review feedback on email outcome tracking

    #1: Extract log_non_send_outcome() private helper in EmailLogger — both
    handle_woocommerce_email_disabled() and handle_woocommerce_email_skipped()
    now delegate to a single method, keeping the context schema defined in
    one place.

    #2: Cache get_recipient() into a local variable in send_notification() and
    send_if_recipient() so the filter-running method is called only once.

    #3: Add WC_Email::SKIP_REASON_NO_RECIPIENT and SKIP_REASON_ALREADY_SENT
    public constants; use them in send_notification(), WC_Email_New_Order, and
    all tests instead of bare strings.

    #4: Extend changelog to document the restore_locale() locale-leak fix and
    the instanceof WC_Order defensive guard that were bundled in the first commit.

    #5: Close the manual-trigger coverage gap — add send_if_recipient() protected
    helper to WC_Email that fires woocommerce_email_skipped/no_recipient without
    checking is_enabled(); update WC_Email_Customer_Invoice,
    WC_Email_Customer_POS_Completed_Order, and WC_Email_Customer_POS_Refunded_Order
    to use it in place of the raw if(get_recipient()){send()} guards.

    #6: Add unit tests for send_notification() (disabled → action fires + returns
    false, no recipient → skipped action fires + returns false, both pass → send()
    called with cached recipient) and for send_if_recipient() (no recipient →
    skipped fires, is_enabled() false → send() still called).

    #7: Tighten hook-priority assertions to assertSame(10, has_action(...)).

    Agent-Logs-Url: https://github.com/jonathanbossenger/woocommerce/sessions/3a30fb52-595f-4c7f-a165-c5e6ebaeebba

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

    * fix: PHP 7.4 compat, tearDown closure leak, split changelog entry

    - Convert all five create_testable_email() call sites from named
      arguments (PHP 8.0+) to positional arguments so the test suite
      continues to pass on the PHP 7.4 CI job.

    - Replace per-handler remove_action() calls in tearDown() with
      remove_all_actions() for woocommerce_email_disabled and
      woocommerce_email_skipped so that test-registered closures are
      cleaned up between tests and cannot bleed into subsequent tests.

    - Split the single changelog file into two: the existing
      rsm-353-capture-email-outcomes (Type: enhancement) now covers only
      the outcome-tracking feature and the defensive instanceof guard;
      a new rsm-353-new-order-restore-locale-fix (Type: fix, Significance:
      patch) covers the restore_locale() locale-leak fix.

    Agent-Logs-Url: https://github.com/jonathanbossenger/woocommerce/sessions/f6a0db0c-64a1-4fb9-8d74-66ec38bff467

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

    * tweak: log disabled/skipped email outcomes at NOTICE

    Distinguish "normal but significant" non-send outcomes from routine successful sends. Admins scanning the transactional-emails log can now filter past disabled/skipped rows without losing them in the noise of every successful send.

    failed remains WARNING; sent remains INFO.

    Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

    * refactor: remove already_sent skip-reason from new order duplicate guard

    The duplicate-send guard in WC_Email_New_Order::trigger() intentionally
    blocks a second send when resend is not allowed. Firing
    woocommerce_email_skipped on that path implied the outcome was
    observable and controllable the same way as a no_recipient skip, but
    the underlying behaviour (silently blocking a manual resend action) is
    not a scenario that should be surfaced as a loggable skip reason.

    Changes:
    - Remove `do_action( 'woocommerce_email_skipped', SKIP_REASON_ALREADY_SENT ... )`
      from WC_Email_New_Order::trigger() — restore_locale() stays on that
      path to keep the locale-leak fix.
    - Remove the SKIP_REASON_ALREADY_SENT public constant from WC_Email.
    - Update the woocommerce_email_skipped docblock in class-wc-email.php to
      remove the SKIP_REASON_ALREADY_SENT bullet point.
    - Update EmailLogger::handle_woocommerce_email_skipped() docblock.
    - Update two EmailLoggerTest assertions that used SKIP_REASON_ALREADY_SENT
      to use SKIP_REASON_NO_RECIPIENT instead (they were testing the handler
      behaviour with any valid reason, not the new_order path specifically).
    - Update changelog entry to drop "already sent" from the skip-reason list.

    Agent-Logs-Url: https://github.com/jonathanbossenger/woocommerce/sessions/68d31c0c-97c7-4bea-9ec9-397d5185dfb3

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

    * Mark test emails in the transactional email log

    Tag log entries from the email preview send-test flow with `is_test: true`
    in the log context and prefix the message with "Test email" so store owners
    can distinguish test sends from real transactional email attempts.

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

    * Ensure temporary log filter is always removed

    * Address review: bump @since to 10.9.0 and drop duplicate changelog

    - Update five @since tags in WC_Email (SKIP_REASON_NO_RECIPIENT,
      send_notification, woocommerce_email_disabled, woocommerce_email_skipped,
      send_if_recipient) from 10.8.0 to 10.9.0 to match the actual target version.
    - Remove plugins/woocommerce/changelog/64477, which duplicated the already-
      shipped entry from #64491. This PR's specific changes are covered by the
      three rsm-353-* changelog files.

    * Fix CI: stale PHPStan baseline + missing PHPCS docblocks

    - phpstan-baseline.neon: drop two stale class-wc-email-new-order.php ignores
      (save / update_meta_data on WC_Order|WC_Order_Refund|false). The defensive
      instanceof guard added in rsm-353-capture-email-outcomes resolved both.
    - class-wc-email.php: full docblock with @since 10.9.0 on the
      woocommerce_email_skipped do_action() call inside send_if_recipient(); the
      shorthand "documented in..." form failed WooCommerce.Commenting.CommentHooks.
    - EmailLogger.php: same treatment for the two "documented in..." references to
      woocommerce_email_log_enabled and woocommerce_email_log_context inside
      log_non_send_outcome().
    - EmailLoggerTest.php: add docblocks + tighten property type spacing on the
      anonymous WC_Email test double helper.

    ---------

    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>

diff --git a/plugins/woocommerce/changelog/rsm-353-capture-email-outcomes b/plugins/woocommerce/changelog/rsm-353-capture-email-outcomes
new file mode 100644
index 00000000000..6dd4618fe22
--- /dev/null
+++ b/plugins/woocommerce/changelog/rsm-353-capture-email-outcomes
@@ -0,0 +1,4 @@
+Significance: minor
+Type: enhancement
+
+Track transactional email outcomes: log when emails are disabled (is_enabled() false) or skipped (no recipient) in addition to the existing send-attempt logging. Adds a defensive instanceof guard in WC_Email_New_Order::trigger() before updating order meta on a successful send.
diff --git a/plugins/woocommerce/changelog/rsm-353-log-level-disabled-skipped-notice b/plugins/woocommerce/changelog/rsm-353-log-level-disabled-skipped-notice
new file mode 100644
index 00000000000..78ed5753491
--- /dev/null
+++ b/plugins/woocommerce/changelog/rsm-353-log-level-disabled-skipped-notice
@@ -0,0 +1,4 @@
+Significance: patch
+Type: tweak
+
+Use the NOTICE log level (instead of INFO) for transactional email entries with status `disabled` or `skipped`, so admins can distinguish "normal but significant" non-send outcomes from routine successful sends.
diff --git a/plugins/woocommerce/changelog/rsm-353-new-order-restore-locale-fix b/plugins/woocommerce/changelog/rsm-353-new-order-restore-locale-fix
new file mode 100644
index 00000000000..b5988a21f01
--- /dev/null
+++ b/plugins/woocommerce/changelog/rsm-353-new-order-restore-locale-fix
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Fix locale leak in WC_Email_New_Order::trigger(): restore_locale() was not called when the email was skipped due to already being sent, leaving the locale set for the remainder of the request.
diff --git a/plugins/woocommerce/includes/emails/class-wc-email-admin-payment-gateway-enabled.php b/plugins/woocommerce/includes/emails/class-wc-email-admin-payment-gateway-enabled.php
index afd4215ef83..e8bc2cfeffa 100644
--- a/plugins/woocommerce/includes/emails/class-wc-email-admin-payment-gateway-enabled.php
+++ b/plugins/woocommerce/includes/emails/class-wc-email-admin-payment-gateway-enabled.php
@@ -144,9 +144,7 @@ if ( ! class_exists( 'WC_Email_Admin_Payment_Gateway_Enabled', false ) ) :
 				$this->placeholders['{site_title}']    = $this->get_blogname();
 			}

-			if ( $this->is_enabled() && $this->get_recipient() ) {
-				$this->send( $this->get_recipient(), $this->get_subject(), $this->get_content(), $this->get_headers(), $this->get_attachments() );
-			}
+			$this->send_notification();

 			$this->restore_locale();
 		}
diff --git a/plugins/woocommerce/includes/emails/class-wc-email-cancelled-order.php b/plugins/woocommerce/includes/emails/class-wc-email-cancelled-order.php
index c21953153d3..e9fcf578ffb 100644
--- a/plugins/woocommerce/includes/emails/class-wc-email-cancelled-order.php
+++ b/plugins/woocommerce/includes/emails/class-wc-email-cancelled-order.php
@@ -103,9 +103,7 @@ if ( ! class_exists( 'WC_Email_Cancelled_Order', false ) ) :
 				$this->placeholders['{order_billing_full_name}'] = $this->object->get_formatted_billing_full_name();
 			}

-			if ( $this->is_enabled() && $this->get_recipient() ) {
-				$this->send( $this->get_recipient(), $this->get_subject(), $this->get_content(), $this->get_headers(), $this->get_attachments() );
-			}
+			$this->send_notification();

 			$this->restore_locale();
 		}
diff --git a/plugins/woocommerce/includes/emails/class-wc-email-customer-cancelled-order.php b/plugins/woocommerce/includes/emails/class-wc-email-customer-cancelled-order.php
index a8a8ca6bdd9..2c530023777 100644
--- a/plugins/woocommerce/includes/emails/class-wc-email-customer-cancelled-order.php
+++ b/plugins/woocommerce/includes/emails/class-wc-email-customer-cancelled-order.php
@@ -102,9 +102,7 @@ if ( ! class_exists( 'WC_Email_Customer_Cancelled_Order', false ) ) :
 				$this->placeholders['{order_billing_full_name}'] = $this->object->get_formatted_billing_full_name();
 			}

-			if ( $this->is_enabled() && $this->get_recipient() ) {
-				$this->send( $this->get_recipient(), $this->get_subject(), $this->get_content(), $this->get_headers(), $this->get_attachments() );
-			}
+			$this->send_notification();

 			$this->restore_locale();
 		}
diff --git a/plugins/woocommerce/includes/emails/class-wc-email-customer-completed-order.php b/plugins/woocommerce/includes/emails/class-wc-email-customer-completed-order.php
index 33c5e463d7b..fe9f5f933c8 100644
--- a/plugins/woocommerce/includes/emails/class-wc-email-customer-completed-order.php
+++ b/plugins/woocommerce/includes/emails/class-wc-email-customer-completed-order.php
@@ -75,9 +75,7 @@ if ( ! class_exists( 'WC_Email_Customer_Completed_Order', false ) ) :
 				$this->placeholders['{order_number}'] = $this->object->get_order_number();
 			}

-			if ( $this->is_enabled() && $this->get_recipient() ) {
-				$this->send( $this->get_recipient(), $this->get_subject(), $this->get_content(), $this->get_headers(), $this->get_attachments() );
-			}
+			$this->send_notification();

 			$this->restore_locale();
 		}
diff --git a/plugins/woocommerce/includes/emails/class-wc-email-customer-failed-order.php b/plugins/woocommerce/includes/emails/class-wc-email-customer-failed-order.php
index 327a5dd7fa2..1685fac9455 100644
--- a/plugins/woocommerce/includes/emails/class-wc-email-customer-failed-order.php
+++ b/plugins/woocommerce/includes/emails/class-wc-email-customer-failed-order.php
@@ -74,9 +74,7 @@ if ( ! class_exists( 'WC_Email_Customer_Failed_Order', false ) ) :
 				$this->placeholders['{order_number}'] = $this->object->get_order_number();
 			}

-			if ( $this->is_enabled() && $this->get_recipient() ) {
-				$this->send( $this->get_recipient(), $this->get_subject(), $this->get_content(), $this->get_headers(), $this->get_attachments() );
-			}
+			$this->send_notification();

 			$this->restore_locale();
 		}
diff --git a/plugins/woocommerce/includes/emails/class-wc-email-customer-fulfillment-created.php b/plugins/woocommerce/includes/emails/class-wc-email-customer-fulfillment-created.php
index b61fe72b72a..cb3445990cb 100644
--- a/plugins/woocommerce/includes/emails/class-wc-email-customer-fulfillment-created.php
+++ b/plugins/woocommerce/includes/emails/class-wc-email-customer-fulfillment-created.php
@@ -79,9 +79,7 @@ if ( ! class_exists( 'WC_Email_Customer_Fulfillment_Created', false ) ) :
 				$this->placeholders['{order_number}'] = $this->object->get_order_number();
 			}

-			if ( $this->is_enabled() && $this->get_recipient() ) {
-				$this->send( $this->get_recipient(), $this->get_subject(), $this->get_content(), $this->get_headers(), $this->get_attachments() );
-			}
+			$this->send_notification();

 			$this->restore_locale();
 		}
diff --git a/plugins/woocommerce/includes/emails/class-wc-email-customer-fulfillment-deleted.php b/plugins/woocommerce/includes/emails/class-wc-email-customer-fulfillment-deleted.php
index f5eed0e8f23..cdd9ffc5716 100644
--- a/plugins/woocommerce/includes/emails/class-wc-email-customer-fulfillment-deleted.php
+++ b/plugins/woocommerce/includes/emails/class-wc-email-customer-fulfillment-deleted.php
@@ -79,9 +79,7 @@ if ( ! class_exists( 'WC_Email_Customer_Fulfillment_Deleted', false ) ) :
 				$this->placeholders['{order_number}'] = $this->object->get_order_number();
 			}

-			if ( $this->is_enabled() && $this->get_recipient() ) {
-				$this->send( $this->get_recipient(), $this->get_subject(), $this->get_content(), $this->get_headers(), $this->get_attachments() );
-			}
+			$this->send_notification();

 			$this->restore_locale();
 		}
diff --git a/plugins/woocommerce/includes/emails/class-wc-email-customer-fulfillment-updated.php b/plugins/woocommerce/includes/emails/class-wc-email-customer-fulfillment-updated.php
index 0e56ebda326..b6a931a8c3d 100644
--- a/plugins/woocommerce/includes/emails/class-wc-email-customer-fulfillment-updated.php
+++ b/plugins/woocommerce/includes/emails/class-wc-email-customer-fulfillment-updated.php
@@ -88,9 +88,7 @@ if ( ! class_exists( 'WC_Email_Customer_Fulfillment_Updated', false ) ) :
 				$this->placeholders['{order_number}'] = $this->object->get_order_number();
 			}

-			if ( $this->is_enabled() && $this->get_recipient() ) {
-				$this->send( $this->get_recipient(), $this->get_subject(), $this->get_content(), $this->get_headers(), $this->get_attachments() );
-			}
+			$this->send_notification();

 			$this->restore_locale();
 		}
diff --git a/plugins/woocommerce/includes/emails/class-wc-email-customer-invoice.php b/plugins/woocommerce/includes/emails/class-wc-email-customer-invoice.php
index d361c58359b..919097fc628 100644
--- a/plugins/woocommerce/includes/emails/class-wc-email-customer-invoice.php
+++ b/plugins/woocommerce/includes/emails/class-wc-email-customer-invoice.php
@@ -153,9 +153,7 @@ if ( ! class_exists( 'WC_Email_Customer_Invoice', false ) ) :
 				$this->placeholders['{order_number}'] = $this->object->get_order_number();
 			}

-			if ( $this->get_recipient() ) {
-				$this->send( $this->get_recipient(), $this->get_subject(), $this->get_content(), $this->get_headers(), $this->get_attachments() );
-			}
+			$this->send_if_recipient();

 			$this->restore_locale();
 		}
diff --git a/plugins/woocommerce/includes/emails/class-wc-email-customer-new-account.php b/plugins/woocommerce/includes/emails/class-wc-email-customer-new-account.php
index 5f8e9346a7a..763393920dc 100644
--- a/plugins/woocommerce/includes/emails/class-wc-email-customer-new-account.php
+++ b/plugins/woocommerce/includes/emails/class-wc-email-customer-new-account.php
@@ -118,9 +118,7 @@ if ( ! class_exists( 'WC_Email_Customer_New_Account', false ) ) {
 				$this->password_generated = $password_generated;
 			}

-			if ( $this->is_enabled() && $this->get_recipient() ) {
-				$this->send( $this->get_recipient(), $this->get_subject(), $this->get_content(), $this->get_headers(), $this->get_attachments() );
-			}
+			$this->send_notification();

 			$this->restore_locale();
 		}
diff --git a/plugins/woocommerce/includes/emails/class-wc-email-customer-note.php b/plugins/woocommerce/includes/emails/class-wc-email-customer-note.php
index c4cb16263b3..fe1b056c01f 100644
--- a/plugins/woocommerce/includes/emails/class-wc-email-customer-note.php
+++ b/plugins/woocommerce/includes/emails/class-wc-email-customer-note.php
@@ -115,9 +115,7 @@ if ( ! class_exists( 'WC_Email_Customer_Note', false ) ) :
 				}
 			}

-			if ( $this->is_enabled() && $this->get_recipient() ) {
-				$this->send( $this->get_recipient(), $this->get_subject(), $this->get_content(), $this->get_headers(), $this->get_attachments() );
-			}
+			$this->send_notification();

 			$this->restore_locale();
 		}
diff --git a/plugins/woocommerce/includes/emails/class-wc-email-customer-on-hold-order.php b/plugins/woocommerce/includes/emails/class-wc-email-customer-on-hold-order.php
index ed124205e4d..1600fa763ca 100644
--- a/plugins/woocommerce/includes/emails/class-wc-email-customer-on-hold-order.php
+++ b/plugins/woocommerce/includes/emails/class-wc-email-customer-on-hold-order.php
@@ -103,9 +103,7 @@ if ( ! class_exists( 'WC_Email_Customer_On_Hold_Order', false ) ) :
 				$this->placeholders['{order_number}'] = $this->object->get_order_number();
 			}

-			if ( $this->is_enabled() && $this->get_recipient() ) {
-				$this->send( $this->get_recipient(), $this->get_subject(), $this->get_content(), $this->get_headers(), $this->get_attachments() );
-			}
+			$this->send_notification();

 			$this->restore_locale();
 		}
diff --git a/plugins/woocommerce/includes/emails/class-wc-email-customer-pos-completed-order.php b/plugins/woocommerce/includes/emails/class-wc-email-customer-pos-completed-order.php
index aa1c329fd0b..48983268456 100644
--- a/plugins/woocommerce/includes/emails/class-wc-email-customer-pos-completed-order.php
+++ b/plugins/woocommerce/includes/emails/class-wc-email-customer-pos-completed-order.php
@@ -88,9 +88,7 @@ if ( ! class_exists( 'WC_Email_Customer_POS_Completed_Order', false ) ) :
 				$this->placeholders['{order_number}'] = $this->object->get_order_number();
 			}

-			if ( $this->get_recipient() ) {
-				$this->send( $this->get_recipient(), $this->get_subject(), $this->get_content(), $this->get_headers(), $this->get_attachments() );
-			}
+			$this->send_if_recipient();

 			$this->restore_locale();
 		}
diff --git a/plugins/woocommerce/includes/emails/class-wc-email-customer-pos-refunded-order.php b/plugins/woocommerce/includes/emails/class-wc-email-customer-pos-refunded-order.php
index 7797583fd21..564850771d2 100644
--- a/plugins/woocommerce/includes/emails/class-wc-email-customer-pos-refunded-order.php
+++ b/plugins/woocommerce/includes/emails/class-wc-email-customer-pos-refunded-order.php
@@ -222,9 +222,7 @@ if ( ! class_exists( 'WC_Email_Customer_POS_Refunded_Order', false ) ) :
 				$this->refund = false;
 			}

-			if ( $this->get_recipient() ) {
-				$this->send( $this->get_recipient(), $this->get_subject(), $this->get_content(), $this->get_headers(), $this->get_attachments() );
-			}
+			$this->send_if_recipient();

 			$this->restore_locale();
 		}
diff --git a/plugins/woocommerce/includes/emails/class-wc-email-customer-processing-order.php b/plugins/woocommerce/includes/emails/class-wc-email-customer-processing-order.php
index f2a387ad315..eeda292abd1 100644
--- a/plugins/woocommerce/includes/emails/class-wc-email-customer-processing-order.php
+++ b/plugins/woocommerce/includes/emails/class-wc-email-customer-processing-order.php
@@ -99,9 +99,7 @@ if ( ! class_exists( 'WC_Email_Customer_Processing_Order', false ) ) :
 				$this->placeholders['{order_number}'] = $this->object->get_order_number();
 			}

-			if ( $this->is_enabled() && $this->get_recipient() ) {
-				$this->send( $this->get_recipient(), $this->get_subject(), $this->get_content(), $this->get_headers(), $this->get_attachments() );
-			}
+			$this->send_notification();

 			$this->restore_locale();
 		}
diff --git a/plugins/woocommerce/includes/emails/class-wc-email-customer-refunded-order.php b/plugins/woocommerce/includes/emails/class-wc-email-customer-refunded-order.php
index 0911d0a3c4e..361bc916324 100644
--- a/plugins/woocommerce/includes/emails/class-wc-email-customer-refunded-order.php
+++ b/plugins/woocommerce/includes/emails/class-wc-email-customer-refunded-order.php
@@ -207,9 +207,7 @@ if ( ! class_exists( 'WC_Email_Customer_Refunded_Order', false ) ) :
 				$this->refund = false;
 			}

-			if ( $this->is_enabled() && $this->get_recipient() ) {
-				$this->send( $this->get_recipient(), $this->get_subject(), $this->get_content(), $this->get_headers(), $this->get_attachments() );
-			}
+			$this->send_notification();

 			$this->restore_locale();
 		}
diff --git a/plugins/woocommerce/includes/emails/class-wc-email-customer-reset-password.php b/plugins/woocommerce/includes/emails/class-wc-email-customer-reset-password.php
index c813448de63..32452c55694 100644
--- a/plugins/woocommerce/includes/emails/class-wc-email-customer-reset-password.php
+++ b/plugins/woocommerce/includes/emails/class-wc-email-customer-reset-password.php
@@ -122,9 +122,7 @@ if ( ! class_exists( 'WC_Email_Customer_Reset_Password', false ) ) :
 				$this->recipient  = $this->user_email;
 			}

-			if ( $this->is_enabled() && $this->get_recipient() ) {
-				$this->send( $this->get_recipient(), $this->get_subject(), $this->get_content(), $this->get_headers(), $this->get_attachments() );
-			}
+			$this->send_notification();

 			$this->restore_locale();
 		}
diff --git a/plugins/woocommerce/includes/emails/class-wc-email-failed-order.php b/plugins/woocommerce/includes/emails/class-wc-email-failed-order.php
index a50f7f2448f..875551e7000 100644
--- a/plugins/woocommerce/includes/emails/class-wc-email-failed-order.php
+++ b/plugins/woocommerce/includes/emails/class-wc-email-failed-order.php
@@ -100,9 +100,7 @@ if ( ! class_exists( 'WC_Email_Failed_Order', false ) ) :
 				$this->placeholders['{order_number}'] = $this->object->get_order_number();
 			}

-			if ( $this->is_enabled() && $this->get_recipient() ) {
-				$this->send( $this->get_recipient(), $this->get_subject(), $this->get_content(), $this->get_headers(), $this->get_attachments() );
-			}
+			$this->send_notification();

 			$this->restore_locale();
 		}
diff --git a/plugins/woocommerce/includes/emails/class-wc-email-new-order.php b/plugins/woocommerce/includes/emails/class-wc-email-new-order.php
index f7fa65093ff..11336bc15f7 100644
--- a/plugins/woocommerce/includes/emails/class-wc-email-new-order.php
+++ b/plugins/woocommerce/includes/emails/class-wc-email-new-order.php
@@ -120,15 +120,14 @@ if ( ! class_exists( 'WC_Email_New_Order' ) ) :
 			 * @param bool $allows Defaults to false.
 			 */
 			if ( $email_already_sent && ! apply_filters( 'woocommerce_new_order_email_allows_resend', false ) ) {
+				$this->restore_locale();
 				return;
 			}

-			if ( $this->is_enabled() && $this->get_recipient() ) {
-				$email_sent_successfully = $this->send( $this->get_recipient(), $this->get_subject(), $this->get_content(), $this->get_headers(), $this->get_attachments() );
-				if ( $email_sent_successfully ) {
-					$order->update_meta_data( '_new_order_email_sent', 'true' );
-					$order->save();
-				}
+			$email_sent_successfully = $this->send_notification();
+			if ( $email_sent_successfully && $order instanceof WC_Order ) {
+				$order->update_meta_data( '_new_order_email_sent', 'true' );
+				$order->save();
 			}

 			$this->restore_locale();
diff --git a/plugins/woocommerce/includes/emails/class-wc-email.php b/plugins/woocommerce/includes/emails/class-wc-email.php
index f998ae528d9..004980c8b47 100644
--- a/plugins/woocommerce/includes/emails/class-wc-email.php
+++ b/plugins/woocommerce/includes/emails/class-wc-email.php
@@ -32,6 +32,13 @@ if ( class_exists( 'WC_Email', false ) ) {
  */
 class WC_Email extends WC_Settings_API {

+	/**
+	 * Skip-reason identifier used when the email has no recipient address.
+	 *
+	 * @since 10.9.0
+	 */
+	public const SKIP_REASON_NO_RECIPIENT = 'no_recipient';
+
 	/**
 	 * Email method ID.
 	 *
@@ -1115,6 +1122,99 @@ class WC_Email extends WC_Settings_API {
 		$this->object = $object;
 	}

+	/**
+	 * Send the email notification when enabled and a recipient is available.
+	 *
+	 * This is the standard helper used by trigger() methods. It checks whether the email
+	 * is enabled and whether a recipient address exists, fires appropriate action hooks for
+	 * the disabled or skipped outcome, and otherwise delegates to send() with the
+	 * standard content parameters.
+	 *
+	 * Subclasses that intentionally bypass the enabled check (e.g. manually-triggered invoice
+	 * emails, POS receipts) should NOT call this method and should continue to call send()
+	 * directly.
+	 *
+	 * @since 10.9.0
+	 * @return bool Whether the email was sent successfully.
+	 */
+	protected function send_notification(): bool {
+		if ( ! $this->is_enabled() ) {
+			/**
+			 * Fires when a transactional email is not sent because the email type is disabled.
+			 *
+			 * @since 10.9.0
+			 *
+			 * @param string   $email_id The email type ID (e.g. `customer_processing_order`).
+			 * @param WC_Email $email    The WC_Email instance.
+			 */
+			do_action( 'woocommerce_email_disabled', $this->id, $this );
+			return false;
+		}
+
+		$recipient = $this->get_recipient();
+
+		if ( ! $recipient ) {
+			/**
+			 * Fires when a transactional email is not sent for a reason other than being disabled.
+			 *
+			 * The $reason parameter identifies why the email was not sent:
+			 * - WC_Email::SKIP_REASON_NO_RECIPIENT: No recipient address was available at send time.
+			 *
+			 * @since 10.9.0
+			 *
+			 * @param string   $reason   Short identifier for why the email was skipped.
+			 * @param string   $email_id The email type ID.
+			 * @param WC_Email $email    The WC_Email instance.
+			 */
+			do_action( 'woocommerce_email_skipped', self::SKIP_REASON_NO_RECIPIENT, $this->id, $this );
+			return false;
+		}
+
+		return $this->send(
+			$recipient,
+			$this->get_subject(),
+			$this->get_content(),
+			$this->get_headers(),
+			$this->get_attachments()
+		);
+	}
+
+	/**
+	 * Send the email when a recipient is available, regardless of the enabled setting.
+	 *
+	 * This helper is intended for manually-triggered emails (e.g. invoice resend, POS receipts)
+	 * that intentionally bypass the enabled/disabled check. It fires
+	 * `woocommerce_email_skipped` with reason {@see WC_Email::SKIP_REASON_NO_RECIPIENT} when
+	 * no recipient is available so the outcome is still observable via the EmailLogger, and
+	 * otherwise delegates to send().
+	 *
+	 * @since 10.9.0
+	 * @return bool Whether the email was sent successfully.
+	 */
+	protected function send_if_recipient(): bool {
+		$recipient = $this->get_recipient();
+
+		if ( ! $recipient ) {
+			/**
+			 * Fires when a transactional email is not sent for a reason other than being disabled.
+			 *
+			 * This action is documented in includes/emails/class-wc-email.php
+			 *
+			 * @since 10.9.0
+			 */
+			do_action( 'woocommerce_email_skipped', self::SKIP_REASON_NO_RECIPIENT, $this->id, $this );
+			return false;
+		}
+
+		return $this->send(
+			$recipient,
+			$this->get_subject(),
+			$this->get_content(),
+			$this->get_headers(),
+			$this->get_attachments()
+		);
+	}
+
 	/**
 	 * Send an email.
 	 *
diff --git a/plugins/woocommerce/phpstan-baseline.neon b/plugins/woocommerce/phpstan-baseline.neon
index cd79245e2b4..0ad147e39d9 100644
--- a/plugins/woocommerce/phpstan-baseline.neon
+++ b/plugins/woocommerce/phpstan-baseline.neon
@@ -19128,18 +19128,6 @@ parameters:
 			count: 1
 			path: includes/emails/class-wc-email-failed-order.php

-		-
-			message: '#^Cannot call method save\(\) on WC_Order\|WC_Order_Refund\|false\.$#'
-			identifier: method.nonObject
-			count: 1
-			path: includes/emails/class-wc-email-new-order.php
-
-		-
-			message: '#^Cannot call method update_meta_data\(\) on WC_Order\|WC_Order_Refund\|false\.$#'
-			identifier: method.nonObject
-			count: 1
-			path: includes/emails/class-wc-email-new-order.php
-
 		-
 			message: '#^Method WC_Email_New_Order\:\:init_form_fields\(\) has no return type specified\.$#'
 			identifier: missingType.return
diff --git a/plugins/woocommerce/src/Internal/Admin/EmailPreview/EmailPreviewRestController.php b/plugins/woocommerce/src/Internal/Admin/EmailPreview/EmailPreviewRestController.php
index 50a41657f2f..2363e6e340b 100644
--- a/plugins/woocommerce/src/Internal/Admin/EmailPreview/EmailPreviewRestController.php
+++ b/plugins/woocommerce/src/Internal/Admin/EmailPreview/EmailPreviewRestController.php
@@ -298,7 +298,16 @@ class EmailPreviewRestController extends RestApiControllerBase {
 		// 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() );
+		$mark_as_test     = function ( array $context ): array {
+			$context['is_test'] = true;
+			return $context;
+		};
+		add_filter( 'woocommerce_email_log_context', $mark_as_test );
+		try {
+			$sent = $email->send( $email_address, $email_subject, $email_content, $email->get_headers(), $email->get_attachments() );
+		} finally {
+			remove_filter( 'woocommerce_email_log_context', $mark_as_test );
+		}

 		if ( $sent ) {
 			return array(
diff --git a/plugins/woocommerce/src/Internal/Email/EmailLogger.php b/plugins/woocommerce/src/Internal/Email/EmailLogger.php
index f27de85fa16..0989eed029b 100644
--- a/plugins/woocommerce/src/Internal/Email/EmailLogger.php
+++ b/plugins/woocommerce/src/Internal/Email/EmailLogger.php
@@ -44,6 +44,8 @@ class EmailLogger implements RegisterHooksInterface {
 	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 );
+		add_action( 'woocommerce_email_disabled', array( $this, 'handle_woocommerce_email_disabled' ), 10, 2 );
+		add_action( 'woocommerce_email_skipped', array( $this, 'handle_woocommerce_email_skipped' ), 10, 3 );
 	}

 	/**
@@ -88,17 +90,11 @@ class EmailLogger implements RegisterHooksInterface {
 			return;
 		}

-		$object_context = $this->get_object_context( $email->object );
-		$object_label   = isset( $object_context['type'], $object_context['id'] )
+		$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 );
-		}
+		$last_mail_error = $this->last_mail_error;

 		$this->last_mail_error = null;

@@ -124,10 +120,106 @@ class EmailLogger implements RegisterHooksInterface {
 		 */
 		$context = (array) apply_filters( 'woocommerce_email_log_context', $context, $email_id, $email );

+		$type_label = ! empty( $context['is_test'] ) ? 'Test email' : 'Email';
+
+		if ( $success ) {
+			$message = sprintf( '%s "%s"%s sent', $type_label, $email_id, $object_label );
+		} else {
+			$reason  = $last_mail_error ? ': ' . $this->redact_emails( $last_mail_error ) : '';
+			$message = sprintf( '%s "%s"%s failed to send%s', $type_label, $email_id, $object_label, $reason );
+		}
+
 		$level = $success ? WC_Log_Levels::INFO : WC_Log_Levels::WARNING;
 		wc_get_logger()->log( $level, $message, $context );
 	}

+	/**
+	 * Handle the woocommerce_email_disabled action.
+	 *
+	 * @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_disabled( string $email_id, WC_Email $email ): void {
+		$this->log_non_send_outcome( $email_id, $email, 'disabled' );
+	}
+
+	/**
+	 * Handle the woocommerce_email_skipped action.
+	 *
+	 * @param string   $reason   Short identifier for why the email was skipped (e.g. 'no_recipient').
+	 * @param string   $email_id The email type ID (e.g. `new_order`).
+	 * @param WC_Email $email    The WC_Email instance.
+	 * @return void
+	 */
+	public function handle_woocommerce_email_skipped( string $reason, string $email_id, WC_Email $email ): void {
+		$this->log_non_send_outcome( $email_id, $email, 'skipped', $reason );
+	}
+
+	/**
+	 * Write a log entry for an email that was not sent (disabled or skipped).
+	 *
+	 * Centralises the shared logic for disabled and skipped outcomes so that the context
+	 * schema (`source`, `email_type`, `status`, `reason`, `recipient`, object key) is
+	 * defined in exactly one place. Future additions (e.g. a `correlation_id` field) only
+	 * need to be made here.
+	 *
+	 * @param string      $email_id The email type ID.
+	 * @param WC_Email    $email    The WC_Email instance.
+	 * @param string      $status   The outcome status: 'disabled' or 'skipped'.
+	 * @param string|null $reason   Optional reason identifier (only set for 'skipped' status).
+	 * @return void
+	 */
+	private function log_non_send_outcome( string $email_id, WC_Email $email, string $status, ?string $reason = null ): void {
+		/**
+		 * Filter whether to log this transactional email attempt.
+		 *
+		 * This filter is documented in src/Internal/Email/EmailLogger.php
+		 *
+		 * @since 10.9.0
+		 */
+		if ( ! apply_filters( 'woocommerce_email_log_enabled', true, $email_id, $email ) ) {
+			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 ( 'disabled' === $status ) {
+			$message = sprintf( 'Email "%s"%s not sent: email type is disabled', $email_id, $object_label );
+		} else {
+			$message = sprintf( 'Email "%s"%s not sent: %s', $email_id, $object_label, $reason );
+		}
+
+		$context = array(
+			'source'     => self::LOG_SOURCE,
+			'email_type' => $email_id,
+			'status'     => $status,
+			'recipient'  => $this->resolve_recipient( $email->get_recipient() ),
+		);
+
+		if ( null !== $reason ) {
+			$context['reason'] = $reason;
+		}
+
+		if ( ! empty( $object_context ) ) {
+			$context[ $object_context['type'] ] = $object_context['id'] ?? null;
+		}
+
+		/**
+		 * Filter the context array logged for each transactional email attempt.
+		 *
+		 * This filter is documented in src/Internal/Email/EmailLogger.php
+		 *
+		 * @since 10.9.0
+		 */
+		$context = (array) apply_filters( 'woocommerce_email_log_context', $context, $email_id, $email );
+
+		wc_get_logger()->log( WC_Log_Levels::NOTICE, $message, $context );
+	}
+
 	/**
 	 * Resolve a recipient email string to an identifier safe for logging.
 	 *
diff --git a/plugins/woocommerce/tests/php/src/Internal/Email/EmailLoggerTest.php b/plugins/woocommerce/tests/php/src/Internal/Email/EmailLoggerTest.php
index c509f0cd8d7..10b897ca0df 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Email/EmailLoggerTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Email/EmailLoggerTest.php
@@ -37,24 +37,38 @@ 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_actions( 'woocommerce_email_disabled' );
+		remove_all_actions( 'woocommerce_email_skipped' );
 		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.
+	 * @testdox Register method adds hooks for woocommerce_email_sent, wp_mail_failed, woocommerce_email_disabled, and woocommerce_email_skipped.
 	 */
 	public function test_register_adds_hook(): void {
 		$this->sut->register();

-		$this->assertNotFalse(
+		$this->assertSame(
+			10,
 			has_action( 'woocommerce_email_sent', array( $this->sut, 'handle_woocommerce_email_sent' ) ),
-			'Expected hook to be registered for woocommerce_email_sent'
+			'Expected hook to be registered at priority 10 for woocommerce_email_sent'
 		);
-		$this->assertNotFalse(
+		$this->assertSame(
+			10,
 			has_action( 'wp_mail_failed', array( $this->sut, 'capture_mail_error' ) ),
-			'Expected hook to be registered for wp_mail_failed'
+			'Expected hook to be registered at priority 10 for wp_mail_failed'
+		);
+		$this->assertSame(
+			10,
+			has_action( 'woocommerce_email_disabled', array( $this->sut, 'handle_woocommerce_email_disabled' ) ),
+			'Expected hook to be registered at priority 10 for woocommerce_email_disabled'
+		);
+		$this->assertSame(
+			10,
+			has_action( 'woocommerce_email_skipped', array( $this->sut, 'handle_woocommerce_email_skipped' ) ),
+			'Expected hook to be registered at priority 10 for woocommerce_email_skipped'
 		);
 	}

@@ -353,6 +367,357 @@ class EmailLoggerTest extends WC_Unit_Test_Case {
 		$this->assertLogged( 'info', 'new_order', array( 'custom_key' => 'custom_value' ) );
 	}

+	/**
+	 * @testdox Logs a notice entry when email is disabled.
+	 */
+	public function test_logs_notice_when_email_is_disabled(): void {
+		$email = $this->create_mock_email( 'customer_processing_order', 'customer@example.com' );
+
+		$this->sut->handle_woocommerce_email_disabled( 'customer_processing_order', $email );
+
+		$this->assertLogged( 'notice', 'customer_processing_order' );
+	}
+
+	/**
+	 * @testdox Disabled log context contains status "disabled".
+	 */
+	public function test_disabled_log_context_contains_disabled_status(): void {
+		$email = $this->create_mock_email( 'new_order', 'admin@example.com' );
+
+		$this->sut->handle_woocommerce_email_disabled( 'new_order', $email );
+
+		$this->assertLogged(
+			'notice',
+			'new_order',
+			array(
+				'source'     => 'transactional-emails',
+				'email_type' => 'new_order',
+				'status'     => 'disabled',
+			)
+		);
+	}
+
+	/**
+	 * @testdox Disabled log message contains "disabled".
+	 */
+	public function test_disabled_log_message_contains_disabled(): void {
+		$email = $this->create_mock_email( 'new_order', 'admin@example.com' );
+
+		$this->sut->handle_woocommerce_email_disabled( 'new_order', $email );
+
+		$this->assertLogged( 'notice', 'disabled' );
+	}
+
+	/**
+	 * @testdox woocommerce_email_log_enabled filter suppresses disabled log entry.
+	 */
+	public function test_log_enabled_filter_suppresses_disabled_entry(): 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_disabled( 'customer_processing_order', $email );
+
+		$this->assertEmpty( $this->captured_logs, 'No log entry should be written when the enabled filter returns false' );
+	}
+
+	/**
+	 * @testdox Logs a notice entry when email is skipped.
+	 */
+	public function test_logs_notice_when_email_is_skipped(): void {
+		$email = $this->create_mock_email( 'customer_processing_order', 'customer@example.com' );
+
+		$this->sut->handle_woocommerce_email_skipped( \WC_Email::SKIP_REASON_NO_RECIPIENT, 'customer_processing_order', $email );
+
+		$this->assertLogged( 'notice', 'customer_processing_order' );
+	}
+
+	/**
+	 * @testdox Skipped log context contains status "skipped" and the skip reason.
+	 */
+	public function test_skipped_log_context_contains_skipped_status_and_reason(): void {
+		$email = $this->create_mock_email( 'new_order', 'admin@example.com' );
+
+		$this->sut->handle_woocommerce_email_skipped( \WC_Email::SKIP_REASON_NO_RECIPIENT, 'new_order', $email );
+
+		$this->assertLogged(
+			'notice',
+			'new_order',
+			array(
+				'source'     => 'transactional-emails',
+				'email_type' => 'new_order',
+				'status'     => 'skipped',
+				'reason'     => \WC_Email::SKIP_REASON_NO_RECIPIENT,
+			)
+		);
+	}
+
+	/**
+	 * @testdox Skipped log message contains the skip reason.
+	 */
+	public function test_skipped_log_message_contains_reason(): void {
+		$email = $this->create_mock_email( 'new_order', 'admin@example.com' );
+
+		$this->sut->handle_woocommerce_email_skipped( \WC_Email::SKIP_REASON_NO_RECIPIENT, 'new_order', $email );
+
+		$this->assertLogged( 'notice', \WC_Email::SKIP_REASON_NO_RECIPIENT );
+	}
+
+	/**
+	 * @testdox woocommerce_email_log_enabled filter suppresses skipped log entry.
+	 */
+	public function test_log_enabled_filter_suppresses_skipped_entry(): 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_skipped( \WC_Email::SKIP_REASON_NO_RECIPIENT, 'customer_processing_order', $email );
+
+		$this->assertEmpty( $this->captured_logs, 'No log entry should be written when the enabled filter returns false' );
+	}
+
+	/**
+	 * @testdox Disabled log includes object context for WC_Order.
+	 */
+	public function test_disabled_log_includes_order_context(): void {
+		$order = $this->createMock( \WC_Order::class );
+		$order->method( 'get_id' )->willReturn( 99 );
+		$email = $this->create_mock_email( 'customer_processing_order', 'customer@example.com', $order );
+
+		$this->sut->handle_woocommerce_email_disabled( 'customer_processing_order', $email );
+
+		$this->assertLogged(
+			'notice',
+			'customer_processing_order',
+			array( 'order' => 99 )
+		);
+	}
+
+	/**
+	 * @testdox Skipped log includes object context for WC_Order.
+	 */
+	public function test_skipped_log_includes_order_context(): void {
+		$order = $this->createMock( \WC_Order::class );
+		$order->method( 'get_id' )->willReturn( 77 );
+		$email = $this->create_mock_email( 'new_order', 'admin@example.com', $order );
+
+		$this->sut->handle_woocommerce_email_skipped( \WC_Email::SKIP_REASON_NO_RECIPIENT, 'new_order', $email );
+
+		$this->assertLogged(
+			'notice',
+			'new_order',
+			array( 'order' => 77 )
+		);
+	}
+
+	/**
+	 * @testdox send_notification() fires woocommerce_email_disabled and returns false when email is disabled.
+	 */
+	public function test_send_notification_fires_disabled_and_returns_false_when_disabled(): void {
+		$email = $this->create_testable_email( 'my_email', '', false );
+
+		$disabled_fired = false;
+		add_action(
+			'woocommerce_email_disabled',
+			function ( $email_id ) use ( &$disabled_fired ) {
+				if ( 'my_email' === $email_id ) {
+					$disabled_fired = true;
+				}
+			}
+		);
+
+		$result = $email->run_send_notification();
+
+		$this->assertFalse( $result, 'send_notification() should return false when email is disabled' );
+		$this->assertTrue( $disabled_fired, 'woocommerce_email_disabled should fire when email is disabled' );
+		$this->assertFalse( $email->send_called, 'send() should not be called when email is disabled' );
+	}
+
+	/**
+	 * @testdox send_notification() fires woocommerce_email_skipped with no_recipient and returns false when recipient is empty.
+	 */
+	public function test_send_notification_fires_skipped_and_returns_false_when_no_recipient(): void {
+		$email = $this->create_testable_email( 'my_email', '', true );
+
+		$skipped_reason = null;
+		add_action(
+			'woocommerce_email_skipped',
+			function ( $reason, $email_id ) use ( &$skipped_reason ) {
+				if ( 'my_email' === $email_id ) {
+					$skipped_reason = $reason;
+				}
+			},
+			10,
+			2
+		);
+
+		$result = $email->run_send_notification();
+
+		$this->assertFalse( $result, 'send_notification() should return false when no recipient' );
+		$this->assertSame( \WC_Email::SKIP_REASON_NO_RECIPIENT, $skipped_reason, 'woocommerce_email_skipped should fire with no_recipient reason' );
+		$this->assertFalse( $email->send_called, 'send() should not be called when no recipient' );
+	}
+
+	/**
+	 * @testdox send_notification() calls send() with the correct arguments and forwards its return value when enabled and recipient exists.
+	 */
+	public function test_send_notification_calls_send_and_returns_result_when_conditions_met(): void {
+		$email = $this->create_testable_email( 'my_email', 'admin@example.com', true, true );
+
+		$result = $email->run_send_notification();
+
+		$this->assertTrue( $result, 'send_notification() should forward the return value from send()' );
+		$this->assertTrue( $email->send_called, 'send() should be called when email is enabled and has a recipient' );
+		$this->assertSame( 'admin@example.com', $email->send_args[0], 'send() should receive the cached recipient as first argument' );
+	}
+
+	/**
+	 * @testdox send_if_recipient() fires woocommerce_email_skipped and returns false when recipient is empty.
+	 */
+	public function test_send_if_recipient_fires_skipped_and_returns_false_when_no_recipient(): void {
+		$email = $this->create_testable_email( 'my_email', '', false );
+
+		$skipped_fired = false;
+		add_action(
+			'woocommerce_email_skipped',
+			function ( $reason, $email_id ) use ( &$skipped_fired ) {
+				if ( 'my_email' === $email_id && \WC_Email::SKIP_REASON_NO_RECIPIENT === $reason ) {
+					$skipped_fired = true;
+				}
+			},
+			10,
+			2
+		);
+
+		$result = $email->run_send_if_recipient();
+
+		$this->assertFalse( $result, 'send_if_recipient() should return false when no recipient' );
+		$this->assertTrue( $skipped_fired, 'woocommerce_email_skipped should fire with no_recipient reason' );
+		$this->assertFalse( $email->send_called, 'send() should not be called when no recipient' );
+	}
+
+	/**
+	 * @testdox send_if_recipient() calls send() even when is_enabled() is false, bypassing the enabled check.
+	 */
+	public function test_send_if_recipient_calls_send_even_when_disabled(): void {
+		$email = $this->create_testable_email( 'my_email', 'admin@example.com', false, true );
+
+		$result = $email->run_send_if_recipient();
+
+		$this->assertTrue( $result, 'send_if_recipient() should forward the return value from send()' );
+		$this->assertTrue( $email->send_called, 'send() should be called regardless of is_enabled() state' );
+	}
+
+	/**
+	 * Create a minimal WC_Email subclass for unit-testing send_notification() and send_if_recipient().
+	 *
+	 * Exposes both protected helpers as public `run_*` wrappers and records whether send() was called.
+	 *
+	 * @param string $email_id    Email type ID.
+	 * @param string $recipient   Recipient email address (empty string = no recipient).
+	 * @param bool   $is_enabled  Return value for is_enabled().
+	 * @param bool   $send_return Return value for the stubbed send().
+	 * @return object Anonymous class instance with `run_send_notification()`, `run_send_if_recipient()`,
+	 *                `send_called`, and `send_args` properties.
+	 */
+	private function create_testable_email( string $email_id, string $recipient, bool $is_enabled, bool $send_return = false ): object {
+		return new class( $email_id, $recipient, $is_enabled, $send_return ) extends \WC_Email {
+			/** @var bool Whether send() has been invoked. */
+			public bool $send_called = false;
+			/** @var array Arguments captured from the most recent send() call. */
+			public array $send_args = array();
+
+			/** @var string Recipient returned by get_recipient(). */
+			private string $test_recipient;
+			/** @var bool Value returned by is_enabled(). */
+			private bool $test_is_enabled;
+			/** @var bool Value returned by send(). */
+			private bool $test_send_return;
+
+			/**
+			 * Construct the test double.
+			 *
+			 * @param string $email_id    The email type ID to expose on the instance.
+			 * @param string $recipient   Recipient string for get_recipient().
+			 * @param bool   $is_enabled  Value to return from is_enabled().
+			 * @param bool   $send_return Value to return from send().
+			 */
+			public function __construct( string $email_id, string $recipient, bool $is_enabled, bool $send_return ) {
+				// Deliberately skip parent::__construct() to avoid side-effects in tests.
+				$this->id               = $email_id;
+				$this->test_recipient   = $recipient;
+				$this->test_is_enabled  = $is_enabled;
+				$this->test_send_return = $send_return;
+			}
+
+			/**
+			 * @return bool Configured is_enabled() return value.
+			 */
+			public function is_enabled(): bool {
+				return $this->test_is_enabled;
+			}
+
+			/**
+			 * @return string Configured recipient string.
+			 */
+			public function get_recipient(): string {
+				return $this->test_recipient;
+			}
+
+			/**
+			 * @return string Static test subject.
+			 */
+			public function get_subject(): string {
+				return 'Test subject';
+			}
+
+			/**
+			 * @return string Static test content.
+			 */
+			public function get_content(): string {
+				return 'Test content';
+			}
+
+			/**
+			 * @return string Empty headers string.
+			 */
+			public function get_headers(): string {
+				return '';
+			}
+
+			/**
+			 * @return array Empty attachments array.
+			 */
+			public function get_attachments(): array {
+				return array();
+			}
+
+			/**
+			 * Record the send() invocation and return the configured result.
+			 *
+			 * @param string $to          Recipient.
+			 * @param string $subject     Subject.
+			 * @param string $message     Body.
+			 * @param string $headers     Headers.
+			 * @param array  $attachments Attachments.
+			 * @return bool Configured send() return value.
+			 */
+			public function send( $to, $subject, $message, $headers, $attachments ): bool {
+				$this->send_called = true;
+				$this->send_args   = array( $to, $subject, $message, $headers, $attachments );
+				return $this->test_send_return;
+			}
+
+			/** Exposes the protected send_notification() for testing. */
+			public function run_send_notification(): bool {
+				return $this->send_notification();
+			}
+
+			/** Exposes the protected send_if_recipient() for testing. */
+			public function run_send_if_recipient(): bool {
+				return $this->send_if_recipient();
+			}
+		};
+	}
+
 	/**
 	 * Create a mock WC_Email object for testing.
 	 *