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