Commit 17eb00d11a6 for woocommerce

commit 17eb00d11a63603075a6b7b38ee8aa6bd15ae7d4
Author: Mike Jolley <mike.jolley@me.com>
Date:   Tue Jun 23 14:20:45 2026 +0100

    Add customer email verification to link past guest orders (#65822)

    * Add customer email verification and guest-order linking

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

    * Simplify guest-order linking and tidy verification copy/tests

    Guard wc_update_new_customer_past_orders() against invalid user IDs and hook it directly to woocommerce_customer_email_verified, dropping the redundant OrderLinker::link_past_orders wrapper. Shorten the My Account verification button to 'Confirm email address' and dedupe guest-order setup in MyAccountPromptTest.

    * Fix PHPStan: void-wrap order-linking callback and drop stale baseline entries

    Hooking wc_update_new_customer_past_orders() (returns int) directly to an action tripped the return.void rule; wrap it in a void closure. The added invalid-user guard narrowed WP_User|false, so remove the now-unmatched $ID/$user_email baseline entries for wc-user-functions.php.

    * Fix exit-truncated send-request tests and stale-recipient email send

    handle_send_request() ends in wp_safe_redirect()+exit; the tests filtered wp_redirect with __return_false, which does not stop exit, so the process died after 7 of 12 MyAccountPromptTest methods and the handle_send_request assertions never ran. Trap the redirect with a throwing filter instead so all 12 tests execute (verified: 12 tests, 15 assertions).

    Also make CustomerVerifyEmail::trigger() a true no-op when args are missing by sending only inside the guard - the email object is a singleton, so an argless call after a real one would otherwise re-send to the previous recipient. Use precise remove_action() in place of remove_all_actions() so tests stop wiping shared hook callbacks.

    * Gate email verification to the link owner and harden hook callback

    Refuse to verify (and silently switch sessions) when a verify link is opened while logged in as a different user; show an error notice instead. Only the link owner or a logged-out visitor can complete verification. Add a test covering the cross-account rejection.

    Leave the woocommerce_customer_email_verified callback argument untyped and absint() it so a third party firing the hook with a numeric string under strict_types does not throw. Use precise remove_action() in VerificationControllerTest, give the test redirect-abort filter its $location argument, and correct the clear_verification() docblock (verified-email meta, not a timestamp).

    * Wire up verify-email block template and harden timing edges

    Set template_block_content to the dedicated emails/block/customer-verify-email.php so the block email editor renders the verify content instead of falling back to the generic block template (the custom template was previously unreferenced).

    Clamp seconds_since_last_key() to zero so a future stored timestamp can't report negative elapsed time and wedge the resend rate-limit. Loosen the elapsed-time test bound to a non-negative/<60 window to avoid slow-runner flakiness, and resolve PHPCS findings on the new test helpers (escape thrown messages, move trailing comments, pre-increment).

    * Revert block-content override and address CodeRabbit feedback

    Revert the template_block_content override from 986f020: the dedicated emails/block/customer-verify-email.php is the email *wrapper* (loaded by the editor via the emails/block/{id}.php convention and containing the WOO_CONTENT placeholder), not the placeholder content. Pointing template_block_content at it made get_block_editor_email_template_content() return the wrapper - duplicating the heading/footer, leaking a literal placeholder, and dropping the verify link. The default general-block-email.php already renders the verify CTA via its customer_verify_email branch, matching the sibling account emails.

    Redirect verification completions to the Orders endpoint to match the send/resend flow and surface the linked orders. Use an empty-aware $verify_url fallback in the three live templates, and assert the redirect destination in the cross-account guard test.

    * Condense verification guard comments

    * Fix typo in verification guard comment

    * Bind email-verification tokens to the email at issuance

    * Refine verify-email templates and wire preview data

    * Fall back to default email button color when option is empty

    * Declare preview-populated properties on CustomerVerifyEmail

    * Drop confirm auto-login and fix JS auto-submit marker

    Verifying via the emailed link no longer sets an auth cookie: the logged-out
    nonce isn't browser-bound, so auto-login was exploitable as login CSRF. Users
    now verify and then log in.

    Also move the wc_verify_email_submit marker from the submit button to a hidden
    field. HTMLFormElement.submit() (the JS auto-submit) omits submit-button
    name/value, so with JS enabled the marker never arrived and verification
    silently fell through to the plain account page.

    * Simplify verify-email confirm notice and reuse email button template

    * Replace email verification link with a one-time code

    Customers confirm their email by entering a 6-digit code on the My Account
    orders panel instead of clicking an emailed link, closing the link-prefetch
    auto-verify vector. Adds a 10-minute TTL, 3-attempts-per-code and 10 cumulative
    failures (permanent lockout), a per-user database lock so concurrent guesses
    can't bypass the limits, and progressive-enhancement form behaviour (paste to
    trim and auto-submit, empty-field disable, loading spinner).

    * Move email verification form to an /orders/verify sub-page

    Host the code-entry form on the orders endpoint's reserved "verify" value
    (/my-account/orders/verify/) instead of inline on the orders panel. Reusing the
    orders endpoint's existing rewrite avoids registering a new endpoint (no rewrite
    flush needed). The orders panel keeps the initiating notice, which links here.

    * Add left margin to the notice banner forward button

    * Simplify verification notice builders and script enqueue

    Fold the two button-notice builders into a shared helper and drop the redundant
    registered-check before enqueuing the form script. No behaviour change.

    * Inline the verification notice builders

    Revert the shared button-notice helper: its required docblock cost more lines
    than the duplication it removed. The two builders are short and self-contained.

    * Add verification form loading styles to block theme stylesheet

    The submit spinner and disabled-button styles were only in woocommerce.scss;
    mirror them in woocommerce-blocktheme.scss so block themes get them too.

    * Dim the verify form submit button instead of a spinner

    Drop the loading-spinner styles (and the block theme's mixins/animation imports
    added for them). The script already disables the button while submitting, so the
    existing disabled rule dims it — simpler, and identical in both stylesheets.

    * Drop OrderLinker; link guest orders via a direct action hook

    wc_update_new_customer_past_orders() already casts the ID, no-ops for guest/invalid
    users, and its return is unused — so the wrapper closure added nothing. has_linkable_orders()
    was also dead (should_show_prompt no longer calls it). Hook the function straight onto
    woocommerce_customer_email_verified in the boot class and delete the class and its test.

    * Replace verify-code DB lock with a compare-and-swap counter

    GET_LOCK has no Woo/WP precedent and fails closed on hosts that don't support it
    (breaking verification for everyone there). Instead, claim each guess against the
    cumulative failure counter with a compare-and-swap before comparing the code: only
    the writer that moves the counter proceeds, so concurrent submissions are serialised
    into distinct slots and at most MAX_FAILURES are ever compared. create_code() pre-creates
    the counter row at zero so the CAS only updates (never inserts, which would race into
    duplicate rows) and resends never reset the count.

    * Count attempts down so the compare-and-swap is always conditional

    update_user_meta() ignores its $prev_value when it is empty, and PHP treats '0' as
    empty — so a count-up swap from 0->1 was non-conditional. Store remaining attempts
    instead, counting 10 down to 0: the previous value supplied to the swap is always
    '1'..'10', never '0', so every claim is genuinely conditional with no off-by-one.
    verify_code() also fails closed if a live code exists but the counter row is missing,
    rather than letting the swap re-insert it with a fresh budget.

    * Simplify EmailVerificationService email lookups and trim docs

    Replace the get_user() helper with get_account_email(): every caller only
    needed the lower-cased account email, so this folds in the repeated
    strtolower() and drops the per-call existence guards. is_verified() becomes
    an exact match on the normalised address. Consolidate the compare-and-swap
    rationale into claim_attempt() instead of repeating it across four docblocks.

    * Improve the email-verification form and scope its assets per-screen

    Move the code form's CSS and JS to an inline, empty-source handle enqueued
    only on the verify sub-page, and drop the styles from woocommerce.scss /
    woocommerce-blocktheme.scss (now back at trunk) so they no longer ship on
    every page.

    Dim the submit button with native :invalid (the input is required with a
    6-digit pattern) instead of JS toggling disabled. Notify the customer on
    send — success when a new code is dispatched, an info notice when a resend
    is suppressed by the rate limit. Refine the form copy and force a clean
    "Orders" title on the sub-page (the reused pagination slot would otherwise
    render "Orders (Page 0)").

    * Refine verification form copy and autofocus the code input

    ---------

    Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

diff --git a/plugins/woocommerce/changelog/add-customer-email-verification-order-linking b/plugins/woocommerce/changelog/add-customer-email-verification-order-linking
new file mode 100644
index 00000000000..1ec360afea7
--- /dev/null
+++ b/plugins/woocommerce/changelog/add-customer-email-verification-order-linking
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Add customer email verification, letting shoppers securely link past guest orders to their account once they confirm ownership of their email address.
diff --git a/plugins/woocommerce/client/blocks/assets/js/base/components/notice-banner/style.scss b/plugins/woocommerce/client/blocks/assets/js/base/components/notice-banner/style.scss
index da54461b45a..e582ca2b794 100644
--- a/plugins/woocommerce/client/blocks/assets/js/base/components/notice-banner/style.scss
+++ b/plugins/woocommerce/client/blocks/assets/js/base/components/notice-banner/style.scss
@@ -47,7 +47,7 @@
 			color: $gray-800 !important;
 			background: transparent !important; // For transparent notice button in Twenty Twenty-One theme.
 			padding: 0 !important;
-			margin: 0;
+			margin: 0 0 0 20px;
 			border: 0;
 			appearance: none;
 			opacity: 0.7;
diff --git a/plugins/woocommerce/includes/class-woocommerce.php b/plugins/woocommerce/includes/class-woocommerce.php
index 1ffa2257f36..b7e140d9a26 100644
--- a/plugins/woocommerce/includes/class-woocommerce.php
+++ b/plugins/woocommerce/includes/class-woocommerce.php
@@ -41,6 +41,7 @@ use Automattic\WooCommerce\Caches\OrderCountCacheService;
 use Automattic\WooCommerce\Internal\Caches\ProductVersionStringInvalidator;
 use Automattic\WooCommerce\Internal\Caches\OrdersVersionStringInvalidator;
 use Automattic\WooCommerce\Internal\Caches\TaxRateVersionStringInvalidator;
+use Automattic\WooCommerce\Internal\CustomerEmailVerification\CustomerEmailVerification;
 use Automattic\WooCommerce\Internal\StockNotifications\StockNotifications;
 use Automattic\Jetpack\Constants;

@@ -383,6 +384,7 @@ final class WooCommerce {
 		$container->get( OrdersVersionStringInvalidator::class );
 		$container->get( TaxRateVersionStringInvalidator::class );
 		$container->get( OrderMilestoneEasterEgg::class );
+		$container->get( CustomerEmailVerification::class );

 		// Feature flags.
 		if ( Constants::is_true( 'WOOCOMMERCE_BIS_ALPHA_ENABLED' ) ) {
diff --git a/plugins/woocommerce/includes/wc-user-functions.php b/plugins/woocommerce/includes/wc-user-functions.php
index 289e74f09db..708ac3b89ba 100644
--- a/plugins/woocommerce/includes/wc-user-functions.php
+++ b/plugins/woocommerce/includes/wc-user-functions.php
@@ -326,9 +326,14 @@ function wc_set_customer_auth_cookie( $customer_id ) {
  * @return int
  */
 function wc_update_new_customer_past_orders( $customer_id ) {
+	$customer = get_user_by( 'id', absint( $customer_id ) );
+
+	if ( ! $customer ) {
+		return 0;
+	}
+
 	$linked          = 0;
 	$complete        = 0;
-	$customer        = get_user_by( 'id', absint( $customer_id ) );
 	$customer_orders = wc_get_orders(
 		array(
 			'limit'    => -1,
diff --git a/plugins/woocommerce/phpstan-baseline.neon b/plugins/woocommerce/phpstan-baseline.neon
index b97b98be32e..ea11b85e271 100644
--- a/plugins/woocommerce/phpstan-baseline.neon
+++ b/plugins/woocommerce/phpstan-baseline.neon
@@ -36357,18 +36357,6 @@ parameters:
 			count: 1
 			path: includes/wc-user-functions.php

-		-
-			message: '#^Cannot access property \$ID on WP_User\|false\.$#'
-			identifier: property.nonObject
-			count: 1
-			path: includes/wc-user-functions.php
-
-		-
-			message: '#^Cannot access property \$user_email on WP_User\|false\.$#'
-			identifier: property.nonObject
-			count: 1
-			path: includes/wc-user-functions.php
-
 		-
 			message: '#^Cannot call method get_customer_id\(\) on WC_Order\|WC_Order_Refund\|false\.$#'
 			identifier: method.nonObject
diff --git a/plugins/woocommerce/src/Internal/Admin/EmailPreview/EmailPreview.php b/plugins/woocommerce/src/Internal/Admin/EmailPreview/EmailPreview.php
index 73f3f418dc8..20b15ebfd55 100644
--- a/plugins/woocommerce/src/Internal/Admin/EmailPreview/EmailPreview.php
+++ b/plugins/woocommerce/src/Internal/Admin/EmailPreview/EmailPreview.php
@@ -31,6 +31,7 @@ class EmailPreview {
 	const USER_OBJECT_EMAILS = array(
 		'WC_Email_Customer_New_Account',
 		'WC_Email_Customer_Reset_Password',
+		'Automattic\WooCommerce\Internal\CustomerEmailVerification\Emails\CustomerVerifyEmail',
 	);

 	const TRANSIENT_PREVIEW_EMAIL_IMPROVEMENTS = 'woocommerce_preview_email_improvements';
@@ -201,6 +202,10 @@ class EmailPreview {
 			$this->email->user_email = $object->user_email;
 			$this->email->user_login = $object->user_login;

+			if ( property_exists( $this->email, 'user_display_name' ) ) {
+				$this->email->user_display_name = $object->first_name;
+			}
+
 			if ( property_exists( $this->email, 'reset_key' ) ) {
 				$this->email->reset_key = 'reset_key';
 			}
@@ -213,6 +218,10 @@ class EmailPreview {
 				$this->email->user_id = 0;
 			}

+			if ( property_exists( $this->email, 'verify_code' ) ) {
+				$this->email->verify_code = '123456';
+			}
+
 			$this->email->set_object( $object );
 		} else {
 			$object = $this->get_dummy_order();
diff --git a/plugins/woocommerce/src/Internal/CustomerEmailVerification/CustomerEmailVerification.php b/plugins/woocommerce/src/Internal/CustomerEmailVerification/CustomerEmailVerification.php
new file mode 100644
index 00000000000..2e4fd7b5dd9
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/CustomerEmailVerification/CustomerEmailVerification.php
@@ -0,0 +1,59 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Internal\CustomerEmailVerification;
+
+use Automattic\WooCommerce\Internal\CustomerEmailVerification\Emails\CustomerVerifyEmail;
+
+/**
+ * Boot class for the customer email verification subsystem.
+ *
+ * Resolves each controller so that their constructors register hooks during the
+ * plugins_loaded action.
+ *
+ * @since 11.0.0
+ */
+class CustomerEmailVerification {
+
+	/**
+	 * Initialize the subsystem.
+	 *
+	 * @since 11.0.0
+	 */
+	public function __construct() {
+		add_action( 'plugins_loaded', array( $this, 'init_hooks' ) );
+	}
+
+	/**
+	 * Resolve all subsystem controllers so their constructors register hooks.
+	 *
+	 * @internal
+	 * @since 11.0.0
+	 */
+	public function init_hooks(): void {
+		add_filter( 'woocommerce_email_classes', array( $this, 'register_email_classes' ) );
+
+		// Link a customer's matching guest orders to their account once they verify their email.
+		// wc_update_new_customer_past_orders() casts the ID and no-ops for guest/invalid users; the
+		// order count it returns is unused here.
+		// @phpstan-ignore-next-line return.void -- The returned count is intentionally discarded.
+		add_action( 'woocommerce_customer_email_verified', 'wc_update_new_customer_past_orders' );
+
+		$container = wc_get_container();
+		$container->get( VerificationController::class );
+		$container->get( VerificationEventListener::class );
+	}
+
+	/**
+	 * Register the customer email verification email with WooCommerce.
+	 *
+	 * @internal
+	 *
+	 * @param array $emails Registered email classes.
+	 * @return array
+	 */
+	public function register_email_classes( array $emails ): array {
+		$emails['WC_Email_Customer_Verify_Email'] = new CustomerVerifyEmail();
+		return $emails;
+	}
+}
diff --git a/plugins/woocommerce/src/Internal/CustomerEmailVerification/EmailVerificationService.php b/plugins/woocommerce/src/Internal/CustomerEmailVerification/EmailVerificationService.php
new file mode 100644
index 00000000000..a639deff89b
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/CustomerEmailVerification/EmailVerificationService.php
@@ -0,0 +1,421 @@
+<?php
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Internal\CustomerEmailVerification;
+
+use Automattic\WooCommerce\Internal\Utilities\Users;
+
+/**
+ * Service class providing the foundational primitives for customer email verification.
+ *
+ * This class is the single source of truth for whether a customer has proven they
+ * control their account email address. It manages the verified status meta and the
+ * short-lived, single-use numeric code (OTP) emailed to the customer, together with
+ * the per-code attempt limit and the attempt-budget lockout that protect it.
+ *
+ * @since 11.0.0
+ */
+class EmailVerificationService {
+
+	/**
+	 * Result of {@see self::verify_code()}: the code matched and the user is now verified.
+	 */
+	public const RESULT_OK = 'ok';
+
+	/**
+	 * Result of {@see self::verify_code()}: the code was wrong but attempts remain on it.
+	 */
+	public const RESULT_WRONG = 'wrong';
+
+	/**
+	 * Result of {@see self::verify_code()}: the code was wrong and has now used up its attempts.
+	 */
+	public const RESULT_BURNED = 'burned';
+
+	/**
+	 * Result of {@see self::verify_code()}: the code has expired (not counted as a failed guess).
+	 */
+	public const RESULT_EXPIRED = 'expired';
+
+	/**
+	 * Result of {@see self::verify_code()}: there is no pending code to check.
+	 */
+	public const RESULT_NONE = 'none';
+
+	/**
+	 * Result of {@see self::verify_code()}: the attempt budget is exhausted; the user is locked out.
+	 */
+	public const RESULT_LOCKED = 'locked';
+
+	/**
+	 * How long a freshly minted code remains valid.
+	 */
+	private const OTP_TTL = 10 * MINUTE_IN_SECONDS;
+
+	/**
+	 * Wrong guesses allowed against a single code before it is burned and a new one must be requested.
+	 */
+	private const MAX_ATTEMPTS = 3;
+
+	/**
+	 * Total guesses allowed (across all codes) before the user is permanently locked out of the code
+	 * flow and must contact the store owner (who can verify them from the admin). Stored as a
+	 * countdown in self::ATTEMPTS_META.
+	 */
+	private const ATTEMPT_BUDGET = 10;
+
+	/**
+	 * User meta key that stores the verified email address (lower-cased).
+	 * The customer is considered verified only while this matches their current account email.
+	 */
+	private const VERIFIED_META = '_wc_email_verified';
+
+	/**
+	 * User meta key that stores the pending code as "{timestamp}:{code_hash}:{email_hash}:{attempts}".
+	 * Overwritten on every new code; deleted when the code is consumed, burned, or the user verifies.
+	 */
+	private const KEY_META = '_wc_email_verification_key';
+
+	/**
+	 * User meta key for the number of guesses remaining, as a plain integer counting down from
+	 * self::ATTEMPT_BUDGET to 0 (locked out). Spans codes — requesting a new code does not reset it —
+	 * so the lockout can't be sidestepped by re-requesting. See {@see self::claim_attempt()} for why
+	 * it counts down rather than up.
+	 */
+	private const ATTEMPTS_META = '_wc_email_verification_attempts';
+
+	/**
+	 * The user's account email, lower-cased, or null when the user does not exist.
+	 *
+	 * Lower-casing here is the single normalisation point, so the verified-status match and the
+	 * code's email-binding hash stay consistent however the address was capitalised.
+	 *
+	 * @param int $user_id WordPress user ID.
+	 * @return string|null
+	 */
+	private function get_account_email( int $user_id ): ?string {
+		$user = get_user_by( 'id', $user_id );
+
+		return $user instanceof \WP_User ? strtolower( $user->user_email ) : null;
+	}
+
+	/**
+	 * Return whether the given user has verified their current account email address.
+	 *
+	 * A user is verified only while the stored verified email matches their current
+	 * account email, so changing the account email automatically invalidates the
+	 * status — no change event needs to be observed.
+	 *
+	 * @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 {
+		$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.
+		return '' !== $verified_email && $verified_email === $this->get_account_email( $user_id );
+	}
+
+	/**
+	 * Mark the given user as having verified their current account email address.
+	 *
+	 * Stores the verified email address, clears any pending code and the attempts counter, and
+	 * fires the {@see 'woocommerce_customer_email_verified'} action. No-ops if the
+	 * user is already verified for their current email.
+	 *
+	 * @since 11.0.0
+	 *
+	 * @param int $user_id WordPress user ID.
+	 * @return void
+	 */
+	public function mark_verified( int $user_id ): void {
+		if ( $this->is_verified( $user_id ) ) {
+			return;
+		}
+
+		$account_email = $this->get_account_email( $user_id );
+
+		if ( null === $account_email ) {
+			return;
+		}
+
+		// Storing the email (not a bool) lets the status self-invalidate if the account email later changes.
+		Users::update_site_user_meta( $user_id, self::VERIFIED_META, $account_email );
+		Users::delete_site_user_meta( $user_id, self::KEY_META );
+		Users::delete_site_user_meta( $user_id, self::ATTEMPTS_META );
+
+		/**
+		 * Fires after a customer has verified their email address.
+		 *
+		 * @param int $user_id The WordPress user ID of the verified customer.
+		 *
+		 * @since 11.0.0
+		 */
+		do_action( 'woocommerce_customer_email_verified', $user_id );
+	}
+
+	/**
+	 * Clear the email-verification status for the given user.
+	 *
+	 * Removes the verified-email meta, any pending code, and the remaining-attempts counter,
+	 * effectively resetting the user to a clean unverified state (also lifting any lockout).
+	 *
+	 * @since 11.0.0
+	 *
+	 * @param int $user_id WordPress user ID.
+	 * @return void
+	 */
+	public function clear_verification( int $user_id ): void {
+		Users::delete_site_user_meta( $user_id, self::VERIFIED_META );
+		Users::delete_site_user_meta( $user_id, self::KEY_META );
+		Users::delete_site_user_meta( $user_id, self::ATTEMPTS_META );
+	}
+
+	/**
+	 * Generate and store a one-time numeric verification code for the given user.
+	 *
+	 * The plaintext 6-digit code is returned for inclusion in the verification email. The stored
+	 * value is a "{timestamp}:{code_hash}:{email_hash}:{attempts}" tuple so the plaintext is never
+	 * persisted, the code expires after {@see self::OTP_TTL}, and the email hash binds the code to
+	 * the account email in effect at issuance (a code emailed to one address can never verify a
+	 * different address the account is later switched to). The attempt counter starts at zero.
+	 *
+	 * Minting a new code does not reset the remaining-attempts counter, so the lockout cannot be
+	 * sidestepped by simply requesting fresh codes.
+	 *
+	 * @since 11.0.0
+	 *
+	 * @param int $user_id WordPress user ID.
+	 * @return string The plaintext 6-digit code.
+	 */
+	public function create_code( int $user_id ): string {
+		$code          = $this->generate_code();
+		$account_email = $this->get_account_email( $user_id );
+		$email_hash    = null !== $account_email ? wp_fast_hash( $account_email ) : '';
+
+		// Seed the attempts counter only when absent: pre-creating the row keeps verify_code()'s
+		// compare-and-swap an update (never a racy insert), and not resetting it on resend means
+		// re-requesting codes can't lift a lockout in progress.
+		if ( '' === (string) Users::get_site_user_meta( $user_id, self::ATTEMPTS_META ) ) {
+			Users::update_site_user_meta( $user_id, self::ATTEMPTS_META, (string) self::ATTEMPT_BUDGET );
+		}
+
+		Users::update_site_user_meta( $user_id, self::KEY_META, time() . ':' . wp_fast_hash( $code ) . ':' . $email_hash . ':0' );
+
+		return $code;
+	}
+
+	/**
+	 * Verify a submitted code for the given user and record the outcome.
+	 *
+	 * Each guess first claims a slot from the remaining-attempts budget via {@see self::claim_attempt()},
+	 * so concurrent submissions can't slip past the cap. Expired, missing, or email-mismatched codes
+	 * return before a guess is claimed, so they never count against the customer. A correct code marks
+	 * the user verified; reaching {@see self::MAX_ATTEMPTS} on one code burns it (a fresh one must be
+	 * requested) and exhausting the budget locks the user out permanently.
+	 *
+	 * @since 11.0.0
+	 *
+	 * @param int    $user_id WordPress user ID.
+	 * @param string $code    The plaintext code submitted by the customer.
+	 * @return string One of the RESULT_* constants.
+	 */
+	public function verify_code( int $user_id, string $code ): string {
+		$remaining = $this->attempts_remaining( $user_id );
+
+		if ( null !== $remaining && $remaining <= 0 ) {
+			return self::RESULT_LOCKED;
+		}
+
+		$parsed = $this->parse_stored_key( $user_id );
+
+		if ( null === $parsed ) {
+			return self::RESULT_NONE;
+		}
+
+		$account_email = $this->get_account_email( $user_id );
+
+		if ( null === $account_email ) {
+			return self::RESULT_NONE;
+		}
+
+		list( $timestamp, $hash, $email_hash, $attempts ) = $parsed;
+
+		if ( time() - $timestamp > self::OTP_TTL ) {
+			// Expired: a timeout, not a guess. Clear the dead code without penalising the customer.
+			Users::delete_site_user_meta( $user_id, self::KEY_META );
+			return self::RESULT_EXPIRED;
+		}
+
+		// The code is void if the account email no longer matches the one it was minted for.
+		if ( ! wp_verify_fast_hash( $account_email, $email_hash ) ) {
+			Users::delete_site_user_meta( $user_id, self::KEY_META );
+			return self::RESULT_NONE;
+		}
+
+		// A live code exists, so create_code() must have created the counter row. If it is somehow
+		// missing, fail closed rather than letting the compare-and-swap re-insert it with a fresh budget.
+		if ( null === $remaining ) {
+			return self::RESULT_LOCKED;
+		}
+
+		// Claim this guess by decrementing the remaining budget before comparing. If another request
+		// moved the counter first, we lose the swap and turn away without a guess (no double-counting).
+		if ( ! $this->claim_attempt( $user_id, $remaining ) ) {
+			return self::RESULT_WRONG;
+		}
+
+		if ( '' !== $code && wp_verify_fast_hash( $code, $hash ) ) {
+			$this->mark_verified( $user_id );
+			return self::RESULT_OK;
+		}
+
+		// Wrong guess. The budget has already dropped to $remaining - 1.
+		if ( $remaining - 1 <= 0 ) {
+			// That was the final allowed guess: lock out and drop the live code.
+			Users::delete_site_user_meta( $user_id, self::KEY_META );
+			return self::RESULT_LOCKED;
+		}
+
+		++$attempts;
+
+		if ( $attempts >= self::MAX_ATTEMPTS ) {
+			// Burn this code; the customer must request a fresh one.
+			Users::delete_site_user_meta( $user_id, self::KEY_META );
+			return self::RESULT_BURNED;
+		}
+
+		Users::update_site_user_meta(
+			$user_id,
+			self::KEY_META,
+			$timestamp . ':' . $hash . ':' . $email_hash . ':' . $attempts
+		);
+
+		return self::RESULT_WRONG;
+	}
+
+	/**
+	 * Atomically claim a guess by decrementing the remaining-attempts budget via a compare-and-swap.
+	 *
+	 * Moves the counter from $remaining to $remaining - 1 only while it still equals $remaining, so
+	 * concurrent submissions are serialised into distinct slots and at most ATTEMPT_BUDGET ever pass.
+	 * $remaining is always 1..ATTEMPT_BUDGET here, so the previous value is never "0" — which
+	 * update_user_meta() treats as empty and would ignore, making the swap non-conditional. The row is
+	 * pre-created by {@see self::create_code()} so this only ever updates, never inserts (which would
+	 * race into duplicate rows). Returns false when another request moved the counter first.
+	 *
+	 * @param int $user_id   WordPress user ID.
+	 * @param int $remaining The remaining count this request observed (>= 1).
+	 * @return bool True when this request claimed the slot.
+	 */
+	private function claim_attempt( int $user_id, int $remaining ): bool {
+		return (bool) Users::update_site_user_meta( $user_id, self::ATTEMPTS_META, (string) ( $remaining - 1 ), (string) $remaining );
+	}
+
+	/**
+	 * Whether the user currently has a pending (minted, unexpired) code awaiting entry.
+	 *
+	 * Used to decide whether the My Account prompt shows the code-entry form or the "send code"
+	 * call to action.
+	 *
+	 * @since 11.0.0
+	 *
+	 * @param int $user_id WordPress user ID.
+	 * @return bool
+	 */
+	public function has_pending_code( int $user_id ): bool {
+		$parsed = $this->parse_stored_key( $user_id );
+
+		return null !== $parsed && time() - $parsed[0] <= self::OTP_TTL;
+	}
+
+	/**
+	 * Whether the user has used up their attempt budget and is permanently locked out.
+	 *
+	 * The lockout only lifts when the user is verified another way (e.g. password reset) or the
+	 * store owner verifies them from the admin — both of which clear the counter.
+	 *
+	 * @since 11.0.0
+	 *
+	 * @param int $user_id WordPress user ID.
+	 * @return bool
+	 */
+	public function is_locked_out( int $user_id ): bool {
+		$remaining = $this->attempts_remaining( $user_id );
+
+		return null !== $remaining && $remaining <= 0;
+	}
+
+	/**
+	 * Return the number of guesses the user has left, or null if the flow has not started (no counter
+	 * row yet).
+	 *
+	 * @param int $user_id WordPress user ID.
+	 * @return int|null
+	 */
+	private function attempts_remaining( int $user_id ): ?int {
+		$raw = (string) Users::get_site_user_meta( $user_id, self::ATTEMPTS_META );
+
+		return '' === $raw ? null : (int) $raw;
+	}
+
+	/**
+	 * Generate a zero-padded 6-digit numeric code.
+	 *
+	 * @return string
+	 */
+	private function generate_code(): string {
+		return str_pad( (string) wp_rand( 0, 999999 ), 6, '0', STR_PAD_LEFT );
+	}
+
+	/**
+	 * Parse the stored code tuple into its timestamp, hash, email-hash, and attempt parts.
+	 *
+	 * The tuple is persisted as "{timestamp}:{code_hash}:{email_hash}:{attempts}"; this is the
+	 * single place that knows that format.
+	 *
+	 * @param int $user_id WordPress user ID.
+	 * @return array{0: int, 1: string, 2: string, 3: int}|null The tuple, or null when none is stored.
+	 */
+	private function parse_stored_key( int $user_id ): ?array {
+		$stored = (string) Users::get_site_user_meta( $user_id, self::KEY_META );
+
+		if ( ! str_contains( $stored, ':' ) ) {
+			return null;
+		}
+
+		$parts      = explode( ':', $stored, 4 );
+		$timestamp  = (int) ( $parts[0] ?? 0 );
+		$hash       = (string) ( $parts[1] ?? '' );
+		$email_hash = (string) ( $parts[2] ?? '' );
+		$attempts   = (int) ( $parts[3] ?? 0 );
+
+		if ( '' === $hash || '' === $email_hash || 0 === $timestamp ) {
+			return null;
+		}
+
+		return array( $timestamp, $hash, $email_hash, $attempts );
+	}
+
+	/**
+	 * Return the number of seconds elapsed since the last code was issued, or null if none exists.
+	 *
+	 * @since 11.0.0
+	 *
+	 * @param int $user_id WordPress user ID.
+	 * @return int|null Seconds since the last code was created, or null when none is stored.
+	 */
+	public function seconds_since_last_key( int $user_id ): ?int {
+		$parsed = $this->parse_stored_key( $user_id );
+
+		if ( null === $parsed ) {
+			return null;
+		}
+
+		// Clamp to zero so a future timestamp (clock skew, migrations) can't report negative
+		// elapsed time and wedge the resend rate-limit / "recently sent" notice logic.
+		return max( 0, time() - $parsed[0] );
+	}
+}
diff --git a/plugins/woocommerce/src/Internal/CustomerEmailVerification/Emails/CustomerVerifyEmail.php b/plugins/woocommerce/src/Internal/CustomerEmailVerification/Emails/CustomerVerifyEmail.php
new file mode 100644
index 00000000000..02e38b960c5
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/CustomerEmailVerification/Emails/CustomerVerifyEmail.php
@@ -0,0 +1,182 @@
+<?php
+
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Internal\CustomerEmailVerification\Emails;
+
+use WC_Customer;
+use WC_Email;
+use WP_User;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Customer email verification email.
+ *
+ * Sent to a customer with a link to confirm they own their account email address.
+ *
+ * @since 11.0.0
+ */
+class CustomerVerifyEmail extends WC_Email {
+
+	/**
+	 * One-time verification code included in the email.
+	 *
+	 * @var string
+	 */
+	public $verify_code;
+
+	/**
+	 * Display name used to greet the customer.
+	 *
+	 * @var string
+	 */
+	public $user_display_name;
+
+	/**
+	 * Customer email address. Populated by the email preview ({@see EmailPreview}) for the editor.
+	 *
+	 * @var string
+	 */
+	public $user_email;
+
+	/**
+	 * Customer login. Populated by the email preview ({@see EmailPreview}) for the editor.
+	 *
+	 * @var string
+	 */
+	public $user_login;
+
+	/**
+	 * Constructor.
+	 */
+	public function __construct() {
+		$this->id             = 'customer_verify_email';
+		$this->customer_email = true;
+		$this->title          = __( 'Confirm email address', 'woocommerce' );
+		$this->description    = __( 'Sent to customers with a link to confirm they own their account email address.', 'woocommerce' );
+		$this->template_html  = 'emails/customer-verify-email.php';
+		$this->template_plain = 'emails/plain/customer-verify-email.php';
+		$this->email_group    = 'accounts';
+
+		// Trigger.
+		add_action( 'woocommerce_customer_verify_email_notification', array( $this, 'trigger' ), 10, 2 );
+
+		// Call parent constructor.
+		parent::__construct();
+	}
+
+	/**
+	 * Get email subject.
+	 *
+	 * @return string
+	 */
+	public function get_default_subject() {
+		return __( 'Confirm your email address for {site_title}', 'woocommerce' );
+	}
+
+	/**
+	 * Get email heading.
+	 *
+	 * @return string
+	 */
+	public function get_default_heading() {
+		return __( 'Confirm your email address', 'woocommerce' );
+	}
+
+	/**
+	 * Default content to show below the main email content.
+	 *
+	 * @return string
+	 */
+	public function get_default_additional_content() {
+		return __( 'Thanks for reading.', 'woocommerce' );
+	}
+
+	/**
+	 * Trigger the sending of this email.
+	 *
+	 * @param int    $user_id     The user ID to send the email to.
+	 * @param string $verify_code The one-time verification code.
+	 * @return void
+	 */
+	public function trigger( $user_id, $verify_code = '' ) {
+		$this->setup_locale();
+
+		if ( $user_id && $verify_code ) {
+			$this->object            = new WP_User( $user_id );
+			$this->verify_code       = $verify_code;
+			$this->recipient         = wp_unslash( $this->object->user_email );
+			$customer                = new WC_Customer( $user_id );
+			$first_name              = ! empty( $customer->get_billing_first_name() ) ? $customer->get_billing_first_name() : $this->object->first_name;
+			$this->user_display_name = ! empty( $first_name ) ? $first_name : $this->object->user_login;
+
+			$this->send_notification();
+		}
+
+		$this->restore_locale();
+	}
+
+	/**
+	 * Get content html.
+	 *
+	 * @return string
+	 */
+	public function get_content_html() {
+		return wc_get_template_html(
+			$this->template_html,
+			array(
+				'email_heading'      => $this->get_heading(),
+				'additional_content' => $this->get_additional_content(),
+				'user_display_name'  => $this->user_display_name,
+				'user_email'         => $this->object instanceof WP_User ? $this->object->user_email : '',
+				'verify_code'        => $this->verify_code,
+				'blogname'           => $this->get_blogname(),
+				'sent_to_admin'      => false,
+				'plain_text'         => false,
+				'email'              => $this,
+			)
+		);
+	}
+
+	/**
+	 * Get content plain.
+	 *
+	 * @return string
+	 */
+	public function get_content_plain() {
+		return wc_get_template_html(
+			$this->template_plain,
+			array(
+				'email_heading'      => $this->get_heading(),
+				'additional_content' => $this->get_additional_content(),
+				'user_display_name'  => $this->user_display_name,
+				'user_email'         => $this->object instanceof WP_User ? $this->object->user_email : '',
+				'verify_code'        => $this->verify_code,
+				'blogname'           => $this->get_blogname(),
+				'sent_to_admin'      => false,
+				'plain_text'         => true,
+				'email'              => $this,
+			)
+		);
+	}
+
+	/**
+	 * Get the content rendered into the block email editor's WooCommerce content placeholder.
+	 *
+	 * @return string
+	 */
+	public function get_block_editor_email_template_content() {
+		return wc_get_template_html(
+			$this->template_block_content,
+			array(
+				'user_display_name' => $this->user_display_name,
+				'user_email'        => $this->object instanceof WP_User ? $this->object->user_email : '',
+				'verify_code'       => $this->verify_code,
+				'sent_to_admin'     => false,
+				'plain_text'        => false,
+				'email'             => $this,
+			)
+		);
+	}
+}
diff --git a/plugins/woocommerce/src/Internal/CustomerEmailVerification/VerificationController.php b/plugins/woocommerce/src/Internal/CustomerEmailVerification/VerificationController.php
new file mode 100644
index 00000000000..90242cc6552
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/CustomerEmailVerification/VerificationController.php
@@ -0,0 +1,612 @@
+<?php
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Internal\CustomerEmailVerification;
+
+/**
+ * Drives the customer email-verification UI on My Account and processes its requests.
+ *
+ * Verification uses a short-lived 6-digit code (OTP) the customer types into a form on a My Account
+ * sub-page (/orders/verify/) — the same logged-in session that requested it. There is no
+ * verification link, so an email client or security scanner that prefetches URLs cannot complete
+ * verification.
+ *
+ * @since 11.0.0
+ */
+class VerificationController {
+
+	/**
+	 * Nonce action used to protect the send-code request.
+	 */
+	private const SEND_NONCE_ACTION = 'woocommerce-send-verification-email';
+
+	/**
+	 * Nonce action used to protect the code submission.
+	 */
+	private const VERIFY_NONCE_ACTION = 'woocommerce-verify-email';
+
+	/**
+	 * Query param used to trigger the send-code request.
+	 */
+	private const SEND_PARAM = 'wc_send_verification';
+
+	/**
+	 * Form field carrying the submitted code.
+	 */
+	private const CODE_FIELD = 'wc_verify_email_code';
+
+	/**
+	 * Hidden form field marking a code submission.
+	 */
+	private const SUBMIT_FIELD = 'wc_verify_email_submit';
+
+	/**
+	 * Minimum seconds between code sends (rate limit).
+	 */
+	private const SEND_RATE_LIMIT = 60;
+
+	/**
+	 * Reserved value of the orders endpoint that renders the code-entry sub-page (/orders/verify/).
+	 * Reusing the orders endpoint's existing value segment avoids registering a new rewrite endpoint.
+	 */
+	private const VERIFY_VALUE = 'verify';
+
+	/**
+	 * Verification service.
+	 *
+	 * @var EmailVerificationService
+	 */
+	private $service;
+
+	/**
+	 * Constructor. Registers hooks.
+	 */
+	public function __construct() {
+		add_action( 'template_redirect', array( $this, 'maybe_process_request' ) );
+		add_action( 'woocommerce_before_account_orders', array( $this, 'render_prompt' ) );
+		// Render the form on the /orders/verify/ sub-page (priority 1, before the default orders list).
+		add_action( 'woocommerce_account_orders_endpoint', array( $this, 'maybe_render_on_orders_endpoint' ), 1 );
+		add_filter( 'woocommerce_endpoint_orders_title', array( $this, 'maybe_filter_orders_title' ) );
+	}
+
+	/**
+	 * Inject dependencies.
+	 *
+	 * @internal
+	 * @param EmailVerificationService $service Verification service.
+	 */
+	final public function init( EmailVerificationService $service ): void {
+		$this->service = $service;
+	}
+
+	/**
+	 * Route an incoming request: either a send-code request or a code submission.
+	 *
+	 * @since 11.0.0
+	 */
+	public function maybe_process_request(): void {
+		if ( isset( $_GET[ self::SEND_PARAM ] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
+			$this->handle_send_request();
+			return;
+		}
+
+		if ( $this->is_code_submission() ) {
+			$this->handle_code_submission();
+			return;
+		}
+
+		// On the /orders/verify/ sub-page, bounce anyone with nothing to verify back to orders. Done
+		// here, before output, because the rendering callback can't safely redirect.
+		if ( self::VERIFY_VALUE === get_query_var( 'orders' ) && ! $this->should_show_prompt() ) {
+			wp_safe_redirect( wc_get_account_endpoint_url( 'orders' ) );
+			exit;
+		}
+	}
+
+	/**
+	 * Whether the current request is a submission of the code form.
+	 *
+	 * @return bool
+	 */
+	private function is_code_submission(): bool {
+		$method = isset( $_SERVER['REQUEST_METHOD'] ) ? strtoupper( sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) ) : 'GET';
+
+		// Nonce is verified in handle_code_submission(); this only routes the request.
+		return 'POST' === $method && isset( $_POST[ self::SUBMIT_FIELD ] ); // phpcs:ignore WordPress.Security.NonceVerification.Missing
+	}
+
+	/**
+	 * Verify the submitted code and record the outcome.
+	 *
+	 * @since 11.0.0
+	 */
+	private function handle_code_submission(): void {
+		$nonce = isset( $_POST['_wpnonce'] ) ? sanitize_text_field( wp_unslash( $_POST['_wpnonce'] ) ) : '';
+
+		if ( ! wp_verify_nonce( $nonce, self::VERIFY_NONCE_ACTION ) ) {
+			wc_add_notice( __( 'Invalid request. Please try again.', 'woocommerce' ), 'error' );
+			wp_safe_redirect( wc_get_account_endpoint_url( 'orders' ) );
+			exit;
+		}
+
+		$user_id = get_current_user_id();
+
+		if ( ! $user_id ) {
+			wp_safe_redirect( wc_get_account_endpoint_url( 'orders' ) );
+			exit;
+		}
+
+		// Keep digits only so spaces or stray characters in the pasted code don't cause a false mismatch.
+		$code = preg_replace( '/\D/', '', isset( $_POST[ self::CODE_FIELD ] ) ? sanitize_text_field( wp_unslash( $_POST[ self::CODE_FIELD ] ) ) : '' );
+
+		$result = $this->service->verify_code( $user_id, (string) $code );
+
+		switch ( $result ) {
+			case EmailVerificationService::RESULT_OK:
+				wc_add_notice( __( 'Your email address has been confirmed.', 'woocommerce' ) );
+				break;
+			case EmailVerificationService::RESULT_WRONG:
+				wc_add_notice( __( 'That code is incorrect. Please try again.', 'woocommerce' ), 'error' );
+				break;
+			case EmailVerificationService::RESULT_BURNED:
+				wc_add_notice( __( 'That code is no longer valid. Please request a new one.', 'woocommerce' ), 'error' );
+				break;
+			case EmailVerificationService::RESULT_EXPIRED:
+				wc_add_notice( __( 'That code has expired. Please request a new one.', 'woocommerce' ), 'error' );
+				break;
+			case EmailVerificationService::RESULT_LOCKED:
+				wc_add_notice( $this->locked_message(), 'error' );
+				break;
+			default:
+				// RESULT_NONE: no pending code. If a prior submission (e.g. a double click) already
+				// verified the address, show success rather than a stale "request a new code" error.
+				if ( $this->service->is_verified( $user_id ) ) {
+					wc_add_notice( __( 'Your email address has been confirmed.', 'woocommerce' ) );
+				} else {
+					wc_add_notice( __( 'Please request a new code to confirm your email address.', 'woocommerce' ), 'error' );
+				}
+				break;
+		}
+
+		// Verified customers go to their orders; everyone else stays on the form to try again.
+		$verified = EmailVerificationService::RESULT_OK === $result || $this->service->is_verified( $user_id );
+		wp_safe_redirect( $verified ? wc_get_account_endpoint_url( 'orders' ) : $this->verify_url() );
+		exit;
+	}
+
+	/**
+	 * Handle a request to send (or resend) a verification code, triggered by the My Account prompt.
+	 *
+	 * Verifies the nonce, refuses to mint for a locked-out user, applies a resend rate-limit, then
+	 * dispatches the code and redirects back to the orders panel (where the entry form is shown).
+	 *
+	 * @since 11.0.0
+	 */
+	public function handle_send_request(): void {
+		$user_id = get_current_user_id();
+
+		if ( ! $user_id ) {
+			return;
+		}
+
+		$nonce = isset( $_GET['_wpnonce'] ) ? sanitize_text_field( wp_unslash( $_GET['_wpnonce'] ) ) : '';
+
+		if ( ! wp_verify_nonce( $nonce, self::SEND_NONCE_ACTION ) ) {
+			wc_add_notice( __( 'Invalid request. Please try again.', 'woocommerce' ), 'error' );
+			wp_safe_redirect( wc_get_account_endpoint_url( 'orders' ) );
+			exit;
+		}
+
+		// A locked-out customer can only be verified another way (e.g. by the store owner); never mint.
+		if ( $this->service->is_locked_out( $user_id ) ) {
+			wp_safe_redirect( $this->verify_url() );
+			exit;
+		}
+
+		// Only send a fresh code once the last one is outside the rate-limit window; otherwise the
+		// existing code still stands and the entry form continues to point the customer to their inbox.
+		$seconds_since = $this->service->seconds_since_last_key( $user_id );
+		if ( null === $seconds_since || $seconds_since >= self::SEND_RATE_LIMIT ) {
+			$this->send_verification_email( $user_id );
+			wc_add_notice( __( 'A code has been sent to your email address. Please check your inbox.', 'woocommerce' ), 'success' );
+		} else {
+			wc_add_notice( __( 'A code was sent recently. Please check your inbox, or wait a moment before requesting a new one.', 'woocommerce' ), 'notice' );
+		}
+
+		// Land on the /orders/verify/ sub-page, where the entry form is shown.
+		wp_safe_redirect( $this->verify_url() );
+		exit;
+	}
+
+	/**
+	 * Return whether the verification prompt should be shown for the current user.
+	 *
+	 * True for a logged-in, unverified customer, except one still using a temporary password (those
+	 * confirm via their set-password link, so the temporary-password notice already covers it). This
+	 * must not depend on whether matching guest orders exist, because that would disclose order
+	 * existence before the customer proves they control the email address.
+	 *
+	 * @since 11.0.0
+	 *
+	 * @return bool
+	 */
+	public function should_show_prompt(): bool {
+		$user_id = get_current_user_id();
+
+		if ( ! $user_id ) {
+			return false;
+		}
+
+		if ( $this->service->is_verified( $user_id ) ) {
+			return false;
+		}
+
+		// A temporary-password account already has a set-password link (which also verifies on use),
+		// surfaced by the temporary-password notice — don't show a second prompt alongside it.
+		if ( get_user_option( 'default_password_nag', $user_id ) ) {
+			return false;
+		}
+
+		return true;
+	}
+
+	/**
+	 * Render the verification prompt notice on the My Account "Orders" panel.
+	 *
+	 * The notice initiates the flow but does not host the form: a permanent-lockout message, a
+	 * pointer to the /orders/verify/ sub-page when a code is already pending, or the "send code" call
+	 * to action (which sends a code and redirects to the /orders/verify/ sub-page).
+	 *
+	 * @internal
+	 * @since 11.0.0
+	 */
+	public function render_prompt(): void {
+		if ( ! $this->should_show_prompt() ) {
+			return;
+		}
+
+		$user_id = get_current_user_id();
+
+		if ( $this->service->is_locked_out( $user_id ) ) {
+			$html = $this->get_locked_html();
+		} elseif ( $this->service->has_pending_code( $user_id ) ) {
+			$html = $this->get_pending_notice_html();
+		} else {
+			$html = $this->get_send_cta_html();
+		}
+
+		// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- each helper escapes every interpolated value.
+		echo $html;
+	}
+
+	/**
+	 * Render the verification sub-page content: the code-entry form (or the lockout / send states).
+	 *
+	 * Reached from the orders prompt via {@see self::maybe_render_on_orders_endpoint()}. Access is
+	 * gated in {@see self::maybe_process_request()}, which redirects anyone with nothing to verify
+	 * back to orders before output.
+	 *
+	 * @internal
+	 * @since 11.0.0
+	 */
+	public function render_endpoint_content(): void {
+		if ( ! $this->should_show_prompt() ) {
+			return;
+		}
+
+		$user_id = get_current_user_id();
+
+		if ( $this->service->is_locked_out( $user_id ) ) {
+			$html = $this->get_locked_html();
+		} elseif ( $this->service->has_pending_code( $user_id ) ) {
+			$this->enqueue_form_assets();
+			$html = $this->get_code_form_html();
+		} else {
+			$html = $this->get_send_cta_html();
+		}
+
+		// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- each helper escapes every interpolated value.
+		echo $html;
+	}
+
+	/**
+	 * Render the verification UI on the /orders/verify/ sub-page.
+	 *
+	 * Hooked early on the orders endpoint. When the orders value is the reserved "verify" token, this
+	 * renders the verification UI and removes the default orders-list output so only the form shows;
+	 * for any other value (a page number, or none) it is a no-op and the orders list renders normally.
+	 *
+	 * @internal
+	 * @param string $value The orders endpoint value (a page number, or the verify token).
+	 * @return void
+	 */
+	public function maybe_render_on_orders_endpoint( $value ): void {
+		if ( self::VERIFY_VALUE !== $value ) {
+			return;
+		}
+
+		remove_action( 'woocommerce_account_orders_endpoint', 'woocommerce_account_orders' );
+		$this->render_endpoint_content();
+	}
+
+	/**
+	 * Force a clean "Orders" title on the /orders/verify/ sub-page.
+	 *
+	 * The sub-page reuses the orders endpoint's value slot, so WooCommerce would otherwise render the
+	 * title as "Orders (Page 0)". Other orders pages keep their default title.
+	 *
+	 * @internal
+	 * @param string $title Default orders endpoint title.
+	 * @return string
+	 */
+	public function maybe_filter_orders_title( $title ): string {
+		if ( self::VERIFY_VALUE === get_query_var( 'orders' ) ) {
+			return __( 'Orders', 'woocommerce' );
+		}
+		return $title;
+	}
+
+	/**
+	 * URL of the /orders/verify/ sub-page that hosts the code-entry form.
+	 *
+	 * Reuses the orders endpoint's existing value segment, so no new rewrite rule (and no flush) is
+	 * needed for the URL to resolve.
+	 *
+	 * @return string
+	 */
+	private function verify_url(): string {
+		return wc_get_endpoint_url( 'orders', self::VERIFY_VALUE, wc_get_page_permalink( 'myaccount' ) );
+	}
+
+	/**
+	 * Build the permanent-lockout message block.
+	 *
+	 * @return string Fully escaped HTML.
+	 */
+	private function get_locked_html(): string {
+		return wc_print_notice( esc_html( $this->locked_message() ), 'error', array(), true );
+	}
+
+	/**
+	 * Build the "send confirmation code" call-to-action block.
+	 *
+	 * @return string Fully escaped HTML.
+	 */
+	private function get_send_cta_html(): string {
+		$send_url = wp_nonce_url(
+			add_query_arg( self::SEND_PARAM, '1', wc_get_account_endpoint_url( 'orders' ) ),
+			self::SEND_NONCE_ACTION
+		);
+
+		$notice = sprintf(
+			'<a href="%2$s" class="button wc-forward">%3$s</a> %1$s',
+			esc_html__( 'Confirm your email address to check for past orders and link them to your account.', 'woocommerce' ),
+			esc_url( $send_url ),
+			esc_html__( 'Send confirmation code', 'woocommerce' )
+		);
+
+		return wc_print_notice( $notice, 'notice', array(), true );
+	}
+
+	/**
+	 * Build the orders-panel notice shown when a code is already pending: a pointer to the
+	 * /orders/verify/ sub-page where the customer enters it.
+	 *
+	 * @return string Fully escaped HTML.
+	 */
+	private function get_pending_notice_html(): string {
+		$notice = sprintf(
+			'<a href="%2$s" class="button wc-forward">%3$s</a> %1$s',
+			esc_html__( 'We emailed you a confirmation code to confirm your email address.', 'woocommerce' ),
+			esc_url( $this->verify_url() ),
+			esc_html__( 'Enter your code', 'woocommerce' )
+		);
+
+		return wc_print_notice( $notice, 'notice', array(), true );
+	}
+
+	/**
+	 * Build the code-entry form block.
+	 *
+	 * @return string Fully escaped HTML.
+	 */
+	private function get_code_form_html(): string {
+		$user       = wp_get_current_user();
+		$resend_url = wp_nonce_url(
+			add_query_arg( self::SEND_PARAM, '1', wc_get_account_endpoint_url( 'orders' ) ),
+			self::SEND_NONCE_ACTION
+		);
+
+		$template = '
+<form method="post" action="%1$s" class="woocommerce-verify-email-form">
+<p>%2$s</p>
+<div class="woocommerce-otp-input-wrapper">
+<input type="text" name="%3$s" aria-label="%4$s" placeholder="······" inputmode="numeric" autocomplete="one-time-code" pattern="[0-9]{6}" maxlength="6" required class="input-text woocommerce-otp-input" />
+<button type="submit" class="wp-element-button button">%5$s</button>
+</div>
+<p class="woocommerce-otp-resend">%6$s <a href="%7$s" class="wc-forward">%8$s</a></p>
+<input type="hidden" name="%9$s" value="1" />
+%10$s
+</form>
+';
+
+		return sprintf(
+			$template,
+			esc_url( $this->verify_url() ),
+			// translators: %s: the customer's email address.
+			sprintf( esc_html__( 'Enter the 6-digit code that was sent to %s within 10 minutes to confirm your email address.', 'woocommerce' ), '<b>' . esc_html( $user->user_email ) . '</b>' ),
+			esc_attr( self::CODE_FIELD ),
+			esc_attr__( 'Verification code', 'woocommerce' ),
+			esc_html__( 'Confirm email', 'woocommerce' ),
+			esc_html__( 'Didn\'t receive the code?', 'woocommerce' ),
+			esc_url( $resend_url ),
+			esc_html__( 'Resend a new one', 'woocommerce' ),
+			esc_attr( self::SUBMIT_FIELD ),
+			wp_nonce_field( self::VERIFY_NONCE_ACTION, '_wpnonce', true, false )
+		);
+	}
+
+	/**
+	 * Enqueue the code-entry form's inline CSS and progressive-enhancement JS.
+	 *
+	 * Both attach to an empty-source handle and print only when the form renders, so neither ships
+	 * site-wide and no build step is needed. The form is fully usable without the JS: native
+	 * constraints (`required`, a 6-digit `pattern`) gate submission and `:invalid` styling dims the
+	 * button until a complete code is entered. The JS only adds niceties — it trims pasted input,
+	 * auto-submits a complete pasted code, and guards against a double submit.
+	 *
+	 * @return void
+	 */
+	private function enqueue_form_assets(): void {
+		$handle = 'wc-customer-email-verification';
+
+		// Empty-source handle carrying only the inline CSS/JS; re-registering is a no-op if it exists.
+		wp_register_style( $handle, false, array(), \WC_VERSION );
+		wp_enqueue_style( $handle );
+		wp_add_inline_style( $handle, $this->get_form_styles() );
+
+		wp_register_script( $handle, false, array(), \WC_VERSION, true );
+		wp_enqueue_script( $handle );
+		wp_add_inline_script( $handle, $this->get_form_script() );
+	}
+
+	/**
+	 * The inline CSS for the code-entry form.
+	 *
+	 * Printed only on the verification sub-page (via {@see self::enqueue_form_assets()}) rather than in
+	 * the site-wide stylesheet. Selectors must stay in sync with {@see self::get_code_form_html()}. The
+	 * submit button is dimmed purely via CSS: `:invalid` (the input is `required` with a 6-digit
+	 * `pattern`) while the code is incomplete, and `.is-submitting` while the POST is in flight.
+	 *
+	 * @return string
+	 */
+	private function get_form_styles(): string {
+		return <<<'CSS'
+.woocommerce-verify-email-form {
+	text-align: center;
+	text-wrap: balance;
+	border: 1px solid color-mix(in srgb, currentColor 20%, transparent);
+	padding: 16px;
+	margin: 0 0 16px;
+	border-radius: 4px;
+}
+.woocommerce-verify-email-form .woocommerce-otp-input-wrapper {
+	display: inline-flex;
+	align-items: stretch;
+	justify-content: center;
+	flex-direction: column;
+	gap: 8px;
+	margin: 16px 0;
+}
+.woocommerce-verify-email-form .woocommerce-otp-input.input-text {
+	font-size: 32px;
+	font-weight: 700;
+	letter-spacing: 0.4em;
+	text-indent: 0.4em;
+	padding: 0.2em 0.4em;
+	font-variant-numeric: tabular-nums;
+	text-align: center;
+	line-height: 1;
+	color: black;
+	width: 6.8em;
+	font-family: monospace;
+	flex: 0;
+}
+.woocommerce-verify-email-form .woocommerce-otp-input-wrapper .button.wp-element-button {
+	margin: 0;
+}
+.woocommerce-verify-email-form .woocommerce-otp-input:invalid + .button.wp-element-button,
+.woocommerce-verify-email-form.is-submitting .button.wp-element-button {
+	cursor: not-allowed;
+	opacity: 0.5;
+}
+.woocommerce-verify-email-form .woocommerce-otp-resend {
+	font-size: var(--wp--preset--font-size--x-small, calc(var(--wp--preset--font-size--small, 14px) * 0.875));
+}
+CSS;
+	}
+
+	/**
+	 * The inline JavaScript that enhances the code-entry form.
+	 *
+	 * Pure progressive enhancement — the form works without it. Selectors must stay in sync with
+	 * {@see self::get_code_form_html()}: the `.woocommerce-verify-email-form` form and its
+	 * `wc_verify_email_code` input. The submit button's dimmed state is owned by CSS (`:invalid`), not
+	 * here; this only trims input, auto-submits a complete pasted code, and blocks a double submit.
+	 *
+	 * @return string
+	 */
+	private function get_form_script(): string {
+		return <<<'JS'
+( function () {
+	var form = document.querySelector( '.woocommerce-verify-email-form' );
+	if ( ! form ) { return; }
+	var input = form.querySelector( 'input[name="wc_verify_email_code"]' );
+	if ( ! input ) { return; }
+
+	input.focus();
+
+	input.addEventListener( 'input', function ( event ) {
+		// Trim whitespace and any non-digits (e.g. from a pasted code), capped at six digits.
+		var digits = input.value.replace( /\D/g, '' ).slice( 0, 6 );
+		if ( digits !== input.value ) { input.value = digits; }
+
+		// Auto-submit only a complete code that was pasted or dropped in — never while typing, so a
+		// mistyped digit can't submit by accident. requestSubmit() still runs native validation.
+		var inserted = event && event.inputType;
+		var pasted = 'insertFromPaste' === inserted || 'insertFromDrop' === inserted;
+		if ( pasted && 6 === digits.length && form.requestSubmit ) {
+			form.requestSubmit();
+		}
+	} );
+
+	form.addEventListener( 'submit', function ( event ) {
+		// Block a double submit; .is-submitting also dims the button while the POST is in flight.
+		if ( form.classList.contains( 'is-submitting' ) ) {
+			event.preventDefault();
+			return;
+		}
+		form.classList.add( 'is-submitting' );
+		input.readOnly = true;
+	} );
+}() );
+JS;
+	}
+
+	/**
+	 * The permanent-lockout message, shared by the rendered block and the submission notice.
+	 *
+	 * @return string
+	 */
+	private function locked_message(): string {
+		return __( 'Too many incorrect attempts. Please contact the store owner to confirm your email address.', 'woocommerce' );
+	}
+
+	/**
+	 * Send (or resend) a verification code to a user.
+	 *
+	 * @since 11.0.0
+	 *
+	 * @param int $user_id User ID.
+	 */
+	public function send_verification_email( int $user_id ): void {
+		$user = get_user_by( 'id', $user_id );
+		if ( ! $user ) {
+			return;
+		}
+
+		$code = $this->service->create_code( $user_id );
+
+		WC()->mailer();
+
+		/**
+		 * Triggers sending of the customer email-verification email.
+		 *
+		 * @param int    $user_id     The WordPress user ID of the customer.
+		 * @param string $verify_code The one-time numeric code to include in the email.
+		 *
+		 * @since 11.0.0
+		 */
+		do_action( 'woocommerce_customer_verify_email_notification', $user_id, $code );
+	}
+}
diff --git a/plugins/woocommerce/src/Internal/CustomerEmailVerification/VerificationEventListener.php b/plugins/woocommerce/src/Internal/CustomerEmailVerification/VerificationEventListener.php
new file mode 100644
index 00000000000..021f76030d3
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/CustomerEmailVerification/VerificationEventListener.php
@@ -0,0 +1,58 @@
+<?php
+
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Internal\CustomerEmailVerification;
+
+use WP_User;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Listens for account events that should change a customer's email-verification status.
+ *
+ * Completing a password reset proves the customer controls their inbox, so it marks the
+ * email verified. This fires for both WordPress core resets (wp-login.php) and WooCommerce
+ * resets (lost-password and the new-account set-password link), all of which are email-based
+ * and dispatch the core `after_password_reset` action.
+ *
+ * @since 11.0.0
+ */
+class VerificationEventListener {
+
+	/**
+	 * Verification service.
+	 *
+	 * @var EmailVerificationService
+	 */
+	private $service;
+
+	/**
+	 * Constructor. Registers hooks.
+	 */
+	public function __construct() {
+		add_action( 'after_password_reset', array( $this, 'on_password_reset' ) );
+	}
+
+	/**
+	 * Inject dependencies.
+	 *
+	 * @internal
+	 *
+	 * @param EmailVerificationService $service Verification service.
+	 */
+	final public function init( EmailVerificationService $service ): void {
+		$this->service = $service;
+	}
+
+	/**
+	 * Mark the user's email verified after a completed password reset.
+	 *
+	 * @param WP_User|mixed $user The user whose password was reset.
+	 */
+	public function on_password_reset( $user ): void {
+		if ( $user instanceof WP_User ) {
+			$this->service->mark_verified( $user->ID );
+		}
+	}
+}
diff --git a/plugins/woocommerce/src/Internal/EmailEditor/WCTransactionalEmails/WCTransactionalEmails.php b/plugins/woocommerce/src/Internal/EmailEditor/WCTransactionalEmails/WCTransactionalEmails.php
index 168b71c6088..d86edb516b1 100644
--- a/plugins/woocommerce/src/Internal/EmailEditor/WCTransactionalEmails/WCTransactionalEmails.php
+++ b/plugins/woocommerce/src/Internal/EmailEditor/WCTransactionalEmails/WCTransactionalEmails.php
@@ -36,6 +36,7 @@ class WCTransactionalEmails {
 		'customer_partially_refunded_order',
 		'customer_reset_password',
 		'customer_review_request',
+		'customer_verify_email',
 		'failed_order',
 		'new_order',
 	);
diff --git a/plugins/woocommerce/templates/emails/block/customer-verify-email.php b/plugins/woocommerce/templates/emails/block/customer-verify-email.php
new file mode 100644
index 00000000000..017120e90c4
--- /dev/null
+++ b/plugins/woocommerce/templates/emails/block/customer-verify-email.php
@@ -0,0 +1,50 @@
+<?php
+/**
+ * Customer verify email address email (initial block version)
+ *
+ * This template can be overridden by editing it in the WooCommerce email editor.
+ *
+ * HOWEVER, on occasion WooCommerce will need to update template files and you
+ * (the theme developer) will need to copy the new files to your theme to
+ * maintain compatibility. We try to do this as little as possible, but it does
+ * happen. When this occurs the version of the template file will be bumped and
+ * the readme will list any important changes.
+ *
+ * @see https://woocommerce.com/document/template-structure/
+ * @package WooCommerce\Templates\Emails\Block
+ * @version 11.0.0
+ */
+
+use Automattic\WooCommerce\Internal\EmailEditor\BlockEmailRenderer;
+
+defined( 'ABSPATH' ) || exit;
+
+// phpcs:disable Squiz.PHP.EmbeddedPhp.ContentBeforeOpen -- removed to prevent empty new lines.
+// phpcs:disable Squiz.PHP.EmbeddedPhp.ContentAfterEnd -- removed to prevent empty new lines.
+?>
+
+<!-- wp:heading -->
+<h2 class="wp-block-heading"> <?php echo esc_html__( 'Confirm your email address', 'woocommerce' ); ?> </h2>
+<!-- /wp:heading -->
+
+<!-- wp:paragraph -->
+<p><?php
+	/* translators: %s: Customer first name */
+	printf( esc_html__( 'Hi %s,', 'woocommerce' ), '<!--[woocommerce/customer-first-name]-->' );
+?></p>
+<!-- /wp:paragraph -->
+
+<!-- wp:paragraph -->
+<p><?php
+	/* translators: %s: Customer email */
+	printf( esc_html__( "Once you've confirmed that %s is your email address, we'll link any past orders to your account.", 'woocommerce' ), '<!--[woocommerce/customer-email]-->' );
+?></p>
+<!-- /wp:paragraph -->
+
+<!-- wp:woocommerce/email-content {"lock":{"move":false,"remove":true}} -->
+<div class="wp-block-woocommerce-email-content"> <?php echo esc_html( BlockEmailRenderer::WOO_EMAIL_CONTENT_PLACEHOLDER ); ?> </div>
+<!-- /wp:woocommerce/email-content -->
+
+<!-- wp:paragraph -->
+<p> <?php echo esc_html__( "If you didn't request this email, there's nothing to worry about, and you can safely ignore it.", 'woocommerce' ); ?> </p>
+<!-- /wp:paragraph -->
diff --git a/plugins/woocommerce/templates/emails/block/general-block-email.php b/plugins/woocommerce/templates/emails/block/general-block-email.php
index ee2a0dacdd4..dd3e8f84aa9 100644
--- a/plugins/woocommerce/templates/emails/block/general-block-email.php
+++ b/plugins/woocommerce/templates/emails/block/general-block-email.php
@@ -13,7 +13,9 @@
  *
  * @see https://woocommerce.com/document/template-structure/
  * @package WooCommerce\Templates\Emails\Block
- * @version 10.5.0
+ * @version 11.0.0
+ *
+ * @var \WC_Order    $order      Order object.
  */

 defined( 'ABSPATH' ) || exit;
@@ -89,6 +91,18 @@ if ( 'customer_reset_password' === $email->id && isset( $reset_key, $user_id ) )
 	<?php
 endif;

+if ( 'customer_verify_email' === $email->id && $verify_code ) {
+	?>
+	<!-- wp:paragraph -->
+	<p><?php esc_html_e( 'Use this code to confirm your email address:', 'woocommerce' ); ?></p>
+	<!-- /wp:paragraph -->
+
+	<!-- wp:paragraph -->
+	<p style="font-size: 32px; font-weight: 700; letter-spacing: 0.4em; text-align: center;"><?php echo esc_html( $verify_code ); ?></p>
+	<!-- /wp:paragraph -->
+	<?php
+}
+
 /**
  * Action hook for email classes to hook into the general block email template.
  *
@@ -110,6 +124,7 @@ $emails_without_order_details = apply_filters( 'woocommerce_emails_general_block
 $accounts_related_emails = array(
 	'customer_reset_password',
 	'customer_new_account',
+	'customer_verify_email',
 );

 $emails_without_order_details = array_merge( $emails_without_order_details ?? array(), $accounts_related_emails );
diff --git a/plugins/woocommerce/templates/emails/customer-verify-email.php b/plugins/woocommerce/templates/emails/customer-verify-email.php
new file mode 100644
index 00000000000..d75f40f204a
--- /dev/null
+++ b/plugins/woocommerce/templates/emails/customer-verify-email.php
@@ -0,0 +1,72 @@
+<?php
+/**
+ * Customer verify email address email
+ *
+ * This template can be overridden by copying it to yourtheme/woocommerce/emails/customer-verify-email.php.
+ *
+ * HOWEVER, on occasion WooCommerce will need to update template files and you
+ * (the theme developer) will need to copy the new files to your theme to
+ * maintain compatibility. We try to do this as little as possible, but it does
+ * happen. When this occurs the version of the template file will be bumped and
+ * the readme will list any important changes.
+ *
+ * @see https://woocommerce.com/document/template-structure/
+ * @package WooCommerce\Templates\Emails
+ * @version 11.0.0
+ *
+ * @var string    $email_heading      Email heading.
+ * @var string    $additional_content Additional content below the body.
+ * @var string    $user_display_name  Customer's display name.
+ * @var string    $user_email         Email address being confirmed.
+ * @var string    $verify_code        One-time verification code.
+ * @var string    $blogname           Site name.
+ * @var bool      $sent_to_admin      Whether sent to admin.
+ * @var bool      $plain_text         Whether plain-text variant.
+ * @var \WC_Email $email              Email object.
+ */
+
+use Automattic\WooCommerce\Utilities\FeaturesUtil;
+
+defined( 'ABSPATH' ) || exit;
+
+$email_improvements_enabled = FeaturesUtil::feature_is_enabled( 'email_improvements' );
+
+/**
+ * Fires to output the email header.
+ *
+ * @hooked WC_Emails::email_header()
+ *
+ * @since 3.7.0
+ */
+do_action( 'woocommerce_email_header', $email_heading, $email ); ?>
+
+<?php echo $email_improvements_enabled ? '<div class="email-introduction">' : ''; ?>
+
+<?php /* translators: %s: Customer first name, or username if name is not available. */ ?>
+<p><?php printf( esc_html__( 'Hi %s,', 'woocommerce' ), esc_html( $user_display_name ) ); ?></p>
+<?php /* translators: %s: the customer's email address. */ ?>
+<p><?php printf( esc_html__( "Once you've confirmed that %s is your email address, we'll link any past orders to your account.", 'woocommerce' ), '<b>' . esc_html( $user_email ) . '</b>' ); ?></p>
+<p><?php esc_html_e( 'Use this code to confirm your email address:', 'woocommerce' ); ?></p>
+<p style="font-size: 32px; font-weight: 700; letter-spacing: 0.4em; text-align: center; margin: 24px 0;"><?php echo esc_html( $verify_code ); ?></p>
+<p><?php esc_html_e( "If you didn't request this email, there's nothing to worry about, and you can safely ignore it.", 'woocommerce' ); ?></p>
+
+<?php echo $email_improvements_enabled ? '</div>' : ''; ?>
+
+<?php
+/**
+ * Show user-defined additional content - this is set in each email's settings.
+ */
+if ( $additional_content ) {
+	echo $email_improvements_enabled ? '<table border="0" cellpadding="0" cellspacing="0" width="100%" role="presentation"><tr><td class="email-additional-content email-additional-content-aligned">' : '';
+	echo wp_kses_post( wpautop( wptexturize( $additional_content ) ) );
+	echo $email_improvements_enabled ? '</td></tr></table>' : '';
+}
+
+/**
+ * Fires to output the email footer.
+ *
+ * @hooked WC_Emails::email_footer()
+ *
+ * @since 3.7.0
+ */
+do_action( 'woocommerce_email_footer', $email );
diff --git a/plugins/woocommerce/templates/emails/plain/customer-verify-email.php b/plugins/woocommerce/templates/emails/plain/customer-verify-email.php
new file mode 100644
index 00000000000..7cd83395521
--- /dev/null
+++ b/plugins/woocommerce/templates/emails/plain/customer-verify-email.php
@@ -0,0 +1,60 @@
+<?php
+/**
+ * Customer verify email address email (plain text)
+ *
+ * This template can be overridden by copying it to yourtheme/woocommerce/emails/plain/customer-verify-email.php.
+ *
+ * HOWEVER, on occasion WooCommerce will need to update template files and you
+ * (the theme developer) will need to copy the new files to your theme to
+ * maintain compatibility. We try to do this as little as possible, but it does
+ * happen. When this occurs the version of the template file will be bumped and
+ * the readme will list any important changes.
+ *
+ * @see https://woocommerce.com/document/template-structure/
+ * @package WooCommerce\Templates\Emails\Plain
+ * @version 11.0.0
+ *
+ * @var string    $email_heading      Email heading.
+ * @var string    $additional_content Additional content below the body.
+ * @var string    $user_display_name  Customer's display name.
+ * @var string    $user_email         Email address being confirmed.
+ * @var string    $verify_code        One-time verification code.
+ * @var string    $blogname           Site name.
+ * @var bool      $sent_to_admin      Whether sent to admin.
+ * @var bool      $plain_text         Whether plain-text variant.
+ * @var \WC_Email $email              Email object.
+ */
+
+defined( 'ABSPATH' ) || exit;
+
+echo "=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=\n";
+echo esc_html( wp_strip_all_tags( $email_heading ) );
+echo "\n=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=\n\n";
+
+/* translators: %s: Customer first name, or username if name is not available */
+echo sprintf( esc_html__( 'Hi %s,', 'woocommerce' ), esc_html( $user_display_name ) ) . "\n\n";
+/* translators: %s: the customer's email address. */
+echo sprintf( esc_html__( "Once you've confirmed that %s is your email address, we'll link any past orders to your account.", 'woocommerce' ), esc_html( $user_email ) ) . "\n\n";
+
+echo esc_html__( 'Use this code to confirm your email address:', 'woocommerce' ) . "\n\n";
+echo "----------------------------------------\n\n";
+echo esc_html( $verify_code ) . "\n\n";
+echo "----------------------------------------\n\n";
+
+echo esc_html__( "If you didn't request this email, there's nothing to worry about, and you can safely ignore it.", 'woocommerce' ) . "\n\n";
+
+/**
+ * Show user-defined additional content - this is set in each email's settings.
+ */
+if ( $additional_content ) {
+	echo esc_html( wp_strip_all_tags( wptexturize( $additional_content ) ) );
+	echo "\n\n----------------------------------------\n\n";
+}
+
+/**
+ * Filter the email footer text.
+ *
+ * @param string $footer_text The footer text.
+ * @since 2.3.0
+ */
+echo wp_kses_post( apply_filters( 'woocommerce_email_footer_text', get_option( 'woocommerce_email_footer_text' ) ) );
diff --git a/plugins/woocommerce/tests/php/src/Internal/CustomerEmailVerification/EmailVerificationServiceTest.php b/plugins/woocommerce/tests/php/src/Internal/CustomerEmailVerification/EmailVerificationServiceTest.php
new file mode 100644
index 00000000000..c207106d25d
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/CustomerEmailVerification/EmailVerificationServiceTest.php
@@ -0,0 +1,308 @@
+<?php
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\CustomerEmailVerification;
+
+use Automattic\WooCommerce\Internal\CustomerEmailVerification\EmailVerificationService;
+use Automattic\WooCommerce\Internal\Utilities\Users;
+use WC_Unit_Test_Case;
+
+/**
+ * Tests for the EmailVerificationService class.
+ */
+class EmailVerificationServiceTest extends WC_Unit_Test_Case {
+
+	private const KEY_META = '_wc_email_verification_key';
+
+	private const ATTEMPTS_META = '_wc_email_verification_attempts';
+
+	/**
+	 * The System Under Test.
+	 *
+	 * @var EmailVerificationService
+	 */
+	private $sut;
+
+	/**
+	 * Set up test fixtures.
+	 */
+	public function setUp(): void {
+		parent::setUp();
+		$this->sut = wc_get_container()->get( EmailVerificationService::class );
+	}
+
+	/**
+	 * @testdox A freshly created customer should not be verified by default.
+	 */
+	public function test_user_is_unverified_by_default(): void {
+		$user_id = wc_create_new_customer( 'a@example.com', 'usera', 'pw' );
+
+		$this->assertFalse( $this->sut->is_verified( $user_id ), 'New customers should not be verified by default' );
+	}
+
+	/**
+	 * @testdox Marking a user as verified should set the meta and fire the hook exactly once.
+	 */
+	public function test_mark_verified_sets_meta_and_fires_hook(): void {
+		$user_id    = wc_create_new_customer( 'b@example.com', 'userb', 'pw' );
+		$hook_calls = 0;
+		$hook_arg   = null;
+
+		$listener = static function ( $id ) use ( &$hook_calls, &$hook_arg ) {
+			++$hook_calls;
+			$hook_arg = $id;
+		};
+		add_action( 'woocommerce_customer_email_verified', $listener );
+
+		$this->sut->mark_verified( $user_id );
+
+		$this->assertTrue( $this->sut->is_verified( $user_id ), 'User should be verified after mark_verified()' );
+		$this->assertSame( 1, $hook_calls, 'Hook should fire exactly once' );
+		$this->assertSame( $user_id, $hook_arg, 'Hook should receive the correct user ID' );
+
+		remove_action( 'woocommerce_customer_email_verified', $listener );
+	}
+
+	/**
+	 * @testdox create_code() returns a fresh, pending six-digit numeric code.
+	 */
+	public function test_create_code_returns_six_digit_code(): void {
+		$user_id = wc_create_new_customer( 'code@example.com', 'codeuser', 'pw' );
+
+		$code = $this->sut->create_code( $user_id );
+
+		$this->assertMatchesRegularExpression( '/^\d{6}$/', $code, 'A code must be six digits' );
+		$this->assertTrue( $this->sut->has_pending_code( $user_id ), 'A freshly minted code should be pending' );
+	}
+
+	/**
+	 * @testdox A correct code verifies the user and consumes the pending code.
+	 */
+	public function test_correct_code_verifies(): void {
+		$user_id = wc_create_new_customer( 'c@example.com', 'userc', 'pw' );
+
+		$code = $this->sut->create_code( $user_id );
+
+		$this->assertSame( EmailVerificationService::RESULT_OK, $this->sut->verify_code( $user_id, $code ) );
+		$this->assertTrue( $this->sut->is_verified( $user_id ), 'User should be verified after a correct code' );
+		$this->assertFalse( $this->sut->has_pending_code( $user_id ), 'The code should be consumed on success' );
+	}
+
+	/**
+	 * @testdox A wrong guess keeps the code pending until its attempts are exhausted, then burns it.
+	 */
+	public function test_wrong_code_burns_after_three_attempts(): void {
+		$user_id = wc_create_new_customer( 'd@example.com', 'userd', 'pw' );
+
+		$code  = $this->sut->create_code( $user_id );
+		$wrong = $this->wrong_code( $code );
+
+		$this->assertSame( EmailVerificationService::RESULT_WRONG, $this->sut->verify_code( $user_id, $wrong ) );
+		$this->assertSame( EmailVerificationService::RESULT_WRONG, $this->sut->verify_code( $user_id, $wrong ) );
+		$this->assertTrue( $this->sut->has_pending_code( $user_id ), 'Code should still be pending after two wrong guesses' );
+
+		$this->assertSame( EmailVerificationService::RESULT_BURNED, $this->sut->verify_code( $user_id, $wrong ) );
+		$this->assertFalse( $this->sut->has_pending_code( $user_id ), 'Code should be burned after the third wrong guess' );
+		$this->assertFalse( $this->sut->is_verified( $user_id ) );
+	}
+
+	/**
+	 * @testdox Minting a code seeds the attempts counter at the full budget so the compare-and-swap never inserts.
+	 */
+	public function test_create_code_seeds_attempts_counter(): void {
+		$user_id = wc_create_new_customer( 'init@example.com', 'inituser', 'pw' );
+
+		$this->assertSame( '', (string) Users::get_site_user_meta( $user_id, self::ATTEMPTS_META ), 'No counter before the flow starts' );
+
+		$this->sut->create_code( $user_id );
+
+		$this->assertSame( '10', (string) Users::get_site_user_meta( $user_id, self::ATTEMPTS_META ), 'The first code must seed the counter at the full budget' );
+	}
+
+	/**
+	 * @testdox Resending a code preserves the remaining-attempts count (it does not lift the lockout budget).
+	 */
+	public function test_resending_a_code_preserves_remaining_attempts(): void {
+		$user_id = wc_create_new_customer( 'resend@example.com', 'resenduser', 'pw' );
+		$code    = $this->sut->create_code( $user_id );
+
+		$this->sut->verify_code( $user_id, $this->wrong_code( $code ) );
+		$this->assertSame( '9', (string) Users::get_site_user_meta( $user_id, self::ATTEMPTS_META ), 'One wrong guess leaves nine of ten remaining' );
+
+		$this->sut->create_code( $user_id );
+
+		$this->assertSame( '9', (string) Users::get_site_user_meta( $user_id, self::ATTEMPTS_META ), 'Resending must not reset the remaining-attempts count' );
+	}
+
+	/**
+	 * @testdox Ten cumulative wrong guesses lock the user out permanently, even from a correct code.
+	 */
+	public function test_lockout_after_ten_cumulative_failures(): void {
+		$user_id = wc_create_new_customer( 'lock@example.com', 'lockuser', 'pw' );
+
+		$this->assertFalse( $this->sut->is_locked_out( $user_id ) );
+
+		$current  = null;
+		$failures = 0;
+		while ( ! $this->sut->is_locked_out( $user_id ) && $failures < 15 ) {
+			if ( ! $this->sut->has_pending_code( $user_id ) ) {
+				$current = $this->sut->create_code( $user_id );
+			}
+			$this->sut->verify_code( $user_id, $this->wrong_code( (string) $current ) );
+			++$failures;
+		}
+
+		$this->assertTrue( $this->sut->is_locked_out( $user_id ), 'User should be locked out' );
+		$this->assertSame( 10, $failures, 'Lockout should take exactly ten cumulative failures' );
+
+		// Once locked out, even the (now-deleted) correct code path returns LOCKED.
+		$this->assertSame( EmailVerificationService::RESULT_LOCKED, $this->sut->verify_code( $user_id, '123456' ) );
+	}
+
+	/**
+	 * @testdox Verifying a user (e.g. by the store owner) lifts an existing lockout.
+	 */
+	public function test_mark_verified_clears_lockout(): void {
+		$user_id = wc_create_new_customer( 'unlock@example.com', 'unlockuser', 'pw' );
+		$this->force_lockout( $user_id );
+
+		$this->sut->mark_verified( $user_id );
+
+		$this->assertFalse( $this->sut->is_locked_out( $user_id ), 'Marking verified must clear the lockout' );
+		$this->assertTrue( $this->sut->is_verified( $user_id ) );
+	}
+
+	/**
+	 * @testdox Clearing verification also lifts an existing lockout.
+	 */
+	public function test_clear_verification_clears_lockout(): void {
+		$user_id = wc_create_new_customer( 'unlock2@example.com', 'unlockuser2', 'pw' );
+		$this->force_lockout( $user_id );
+
+		$this->sut->clear_verification( $user_id );
+
+		$this->assertFalse( $this->sut->is_locked_out( $user_id ), 'Clearing verification must clear the lockout' );
+	}
+
+	/**
+	 * @testdox An expired code is reported as expired and does not count as a failed guess.
+	 */
+	public function test_expired_code_not_counted_as_failure(): void {
+		$user_id = wc_create_new_customer( 'exp@example.com', 'expuser', 'pw' );
+
+		$code = $this->sut->create_code( $user_id );
+
+		// Age the stored code past the 10-minute TTL, keeping its hash and attempt count intact.
+		$parts    = explode( ':', (string) Users::get_site_user_meta( $user_id, self::KEY_META ), 4 );
+		$parts[0] = (string) ( time() - 11 * MINUTE_IN_SECONDS );
+		Users::update_site_user_meta( $user_id, self::KEY_META, implode( ':', $parts ) );
+
+		$this->assertSame( EmailVerificationService::RESULT_EXPIRED, $this->sut->verify_code( $user_id, $code ) );
+		$this->assertFalse( $this->sut->is_locked_out( $user_id ), 'An expiry must not move the user towards lockout' );
+		$this->assertFalse( $this->sut->has_pending_code( $user_id ), 'An expired code is no longer pending' );
+	}
+
+	/**
+	 * @testdox A code is void after the account email changes, so it can't verify a different address.
+	 */
+	public function test_code_void_after_email_change(): void {
+		$user_id = wc_create_new_customer( 'issued-for@example.com', 'codechange', 'pw' );
+
+		$code = $this->sut->create_code( $user_id );
+
+		wp_update_user(
+			array(
+				'ID'         => $user_id,
+				'user_email' => 'changed-to@example.com',
+			)
+		);
+		clean_user_cache( $user_id );
+
+		$this->assertSame(
+			EmailVerificationService::RESULT_NONE,
+			$this->sut->verify_code( $user_id, $code ),
+			'A code minted for the old email must not verify the new email'
+		);
+		$this->assertFalse( $this->sut->is_verified( $user_id ) );
+	}
+
+	/**
+	 * @testdox Clearing verification should reset the user's verified status.
+	 */
+	public function test_clear_verification_resets_status(): void {
+		$user_id = wc_create_new_customer( 'e@example.com', 'usere', 'pw' );
+
+		$this->sut->mark_verified( $user_id );
+		$this->assertTrue( $this->sut->is_verified( $user_id ), 'User should be verified before clearing' );
+
+		$this->sut->clear_verification( $user_id );
+
+		$this->assertFalse( $this->sut->is_verified( $user_id ), 'User should not be verified after clearing' );
+	}
+
+	/**
+	 * @testdox A verified status self-invalidates when the account email changes.
+	 */
+	public function test_is_verified_false_after_email_change(): void {
+		$user_id = wc_create_new_customer( 'before-change@example.com', 'changeuser', 'pw' );
+
+		$this->sut->mark_verified( $user_id );
+		$this->assertTrue( $this->sut->is_verified( $user_id ), 'User should be verified for their current email' );
+
+		wp_update_user(
+			array(
+				'ID'         => $user_id,
+				'user_email' => 'after-change@example.com',
+			)
+		);
+		clean_user_cache( $user_id );
+
+		$this->assertFalse( $this->sut->is_verified( $user_id ), 'Changing the account email must invalidate verification' );
+	}
+
+	/**
+	 * @testdox A verified status is preserved across non-email profile changes.
+	 */
+	public function test_is_verified_preserved_after_non_email_change(): void {
+		$user_id = wc_create_new_customer( 'keep-verified@example.com', 'keepuser', 'pw' );
+
+		$this->sut->mark_verified( $user_id );
+
+		wp_update_user(
+			array(
+				'ID'           => $user_id,
+				'display_name' => 'Renamed Customer',
+			)
+		);
+		clean_user_cache( $user_id );
+
+		$this->assertTrue( $this->sut->is_verified( $user_id ), 'Non-email profile changes must not invalidate verification' );
+	}
+
+	/**
+	 * Return a six-digit code guaranteed to differ from the given one.
+	 *
+	 * @param string $code The code to avoid.
+	 * @return string
+	 */
+	private function wrong_code( string $code ): string {
+		return '000000' === $code ? '111111' : '000000';
+	}
+
+	/**
+	 * Drive the service into a locked-out state for the given user.
+	 *
+	 * @param int $user_id User ID.
+	 */
+	private function force_lockout( int $user_id ): void {
+		$current = null;
+		$guard   = 0;
+		while ( ! $this->sut->is_locked_out( $user_id ) && $guard < 15 ) {
+			if ( ! $this->sut->has_pending_code( $user_id ) ) {
+				$current = $this->sut->create_code( $user_id );
+			}
+			$this->sut->verify_code( $user_id, $this->wrong_code( (string) $current ) );
+			++$guard;
+		}
+	}
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/CustomerEmailVerification/Emails/CustomerVerifyEmailTest.php b/plugins/woocommerce/tests/php/src/Internal/CustomerEmailVerification/Emails/CustomerVerifyEmailTest.php
new file mode 100644
index 00000000000..0a0c41125a7
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/CustomerEmailVerification/Emails/CustomerVerifyEmailTest.php
@@ -0,0 +1,77 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\CustomerEmailVerification\Emails;
+
+use Automattic\WooCommerce\Internal\CustomerEmailVerification\Emails\CustomerVerifyEmail;
+use WC_Unit_Test_Case;
+
+/**
+ * Tests for CustomerVerifyEmail.
+ *
+ * @covers \Automattic\WooCommerce\Internal\CustomerEmailVerification\Emails\CustomerVerifyEmail
+ */
+class CustomerVerifyEmailTest extends WC_Unit_Test_Case {
+
+	/**
+	 * The System Under Test.
+	 *
+	 * @var CustomerVerifyEmail
+	 */
+	private $sut;
+
+	/**
+	 * Initialise the mailer (loads the WC_Email base class) before each test.
+	 */
+	public function setUp(): void {
+		parent::setUp();
+
+		WC()->mailer()->init();
+
+		$this->sut = new CustomerVerifyEmail();
+	}
+
+	/**
+	 * @testdox Class is registered with the WC mailer so the Settings > Emails page renders it.
+	 */
+	public function test_is_registered_with_wc_emails(): void {
+		$emails = WC()->mailer()->get_emails();
+
+		$this->assertArrayHasKey( 'WC_Email_Customer_Verify_Email', $emails );
+		$this->assertInstanceOf( CustomerVerifyEmail::class, $emails['WC_Email_Customer_Verify_Email'] );
+	}
+
+	/**
+	 * @testdox trigger() sends an email to the customer containing the verification code.
+	 */
+	public function test_trigger_sends_email_with_code(): void {
+		$user_id = wc_create_new_customer( 'verify@example.com', 'verifytestuser', 'password' );
+		$this->assertIsInt( $user_id );
+
+		$mailer = tests_retrieve_phpmailer_instance();
+		$before = count( $mailer->mock_sent );
+
+		$this->sut->trigger( $user_id, '654321' );
+
+		$after = count( $mailer->mock_sent );
+
+		$this->assertSame( $before + 1, $after, 'trigger() must dispatch exactly one email.' );
+
+		$sent = $mailer->mock_sent[ $before ];
+		$this->assertSame( 'verify@example.com', $sent['to'][0][0], 'Email must be addressed to the customer.' );
+		$this->assertStringContainsString( '654321', $sent['body'], 'Email body must contain the verification code.' );
+	}
+
+	/**
+	 * @testdox trigger() is a no-op when user_id or verify_code is missing.
+	 */
+	public function test_trigger_noop_without_args(): void {
+		$mailer = tests_retrieve_phpmailer_instance();
+		$before = count( $mailer->mock_sent );
+
+		$this->sut->trigger( 0, '' );
+
+		$after = count( $mailer->mock_sent );
+		$this->assertSame( $before, $after, 'trigger() with no args must not send any email.' );
+	}
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/CustomerEmailVerification/ImplicitVerificationTest.php b/plugins/woocommerce/tests/php/src/Internal/CustomerEmailVerification/ImplicitVerificationTest.php
new file mode 100644
index 00000000000..9322710ce17
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/CustomerEmailVerification/ImplicitVerificationTest.php
@@ -0,0 +1,50 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\CustomerEmailVerification;
+
+use Automattic\WooCommerce\Internal\CustomerEmailVerification\EmailVerificationService;
+use Automattic\WooCommerce\Internal\CustomerEmailVerification\VerificationEventListener;
+use WC_Unit_Test_Case;
+
+/**
+ * Tests for implicit email verification triggered by completed password resets.
+ */
+class ImplicitVerificationTest extends WC_Unit_Test_Case {
+
+	/**
+	 * The System Under Test.
+	 *
+	 * @var EmailVerificationService
+	 */
+	private $sut;
+
+	/**
+	 * Set up test fixtures.
+	 */
+	public function setUp(): void {
+		parent::setUp();
+		$this->sut = wc_get_container()->get( EmailVerificationService::class );
+
+		// Resolve the listener so its after_password_reset hook is registered.
+		wc_get_container()->get( VerificationEventListener::class );
+	}
+
+	/**
+	 * Completing a password reset fires the core after_password_reset action, which both
+	 * WordPress core and WooCommerce dispatch. The listener should mark the email verified.
+	 *
+	 * @testdox A completed password reset marks the customer's email as verified.
+	 */
+	public function test_after_password_reset_marks_email_verified(): void {
+		$user_id = wc_create_new_customer( 'reset@example.com', 'resetuser', 'pw' );
+		$user    = get_user_by( 'id', $user_id );
+
+		$this->assertFalse( $this->sut->is_verified( $user_id ), 'New customers should not be verified by default' );
+
+		// phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment -- Firing a core WordPress hook to exercise the listener, not defining a new hook.
+		do_action( 'after_password_reset', $user, 'newpassword123' );
+
+		$this->assertTrue( $this->sut->is_verified( $user_id ), 'Customer should be verified after a password reset' );
+	}
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/CustomerEmailVerification/MyAccountPromptTest.php b/plugins/woocommerce/tests/php/src/Internal/CustomerEmailVerification/MyAccountPromptTest.php
new file mode 100644
index 00000000000..b82886fb5a7
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/CustomerEmailVerification/MyAccountPromptTest.php
@@ -0,0 +1,379 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\CustomerEmailVerification;
+
+use Automattic\WooCommerce\Internal\CustomerEmailVerification\EmailVerificationService;
+use Automattic\WooCommerce\Internal\CustomerEmailVerification\VerificationController;
+use WC_Unit_Test_Case;
+
+/**
+ * Tests for the My Account email-verification prompt and send-trigger.
+ */
+class MyAccountPromptTest extends WC_Unit_Test_Case {
+
+	/**
+	 * The System Under Test.
+	 *
+	 * @var VerificationController
+	 */
+	private $sut;
+
+	/**
+	 * The verification service.
+	 *
+	 * @var EmailVerificationService
+	 */
+	private $service;
+
+	/**
+	 * Set up test fixtures.
+	 */
+	public function setUp(): void {
+		parent::setUp();
+		$this->service = wc_get_container()->get( EmailVerificationService::class );
+		$this->sut     = wc_get_container()->get( VerificationController::class );
+	}
+
+	/**
+	 * Tear down test fixtures.
+	 */
+	public function tearDown(): void {
+		wp_set_current_user( 0 );
+		wc_clear_notices();
+		wp_deregister_script( 'wc-customer-email-verification' );
+		parent::tearDown();
+	}
+
+	/**
+	 * Render the My Account prompt and return its HTML.
+	 *
+	 * @return string
+	 */
+	private function render_prompt(): string {
+		ob_start();
+		$this->sut->render_prompt();
+		return (string) ob_get_clean();
+	}
+
+	/**
+	 * Render the verify-account endpoint content and return its HTML.
+	 *
+	 * @return string
+	 */
+	private function render_endpoint(): string {
+		ob_start();
+		$this->sut->render_endpoint_content();
+		return (string) ob_get_clean();
+	}
+
+	/**
+	 * Drive the service into a locked-out state for the given user.
+	 *
+	 * @param int $user_id User ID.
+	 */
+	private function force_lockout( int $user_id ): void {
+		$current = null;
+		$guard   = 0;
+		while ( ! $this->service->is_locked_out( $user_id ) && $guard < 15 ) {
+			if ( ! $this->service->has_pending_code( $user_id ) ) {
+				$current = $this->service->create_code( $user_id );
+			}
+			$wrong = '000000' === $current ? '111111' : '000000';
+			$this->service->verify_code( $user_id, $wrong );
+			++$guard;
+		}
+	}
+
+	/**
+	 * Invoke handle_send_request(), trapping the wp_safe_redirect() + exit it ends with.
+	 *
+	 * handle_send_request() always finishes with wp_safe_redirect() then exit;. A
+	 * '__return_false' filter does NOT prevent that exit — it would terminate the whole
+	 * PHPUnit run, silently skipping every later test. Throwing from the wp_redirect
+	 * filter aborts control flow before exit so the test survives to assert.
+	 */
+	private function dispatch_send_request(): void {
+		$abort = static function ( $location ): void {
+			throw new \RuntimeException( esc_html( (string) $location ) );
+		};
+		add_filter( 'wp_redirect', $abort );
+		try {
+			$this->sut->handle_send_request();
+		} catch ( \RuntimeException $e ) {
+			// Expected: handle_send_request() redirects and exits.
+			unset( $e );
+		} finally {
+			remove_filter( 'wp_redirect', $abort );
+		}
+	}
+
+	// -------------------------------------------------------------------------
+	// should_show_prompt()
+	// -------------------------------------------------------------------------
+
+	/**
+	 * @testdox should_show_prompt returns false when no user is logged in.
+	 */
+	public function test_should_show_prompt_returns_false_for_logged_out_visitor(): void {
+		wp_set_current_user( 0 );
+
+		$this->assertFalse( $this->sut->should_show_prompt(), 'Logged-out visitors should not see the prompt' );
+	}
+
+	/**
+	 * @testdox should_show_prompt returns true for a logged-in unverified customer.
+	 */
+	public function test_should_show_prompt_returns_true_for_logged_in_unverified_customer(): void {
+		$user_id = wc_create_new_customer( 'prompt-unverified@example.com', 'promptunverified', 'pw' );
+		wp_set_current_user( $user_id );
+
+		$this->assertTrue( $this->sut->should_show_prompt(), 'Unverified customers should see the prompt' );
+	}
+
+	/**
+	 * @testdox should_show_prompt returns true for an unverified customer with no linkable guest orders.
+	 */
+	public function test_should_show_prompt_returns_true_without_linkable_orders(): void {
+		$user_id = wc_create_new_customer( 'prompt-no-orders@example.com', 'promptnoorders', 'pw' );
+		wp_set_current_user( $user_id );
+
+		$this->assertTrue( $this->sut->should_show_prompt(), 'Prompt visibility must not reveal whether matching guest orders exist' );
+	}
+
+	/**
+	 * @testdox should_show_prompt returns false for an account using a temporary password.
+	 */
+	public function test_should_show_prompt_returns_false_with_temporary_password(): void {
+		$user_id = wc_create_new_customer( 'temp-pass@example.com', 'temppassuser', 'pw' );
+		wp_set_current_user( $user_id );
+
+		update_user_option( $user_id, 'default_password_nag', true, true );
+
+		$this->assertFalse( $this->sut->should_show_prompt(), 'Temp-password accounts confirm via their set-password link, so the prompt is suppressed' );
+	}
+
+	/**
+	 * @testdox should_show_prompt returns false for a logged-in customer whose email is verified.
+	 */
+	public function test_should_show_prompt_returns_false_for_verified_customer(): void {
+		$user_id = wc_create_new_customer( 'prompt-verified@example.com', 'promptverified', 'pw' );
+		wp_set_current_user( $user_id );
+		$this->service->mark_verified( $user_id );
+
+		$this->assertFalse( $this->sut->should_show_prompt(), 'Verified customers should not see the prompt' );
+	}
+
+	// -------------------------------------------------------------------------
+	// render_prompt()
+	// -------------------------------------------------------------------------
+
+	/**
+	 * @testdox The prompt shows a send-code call to action when no code is pending.
+	 */
+	public function test_prompt_renders_send_cta_when_no_code(): void {
+		$user_id = wc_create_new_customer( 'cta-prompt@example.com', 'ctapromptuser', 'pw' );
+		wp_set_current_user( $user_id );
+
+		$html = $this->render_prompt();
+
+		$this->assertStringContainsString( 'wc_send_verification', $html, 'A prompt with no pending code should carry the send-code action.' );
+		$this->assertStringNotContainsString( 'name="wc_verify_email_code"', $html, 'No entry form should show before a code is sent.' );
+	}
+
+	/**
+	 * @testdox The orders prompt links to the /orders/verify/ sub-page (not the form) when a code is pending.
+	 */
+	public function test_orders_prompt_links_to_endpoint_when_pending(): void {
+		$user_id = wc_create_new_customer( 'inbox-prompt@example.com', 'inboxpromptuser', 'pw' );
+		wp_set_current_user( $user_id );
+
+		// A code was just sent.
+		$this->service->create_code( $user_id );
+
+		$html         = $this->render_prompt();
+		$expected_url = wc_get_endpoint_url( 'orders', 'verify', wc_get_page_permalink( 'myaccount' ) );
+
+		$this->assertStringContainsString( esc_url( $expected_url ), $html, 'The pending notice should point to the /orders/verify/ sub-page.' );
+		$this->assertStringNotContainsString( 'name="wc_verify_email_code"', $html, 'The form must not render on the orders panel.' );
+		$this->assertFalse( wp_script_is( 'wc-customer-email-verification', 'enqueued' ), 'The orders notice must not enqueue the form script.' );
+	}
+
+	// -------------------------------------------------------------------------
+	// render_endpoint_content()
+	// -------------------------------------------------------------------------
+
+	/**
+	 * @testdox The verify-account endpoint renders the code-entry form and enqueues its script when a code is pending.
+	 */
+	public function test_endpoint_renders_code_form_when_pending(): void {
+		$user_id = wc_create_new_customer( 'endpoint-form@example.com', 'endpointformuser', 'pw' );
+		wp_set_current_user( $user_id );
+
+		$this->service->create_code( $user_id );
+
+		$html = $this->render_endpoint();
+
+		$this->assertStringContainsString( 'name="wc_verify_email_code"', $html, 'A pending code should surface the entry form on the endpoint.' );
+		$this->assertStringContainsString( 'type="hidden" name="wc_verify_email_submit"', $html, 'The submit marker must be a hidden field so the form stays routable regardless of the submit button state.' );
+		$this->assertTrue( wp_script_is( 'wc-customer-email-verification', 'enqueued' ), 'Rendering the endpoint form should enqueue its enhancement script.' );
+	}
+
+	/**
+	 * @testdox The verify-account endpoint shows the contact-the-owner message once the user is locked out.
+	 */
+	public function test_endpoint_renders_locked_message_when_locked_out(): void {
+		$user_id = wc_create_new_customer( 'endpoint-locked@example.com', 'endpointlocked', 'pw' );
+		wp_set_current_user( $user_id );
+		$this->force_lockout( $user_id );
+
+		$html = $this->render_endpoint();
+
+		$this->assertStringContainsString( 'store owner', $html, 'A locked-out user should be told to contact the store owner.' );
+		$this->assertStringNotContainsString( 'name="wc_verify_email_code"', $html, 'A locked-out user must not see the entry form.' );
+	}
+
+	/**
+	 * @testdox The prompt shows a contact-the-owner message once the user is locked out.
+	 */
+	public function test_prompt_renders_locked_message_when_locked_out(): void {
+		$user_id = wc_create_new_customer( 'locked-prompt@example.com', 'lockedpromptuser', 'pw' );
+		wp_set_current_user( $user_id );
+
+		$this->force_lockout( $user_id );
+
+		$html = $this->render_prompt();
+
+		$this->assertStringContainsString( 'store owner', $html, 'A locked-out user should be told to contact the store owner.' );
+		$this->assertStringNotContainsString( 'name="wc_verify_email_code"', $html, 'A locked-out user must not see the entry form.' );
+	}
+
+	// -------------------------------------------------------------------------
+	// handle_send_request()
+	// -------------------------------------------------------------------------
+
+	/**
+	 * @testdox handle_send_request dispatches the verify-email notification when called with a valid nonce.
+	 */
+	public function test_handle_send_request_dispatches_notification_for_valid_nonce(): void {
+		$user_id = wc_create_new_customer( 'send-trigger@example.com', 'sendtrigger', 'pw' );
+		wp_set_current_user( $user_id );
+
+		$_GET['_wpnonce'] = wp_create_nonce( 'woocommerce-send-verification-email' );
+
+		$notification_fired = false;
+		$listener           = static function () use ( &$notification_fired ) {
+			$notification_fired = true;
+		};
+		add_action( 'woocommerce_customer_verify_email_notification', $listener );
+
+		$this->dispatch_send_request();
+
+		remove_action( 'woocommerce_customer_verify_email_notification', $listener );
+		unset( $_GET['_wpnonce'] );
+
+		$this->assertTrue( $notification_fired, 'Notification hook should fire for a valid send request' );
+	}
+
+	/**
+	 * @testdox handle_send_request does not dispatch the notification when the nonce is invalid.
+	 */
+	public function test_handle_send_request_rejects_invalid_nonce(): void {
+		$user_id = wc_create_new_customer( 'bad-nonce@example.com', 'badnonceuser', 'pw' );
+		wp_set_current_user( $user_id );
+
+		$_GET['_wpnonce'] = 'not-a-valid-nonce';
+
+		$notification_fired = false;
+		$listener           = static function () use ( &$notification_fired ) {
+			$notification_fired = true;
+		};
+		add_action( 'woocommerce_customer_verify_email_notification', $listener );
+
+		$this->dispatch_send_request();
+
+		remove_action( 'woocommerce_customer_verify_email_notification', $listener );
+		unset( $_GET['_wpnonce'] );
+
+		$this->assertFalse( $notification_fired, 'Notification hook should not fire when the nonce is invalid' );
+	}
+
+	/**
+	 * @testdox handle_send_request suppresses a second send within the rate-limit window and tells the customer why.
+	 */
+	public function test_handle_send_request_suppresses_immediate_resend(): void {
+		$user_id = wc_create_new_customer( 'rate-limit@example.com', 'ratelimituser', 'pw' );
+		wp_set_current_user( $user_id );
+
+		$notification_count = 0;
+		$listener           = static function () use ( &$notification_count ) {
+			++$notification_count;
+		};
+		add_action( 'woocommerce_customer_verify_email_notification', $listener );
+
+		// First send (no existing code).
+		$_GET['_wpnonce'] = wp_create_nonce( 'woocommerce-send-verification-email' );
+		$this->dispatch_send_request();
+
+		// Second send — code was just created (seconds_since_last_key < 60).
+		$_GET['_wpnonce'] = wp_create_nonce( 'woocommerce-send-verification-email' );
+		$this->dispatch_send_request();
+
+		remove_action( 'woocommerce_customer_verify_email_notification', $listener );
+		unset( $_GET['_wpnonce'] );
+
+		$this->assertSame( 1, $notification_count, 'Notification should fire exactly once despite two send attempts within the rate-limit window' );
+		$this->assertCount( 1, wc_get_notices( 'notice' ), 'A rate-limited resend must surface an informational notice instead of failing silently.' );
+	}
+
+	/**
+	 * @testdox handle_send_request does not mint a new code for a locked-out user.
+	 */
+	public function test_handle_send_request_does_not_mint_when_locked_out(): void {
+		$user_id = wc_create_new_customer( 'locked-send@example.com', 'lockedsenduser', 'pw' );
+		$this->force_lockout( $user_id );
+		wp_set_current_user( $user_id );
+
+		$notification_fired = false;
+		$listener           = static function () use ( &$notification_fired ) {
+			$notification_fired = true;
+		};
+		add_action( 'woocommerce_customer_verify_email_notification', $listener );
+
+		$_GET['_wpnonce'] = wp_create_nonce( 'woocommerce-send-verification-email' );
+		$this->dispatch_send_request();
+
+		remove_action( 'woocommerce_customer_verify_email_notification', $listener );
+		unset( $_GET['_wpnonce'] );
+
+		$this->assertFalse( $notification_fired, 'A locked-out user must not be able to mint fresh codes' );
+	}
+
+	// -------------------------------------------------------------------------
+	// EmailVerificationService::seconds_since_last_key()
+	// -------------------------------------------------------------------------
+
+	/**
+	 * @testdox seconds_since_last_key returns null when no code has been issued.
+	 */
+	public function test_seconds_since_last_key_returns_null_with_no_key(): void {
+		$user_id = wc_create_new_customer( 'nokey@example.com', 'nokeyuser', 'pw' );
+
+		$this->assertNull( $this->service->seconds_since_last_key( $user_id ), 'Should return null when no code has been issued' );
+	}
+
+	/**
+	 * @testdox seconds_since_last_key returns a small non-negative integer immediately after code creation.
+	 */
+	public function test_seconds_since_last_key_returns_small_value_after_key_creation(): void {
+		$user_id = wc_create_new_customer( 'freshkey@example.com', 'freshkeyuser', 'pw' );
+		$this->service->create_code( $user_id );
+
+		$elapsed = $this->service->seconds_since_last_key( $user_id );
+
+		$this->assertNotNull( $elapsed, 'Should return an integer after code creation' );
+		$this->assertGreaterThanOrEqual( 0, $elapsed, 'Elapsed time should never be negative' );
+		// Generous upper bound: proves a real, recent elapsed value without being flaky on a slow runner.
+		$this->assertLessThan( 60, $elapsed, 'Elapsed time should be well within the rate-limit window after code creation' );
+	}
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/CustomerEmailVerification/VerificationControllerTest.php b/plugins/woocommerce/tests/php/src/Internal/CustomerEmailVerification/VerificationControllerTest.php
new file mode 100644
index 00000000000..69f67e53345
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/CustomerEmailVerification/VerificationControllerTest.php
@@ -0,0 +1,278 @@
+<?php
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\CustomerEmailVerification;
+
+use Automattic\WooCommerce\Internal\CustomerEmailVerification\EmailVerificationService;
+use Automattic\WooCommerce\Internal\CustomerEmailVerification\VerificationController;
+use WC_Unit_Test_Case;
+
+/**
+ * Tests for the VerificationController class.
+ */
+class VerificationControllerTest extends WC_Unit_Test_Case {
+
+	/**
+	 * The System Under Test.
+	 *
+	 * @var VerificationController
+	 */
+	private $ctrl;
+
+	/**
+	 * The verification service.
+	 *
+	 * @var EmailVerificationService
+	 */
+	private $service;
+
+	/**
+	 * Set up test fixtures.
+	 */
+	public function setUp(): void {
+		parent::setUp();
+		$this->service = wc_get_container()->get( EmailVerificationService::class );
+		// Resolving the controller also triggers its constructor (hooks) and init() (deps).
+		$this->ctrl = wc_get_container()->get( VerificationController::class );
+		// Link guest orders on verification (the boot wires this in production; register it here for the test).
+		add_action( 'woocommerce_customer_email_verified', 'wc_update_new_customer_past_orders' );
+	}
+
+	/**
+	 * @testdox send_verification_email() emits a six-digit code, and submitting it verifies and links orders.
+	 */
+	public function test_send_then_submit_code_links_orders(): void {
+		$email   = 'roundtrip@example.com';
+		$user_id = wc_create_new_customer( $email, 'roundtripuser', 'pw' );
+		$order   = $this->create_guest_order( $email );
+
+		$code = $this->capture_sent_code( $user_id );
+		$this->assertMatchesRegularExpression( '/^\d{6}$/', $code, 'The emailed value should be a six-digit code' );
+
+		wp_set_current_user( $user_id );
+		$redirect        = $this->submit_code( $code, wp_create_nonce( 'woocommerce-verify-email' ) );
+		$success_notices = wc_get_notices( 'success' );
+		wc_clear_notices();
+		wp_set_current_user( 0 );
+
+		$this->assertTrue( $this->service->is_verified( $user_id ), 'A correct code should verify the address' );
+		$this->assertSame( $user_id, wc_get_order( $order->get_id() )->get_customer_id(), 'Guest order should link to the verified customer' );
+		$this->assertNotEmpty( $success_notices, 'A confirmation notice should be shown' );
+		$this->assertStringContainsString( 'orders', $redirect, 'Should redirect to the Orders endpoint' );
+	}
+
+	/**
+	 * @testdox A wrong code shows an error, does not verify, and leaves the code pending.
+	 */
+	public function test_wrong_code_errors_and_keeps_pending(): void {
+		$user_id = wc_create_new_customer( 'wrong@example.com', 'wronguser', 'pw' );
+		$code    = $this->service->create_code( $user_id );
+		$wrong   = '000000' === $code ? '111111' : '000000';
+
+		wp_set_current_user( $user_id );
+		$this->submit_code( $wrong, wp_create_nonce( 'woocommerce-verify-email' ) );
+		$error_notices = wc_get_notices( 'error' );
+		wc_clear_notices();
+		wp_set_current_user( 0 );
+
+		$this->assertFalse( $this->service->is_verified( $user_id ), 'A wrong code must not verify' );
+		$this->assertTrue( $this->service->has_pending_code( $user_id ), 'A wrong code (attempts remaining) must stay pending' );
+		$this->assertNotEmpty( $error_notices, 'A wrong code should produce an error notice' );
+	}
+
+	/**
+	 * @testdox A submission with an invalid nonce does not verify or consume the code.
+	 */
+	public function test_submission_requires_valid_nonce(): void {
+		$user_id = wc_create_new_customer( 'bad-nonce@example.com', 'badnonce', 'pw' );
+		$code    = $this->service->create_code( $user_id );
+
+		wp_set_current_user( $user_id );
+		$this->submit_code( $code, 'not-a-valid-nonce' );
+		$error_notices = wc_get_notices( 'error' );
+		wc_clear_notices();
+		wp_set_current_user( 0 );
+
+		$this->assertFalse( $this->service->is_verified( $user_id ), 'An invalid nonce must not verify the address' );
+		$this->assertTrue( $this->service->has_pending_code( $user_id ), 'An invalid nonce must not consume the code' );
+		$this->assertNotEmpty( $error_notices, 'An invalid request should produce an error notice' );
+	}
+
+	/**
+	 * @testdox A code submission with no logged-in user does not verify.
+	 */
+	public function test_submission_without_session_does_not_verify(): void {
+		$user_id = wc_create_new_customer( 'nosession@example.com', 'nosession', 'pw' );
+		$code    = $this->service->create_code( $user_id );
+
+		wp_set_current_user( 0 );
+		$redirect = $this->submit_code( $code, wp_create_nonce( 'woocommerce-verify-email' ) );
+		wc_clear_notices();
+
+		$this->assertFalse( $this->service->is_verified( $user_id ), 'A logged-out submission must not verify anyone' );
+		$this->assertStringContainsString( 'orders', $redirect, 'Should redirect to the Orders endpoint' );
+	}
+
+	/**
+	 * @testdox Submitting twice after success shows success again, not a stale error.
+	 */
+	public function test_double_submission_does_not_error(): void {
+		$user_id = wc_create_new_customer( 'double@example.com', 'doubleuser', 'pw' );
+		$code    = $this->service->create_code( $user_id );
+
+		wp_set_current_user( $user_id );
+		$nonce = wp_create_nonce( 'woocommerce-verify-email' );
+		$this->submit_code( $code, $nonce );
+		$this->submit_code( $code, $nonce );
+		$error_notices   = wc_get_notices( 'error' );
+		$success_notices = wc_get_notices( 'success' );
+		wc_clear_notices();
+		wp_set_current_user( 0 );
+
+		$this->assertTrue( $this->service->is_verified( $user_id ), 'The first submission should verify the address' );
+		$this->assertEmpty( $error_notices, 'A repeat submission once verified must not error' );
+		$this->assertCount( 2, $success_notices, 'Each submission once verified should report success' );
+	}
+
+	/**
+	 * @testdox The orders endpoint renders the verification form for the "verify" value and nothing otherwise.
+	 */
+	public function test_orders_endpoint_renders_form_on_verify_value(): void {
+		$user_id = wc_create_new_customer( 'orders-verify@example.com', 'ordersverify', 'pw' );
+		wp_set_current_user( $user_id );
+		$this->service->create_code( $user_id );
+
+		// A page-number value is a no-op (the orders list renders as usual).
+		ob_start();
+		$this->ctrl->maybe_render_on_orders_endpoint( '2' );
+		$this->assertSame( '', ob_get_clean(), 'A normal page value must not render the verification form.' );
+
+		// The reserved "verify" value renders the form.
+		ob_start();
+		$this->ctrl->maybe_render_on_orders_endpoint( 'verify' );
+		$html = (string) ob_get_clean();
+
+		// Restore the core orders-list callback the verify branch removed.
+		add_action( 'woocommerce_account_orders_endpoint', 'woocommerce_account_orders' );
+		wp_set_current_user( 0 );
+
+		$this->assertStringContainsString( 'name="wc_verify_email_code"', $html, 'The /orders/verify/ sub-page must render the entry form.' );
+	}
+
+	/**
+	 * @testdox The verify sub-page title is normalised to "Orders" instead of the default "Orders (Page 0)".
+	 */
+	public function test_orders_title_on_verify_subpage(): void {
+		// The sub-page reuses the orders pagination slot, so WooCommerce titles it "Orders (Page 0)";
+		// the filter forces a clean "Orders".
+		set_query_var( 'orders', 'verify' );
+		$this->assertSame( 'Orders', $this->ctrl->maybe_filter_orders_title( 'Orders (Page 0)' ) );
+
+		set_query_var( 'orders', '2' );
+		$this->assertSame( 'Orders (Page 2)', $this->ctrl->maybe_filter_orders_title( 'Orders (Page 2)' ), 'A normal orders page keeps its title.' );
+
+		set_query_var( 'orders', '' );
+	}
+
+	/**
+	 * @testdox A verified customer who lands on the /orders/verify/ sub-page is redirected to orders.
+	 */
+	public function test_verify_subpage_redirects_verified_user_to_orders(): void {
+		$user_id = wc_create_new_customer( 'verified-endpoint@example.com', 'verifiedendpoint', 'pw' );
+		$this->service->mark_verified( $user_id );
+		wp_set_current_user( $user_id );
+
+		// Simulate a request on /orders/verify/ (get_query_var reads from $wp_query).
+		set_query_var( 'orders', 'verify' );
+
+		$redirect = '';
+		$abort    = static function ( $location ) {
+			throw new \RuntimeException( esc_html( (string) $location ) );
+		};
+		add_filter( 'wp_redirect', $abort );
+		try {
+			$this->ctrl->maybe_process_request();
+		} catch ( \RuntimeException $e ) {
+			$redirect = $e->getMessage();
+		} finally {
+			remove_filter( 'wp_redirect', $abort );
+			set_query_var( 'orders', '' );
+			wp_set_current_user( 0 );
+		}
+
+		$this->assertStringContainsString( 'orders', $redirect, 'A verified user with nothing to verify should be bounced to orders.' );
+	}
+
+	/**
+	 * Capture the code emitted by send_verification_email().
+	 *
+	 * @param int $user_id User to send to.
+	 * @return string The captured code.
+	 */
+	private function capture_sent_code( int $user_id ): string {
+		$captured = '';
+		$listener = static function ( $uid, $code ) use ( &$captured ) {
+			// The $uid arg is unused but required by the two-argument hook signature.
+			unset( $uid );
+			$captured = $code;
+		};
+		add_action( 'woocommerce_customer_verify_email_notification', $listener, 10, 2 );
+		$this->ctrl->send_verification_email( $user_id );
+		remove_action( 'woocommerce_customer_verify_email_notification', $listener, 10 );
+
+		return (string) $captured;
+	}
+
+	/**
+	 * Create a guest order with the given billing email (linkable to a matching customer).
+	 *
+	 * @param string $email Billing email.
+	 * @return \WC_Order
+	 */
+	private function create_guest_order( string $email ): \WC_Order {
+		$order = \WC_Helper_Order::create_order( 0 );
+		$order->set_billing_email( $email );
+		$order->set_customer_id( 0 );
+		$order->save();
+
+		return $order;
+	}
+
+	/**
+	 * Drive a code-form POST through the controller, returning the captured redirect target.
+	 *
+	 * handle_code_submission() ends in wp_safe_redirect()/exit; a filter throws the redirect target
+	 * so the exit is never reached and the test can assert on the outcome.
+	 *
+	 * @param string $code  Code to submit.
+	 * @param string $nonce Nonce value to submit.
+	 * @return string The redirect location the handler attempted.
+	 */
+	private function submit_code( string $code, string $nonce ): string {
+		$_SERVER['REQUEST_METHOD']       = 'POST';
+		$_POST['wc_verify_email_submit'] = '1';
+		$_POST['_wpnonce']               = $nonce;
+		$_POST['wc_verify_email_code']   = $code;
+
+		$redirect = '';
+		$abort    = static function ( $location ) {
+			throw new \RuntimeException( esc_html( (string) $location ) );
+		};
+		add_filter( 'wp_redirect', $abort );
+		try {
+			$this->ctrl->maybe_process_request();
+		} catch ( \RuntimeException $e ) {
+			$redirect = $e->getMessage();
+		} finally {
+			remove_filter( 'wp_redirect', $abort );
+			$_SERVER['REQUEST_METHOD'] = 'GET';
+			unset(
+				$_POST['wc_verify_email_submit'],
+				$_POST['_wpnonce'],
+				$_POST['wc_verify_email_code']
+			);
+		}
+
+		return $redirect;
+	}
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/EmailEditor/WCTransactionalEmails/WCTransactionalEmailsTest.php b/plugins/woocommerce/tests/php/src/Internal/EmailEditor/WCTransactionalEmails/WCTransactionalEmailsTest.php
index de6f5d95759..0b7cd16181a 100644
--- a/plugins/woocommerce/tests/php/src/Internal/EmailEditor/WCTransactionalEmails/WCTransactionalEmailsTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/EmailEditor/WCTransactionalEmails/WCTransactionalEmailsTest.php
@@ -52,6 +52,7 @@ class WCTransactionalEmailsTest extends \WC_Unit_Test_Case {

 		$this->assertIsArray( $emails );
 		$this->assertContains( 'customer_new_account', $emails );
+		$this->assertContains( 'customer_verify_email', $emails );
 		$this->assertContains( 'customer_completed_order', $emails );
 		$this->assertContains( 'customer_processing_order', $emails );
 		$this->assertContains( 'customer_pos_completed_order', $emails );