*/ private static $registered_this_request = array(); /** * Result of the verification mail sent for each account registered during * this request ('sent', 'throttled', 'failed', ...). Request-scoped; read * by the My Account registration notice so it only claims a link was sent * when one really was. * * @var array */ private static $register_mail_result = array(); /** * True while WooCommerce's own My Account registration form is being * processed. user_register also fires for checkout-created accounts, admin * created users, imports and REST, none of which should get the * "welcome back on My Account" notice, so the notice is gated on this. * * @var bool */ private static $wc_registration_form = false; /** * Registration notice waiting for a session that will survive the request * (see flush_registration_notice()): array( user ID, mail result ) or null. * * @var array{0:int,1:string}|null */ private static $pending_registration_notice = null; /** * Set once the My Account notice has been printed in this request. * * @var bool */ private static $account_notice_printed = false; /** * User whose address change is being applied by the "change address while * resending" form right now; on_profile_update() leaves that one alone * because the form sends the mail itself, under the resend wait. * * @var int */ private static $profile_update_mail_suppressed = 0; /** * Verification link opened by somebody other than the account holder in this * browser: array( user ID, token ) while the confirm page is being shown. * * @var array{0:int,1:string}|null */ private static $pending_confirm = null; /** @var WPCEV_Email_Change|null */ private $email_change = null; public function __construct( WPCEV_Settings $settings, WPCEV_Tokens $tokens, WPCEV_Mailer $mailer ) { $this->settings = $settings; $this->tokens = $tokens; $this->mailer = $mailer; if ( ! $this->settings->get( 'enabled' ) ) { return; } // New registrations: issue token + send email (skip exempt roles). add_action( 'user_register', array( $this, 'on_user_register' ), 20, 1 ); // Marks the request as a WooCommerce My Account registration, so the // customer gets a "check your inbox" notice afterwards. Pass-through // filter: it validates nothing and returns the errors untouched. add_filter( 'woocommerce_process_registration_errors', array( $this, 'note_wc_registration_form' ), 5, 1 ); add_filter( 'woocommerce_registration_redirect', array( $this, 'flush_registration_notice_on_redirect' ), 10, 1 ); add_action( 'wp_loaded', array( $this, 'flush_registration_notice' ), 30 ); // Email-change reset: when a user changes their email, drop verification // and re-issue. Covers WP profile, WC My Account, and admin-edited users. add_action( 'profile_update', array( $this, 'on_profile_update' ), 20, 2 ); // Multisite signup activation backstop — see on_multisite_activate(). if ( is_multisite() ) { add_action( 'wpmu_activate_user', array( $this, 'on_multisite_activate' ), 5, 1 ); } // Verify endpoint — earliest possible to short-circuit before output. add_action( 'init', array( $this, 'maybe_handle_verify' ), 5 ); // Resend trigger + post-redirect notices. add_action( 'wp_loaded', array( $this, 'maybe_handle_resend' ) ); add_action( 'wp_loaded', array( $this, 'maybe_show_redirect_notices' ) ); // "Wrong address? Change it" form in the My Account notice (POST). add_action( 'wp_loaded', array( $this, 'maybe_handle_email_change' ), 11 ); // A password reset needs the inbox, so it proves the address too. Core and // WooCommerce both signal it (WooCommerce 11 also fires the core hook; the // second call finds nothing left to do). add_action( 'after_password_reset', array( $this, 'on_password_reset' ), 10, 1 ); add_action( 'woocommerce_customer_reset_password', array( $this, 'on_password_reset' ), 10, 1 ); /* * WooCommerce 11 ships its own customer email verification (My Account > * Orders prompt, its own mail and meta). Two prompts asking the same thing * would confuse customers, so ours is the only one while we are enabled. * The filter exists from WooCommerce 11.1; on 11.0 the prompt can still * show, which the conflict check reports. */ add_filter( 'woocommerce_customer_email_verification_should_show_prompt', '__return_false', 99 ); // If WooCommerce itself verifies the customer (they used its link), they // are verified here too instead of staying blocked by this plugin. add_action( 'woocommerce_customer_email_verified', array( $this, 'on_wc_email_verified' ), 10, 1 ); // Like WooCommerce does after its own verification: once the inbox is // proven, earlier guest orders placed with this address join the account. add_action( 'wpcev_email_confirmed', array( $this, 'link_guest_orders' ), 10, 2 ); // Force-logout for users whose flag flips mid-session (opt-in). if ( $this->settings->get( 'force_logout_on_unverify' ) ) { add_action( 'init', array( $this, 'maybe_force_logout' ), 20 ); } // Login restriction. Priority 5 so we win against 2FA plugins that hook the // same filter at the default priority — verifying email is a // precondition for 2FA mattering anyway. if ( $this->settings->get( 'block_login' ) ) { // 2 args — the password is needed to avoid leaking account existence. add_filter( 'wp_authenticate_user', array( $this, 'block_login_unverified' ), 5, 2 ); // Application Passwords (REST API auth). add_filter( 'wp_authenticate_application_password_errors', array( $this, 'block_app_password_unverified' ), 10, 2 ); /* * WooCommerce signs a new customer straight in after registration * with wc_set_customer_auth_cookie(), which never goes through * wp_authenticate_user — so with block_login on, a brand-new * unverified account still ended up with a live session. This * filter is WooCommerce's own switch for that auto-login. */ add_filter( 'woocommerce_registration_auth_new_customer', array( $this, 'block_wc_registration_autologin' ), 10, 2 ); } // Checkout restriction (classic + blocks + pay-for-order). if ( $this->settings->get( 'block_checkout' ) ) { add_action( 'woocommerce_after_checkout_validation', array( $this, 'block_classic_checkout' ), 10, 2 ); /* * Classic cart + checkout UX. The server-side gate above is what * actually stops the order; these make the reason visible up front * instead of after a failed submit, and swap the Place order button * for a disabled one. Priority 9 puts the notice above WooCommerce's * own notices wrapper (priority 10). The Blocks pages need neither: * WooCommerce renders our Store API cart error as a notice there * (see block_blocks_cart()). */ add_action( 'woocommerce_before_cart', array( $this, 'render_order_blocked_notice' ), 9 ); add_action( 'woocommerce_before_checkout_form', array( $this, 'render_order_blocked_notice' ), 9 ); add_filter( 'woocommerce_order_button_html', array( $this, 'disable_order_button' ), 99 ); /* * Cart/Checkout Blocks (Store API). Two hooks, deliberately. * * `woocommerce_store_api_cart_errors` is the primary gate. It fires * from CartController::validate_cart(), which the Store API runs * BEFORE the draft order is finalised and before payment, so an * unverified customer is stopped without ever producing an order. * It also feeds CartSchema, so the Cart block shows the reason up * front instead of failing at the last step. This is WooCommerce's * documented cart-validation pattern, present since WC 7.2 and so * comfortably inside our WC 9.2 floor. * * `woocommerce_store_api_checkout_order_processed` stays as a * backstop for any checkout path that reaches order creation * without going through validate_cart(). * * The class_exists() guard is not a WC-version check — the floor * guarantees both hooks. It guards the case where WooCommerce is * not active at all, since this plugin also runs WP-only. */ if ( class_exists( '\Automattic\WooCommerce\StoreApi\Exceptions\RouteException' ) ) { add_action( 'woocommerce_store_api_cart_errors', array( $this, 'block_blocks_cart' ), 10, 2 ); add_action( 'woocommerce_store_api_checkout_order_processed', array( $this, 'block_blocks_checkout' ), 10, 1 ); /* * Before WooCommerce 10.8 the Checkout block creates a * `checkout-draft` order as soon as the page loads (GET) and on * every address change (PUT), without running cart validation, so * the cart-errors hook above never sees it. Refuse those calls * for an unverified customer so no order row of any status is * created. Newer WooCommerce no longer creates drafts there, so * this changes nothing in practice on those versions. * WooCommerce 8.9 to 10.7 build the page-load response through * Blocks hydration instead, which has its own filter (below). */ add_filter( 'rest_pre_dispatch', array( $this, 'block_blocks_draft_order' ), 10, 3 ); // WooCommerce 8.9+ also builds the Checkout block's first response without rest_pre_dispatch. add_filter( 'woocommerce_hydration_dispatch_request', array( $this, 'block_hydration_draft_order' ), 10, 2 ); } // Pay-for-order URL. /* * Pay-for-order must be gated on `wp`, not `template_redirect`. * WooCommerce processes the pay form in WC_Form_Handler::pay_action(), * hooked to `wp` at priority 20 — and `wp` fires before * `template_redirect`, so a gate there ran after payment had already * been taken. Priority 15 puts us ahead of WC's handler. */ add_action( 'wp', array( $this, 'block_pay_for_order' ), 15 ); } // Refuse account creation at the checkout step. if ( $this->settings->get( 'block_checkout' ) && $this->settings->get( 'block_registration_at_checkout' ) ) { add_action( 'woocommerce_after_checkout_validation', array( $this, 'block_registration_at_checkout' ), 5, 2 ); /* * Blocks equivalent. The classic hook above never runs on the Store * API, so this control silently did nothing on a Blocks checkout * while the admin UI claimed it closed the gap. * * `woocommerce_store_api_checkout_update_customer_from_request` * (WC 8.2+) fires from update_customer_from_request(), which the * checkout route runs before process_customer() — the method that * actually calls wc_create_new_customer(). Throwing here refuses the * signup before any user row or auth cookie exists. */ if ( class_exists( '\Automattic\WooCommerce\StoreApi\Exceptions\RouteException' ) ) { add_action( 'woocommerce_store_api_checkout_update_customer_from_request', array( $this, 'block_blocks_registration' ), 10, 2 ); } } // Admin-area restriction — bounce blocked users out of wp-admin, but never // during AJAX (so the resend handler still works) or during the // activation request itself. if ( $this->settings->get( 'block_admin_area' ) ) { add_action( 'admin_init', array( $this, 'block_admin_area' ), 1 ); } // Profile-email-change restriction — refuse to save an email change while // the user is still unverified. if ( $this->settings->get( 'block_profile_email_change' ) ) { add_action( 'user_profile_update_errors', array( $this, 'block_profile_email_change' ), 10, 3 ); // WooCommerce saves account details on its own hook. add_action( 'woocommerce_save_account_details_errors', array( $this, 'block_wc_email_change' ), 10, 2 ); } // Capability-based blocking — drop a configured list of capabilities // while a user is blocked (e.g. publish_posts, comment_post). if ( $this->settings->get( 'restrict_capabilities' ) ) { add_filter( 'user_has_cap', array( $this, 'restrict_user_capabilities' ), 10, 4 ); } // Comment / product-review restriction (off by default). if ( $this->settings->get( 'block_comments' ) ) { add_filter( 'preprocess_comment', array( $this, 'block_comment_unverified' ) ); // REST API comments (wp/v2/comments) answer with a proper 403 JSON error // here, before preprocess_comment would wp_die() with an HTML page. add_filter( 'rest_pre_insert_comment', array( $this, 'block_rest_comment' ), 10, 2 ); } /* * My Account notice, on every endpoint. * * `woocommerce_before_my_account` is deprecated and only fires inside the * dashboard template, so the old banner appeared on one tab, below the * content. `woocommerce_before_account_navigation` fires on every endpoint * (dashboard, orders, addresses, account details, third-party tabs), above * the navigation and full width, so on a phone, where the navigation is * stacked above the content, the notice is still the first thing on the * page. A theme that drops the navigation template never fires it, so * `woocommerce_account_content` (priority 4, above WooCommerce's own * notices wrapper at 5) is the fallback. render_account_notice() prints at * most once per request, so there is exactly one notice per page. */ add_action( 'woocommerce_before_account_navigation', array( $this, 'render_account_notice' ), 5 ); add_action( 'woocommerce_account_content', array( $this, 'render_account_notice' ), 4 ); // Non-WC fallback banners. add_action( 'wp_body_open', array( $this, 'render_frontend_banner' ) ); add_action( 'admin_notices', array( $this, 'render_admin_banner' ) ); // Public filter for third-party form/login plugins. add_filter( 'wpcev_is_verified', array( $this, 'public_is_verified_filter' ), 10, 2 ); } /* ─── Registration ───────────────────────────────────────────────────── */ public function on_user_register( $user_id ) { $user = get_userdata( (int) $user_id ); if ( ! $user ) { return; } // Remembered for the rest of this request so the Blocks checkout gate // can give a just-created account the same grace classic checkout does. self::$registered_this_request[] = (int) $user_id; /** * Allow integrations (SSO plugins, internal user creation flows) to * skip verification for a freshly-registered user. When this filter * returns true, the user is auto-marked verified and no email is * sent. * * @param bool $skip Default false. * @param int $user_id */ if ( apply_filters( 'wpcev_skip_verification_on_register', false, (int) $user_id ) ) { $this->tokens->mark_verified( $user_id, 'filter' ); return; } if ( $this->tokens->is_role_exempt( $user_id ) ) { $this->tokens->mark_verified( $user_id, 'exempt' ); return; } // Multisite signup activation: WP's own signup flow already proved the // address (wpmu_activate_signup() validates the key against the signups // table before creating the user), so don't ask a second time. if ( is_multisite() && $this->is_multisite_activation_request() ) { $this->tokens->mark_verified( $user_id, 'multisite' ); return; } /* * Exactly one verification mail per registration: if this account was * already handled earlier in the request (another plugin re-firing * user_register for the same ID), don't wipe the token and mail again — * that would invalidate the link already in the inbox. */ $user_id = (int) $user_id; if ( isset( self::$register_mail_result[ $user_id ] ) ) { return; } $this->tokens->unmark_verified( $user_id, 'register' ); // With the wait applied: a brand-new account has never been mailed, so it // passes, while an account that was mailed moments ago (a registration // replayed by an import or another plugin) is not mailed twice. $result = $this->mailer->send_verification( $user_id, true ); self::$register_mail_result[ $user_id ] = $result; if ( self::$wc_registration_form ) { self::$pending_registration_notice = array( $user_id, $result ); } } /** * Flag the request as a WooCommerce My Account registration. Pass-through: * hooked on `woocommerce_process_registration_errors`, which only that form * runs, before the account is created. * * @param WP_Error $errors Validation errors, returned untouched. * @return WP_Error */ public function note_wc_registration_form( $errors ) { self::$wc_registration_form = true; return $errors; } /** * Redirect filter that WooCommerce applies after it has signed the new * customer in. Used only as a safe moment to queue the registration notice. * * @param string $redirect Redirect URL, returned untouched. * @return string */ public function flush_registration_notice_on_redirect( $redirect ) { $this->flush_registration_notice(); return $redirect; } /** * Tell a customer who just registered through My Account what happens next. * * The notice is queued late on purpose. user_register fires before * WooCommerce signs the customer in, and signing in re-initialises the * session: a notice queued earlier is lost whenever the visitor had no * session cookie yet, which is the usual case for someone who has just * arrived to register. The two moments used here are after the sign-in (the * redirect filter) and, when auto-login is off and WooCommerce re-renders the * form instead of redirecting, after its form handler has run (wp_loaded 30). * WooCommerce prints queued notices once, so this is shown exactly once. */ public function flush_registration_notice() { if ( null === self::$pending_registration_notice ) { return; } list( $user_id, $result ) = self::$pending_registration_notice; self::$pending_registration_notice = null; $user = get_userdata( (int) $user_id ); if ( ! $user ) { return; } if ( 'sent' === $result ) { if ( $this->add_wc_notice( $this->registration_notice_message( $user ), 'success' ) ) { WC()->session->set( 'wpcev_registered_at', time() ); } return; } // The mail did not go out. Say so rather than promising a link that will // never arrive; the My Account notice carries the resend link. $this->add_wc_notice( esc_html__( 'Your account was created, but we could not send the verification email. You can request a new one from your account page.', 'wpcoretools-email-verify' ), 'notice' ); } /** * The "link sent" message shown right after registration. Deterministic * for a given user and settings so the My Account notice can recognise it * in the notice queue and stay out of its way (see render_account_notice()). * * @param WP_User $user * @return string HTML, already escaped. */ private function registration_notice_message( $user ) { $email = '' . esc_html( $user->user_email ) . ''; if ( $this->settings->get( 'block_login' ) ) { return sprintf( /* translators: %s: customer email address */ esc_html__( 'We sent a confirmation link to %s. Open it to confirm your email address, then log in.', 'wpcoretools-email-verify' ), $email ); } if ( $this->settings->get( 'block_checkout' ) ) { return sprintf( /* translators: %s: customer email address */ esc_html__( 'We sent a confirmation link to %s. You can log in and browse now, but you will need to confirm your email address before you can place an order.', 'wpcoretools-email-verify' ), $email ); } return sprintf( /* translators: %s: customer email address */ esc_html__( 'We sent a confirmation link to %s. Please open it to confirm your email address.', 'wpcoretools-email-verify' ), $email ); } /** * Are we inside a genuine multisite signup activation? * * This deliberately does NOT look at $_GET['key']. That check was both * forgeable and wrong: any request carrying a `key` query arg — including * a WooCommerce registration POST to /my-account/?key=anything — passed it * and created a pre-verified account, while core's real activation never * matched it at all, because wp-activate.php moves the key into a cookie * and redirects before wpmu_activate_signup() runs. * * wp-activate.php is the only core entry point that reaches user_register * with a signup key already validated, so identifying that request is both * sufficient and unforgeable from another URL. Activations triggered * programmatically are caught afterwards by on_multisite_activate(). * * @return bool */ private function is_multisite_activation_request() { if ( did_action( 'activate_header' ) ) { return true; } global $pagenow; return isset( $pagenow ) && 'wp-activate.php' === $pagenow; } /** * Backstop for activations that don't go through wp-activate.php (an admin * approving a signup, or a plugin calling wpmu_activate_signup() directly). * Fires after the key has been validated, so it is safe to trust. * * @param int $user_id */ public function on_multisite_activate( $user_id ) { $user_id = (int) $user_id; if ( ! $user_id ) { return; } $this->tokens->mark_verified( $user_id, 'multisite' ); // WordPress proved the address through the activation key. do_action( 'wpcev_email_confirmed', $user_id, 'multisite' ); } /* ─── Email-change reset ─────────────────────────────────────────────── */ public function on_profile_update( $user_id, $old_user_data ) { if ( ! $old_user_data instanceof WP_User ) { return; } $new = get_userdata( (int) $user_id ); if ( ! $new ) { return; } // Same email → nothing to do. if ( strtolower( (string) $new->user_email ) === strtolower( (string) $old_user_data->user_email ) ) { return; } // The "change address while resending" form is mid-way through its own // change (it checks the resend wait first and mails afterwards). if ( self::$profile_update_mail_suppressed && (int) $user_id === self::$profile_update_mail_suppressed ) { return; } /* * WP sets `_new_email` when a user starts changing their own email in * wp-admin and has not yet clicked WP's confirmation link. * * The previous "any _new_email means skip" test was wrong in two ways. * When the user DOES confirm, core applies the new address and fires * profile_update while `_new_email` is still set — it is only deleted * afterwards — so the reset was skipped on exactly the request that * should have triggered it, and self-service email changes in wp-admin * never re-verified at all. And any abandoned change left the meta * behind permanently, disabling the reset for that user for good. * * Compare the pending address instead: if it matches the address we * now hold, the change just completed and we should re-verify. If it * differs, a change really is still pending, so wait for it. */ $pending = get_user_meta( $user_id, '_new_email', true ); if ( is_array( $pending ) && ! empty( $pending['newemail'] ) ) { if ( strtolower( (string) $pending['newemail'] ) !== strtolower( (string) $new->user_email ) ) { return; } } // Exempt users (admins, editors, shop managers by default) keep their // verified status across email changes. if ( $this->tokens->is_role_exempt( $user_id ) ) { return; } /* * A confirmed address was replaced. The new one has never been confirmed, * so, like a fresh registration, it gets the typo escape hatch (see * maybe_block_email_change()). Without this a customer who verified once * and then mistyped a new address could neither confirm it (the mail went * to the typo) nor correct it. Only done on the change away from a * confirmed address: a correction made while still unconfirmed must keep * counting towards the limit. */ if ( '1' === (string) get_user_meta( $user_id, WPCEV_Tokens::META_VERIFIED, true ) ) { delete_user_meta( $user_id, WPCEV_Tokens::META_EVER_VERIFIED ); delete_user_meta( $user_id, WPCEV_Tokens::META_CORRECTIONS ); } // New email → revoke verification + send a fresh verification mail to // the *new* address. // The old link is dead after this. The mail to the new address respects the // resend wait like every other send; if it is still running, the account // notice shows the countdown and the customer asks again once it is over. $this->tokens->unmark_verified( $user_id, 'email_change' ); $this->mailer->send_verification( $user_id, true ); } /* ─── Verify endpoint ────────────────────────────────────────────────── */ /** * Entry point of the emailed link and of the confirm button. * * Mail scanners and link previewers open every URL in a message, so a GET * alone must not change anything unless it is certainly the customer: * * - GET by the logged-in target user: verify at once (their own click). * - Any other GET (logged out, or a different account): show a page with a * "Confirm my email" button. Nothing is consumed and no login cookie is * created. A scanner stops there. * - POST from that button: verify. The token in the form is the proof; the * visitor stays logged out and is told they can now log in. * * Every response is sent uncacheable (the URL carries a one-time token). */ public function maybe_handle_verify() { // phpcs:disable WordPress.Security.NonceVerification -- the token IS the nonce. $method = isset( $_SERVER['REQUEST_METHOD'] ) ? strtoupper( sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) ) : 'GET'; if ( 'POST' === $method ) { if ( empty( $_POST['wpcev_confirm'] ) || empty( $_POST['wpcev_verify'] ) || empty( $_POST['uid'] ) ) { return; } $uid = absint( $_POST['uid'] ); $token = sanitize_text_field( wp_unslash( $_POST['wpcev_verify'] ) ); WPCEV_Frontend::send_nocache(); $this->finish_verification( $uid, $token ); return; } if ( empty( $_GET['wpcev_verify'] ) || empty( $_GET['uid'] ) ) { return; } $uid = absint( $_GET['uid'] ); $token = sanitize_text_field( wp_unslash( $_GET['wpcev_verify'] ) ); // phpcs:enable WordPress.Security.NonceVerification WPCEV_Frontend::send_nocache(); // The token is in the URL: keep it out of Referer headers of anything the // page (or the theme around it) loads from elsewhere. if ( ! headers_sent() ) { header( 'Referrer-Policy: no-referrer' ); } if ( is_user_logged_in() && get_current_user_id() === $uid ) { $this->finish_verification( $uid, $token ); return; } self::$pending_confirm = array( $uid, $token ); add_action( 'template_redirect', array( $this, 'render_confirm_page' ), 1 ); } /** * Print the "Confirm my email" page inside the site theme and stop. */ public function render_confirm_page() { if ( null === self::$pending_confirm ) { return; } require_once WPCEV_PLUGIN_DIR . 'includes/class-wpcev-confirm-page.php'; $page = new WPCEV_Confirm_Page(); $page->render( self::$pending_confirm[0], self::$pending_confirm[1] ); exit; } /** * Consume the token and send the visitor to the result. * * @param int $uid User ID from the link. * @param string $token Plaintext token from the link. */ private function finish_verification( $uid, $token ) { $result = $this->tokens->consume( $uid, $token ); // Auto-resend on expired link: when the user is still around and // still blocked, issue a fresh token and email it (rate-limited). // Same security profile as the original send — the new token goes // only to the address WP already trusts for that user. if ( 'expired' === $result && $this->settings->get( 'auto_resend_on_expired' ) ) { $user = get_userdata( $uid ); if ( $user && $this->tokens->is_blocked( $uid ) ) { $send = $this->mailer->send_verification( $uid, true ); if ( 'sent' === $send ) { $result = 'expired_resent'; } } } // Prefer WC My Account when WC is active; otherwise fall back to a // simple wp_die so non-WC sites also get visible feedback. $has_wc = function_exists( 'wc_get_page_permalink' ) && function_exists( 'wc_add_notice' ); if ( ! $has_wc ) { $this->render_verify_landing( $result ); exit; } $target = $this->myaccount_or_home(); // 'already' is a success from the visitor's point of view — their link // worked, this is just a second click or a mail-scanner prefetch. if ( 'ok' === $result || 'already' === $result ) { if ( 'already' === $result ) { $flag = 2; } elseif ( is_user_logged_in() && get_current_user_id() !== (int) $uid ) { // Confirmed from a browser that is logged in as somebody else. $flag = 3; } else { $flag = 1; } wp_safe_redirect( add_query_arg( 'wpcev_ok', $flag, $target ) ); exit; } wp_safe_redirect( add_query_arg( 'wpcev_err', $result, $target ) ); exit; } /** * Plain-WP verify landing page used when WooCommerce isn't available to * carry the notice. */ private function render_verify_landing( $result ) { $ok_states = array( 'ok', 'already' ); $title = in_array( $result, $ok_states, true ) ? __( 'Email confirmed', 'wpcoretools-email-verify' ) : __( 'Verification problem', 'wpcoretools-email-verify' ); $body = ''; switch ( $result ) { case 'ok': $body = '

' . esc_html__( 'Thank you — your email is now confirmed. You can close this window and log in.', 'wpcoretools-email-verify' ) . '

' . '

' . esc_html__( 'Go to login', 'wpcoretools-email-verify' ) . '

'; break; case 'already': $body = '

' . esc_html__( 'Your email is already confirmed — no further action needed. You can close this window and log in.', 'wpcoretools-email-verify' ) . '

' . '

' . esc_html__( 'Go to login', 'wpcoretools-email-verify' ) . '

'; break; case 'expired_resent': $body = '

' . esc_html__( 'That link expired — we just sent you a fresh one. Check your inbox.', 'wpcoretools-email-verify' ) . '

' . '

' . esc_html__( 'Go to login', 'wpcoretools-email-verify' ) . '

'; break; case 'expired': $body = '

' . esc_html__( 'That verification link has expired. You can request a new one below.', 'wpcoretools-email-verify' ) . '

'; if ( $this->settings->get( 'allow_logged_out_resend' ) ) { $body .= '

' . esc_html__( 'Resend verification email', 'wpcoretools-email-verify' ) . '

'; } break; default: $body = '

' . esc_html__( 'That verification link is invalid. Try logging in to request a fresh one.', 'wpcoretools-email-verify' ) . '

' . '

' . esc_html__( 'Go to login', 'wpcoretools-email-verify' ) . '

'; } wp_die( wp_kses_post( $body ), esc_html( $title ), array( 'response' => in_array( $result, $ok_states, true ) ? 200 : 410, 'back_link' => false, ) ); } /* ─── Resend (logged-in) ─────────────────────────────────────────────── */ public function maybe_handle_resend() { // phpcs:disable WordPress.Security.NonceVerification.Recommended -- nonce checked below. if ( empty( $_GET['wpcev_resend'] ) || ! is_user_logged_in() ) { return; } $nonce_ok = ! empty( $_GET['_wpnonce'] ) && wp_verify_nonce( sanitize_text_field( wp_unslash( $_GET['_wpnonce'] ) ), 'wpcev_resend' ); // phpcs:enable WordPress.Security.NonceVerification.Recommended $uid = get_current_user_id(); // A state-changing link: never let a page cache or CDN store the answer. WPCEV_Frontend::send_nocache(); // Someone who is already confirmed has no resend link to point at, so say // so whether or not the link they followed was current. if ( ! $this->tokens->is_blocked( $uid ) ) { if ( $this->add_wc_notice( esc_html__( 'Your email address is already confirmed — no further action needed.', 'wpcoretools-email-verify' ), 'notice' ) ) { $this->redirect_after_resend(); } return; } if ( ! $nonce_ok ) { /* * A stale link (the Blocks cart error embeds a link when the page's * cart is fetched, and a tab can stay open past the nonce lifetime). * Doing nothing left the customer clicking a link that silently did * nothing; say so and land them on a page with a fresh one. */ if ( $this->add_wc_notice( esc_html__( 'That link has expired. Please use the resend link in the notice on this page to request a new verification email.', 'wpcoretools-email-verify' ), 'notice' ) ) { $this->redirect_after_resend(); } return; } list( $message, $type ) = $this->resend_result( $this->mailer->send_verification( $uid, true ), $uid ); if ( $this->add_wc_notice( $message, $type ) ) { $this->redirect_after_resend(); } } /** * The notice for the outcome of a resend, shared by the resend link and the * change-address form. * * @param string $code 'sent' | 'throttled' | 'locked' | anything else (failed). * @param int $uid The account, for the seconds still to wait. * @return array{0:string,1:string} Escaped HTML message and notice type. */ public function resend_result( $code, $uid ) { switch ( $code ) { case 'sent': return array( esc_html__( 'A new verification email has been sent.', 'wpcoretools-email-verify' ), 'success' ); case 'throttled': $seconds = max( 1, $this->mailer->wait_remaining( $uid ) ); return array( sprintf( /* translators: %d: number of seconds */ esc_html( _n( 'A verification email was just sent. Please wait %d second before requesting another.', 'A verification email was just sent. Please wait %d seconds before requesting another.', $seconds, 'wpcoretools-email-verify' ) ), $seconds ), 'notice', ); case 'locked': return array( esc_html__( 'Too many verification emails sent. Please contact the site owner if you still need help.', 'wpcoretools-email-verify' ), 'error' ); } return array( esc_html__( 'We could not send the verification email. Please try again later.', 'wpcoretools-email-verify' ), 'error' ); } /** * Post/Redirect/Get after a resend click: land on the same page without the * nonce link in the address bar, so a refresh or a shared URL cannot trigger * another send. The result notice waits in the WooCommerce session. * * Only called once a notice has been queued; with no session to carry it the * request simply continues, as it did before. */ private function redirect_after_resend() { $uri = isset( $_SERVER['REQUEST_URI'] ) ? esc_url_raw( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : ''; $uri = remove_query_arg( array( 'wpcev_resend', '_wpnonce' ), $uri ); wp_safe_redirect( '' !== $uri ? $uri : $this->myaccount_or_home() ); exit; } public function maybe_show_redirect_notices() { if ( ! function_exists( 'wc_add_notice' ) ) { return; } // phpcs:disable WordPress.Security.NonceVerification.Recommended -- read-only flag from our own redirect. if ( ! empty( $_GET['wpcev_ok'] ) ) { $flag = (string) sanitize_key( wp_unslash( $_GET['wpcev_ok'] ) ); if ( '2' === $flag ) { $msg = esc_html__( 'Your email is already confirmed — no further action needed.', 'wpcoretools-email-verify' ); } elseif ( '3' === $flag ) { $msg = esc_html__( 'That email address is now confirmed. You are logged in as a different account, which is not affected.', 'wpcoretools-email-verify' ); } elseif ( is_user_logged_in() ) { $msg = esc_html__( 'Email confirmed — thank you!', 'wpcoretools-email-verify' ); } else { // A visitor who clicked the link without being logged in lands on // the My Account login form; point them at the next step. $msg = esc_html__( 'Email confirmed — thank you! You can now log in.', 'wpcoretools-email-verify' ); } $this->add_wc_notice( $msg, 'success' ); } if ( ! empty( $_GET['wpcev_err'] ) ) { $err = sanitize_key( wp_unslash( $_GET['wpcev_err'] ) ); switch ( $err ) { case 'expired_resent': $this->add_wc_notice( esc_html__( 'That link expired — we just sent you a fresh one. Check your inbox.', 'wpcoretools-email-verify' ), 'notice' ); break; case 'expired': $this->add_wc_notice( $this->link_problem_message( esc_html__( 'That verification link has expired.', 'wpcoretools-email-verify' ), true ), 'error' ); break; default: $this->add_wc_notice( $this->link_problem_message( esc_html__( 'That verification link is invalid.', 'wpcoretools-email-verify' ), false ), 'error' ); } } if ( ! empty( $_GET['wpcev_admin_blocked'] ) ) { $this->add_wc_notice( esc_html__( 'Please confirm your email address before accessing the dashboard. Open the verification link we sent you, or use the resend link in the notice on this page.', 'wpcoretools-email-verify' ), 'error' ); } // phpcs:enable WordPress.Security.NonceVerification.Recommended } /** * Expired/invalid link message with the right way to get a new link for the * visitor in front of us: the logged-in resend link, the logged-out resend * form when that is enabled, or a plain instruction otherwise. * * @param string $problem Escaped first sentence. * @param bool $expired True for an expired (as opposed to invalid) link. * @return string HTML, already escaped. */ private function link_problem_message( $problem, $expired ) { if ( is_user_logged_in() && $this->tokens->is_blocked( get_current_user_id() ) ) { return $problem . ' ' . sprintf( /* translators: 1: opening link tag, 2: closing link tag */ esc_html__( '%1$sSend me a new verification email%2$s.', 'wpcoretools-email-verify' ), $this->resend_open_tag( get_current_user_id(), $this->myaccount_or_home() ), '' ); } if ( ! is_user_logged_in() && $this->settings->get( 'allow_logged_out_resend' ) ) { return $problem . ' ' . sprintf( /* translators: 1: opening link tag, 2: closing link tag */ esc_html__( '%1$sRequest a new verification email%2$s.', 'wpcoretools-email-verify' ), '', '' ); } // Logged-out with no resend form, or logged in and already confirmed. if ( $expired && ! is_user_logged_in() ) { return $problem . ' ' . esc_html__( 'Log in and request a new one.', 'wpcoretools-email-verify' ); } return $problem; } /* ─── Force-logout (opt-in) ──────────────────────────────────────────── */ /** * Should WooCommerce auto-login a freshly-registered customer? * * @param bool $allow * @param int $customer_id * @return bool */ public function block_wc_registration_autologin( $allow, $customer_id = 0 ) { $customer_id = (int) $customer_id; if ( $customer_id && $this->tokens->is_blocked( $customer_id ) ) { return false; } return $allow; } public function maybe_force_logout() { if ( ! is_user_logged_in() ) { return; } if ( ! $this->tokens->is_blocked( get_current_user_id() ) ) { return; } /* * Never log the user out of the requests that let them recover. * * This runs on init; the resend handler runs on wp_loaded, which is * later — so logging out here killed the session before the only * self-service escape hatch could run, and the user was stuck: unable * to verify, unable to request a new link, and bounced out of every * page that offers one. */ // phpcs:ignore WordPress.Security.NonceVerification.Recommended if ( ! empty( $_GET['wpcev_verify'] ) || ! empty( $_GET['wpcev_resend'] ) ) { return; } if ( wp_doing_ajax() || ( defined( 'DOING_CRON' ) && DOING_CRON ) ) { return; } wp_logout(); } /* ─── Login restriction ────────────────────────────────────────────── */ /** * @param WP_User|WP_Error $user * @param string $password Submitted password. * @return WP_User|WP_Error */ public function block_login_unverified( $user, $password = '' ) { if ( is_wp_error( $user ) ) { return $user; } if ( ! $user instanceof WP_User ) { return $user; } if ( ! $this->tokens->is_blocked( $user->ID ) ) { return $user; } /* * Core runs this filter BEFORE wp_check_password(), so answering here * unconditionally told anyone submitting a junk password the difference * between "no such account", "wrong password" and "real account, not yet * verified" — a free account-existence oracle on the login form. * * Verify the password first and fall through to core's own generic * failure when it is wrong, so only someone who already holds the * credentials learns the account is unverified. */ if ( '' !== (string) $password && ! wp_check_password( $password, $user->user_pass, $user->ID ) ) { return $user; } return new WP_Error( 'wpcev_unverified', self::login_blocked_message() ); } /** * The login error for an unverified account. One place, because the logged-out * resend link is attached to it by matching this text (see WPCEV_Resend::add_wc_login_resend_link()). * * @return string */ public static function login_blocked_message() { return esc_html__( 'Please confirm your email address before logging in. Check your inbox for the verification link.', 'wpcoretools-email-verify' ); } /* ─── Application Passwords (REST API auth, WP 5.6+) ────────────────── */ public function block_app_password_unverified( $error, $user ) { // `$error` is always a WP_Error instance — even when no errors have // been added — so we can't use `is_wp_error()` as the short-circuit. // Use `has_errors()` instead. if ( ! $user instanceof WP_User ) { return $error; } if ( $error instanceof WP_Error && $error->has_errors() ) { // Upstream already failed it; don't pile on. return $error; } if ( $this->tokens->is_blocked( $user->ID ) ) { if ( ! $error instanceof WP_Error ) { $error = new WP_Error(); } $error->add( 'wpcev_unverified', esc_html__( 'Please confirm your email address before using this account.', 'wpcoretools-email-verify' ) ); } return $error; } /* ─── Classic checkout restriction ────────────────────────────────── */ public function block_classic_checkout( $data, $errors ) { unset( $data ); if ( ! is_user_logged_in() || ! ( $errors instanceof WP_Error ) ) { return; } if ( $this->tokens->is_blocked( get_current_user_id() ) ) { // Same wording and resend link as the notice above the form. The link // returns to the checkout page: this request is a wc-ajax URL, so the // current URL is no use as a landing page. $base = function_exists( 'wc_get_checkout_url' ) ? wc_get_checkout_url() : $this->myaccount_or_home(); $errors->add( 'wpcev_unverified', wp_kses( $this->order_blocked_message( wp_get_current_user(), $base ), $this->notice_allowed_html() ) ); } } /* ─── Admin-area restriction (opt-in) ─────────────────────────────── */ public function block_admin_area() { // Guests, AJAX, REST, and admin-post never go through this branch. if ( ! is_user_logged_in() ) { return; } if ( wp_doing_ajax() || ( defined( 'REST_REQUEST' ) && REST_REQUEST ) ) { return; } // admin-post.php handles our own resend / wizard / settings forms; // don't lock blocked users out of those, otherwise they can't fix // their own state from the front-end either. // phpcs:ignore WordPress.Security.NonceVerification.Recommended $script = isset( $_SERVER['SCRIPT_NAME'] ) ? sanitize_text_field( wp_unslash( $_SERVER['SCRIPT_NAME'] ) ) : ''; if ( false !== strpos( $script, 'admin-post.php' ) || false !== strpos( $script, 'admin-ajax.php' ) ) { return; } if ( ! $this->tokens->is_blocked( get_current_user_id() ) ) { return; } // Don't redirect-loop on the verify endpoint itself. // phpcs:ignore WordPress.Security.NonceVerification.Recommended if ( ! empty( $_GET['wpcev_verify'] ) ) { return; } $target = function_exists( 'wc_get_page_permalink' ) ? wc_get_page_permalink( 'myaccount' ) : home_url( '/' ); wp_safe_redirect( add_query_arg( 'wpcev_admin_blocked', 1, $target ? $target : home_url( '/' ) ) ); exit; } /* ─── Profile-email-change restriction (opt-in) ───────────────────── */ /** * wp-admin profile / user-edit screens. * * Core builds a plain stdClass for this hook and passes it by reference — * it is never a WP_User. The old `instanceof WP_User` guard therefore made * this entire feature dead code: it returned on every call, so a default-on * setting advertised in the UI did nothing. * * @param WP_Error $errors * @param bool $update * @param stdClass|object $user User object being saved (by reference). */ public function block_profile_email_change( $errors, $update, $user ) { if ( ! $update || ! is_object( $user ) || empty( $user->ID ) ) { return; } $this->maybe_block_email_change( $errors, (int) $user->ID, isset( $user->user_email ) ? $user->user_email : '' ); } /** * WooCommerce My Account → Account details. * * WooCommerce saves account details itself and never fires * `user_profile_update_errors`, so the wp-admin hook above does not cover * the screen most customers actually use. * * @param WP_Error $errors * @param stdClass|object $user */ public function block_wc_email_change( $errors, $user ) { if ( ! is_object( $user ) || empty( $user->ID ) ) { return; } $this->maybe_block_email_change( $errors, (int) $user->ID, isset( $user->user_email ) ? $user->user_email : '' ); } /** * Would a change of this unverified customer's address be accepted right now? * * True when the address has never been confirmed and the correction * allowance is not used up. Also decides whether the My Account notice offers * the "Wrong address?" link, so it never points at a screen that would refuse * the change. Callers only reach the refusal when block_profile_email_change * is on; with it off every change is accepted. * * @param int $user_id * @return bool */ private function can_correct_email( $user_id ) { if ( ! $this->settings->get( 'block_profile_email_change' ) ) { return true; } if ( '1' === (string) get_user_meta( $user_id, WPCEV_Tokens::META_EVER_VERIFIED, true ) ) { return false; } return (int) get_user_meta( $user_id, WPCEV_Tokens::META_CORRECTIONS, true ) < self::MAX_EMAIL_CORRECTIONS; } /** * Shared predicate for both email-change screens. * * @param WP_Error $errors * @param int $user_id * @param string $new_email Submitted address. */ private function maybe_block_email_change( $errors, $user_id, $new_email ) { if ( ! $user_id || '' === (string) $new_email ) { return; } if ( ! is_wp_error( $errors ) ) { return; } if ( ! $this->tokens->is_blocked( $user_id ) ) { return; } $old = get_userdata( $user_id ); if ( ! $old ) { return; } // Only object to an actual change of address. if ( strtolower( (string) $new_email ) === strtolower( (string) $old->user_email ) ) { return; } /* * Typo escape hatch. * * A user who registered as alice@gmial.com never receives the mail, so * "confirm your current address before changing it" is unsatisfiable — * the account is permanently stuck. Allow a small number of corrections * for an account that has never completed verification; profile_update * then re-issues a token to the new address as normal. An account that * HAS verified before is still held to the rule, which is the case the * setting is actually protecting against. */ if ( $this->can_correct_email( $user_id ) ) { update_user_meta( $user_id, WPCEV_Tokens::META_CORRECTIONS, (int) get_user_meta( $user_id, WPCEV_Tokens::META_CORRECTIONS, true ) + 1 ); return; } $errors->add( 'wpcev_email_change_blocked', esc_html__( 'Please confirm your current email address before changing it. Open the verification link we sent you, or ask the site administrator for help.', 'wpcoretools-email-verify' ) ); } /* ─── Capability-based blocking (opt-in) ─────────────────────────────── */ /** * Drop a configured list of capabilities for blocked users. The * `$user_has_cap` filter receives both the resolved cap map and the * meta-cap list being checked; we only need to mutate the map. * * @param array $allcaps Resolved cap map for $user. * @param array $caps Capabilities being checked (unused). * @param array $args [cap, user_id, ...] (unused). * @param WP_User $user * @return array */ public function restrict_user_capabilities( $allcaps, $caps, $args, $user ) { unset( $caps, $args ); if ( ! $user instanceof WP_User ) { return $allcaps; } if ( ! $this->tokens->is_blocked( $user->ID ) ) { return $allcaps; } $restricted = (array) $this->settings->get( 'restricted_capabilities', array() ); foreach ( $restricted as $cap ) { $cap = (string) $cap; if ( '' !== $cap ) { $allcaps[ $cap ] = false; } } return $allcaps; } /** * When `block_registration_at_checkout` is on, refuse to let an anonymous * customer create an account during checkout. Without this, the first * order at signup is placed before our verification email arrives. */ public function block_registration_at_checkout( $data, $errors ) { unset( $data ); if ( is_user_logged_in() || ! ( $errors instanceof WP_Error ) ) { return; } // phpcs:ignore WordPress.Security.NonceVerification.Missing -- WC verifies its own checkout nonce. $create_raw = isset( $_POST['createaccount'] ) ? sanitize_text_field( wp_unslash( $_POST['createaccount'] ) ) : ''; if ( '' === $create_raw || '0' === $create_raw ) { return; } $register_url = wp_registration_url(); $msg = sprintf( /* translators: %s: registration URL */ esc_html__( 'Please %1$sregister an account%2$s and confirm your email before placing your first order.', 'wpcoretools-email-verify' ), '', '' ); $errors->add( 'wpcev_signup_blocked', wp_kses( $msg, array( 'a' => array( 'href' => array() ) ) ) ); } /** * Refuse "create an account" on a Blocks checkout. * * Mirrors block_registration_at_checkout(): only an explicit request to * create an account is refused. Stores that *require* an account are left * alone, because blocking those would block guest checkout outright rather * than block signup. * * @param WC_Customer $customer Unused. * @param WP_REST_Request $request Checkout request. * @throws \Automattic\WooCommerce\StoreApi\Exceptions\RouteException When signup is refused. */ public function block_blocks_registration( $customer, $request ) { unset( $customer ); if ( is_user_logged_in() || ! is_object( $request ) ) { return; } $wants_account = isset( $request['create_account'] ) ? filter_var( $request['create_account'], FILTER_VALIDATE_BOOLEAN ) : false; if ( ! $wants_account ) { return; } throw new \Automattic\WooCommerce\StoreApi\Exceptions\RouteException( 'wpcev_signup_blocked', esc_html__( 'Account creation during checkout is disabled on this store. Please register first and confirm your email address, then place your order.', 'wpcoretools-email-verify' ), 403 ); } /* ─── Cart/Checkout Blocks restriction (Store API) ────────────────── */ /** * The message shown on every Blocks checkout refusal. * * Cart/Checkout Blocks print Store API cart errors as a notice at the top of * the page and, unlike what the JSON suggests, render basic markup in them * (checked against WooCommerce 11.1: links and come through, other * tags are stripped). So the message itself carries the resend link — no * script, and nothing to render server-side on a page WooCommerce draws in * the browser. The text still reads correctly if a store or an older * WooCommerce strips the markup. * * The link points at My Account rather than "the current page": a Store API * request has no meaningful page URL, and My Account is where the resend * result and the full notice are shown. * * @return string HTML limited to and . */ private function blocks_blocked_message() { return $this->order_blocked_message( wp_get_current_user(), $this->myaccount_or_home() ); } /** * Primary Blocks gate — runs during Store API cart validation, before the * order is finalised and before payment is taken. * * WooCommerce turns a non-empty $errors into an InvalidCartException with * HTTP 409, and surfaces the same errors through CartSchema so the Cart * block can warn the customer before they start filling in checkout. * * An empty cart is left alone: there is nothing to order yet, and the * error would otherwise show on the empty-cart view and in the mini cart. * * @param WP_Error $errors Cart errors, added to by reference. * @param WC_Cart|null $cart The cart being validated. */ public function block_blocks_cart( $errors, $cart = null ) { if ( ! is_wp_error( $errors ) ) { return; } if ( $cart instanceof WC_Cart && $cart->is_empty() ) { return; } $uid = get_current_user_id(); if ( $uid && $this->tokens->is_blocked( $uid ) ) { $errors->add( 'wpcev_unverified', $this->blocks_blocked_message() ); } } /** * Keep an unverified customer from producing a draft order through the * Store API checkout route. * * WooCommerce before 10.8 builds a `checkout-draft` order, and reserves * stock for it, on GET and PUT /wc/store/v1/checkout. The Checkout block's * page render does the GET, and cart validation never runs for it, so the * cart-errors gate cannot stop it. POST is left to that gate, which runs * first on that route. Newer WooCommerce answers these calls without * persisting anything (its checkout schema has get_draft_response()), so * nothing is done there. Empty carts are left alone: nothing can be drafted. * * PUT and PATCH get the same 409 as a checkout attempt. The page-load GET * gets an order-less 200 instead, because an error there leaves the Checkout * block with no data at all and only a generic error screen. See * blocks_checkout_stub(). * * @param mixed $result Pre-dispatch result; non-null means already answered. * @param WP_REST_Server $server Server instance (unused). * @param WP_REST_Request $request Request being dispatched. * @return mixed */ public function block_blocks_draft_order( $result, $server, $request ) { unset( $server ); if ( null !== $result ) { return $result; } $uid = $this->draft_order_guard_user( $request ); if ( ! $uid ) { return $result; } if ( 'GET' === $request->get_method() ) { $stub = $this->blocks_checkout_stub( $uid ); if ( $stub ) { return $stub; } } return new WP_Error( 'wpcev_unverified', wp_kses( $this->blocks_blocked_message(), $this->notice_allowed_html() ), array( 'status' => 409 ) ); } /** * The same guard for WooCommerce's Blocks hydration (WC 8.9+). * * While it prints the Checkout block page, WooCommerce calls the Store API * checkout controller directly (Hydration::get_response_from_controller), * which never goes through rest_pre_dispatch, so block_blocks_draft_order() * does not see it and the controller would create the draft order. This * filter is hydration's own switch for that. Hydration expects a response * object (or a falsy value, meaning nothing to preload), never a WP_Error, * so the stub or `false` is returned; the block then asks the REST API * itself and gets the 409 from the guard above. * * @param mixed $result Hydration result; non-null means already answered. * @param WP_REST_Request $request Request used for hydration. * @return mixed */ public function block_hydration_draft_order( $result, $request = null ) { if ( null !== $result ) { return $result; } $uid = $this->draft_order_guard_user( $request ); if ( ! $uid ) { return $result; } $stub = $this->blocks_checkout_stub( $uid ); return $stub ? $stub : false; } /** * Shared test of the two draft-order guards: is this a Store API checkout * call that would create a draft order for a blocked, signed-in customer * with something in the cart? * * @param mixed $request Request being dispatched or hydrated. * @return int Customer ID to refuse, 0 to let the call through. */ private function draft_order_guard_user( $request ) { if ( ! $request instanceof WP_REST_Request ) { return 0; } if ( ! in_array( $request->get_method(), array( 'GET', 'PUT', 'PATCH' ), true ) ) { return 0; } // WordPress matches REST routes case-insensitively, so this must too. if ( ! preg_match( '#^/wc/store(?:/v\d+)?/checkout/?$#i', $request->get_route() ) ) { return 0; } $uid = get_current_user_id(); if ( ! $uid || ! $this->tokens->is_blocked( $uid ) ) { return 0; } if ( method_exists( '\Automattic\WooCommerce\StoreApi\Schemas\V1\CheckoutSchema', 'get_draft_response' ) ) { return 0; } if ( ! function_exists( 'WC' ) ) { return 0; } /* * On a pretty-permalink REST request (/wp-json/...) WooCommerce does not * load the cart at woocommerce_init; the Store API loads it inside the * route callback, which runs after this filter. Load it here, or the * guard would see no cart and let the draft order through. */ if ( ! WC()->cart instanceof WC_Cart && function_exists( 'wc_load_cart' ) ) { wc_load_cart(); } if ( ! WC()->cart instanceof WC_Cart || WC()->cart->is_empty() ) { return 0; } return (int) $uid; } /** * A checkout response with no order behind it, in the shape the Checkout * block expects: order ID 0, the customer's own addresses, no payment result. * Built from an order object that is never saved, through WooCommerce's own * schema so it follows that version's response format. The Checkout block of * these WooCommerce versions cannot show its form without an order, so it * shows an error screen with this response's message; that is intended. * * @param int $uid Customer ID. * @return WP_REST_Response|null Null when it cannot be built; the caller then refuses instead. */ private function blocks_checkout_stub( $uid ) { $schema_controller = \Automattic\WooCommerce\StoreApi\SchemaController::class; $store_api = \Automattic\WooCommerce\StoreApi\StoreApi::class; $payment_result = \Automattic\WooCommerce\StoreApi\Payments\PaymentResult::class; if ( ! class_exists( $store_api ) || ! class_exists( $schema_controller ) || ! class_exists( $payment_result ) || ! WC()->customer instanceof WC_Customer ) { return null; } try { $order = new WC_Order(); $order->set_status( 'checkout-draft' ); $order->set_customer_id( $uid ); foreach ( array( 'billing', 'shipping' ) as $type ) { $getter = 'get_' . $type; foreach ( (array) WC()->customer->$getter() as $key => $value ) { $setter = 'set_' . $type . '_' . $key; if ( is_callable( array( $order, $setter ) ) ) { $order->$setter( $value ); } } } $schema = $store_api::container()->get( $schema_controller )->get( 'checkout' ); $data = $schema->get_item_response( (object) array( 'order' => $order, 'payment_result' => new $payment_result(), ) ); if ( ! is_array( $data ) ) { return null; } /* * These versions of the Checkout block show their error screen when * there is no order, and read that screen's text from `code` and * `message` in this very response. Without them it says "There was a * problem checking out", which tells the customer nothing. The * notice above it carries the address and the resend link. */ $data['code'] = 'wpcev_unverified'; $data['message'] = esc_html__( 'Checkout is unavailable until your email address is confirmed.', 'wpcoretools-email-verify' ); return rest_ensure_response( $data ); } catch ( \Throwable $e ) { return null; } } /** * Backstop gate — only reached if a checkout path created an order without * passing through cart validation. Throwing here still refuses the order, * but the order already exists, so prefer block_blocks_cart(). * * @param WC_Order $order * @throws \Automattic\WooCommerce\StoreApi\Exceptions\RouteException When unverified. */ public function block_blocks_checkout( $order ) { if ( ! ( $order instanceof WC_Order ) ) { return; } $uid = (int) $order->get_customer_id(); /* * Don't fail an order for an account that was created moments ago in * this very request. Classic checkout gives that grace implicitly — its * validation hook runs before the account exists — so without this the * two checkouts disagreed: "create an account and order" succeeded on * classic and hard-failed on Blocks, after taking the customer through * the whole form. */ if ( $uid && in_array( $uid, self::$registered_this_request, true ) ) { return; } if ( $uid && $this->tokens->is_blocked( $uid ) ) { throw new \Automattic\WooCommerce\StoreApi\Exceptions\RouteException( 'wpcev_unverified', wp_kses( $this->order_blocked_message( get_userdata( $uid ), $this->myaccount_or_home() ), $this->notice_allowed_html() ), 409 ); } } /* ─── Pay-for-order restriction ───────────────────────────────────── */ public function block_pay_for_order() { if ( ! function_exists( 'is_wc_endpoint_url' ) || ! is_wc_endpoint_url( 'order-pay' ) ) { return; } if ( ! is_user_logged_in() ) { // Guest pay-for-order via order key — out of scope for this plugin. return; } if ( ! $this->tokens->is_blocked( get_current_user_id() ) ) { return; } if ( function_exists( 'wc_add_notice' ) ) { wc_add_notice( esc_html__( 'Please confirm your email address before paying for this order. Open the verification link we sent you, or request a new one from your account page.', 'wpcoretools-email-verify' ), 'error' ); } $target = function_exists( 'wc_get_page_permalink' ) ? wc_get_page_permalink( 'myaccount' ) : home_url( '/' ); wp_safe_redirect( $target ? $target : home_url( '/' ) ); exit; } /* ─── Comment / product-review restriction (opt-in) ───────────────── */ public function block_comment_unverified( $commentdata ) { if ( ! is_user_logged_in() ) { return $commentdata; } if ( $this->tokens->is_blocked( get_current_user_id() ) ) { wp_die( esc_html__( 'Please confirm your email address before posting comments or reviews. Open the verification link we sent you, or request a new one from your account page.', 'wpcoretools-email-verify' ), esc_html__( 'Email confirmation required', 'wpcoretools-email-verify' ), array( 'response' => 403, 'back_link' => true, ) ); } return $commentdata; } /** * REST API comments (wp/v2/comments): refuse with a 403 JSON error. * * @param array|WP_Error $prepared Comment data about to be inserted. * @param WP_REST_Request $request The request (unused). * @return array|WP_Error */ public function block_rest_comment( $prepared, $request ) { unset( $request ); if ( is_wp_error( $prepared ) ) { return $prepared; } $uid = get_current_user_id(); if ( $uid && $this->tokens->is_blocked( $uid ) ) { return new WP_Error( 'wpcev_unverified', esc_html__( 'Please confirm your email address before posting comments or reviews. Open the verification link we sent you, or request a new one from your account page.', 'wpcoretools-email-verify' ), array( 'status' => 403 ) ); } return $prepared; } /* ─── Notices for unverified customers (WC) ──────────────────────────── */ /** * Tags the notices may contain; everything else is stripped. * * @return array> */ private function notice_allowed_html() { return array( 'a' => array( 'href' => true, 'class' => true, 'data-wpcev-available-at' => true, ), 'strong' => array(), ); } /** * Queue a WooCommerce notice when there is a session to hold it. * * Older WooCommerce releases fatal in wc_add_notice() when there is no * session (wp-admin), so check first rather than call it blindly. * * @param string $message HTML, already escaped. * @param string $type 'success' | 'notice' | 'error'. * @return bool True when the notice was queued. */ public function add_wc_notice( $message, $type = 'notice' ) { if ( ! $this->wc_session_ready() ) { return false; } wc_add_notice( $message, $type ); return true; } /** * Is there a WooCommerce session to read from or write to? False on * WordPress-only sites, in wp-admin, and before woocommerce_init. * * @return bool */ private function wc_session_ready() { return function_exists( 'wc_add_notice' ) && function_exists( 'WC' ) && did_action( 'woocommerce_init' ) && null !== WC()->session; } /** * Nonce-protected resend link for the logged-in customer. * * @param string $base Page the link lands on; the current page when empty. * @return string URL, not yet escaped for output. */ private function resend_url( $base = '' ) { if ( '' === $base ) { $base = remove_query_arg( array( 'wpcev_resend', '_wpnonce', 'wpcev_ok', 'wpcev_err', 'wpcev_admin_blocked' ) ); } return wp_nonce_url( add_query_arg( 'wpcev_resend', '1', $base ), 'wpcev_resend' ); } /** * Opening tag of a resend link: the nonce URL, the class the countdown * script looks for, and the time the next mail is allowed at (omitted when * it is allowed now). The closing tag is the caller's. * * @param int $user_id The customer the link is for. * @param string $base See resend_url(). * @return string */ private function resend_open_tag( $user_id, $base = '' ) { return 'mailer->available_at( $user_id ) ) . '>'; } /** * "Resend verification email" link, ready to print. * * @param int $user_id The customer the link is for. * @param string $base See resend_url(). * @param string $label Link text; "Resend verification email" when empty. * @return string */ private function resend_link( $user_id, $base = '', $label = '' ) { if ( '' === $label ) { $label = __( 'Resend verification email', 'wpcoretools-email-verify' ); } return $this->resend_open_tag( $user_id, $base ) . esc_html( $label ) . ''; } /** * The message for the My Account notice. * * The address is shown in full: the customer is looking at their own * account, and a masked address would hide exactly the typo that explains * why no mail arrived. * * @param WP_User $user Unverified customer. * @return string HTML, already escaped. */ private function account_notice_message( $user ) { // Say what is true: "we sent" only while a link is outstanding. After a // failed send to a changed address there is none (the old one was removed // with the old address), and the message must not contradict the error above it. $sent = $this->tokens->has_pending_token( (int) $user->ID ); if ( $sent ) { $message = sprintf( /* translators: %s: customer email address */ esc_html__( 'Your email address is not confirmed yet. We sent a confirmation link to %s.', 'wpcoretools-email-verify' ), '' . esc_html( $user->user_email ) . '' ); } else { $message = sprintf( /* translators: %s: customer email address */ esc_html__( 'Your email address is not confirmed yet. We have not been able to send a confirmation link to %s.', 'wpcoretools-email-verify' ), '' . esc_html( $user->user_email ) . '' ); } if ( $this->settings->get( 'block_checkout' ) ) { $message .= ' ' . esc_html__( 'You can keep using your account, but you cannot place an order until you confirm it.', 'wpcoretools-email-verify' ); } // A mistyped address is the usual reason a mail never arrives; the // "Wrong address? Change it" form printed under this message (see // render_account_notice()) fixes that and resends in one step. return $message . ' ' . $this->resend_link( (int) $user->ID, '', $sent ? '' : __( 'Send verification email', 'wpcoretools-email-verify' ) ); } /** * The message for the cart, checkout and Store API notices. * * @param WP_User $user Unverified customer. * @param string $base Page the resend link lands on. * @return string HTML, already escaped. */ private function order_blocked_message( $user, $base ) { $email = ( $user instanceof WP_User ) ? $user->user_email : ''; $uid = ( $user instanceof WP_User ) ? (int) $user->ID : 0; $msg = sprintf( /* translators: 1: customer email address, 2: opening link tag, 3: closing link tag */ esc_html__( 'Please confirm your email address (%1$s) to place your order. Open the link we emailed you, or %2$ssend a new verification email%3$s.', 'wpcoretools-email-verify' ), '' . esc_html( $email ) . '', $this->resend_open_tag( $uid, $base ), '' ); // The form lives on My Account; cart, checkout and the Blocks only link to it. if ( $uid && function_exists( 'wc_get_page_permalink' ) ) { $msg .= ' ' . sprintf( /* translators: 1: opening link tag, 2: closing link tag */ esc_html__( 'Wrong address? %1$sChange it%2$s.', 'wpcoretools-email-verify' ), '', '' ); } return $msg; } /** * Print a notice in WooCommerce's own notice markup. * * @param string $message HTML, already escaped. * @param string $modifier Extra modifier class. * @param string $extra_html Pre-escaped markup printed inside the box, after the message. */ private function print_notice( $message, $modifier = '', $extra_html = '' ) { // $extra_html is markup built and escaped by this plugin (the change-address // form), which wp_kses() would strip down to links. echo '
' . wp_kses( $message, $this->notice_allowed_html() ) . $extra_html . // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- built and escaped in WPCEV_Email_Change::render_form(). '
'; } /** * My Account notice for an unverified customer, on every endpoint. */ public function render_account_notice() { // Two hooks lead here (see the constructor); print once per request. if ( self::$account_notice_printed ) { return; } $uid = get_current_user_id(); if ( ! $uid || ! $this->tokens->is_blocked( $uid ) ) { return; } self::$account_notice_printed = true; $user = wp_get_current_user(); /* * Straight after registering, the customer has just been told a link was * sent (see flush_registration_notice()). Saying the same thing again in a * second box on that very page is noise, so skip the banner once for a * freshly registered account. A session flag is used rather than looking * for the notice in the queue, because themes print queued notices before * this hook runs. It lapses with the resend wait: until then a resend link * would only be a disabled countdown. */ if ( $this->wc_session_ready() ) { $registered_at = (int) WC()->session->get( 'wpcev_registered_at', 0 ); if ( $registered_at ) { WC()->session->set( 'wpcev_registered_at', null ); if ( ( time() - $registered_at ) < $this->mailer->wait_seconds() ) { return; } } } $form = ''; if ( function_exists( 'wc_get_page_permalink' ) ) { $form = $this->email_change()->render_form( $user, $this->myaccount_or_home(), $this->mailer->available_at( $uid ) ); } $this->print_notice( $this->account_notice_message( $user ), 'wpcev-banner--account', $form ); } /** * Classic cart + checkout notice: ordering unlocks after confirming. */ public function render_order_blocked_notice() { $uid = get_current_user_id(); if ( ! $uid || ! $this->tokens->is_blocked( $uid ) ) { return; } $base = remove_query_arg( array( 'wpcev_resend', '_wpnonce', 'wpcev_ok', 'wpcev_err', 'wpcev_admin_blocked' ) ); $this->print_notice( $this->order_blocked_message( wp_get_current_user(), $base ), 'wpcev-banner--order' ); } /** * Replace the classic checkout Place order button with a disabled one. * * The server-side gate in block_classic_checkout() is the real protection; * this keeps the customer from submitting a form that is certain to be * refused. The original label is kept (it may have been customised, or * changed by a gateway), and the button keeps WooCommerce's id and classes * so themes style it as usual. The WooCommerce checkout refreshes this * markup on every update_order_review, and it passes through this filter * each time. * * @param string $html Button markup. * @return string */ public function disable_order_button( $html ) { $uid = get_current_user_id(); if ( ! $uid || ! $this->tokens->is_blocked( $uid ) ) { return $html; } $label = trim( wp_strip_all_tags( (string) $html ) ); if ( '' === $label ) { $label = __( 'Place order', 'wpcoretools-email-verify' ); } return '' . '

' . esc_html__( 'Confirm your email address to place your order.', 'wpcoretools-email-verify' ) . '

'; } /** * Front-end banner for non-WC sites — the WC-only `woocommerce_before_my_account` * hook never fires there, leaving unverified users with no resend UI. */ public function render_frontend_banner() { // Don't double up when WC is active: the WC banner above already // shows on My Account, and we don't want to render on every page. if ( function_exists( 'wc_get_page_permalink' ) ) { return; } $uid = get_current_user_id(); if ( ! $uid || ! $this->tokens->is_blocked( $uid ) ) { return; } $resend = wp_nonce_url( add_query_arg( 'wpcev_resend', '1' ), 'wpcev_resend' ); echo ''; } /** * wp-admin banner — when an unverified user somehow lands in the admin * (subscribers can sometimes reach the dashboard depending on theme). */ public function render_admin_banner() { $uid = get_current_user_id(); if ( ! $uid || ! $this->tokens->is_blocked( $uid ) ) { return; } $resend = wp_nonce_url( add_query_arg( array( 'wpcev_resend' => '1' ), admin_url() ), 'wpcev_resend' ); echo '

' . esc_html__( 'Your email address is not yet confirmed. Site features may be limited until you click the verification link.', 'wpcoretools-email-verify' ) . ' mailer->available_at( $uid ) ) . '>' . // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- escaped in countdown_attr(). esc_html__( 'Resend verification email', 'wpcoretools-email-verify' ) . '

'; } /* ─── Change address while resending ────────────────────────────────── */ /** * Lazy-load the change-address helper; the file is only needed for a * blocked customer on My Account or when the form is posted. * * @return WPCEV_Email_Change */ private function email_change() { if ( null === $this->email_change ) { require_once WPCEV_PLUGIN_DIR . 'includes/class-wpcev-email-change.php'; $this->email_change = new WPCEV_Email_Change( $this->settings, $this->tokens, $this->mailer, $this ); } return $this->email_change; } /** * wp_loaded: handle a post of the "Wrong address? Change it" form. */ public function maybe_handle_email_change() { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- cheap pre-check; the handler verifies the nonce. if ( empty( $_POST['wpcev_do'] ) || ! is_user_logged_in() ) { return; } $this->email_change()->maybe_handle(); } /** * Link to the change-address form on My Account, opened. * * @return string URL, not yet escaped for output. */ private function change_email_url() { return add_query_arg( 'wpcev_change', '1', $this->myaccount_or_home() ) . '#' . self::CHANGE_EMAIL_ANCHOR; } /** * Let the change-address form apply its own address change without * on_profile_update() also mailing it (see WPCEV_Email_Change::apply()). * * @param int $user_id User being changed, 0 to switch it off again. */ public function suppress_profile_update_mail( $user_id ) { self::$profile_update_mail_suppressed = (int) $user_id; } /* ─── WooCommerce 11 built-in verification, password reset ──────────── */ /** * WooCommerce verified this customer itself (they used its own link). One * proof is enough: do not keep them blocked here. * * @param int $user_id */ public function on_wc_email_verified( $user_id ) { $user_id = (int) $user_id; // The echo of our own push (WPCEV_WC_Sync): this plugin is the source, there // is nothing to mirror back and nothing to link a second time. if ( class_exists( 'WPCEV_WC_Sync' ) && WPCEV_WC_Sync::is_pushing() ) { return; } if ( ! $user_id || 'unverified' !== $this->tokens->status( $user_id ) ) { return; } // WooCommerce 11 also treats a finished password reset as proof of the // inbox. Honour the switch that turns that off for this plugin. if ( doing_action( 'after_password_reset' ) && ! apply_filters( 'wpcev_verify_on_password_reset', true, $user_id ) ) { return; } // Ticked by an admin in WooCommerce's box on the profile, or confirmed by the customer. $reason = doing_action( 'profile_update' ) ? 'admin' : 'woocommerce'; $this->tokens->mark_verified( $user_id, $reason ); if ( 'woocommerce' === $reason ) { do_action( 'wpcev_email_confirmed', $user_id, 'woocommerce' ); } } /** * Completing a password reset proves the customer reads the inbox the reset * link went to, the same proof as our own link. An account that was * explicitly unverified is verified; nothing else changes. * * @param WP_User|mixed $user The user whose password was reset. */ public function on_password_reset( $user ) { if ( ! $user instanceof WP_User ) { return; } $user_id = (int) $user->ID; if ( ! $user_id || 'unverified' !== $this->tokens->status( $user_id ) ) { return; } /** * Whether finishing a password reset verifies an unverified account. * * @param bool $verify Default true. * @param int $user_id The account. */ if ( ! apply_filters( 'wpcev_verify_on_password_reset', true, $user_id ) ) { return; } $this->tokens->mark_verified( $user_id, 'password_reset' ); do_action( 'wpcev_email_confirmed', $user_id, 'password_reset' ); } /** * Link guest orders placed with the customer's address to the account, as * WooCommerce does when it verifies a customer. Only after a real proof of * the inbox (see the `wpcev_email_confirmed` action); an exempt role or a filter * proves nothing about the address. (An admin marking a user verified is * mirrored to WooCommerce 11, whose own listener links the orders then.) * * @param int $user_id * @param string $source 'link' | 'password_reset' | 'woocommerce' | 'multisite'. */ public function link_guest_orders( $user_id, $source = '' ) { // WooCommerce's own listener already did it, either for its own verification // or when WPCEV_WC_Sync marked this customer verified there a moment ago // (the guest orders are linked exactly once). Without WooCommerce's service, // or when it had the customer verified already, this is the one that links. if ( 'woocommerce' === $source || ! function_exists( 'wc_update_new_customer_past_orders' ) || WPCEV_WC_Sync::orders_linked_by_woocommerce( $user_id ) ) { return; } wc_update_new_customer_past_orders( (int) $user_id ); } /* ─── Public filter ──────────────────────────────────────────────────── */ public function public_is_verified_filter( $value, $user_id ) { unset( $value ); return $this->tokens->is_verified( $user_id ); } /* ─── Helpers ────────────────────────────────────────────────────────── */ public function myaccount_or_home() { if ( function_exists( 'wc_get_page_permalink' ) ) { $url = wc_get_page_permalink( 'myaccount' ); if ( $url ) { return $url; } } return home_url( '/' ); } }