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