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 );