Commit 22fad182011 for woocommerce
commit 22fad182011e67c9aaead7f7935df718da7658f0
Author: Tom Cafferkey <tjcafferkey@gmail.com>
Date: Mon Aug 24 13:13:07 2026 +0100
Add filter to control customer email verification status (#67961)
* Add filter to control customer email verification status
* Add changelog entry for email verification filter
* Base mark_verified() on persisted state, not the is_verified filter
diff --git a/plugins/woocommerce/changelog/67049-add-customer-email-is-verified-filter b/plugins/woocommerce/changelog/67049-add-customer-email-is-verified-filter
new file mode 100644
index 00000000000..960b371a10c
--- /dev/null
+++ b/plugins/woocommerce/changelog/67049-add-customer-email-is-verified-filter
@@ -0,0 +1,4 @@
+Significance: patch
+Type: add
+
+Add the `woocommerce_customer_email_is_verified` filter to `EmailVerificationService::is_verified()`, allowing extensions that manage their own email-verification state to mark a customer's email as verified or unverified.
diff --git a/plugins/woocommerce/src/Internal/CustomerEmailVerification/EmailVerificationService.php b/plugins/woocommerce/src/Internal/CustomerEmailVerification/EmailVerificationService.php
index 39cb8bc8d31..ef9db6494ae 100644
--- a/plugins/woocommerce/src/Internal/CustomerEmailVerification/EmailVerificationService.php
+++ b/plugins/woocommerce/src/Internal/CustomerEmailVerification/EmailVerificationService.php
@@ -67,12 +67,43 @@ class EmailVerificationService {
* account email, so changing the account email automatically invalidates the
* status — no change event needs to be observed.
*
+ * The result is filterable via {@see 'woocommerce_customer_email_is_verified'}, so
+ * extensions that manage their own verification state can mark customers as verified
+ * (or unverified) without writing the verified-status meta.
+ *
* @since 11.0.0
*
* @param int $user_id WordPress user ID.
* @return bool True when the stored verified email matches the user's current email.
*/
public function is_verified( int $user_id ): bool {
+ $is_verified = $this->has_verified_email( $user_id );
+
+ /**
+ * Filters whether a customer's current account email address is considered verified.
+ *
+ * Allows extensions that manage their own email-verification state to report a customer
+ * as verified (or unverified) without writing the verified-status meta.
+ *
+ * @param bool $is_verified Whether the customer's current account email is verified.
+ * @param int $user_id WordPress user ID.
+ *
+ * @since 11.1.0
+ */
+ return (bool) apply_filters( 'woocommerce_customer_email_is_verified', $is_verified, $user_id );
+ }
+
+ /**
+ * Return whether the stored verified-email meta matches the user's current account email.
+ *
+ * Unlike {@see self::is_verified()} this reflects only the persisted state — the
+ * 'woocommerce_customer_email_is_verified' filter is not applied — so write paths such as
+ * {@see self::mark_verified()} stay consistent however extensions filter the reported status.
+ *
+ * @param int $user_id WordPress user ID.
+ * @return bool True when the stored verified email matches the user's current email.
+ */
+ private function has_verified_email( int $user_id ): bool {
$verified_email = (string) Users::get_site_user_meta( $user_id, self::VERIFIED_META );
// Both sides are lower-cased (stored that way, get_account_email() normalises), so === is exact.
@@ -83,8 +114,10 @@ class EmailVerificationService {
* Mark the given user as having verified their current account email address.
*
* Stores the verified email address, clears any pending key, and fires the
- * {@see 'woocommerce_customer_email_verified'} action. No-ops if the user is already
- * verified for their current email.
+ * {@see 'woocommerce_customer_email_verified'} action. No-ops only when the persisted
+ * verified email already matches the current account email — the
+ * 'woocommerce_customer_email_is_verified' filter does not gate the write, so an extension
+ * filtering the reported status can neither block persistence nor cause a duplicate action.
*
* @since 11.0.0
*
@@ -92,7 +125,7 @@ class EmailVerificationService {
* @return void
*/
public function mark_verified( int $user_id ): void {
- if ( $this->is_verified( $user_id ) ) {
+ if ( $this->has_verified_email( $user_id ) ) {
return;
}
diff --git a/plugins/woocommerce/tests/php/src/Internal/CustomerEmailVerification/EmailVerificationServiceTest.php b/plugins/woocommerce/tests/php/src/Internal/CustomerEmailVerification/EmailVerificationServiceTest.php
index acd61ac0777..71b8e03705b 100644
--- a/plugins/woocommerce/tests/php/src/Internal/CustomerEmailVerification/EmailVerificationServiceTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/CustomerEmailVerification/EmailVerificationServiceTest.php
@@ -206,4 +206,152 @@ class EmailVerificationServiceTest extends WC_Unit_Test_Case {
$this->assertTrue( $this->sut->is_verified( $user_id ), 'Non-email profile changes must not invalidate verification' );
}
+
+ /**
+ * @testdox The is_verified filter should mark an unverified user as verified.
+ */
+ public function test_is_verified_filter_marks_unverified_user_verified(): void {
+ $user_id = wc_create_new_customer( 'filter-verify@example.com', 'filterverify', 'pw' );
+
+ add_filter( 'woocommerce_customer_email_is_verified', '__return_true' );
+
+ $filtered = $this->sut->is_verified( $user_id );
+
+ remove_filter( 'woocommerce_customer_email_is_verified', '__return_true' );
+
+ $this->assertTrue( $filtered, 'The filter should be able to mark an unverified user as verified' );
+ $this->assertFalse( $this->sut->is_verified( $user_id ), 'Removing the filter should restore the unfiltered status' );
+ }
+
+ /**
+ * @testdox The is_verified filter should mark a verified user as unverified.
+ */
+ public function test_is_verified_filter_marks_verified_user_unverified(): void {
+ $user_id = wc_create_new_customer( 'filter-unverify@example.com', 'filterunverify', 'pw' );
+ $this->sut->mark_verified( $user_id );
+
+ add_filter( 'woocommerce_customer_email_is_verified', '__return_false' );
+
+ $filtered = $this->sut->is_verified( $user_id );
+
+ remove_filter( 'woocommerce_customer_email_is_verified', '__return_false' );
+
+ $this->assertFalse( $filtered, 'The filter should be able to mark a verified user as unverified' );
+ $this->assertTrue( $this->sut->is_verified( $user_id ), 'Removing the filter should restore the unfiltered status' );
+ }
+
+ /**
+ * @testdox The is_verified filter should receive the unfiltered status and the user ID.
+ */
+ public function test_is_verified_filter_receives_status_and_user_id(): void {
+ $user_id = wc_create_new_customer( 'filter-args@example.com', 'filterargs', 'pw' );
+ $this->sut->mark_verified( $user_id );
+
+ $received_status = null;
+ $received_user_id = null;
+ $listener = static function ( $is_verified, $id ) use ( &$received_status, &$received_user_id ) {
+ $received_status = $is_verified;
+ $received_user_id = $id;
+ return $is_verified;
+ };
+ add_filter( 'woocommerce_customer_email_is_verified', $listener, 10, 2 );
+
+ $this->sut->is_verified( $user_id );
+
+ remove_filter( 'woocommerce_customer_email_is_verified', $listener );
+
+ $this->assertTrue( $received_status, 'The filter should receive the unfiltered verification status' );
+ $this->assertSame( $user_id, $received_user_id, 'The filter should receive the user ID' );
+ }
+
+ /**
+ * @testdox mark_verified should persist and fire the hook for an unverified user even when the filter reports verified.
+ */
+ public function test_mark_verified_persists_for_unverified_user_when_filter_forces_verified(): void {
+ $user_id = wc_create_new_customer( 'filter-mark@example.com', 'filtermark', 'pw' );
+ $key = $this->sut->create_verification_key( $user_id );
+
+ $hook_calls = 0;
+ $listener = static function () use ( &$hook_calls ) {
+ ++$hook_calls;
+ };
+ add_action( 'woocommerce_customer_email_verified', $listener );
+ add_filter( 'woocommerce_customer_email_is_verified', '__return_true' );
+
+ $this->sut->mark_verified( $user_id );
+
+ remove_filter( 'woocommerce_customer_email_is_verified', '__return_true' );
+ remove_action( 'woocommerce_customer_email_verified', $listener );
+
+ $this->assertSame( 1, $hook_calls, 'The filter reporting verified should not block the verified action' );
+ $this->assertTrue( $this->sut->is_verified( $user_id ), 'The filter reporting verified should not block persisting the meta' );
+ $this->assertFalse( $this->sut->check_verification_key( $user_id, $key ), 'The filter reporting verified should not block consuming the pending key' );
+ }
+
+ /**
+ * @testdox mark_verified should stay a no-op for a verified user even when the filter reports verified.
+ */
+ public function test_mark_verified_noops_for_verified_user_when_filter_forces_verified(): void {
+ $user_id = wc_create_new_customer( 'filter-mark-verified@example.com', 'filtermarkverified', 'pw' );
+ $this->sut->mark_verified( $user_id );
+
+ $hook_calls = 0;
+ $listener = static function () use ( &$hook_calls ) {
+ ++$hook_calls;
+ };
+ add_action( 'woocommerce_customer_email_verified', $listener );
+ add_filter( 'woocommerce_customer_email_is_verified', '__return_true' );
+
+ $this->sut->mark_verified( $user_id );
+
+ remove_filter( 'woocommerce_customer_email_is_verified', '__return_true' );
+ remove_action( 'woocommerce_customer_email_verified', $listener );
+
+ $this->assertSame( 0, $hook_calls, 'An already persisted verified user should not re-fire the verified action' );
+ }
+
+ /**
+ * @testdox mark_verified should persist and fire the hook for an unverified user even when the filter reports unverified.
+ */
+ public function test_mark_verified_persists_for_unverified_user_when_filter_forces_unverified(): void {
+ $user_id = wc_create_new_customer( 'filter-mark-false@example.com', 'filtermarkfalse', 'pw' );
+
+ $hook_calls = 0;
+ $listener = static function () use ( &$hook_calls ) {
+ ++$hook_calls;
+ };
+ add_action( 'woocommerce_customer_email_verified', $listener );
+ add_filter( 'woocommerce_customer_email_is_verified', '__return_false' );
+
+ $this->sut->mark_verified( $user_id );
+
+ remove_filter( 'woocommerce_customer_email_is_verified', '__return_false' );
+ remove_action( 'woocommerce_customer_email_verified', $listener );
+
+ $this->assertSame( 1, $hook_calls, 'The filter reporting unverified should not change a normal mark' );
+ $this->assertTrue( $this->sut->is_verified( $user_id ), 'The user should be persisted as verified' );
+ }
+
+ /**
+ * @testdox mark_verified should stay a no-op for a verified user even when the filter reports unverified.
+ */
+ public function test_mark_verified_noops_for_verified_user_when_filter_forces_unverified(): void {
+ $user_id = wc_create_new_customer( 'filter-noop-false@example.com', 'filternoopfalse', 'pw' );
+ $this->sut->mark_verified( $user_id );
+
+ $hook_calls = 0;
+ $listener = static function () use ( &$hook_calls ) {
+ ++$hook_calls;
+ };
+ add_action( 'woocommerce_customer_email_verified', $listener );
+ add_filter( 'woocommerce_customer_email_is_verified', '__return_false' );
+
+ $this->sut->mark_verified( $user_id );
+
+ remove_filter( 'woocommerce_customer_email_is_verified', '__return_false' );
+ remove_action( 'woocommerce_customer_email_verified', $listener );
+
+ $this->assertSame( 0, $hook_calls, 'The filter reporting unverified should not re-fire the verified action for a persisted verified user' );
+ $this->assertTrue( $this->sut->is_verified( $user_id ), 'The persisted verified status should remain' );
+ }
}