settings = $settings; add_action( 'admin_notices', array( $this, 'maybe_render_admin_notice' ) ); add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_dismiss_script' ) ); add_action( 'admin_post_wpcev_dismiss_conflict', array( $this, 'handle_dismiss' ) ); add_action( 'wp_ajax_wpcev_dismiss_conflict', array( $this, 'handle_dismiss_ajax' ) ); add_action( 'admin_post_wpcev_run_scan', array( $this, 'handle_run_scan' ) ); // Bust the cache when settings change or plugins are toggled. add_action( 'update_option_wpcev_settings', array( $this, 'bust_cache' ) ); add_action( 'activated_plugin', array( $this, 'bust_cache' ) ); add_action( 'deactivated_plugin', array( $this, 'bust_cache' ) ); // Bust on WC checkout-mode option changes so the info card stays // fresh when the admin toggles guest checkout / signup-at-checkout. add_action( 'update_option_woocommerce_enable_guest_checkout', array( $this, 'bust_cache' ) ); add_action( 'update_option_woocommerce_enable_signup_and_login_from_checkout', array( $this, 'bust_cache' ) ); } public function bust_cache() { delete_transient( self::CACHE_KEY ); } /** * User meta keys written by verification tools seen in the wild, checked * even before any scan ran. A set value means "pending" for the first, a * zero for the second (see WPCEV_Scanner::key_meaning()). * * @return array meta key => source key it belongs to ('' when not tied to a plugin). */ private static function known_status_keys() { return array( 'wcca_pending_verification' => 'plugin:wc-custom-auth', 'wpctev_verified' => '', ); } /** * Return all detected conflicts. Cached. * * @param bool $force Bypass cache (used by the wizard). * @return array */ public function detect( $force = false ) { if ( ! $force ) { $cached = get_transient( self::CACHE_KEY ); if ( false !== $cached && is_array( $cached ) ) { return $cached; } } $issues = array(); $issues = array_merge( $issues, $this->detect_foreign_verifiers() ); $issues = array_merge( $issues, $this->detect_wc_builtin_verification() ); $issues = array_merge( $issues, $this->detect_wc_delayed_account_creation() ); $issues = array_merge( $issues, $this->detect_wc_signup_at_checkout() ); $issues = array_merge( $issues, $this->detect_competing_auth_filters() ); $issues = array_merge( $issues, $this->detect_mail_layer() ); $issues = array_merge( $issues, $this->detect_users_can_register() ); $issues = array_merge( $issues, $this->detect_guest_checkout_mode() ); set_transient( self::CACHE_KEY, $issues, self::CACHE_TTL ); return $issues; } /** * Plugins this one is known to overlap with, by plugin folder name. * * Folder names (not main files) so the list survives a plugin renaming its * main file. Only entries confirmed from WordPress.org listings or from a * plugin header are here; anything else is caught by the hook check and the * code scan, which do not need to know a name. We deliberately do NOT include * WPCoreTools Disposable Email Guard: it is complementary (a different * problem); the Settings tab and the wizard note when it is active. * * @return array folder slug => display name. */ public static function known_verifiers() { return array( // Zorem: OTP-popup model. 'customer-email-verification-for-woocommerce' => 'Customer Email Verification for WooCommerce (Zorem)', // WPFactory: link-based; installed copies still run. 'emails-verification-for-woocommerce' => 'Email Verification for WooCommerce (WPFactory)', // VerifyMate: link-based. 'verifymate-email-verification-for-woocommerce' => 'VerifyMate Email Verification for WooCommerce', // PickPlugins: broader user-verification suite (email verification, OTP, magic login). 'user-verification' => 'User Verification by PickPlugins', // Dornaweb: small, link-based. 'email-verification-on-signups' => 'Email verification on signups (Dornaweb)', // EverPress: small, link-based with an MX check. 'email-verify' => 'Email Verify (EverPress)', // "WC Custom Auth Forms" (plugin header): replaces the WooCommerce login / // register forms and verifies inside its own AJAX handlers, so no hook shows it. 'wc-custom-auth' => 'WC Custom Auth Forms', ); } /** * Folder slugs of every active plugin (site and network). * * @return string[] */ private function active_plugin_slugs() { $mains = wp_get_active_and_valid_plugins(); if ( is_multisite() && function_exists( 'wp_get_active_network_plugins' ) ) { $mains = array_merge( $mains, wp_get_active_network_plugins() ); } $dir = wp_normalize_path( WP_PLUGIN_DIR ); $slugs = array(); foreach ( $mains as $main ) { $rel = ltrim( substr( wp_normalize_path( $main ), strlen( $dir ) ), '/' ); if ( false !== strpos( $rel, '/' ) ) { $slugs[] = strtok( $rel, '/' ); } } return array_values( array_unique( $slugs ) ); } /** * Which plugin / mu-plugin / theme a PHP file belongs to. * * @param string $file Absolute path. * @return array{key:string,label:string,type:string}|null Null for WordPress core and unknown places. */ private function source_from_file( $file ) { $file = wp_normalize_path( $file ); $plugin_dir = wp_normalize_path( WP_PLUGIN_DIR ); if ( 0 === strpos( $file, $plugin_dir . '/' ) ) { $slug = strtok( substr( $file, strlen( $plugin_dir ) + 1 ), '/' ); return array( 'key' => 'plugin:' . $slug, 'label' => self::plugin_label( $slug ), 'type' => 'plugin', ); } if ( defined( 'WPMU_PLUGIN_DIR' ) ) { $mu_dir = wp_normalize_path( WPMU_PLUGIN_DIR ); if ( 0 === strpos( $file, $mu_dir . '/' ) ) { $first = strtok( substr( $file, strlen( $mu_dir ) + 1 ), '/' ); return array( 'key' => 'mu:' . $first, 'label' => $first . ' (mu-plugin)', 'type' => 'mu-plugin', ); } } $themes = array( get_stylesheet() => wp_normalize_path( get_stylesheet_directory() ), get_template() => wp_normalize_path( get_template_directory() ), ); foreach ( $themes as $slug => $dir ) { if ( 0 === strpos( $file, $dir . '/' ) ) { return array( 'key' => 'theme:' . $slug, 'label' => sprintf( /* translators: %s: theme folder name */ __( 'theme %s', 'wpcoretools-email-verify' ), $slug ), 'type' => 'theme', ); } } return null; } /** * Merge what one detection route found into the per-source list. * * @param array $found List by source key, by reference. * @param array $source array{key,label,type}. * @param array $extra Fields to merge: via, hooks (hook => priorities), meta_keys, strings, handler, files. */ private function add_found( &$found, $source, $extra ) { $key = $source['key']; if ( ! isset( $found[ $key ] ) ) { $found[ $key ] = array( 'key' => $key, 'label' => $source['label'], 'type' => $source['type'], 'known' => false, 'via' => array(), 'hooks' => array(), 'meta_keys' => array(), 'strings' => array(), 'handler' => false, 'high' => false, 'files' => array(), ); } $f = &$found[ $key ]; if ( ! empty( $extra['known'] ) ) { $f['known'] = true; } if ( ! empty( $extra['via'] ) ) { $f['via'][ $extra['via'] ] = true; } foreach ( array( 'meta_keys', 'strings', 'files' ) as $field ) { if ( ! empty( $extra[ $field ] ) ) { $f[ $field ] = array_values( array_unique( array_merge( $f[ $field ], (array) $extra[ $field ] ) ) ); } } if ( ! empty( $extra['hooks'] ) ) { foreach ( (array) $extra['hooks'] as $hook => $priorities ) { // A scan result lists plain hook names; a live check lists priorities. if ( is_int( $hook ) ) { $hook = (string) $priorities; $priorities = array(); } $f['hooks'][ $hook ] = array_values( array_unique( array_merge( isset( $f['hooks'][ $hook ] ) ? $f['hooks'][ $hook ] : array(), (array) $priorities ) ) ); } } $f['handler'] = $f['handler'] || ! empty( $extra['handler'] ); $f['high'] = $f['high'] || ! empty( $extra['high'] ); } /** * Other email-verification mechanisms: known plugins, code hooked into * registration / login / checkout that looks like verification, and code the * on-demand scan found. One finding per source, with the number of users it * marks unverified. */ private function detect_foreign_verifiers() { global $wp_filter; $found = array(); // 1. Known plugins. $known = self::known_verifiers(); foreach ( $this->active_plugin_slugs() as $slug ) { if ( isset( $known[ $slug ] ) ) { $this->add_found( $found, array( 'key' => 'plugin:' . $slug, 'label' => $known[ $slug ], 'type' => 'plugin', ), array( 'known' => true, 'via' => 'known', ) ); } } // 2. Live hooks. Only callbacks whose own file shows verification // signs are reported (a 2FA or captcha plugin on the same hook is not a // verifier); the lookup is per file and bounded. $skip_slugs = array( 'woocommerce', 'wpcoretools-email-verify', 'wpcoretools-disposable-email-guard' ); $seen_files = array(); foreach ( WPCEV_Scanner::watched_hooks() as $hook ) { if ( empty( $wp_filter[ $hook ] ) || ! ( $wp_filter[ $hook ] instanceof WP_Hook ) ) { continue; } foreach ( $wp_filter[ $hook ]->callbacks as $priority => $callbacks ) { foreach ( $callbacks as $cb ) { $fn = isset( $cb['function'] ) ? $cb['function'] : null; // Our own callbacks (any WPCEV_ class) are not a conflict. if ( is_array( $fn ) && isset( $fn[0] ) && is_object( $fn[0] ) && 0 === strpos( get_class( $fn[0] ), 'WPCEV_' ) ) { continue; } $info = $this->describe_callback( $fn ); if ( null === $info || '' === $info['file'] ) { continue; } $source = $this->source_from_file( $info['file'] ); if ( null === $source ) { continue; // WordPress core. } $slug = 0 === strpos( $source['key'], 'plugin:' ) ? substr( $source['key'], 7 ) : ''; if ( '' !== $slug && in_array( $slug, $skip_slugs, true ) ) { continue; } if ( ! isset( $seen_files[ $info['file'] ] ) ) { if ( count( $seen_files ) >= 80 ) { continue; } $seen_files[ $info['file'] ] = WPCEV_Scanner::analyse_file( $info['file'] ); } $analysis = $seen_files[ $info['file'] ]; if ( ! $analysis['suspicious'] ) { continue; } $this->add_found( $found, $source, array( 'via' => 'hooks', 'hooks' => array( $hook => array( (int) $priority ) ), 'meta_keys' => $analysis['meta_keys'], 'strings' => $analysis['strings'], 'handler' => $analysis['handler'], 'high' => $analysis['high'], ) ); } } } // 3. The last on-demand code scan. $scan = WPCEV_Scanner::last_result(); if ( $scan ) { foreach ( $scan['sources'] as $src ) { $this->add_found( $found, array( 'key' => $src['key'], 'label' => $src['label'], 'type' => $src['type'], ), array( 'via' => 'scan', 'hooks' => $src['hooks'], 'meta_keys' => $src['meta_keys'], 'strings' => $src['strings'], 'handler' => $src['handler'], 'high' => $src['handler'] || ( ! empty( $src['meta_keys'] ) && ! empty( $src['hooks'] ) ), 'files' => $src['files'], ) ); } } // 4. Data: how many users does each of them mark unverified? One query. $all_keys = array_keys( self::known_status_keys() ); foreach ( $found as $entry ) { $all_keys = array_merge( $all_keys, $entry['meta_keys'] ); } if ( $scan && ! empty( $scan['meta_keys'] ) ) { $all_keys = array_merge( $all_keys, $scan['meta_keys'] ); } $counts = WPCEV_Scanner::count_unverified( array_unique( $all_keys ) ); $attributed = array(); $issues = array(); foreach ( $found as $entry ) { $users = 0; $keys = $entry['meta_keys']; foreach ( self::known_status_keys() as $k => $owner ) { if ( $owner === $entry['key'] ) { $keys[] = $k; } } $by_key = array(); foreach ( array_unique( $keys ) as $k ) { if ( ! empty( $counts[ $k ] ) ) { $by_key[ $k ] = (int) $counts[ $k ]; $users += (int) $counts[ $k ]; $attributed[ $k ] = true; } } $issues[] = $this->foreign_verifier_issue( $entry, $by_key, $users ); } // Counts that belong to no source we found (a tool that is no longer // active, or whose code is somewhere the scan does not look). foreach ( $counts as $k => $n ) { if ( isset( $attributed[ $k ] ) || $n < 1 ) { continue; } $issues[] = array( 'code' => 'unverified_by_other_tool:' . $k, 'severity' => 'low', 'title' => sprintf( /* translators: 1: number of users, 2: user meta key */ _n( '%1$d user is marked unverified by another tool (user meta "%2$s").', '%1$d users are marked unverified by another tool (user meta "%2$s").', $n, 'wpcoretools-email-verify' ), $n, $k ), 'detail' => __( 'Some other plugin or custom code keeps its own "verified" flag in user meta. If that tool is still active, those customers can stay blocked there even after they confirm here; if it was removed, the flag is harmless leftover data. Run the code scan to find out which it is.', 'wpcoretools-email-verify' ), 'users' => (int) $n, ); } return $issues; } /** * Build the finding for one other verifier. * * @param array $entry See add_found(). * @param array $by_key Unverified users by meta key. * @param int $users Total of $by_key. * @return array */ private function foreign_verifier_issue( $entry, $by_key, $users ) { $lines = array(); if ( $entry['known'] ) { $lines[] = __( 'It is a known email-verification plugin.', 'wpcoretools-email-verify' ); } if ( ! empty( $entry['hooks'] ) ) { $parts = array(); foreach ( $entry['hooks'] as $hook => $priorities ) { $parts[] = empty( $priorities ) ? $hook : sprintf( /* translators: 1: WordPress hook name, 2: priority numbers */ __( '%1$s (priority %2$s)', 'wpcoretools-email-verify' ), $hook, implode( ', ', array_map( 'intval', $priorities ) ) ); } $lines[] = sprintf( /* translators: %s: list of hook names */ __( 'It takes part in registration, login or checkout through: %s.', 'wpcoretools-email-verify' ), implode( ', ', $parts ) ); } if ( ! empty( $entry['meta_keys'] ) ) { $lines[] = sprintf( /* translators: %s: list of user meta keys */ __( 'It keeps its own verification status in user meta: %s.', 'wpcoretools-email-verify' ), implode( ', ', $entry['meta_keys'] ) ); } if ( ! empty( $entry['strings'] ) ) { $lines[] = sprintf( /* translators: %s: quoted phrases found in the code */ __( 'Its code contains: "%s".', 'wpcoretools-email-verify' ), implode( '", "', $entry['strings'] ) ); } if ( $entry['handler'] ) { $lines[] = __( 'It signs people in through its own code (wp_signon / wp_authenticate) and refuses them by its own flag. No WordPress hook shows that, so deactivating this plugin\'s checks elsewhere does not help.', 'wpcoretools-email-verify' ); } if ( ! empty( $entry['files'] ) ) { $lines[] = sprintf( /* translators: %s: file paths */ __( 'Found in: %s', 'wpcoretools-email-verify' ), implode( ', ', $entry['files'] ) ); } foreach ( $by_key as $k => $n ) { $lines[] = sprintf( /* translators: 1: number of users, 2: tool name, 3: user meta key */ _n( '%1$d user is marked unverified by %2$s (user meta "%3$s").', '%1$d users are marked unverified by %2$s (user meta "%3$s").', $n, 'wpcoretools-email-verify' ), $n, $entry['label'], $k ); } $lines[] = __( 'Two verifiers keep separate flags: a customer who confirms in one is still refused by the other, and neither knows. Keep one: switch off the verification in the other tool (or deactivate it), then use Users > Email verification to mark the stuck customers verified.', 'wpcoretools-email-verify' ); $high = $entry['known'] || $entry['high'] || $entry['handler']; return array( 'code' => 'duplicate_verifier:' . $entry['key'], 'severity' => $high ? 'high' : 'medium', 'title' => sprintf( /* translators: %s: other plugin / file name */ __( 'Conflict: another email-verification mechanism is active (%s).', 'wpcoretools-email-verify' ), $entry['label'] ), 'detail' => implode( "\n", $lines ), 'users' => (int) $users, 'source' => $entry['key'], ); } /** * WooCommerce 11's own customer email verification. * * It asks, on My Account > Orders, for the same thing this plugin does, with * its own mail and its own flag. While this plugin is enabled its prompt is * switched off (WooCommerce 11.1 added the filter for that) and a * verification in either place counts in both. */ private function detect_wc_builtin_verification() { if ( ! class_exists( 'Automattic\\WooCommerce\\Internal\\CustomerEmailVerification\\EmailVerificationService' ) ) { return array(); } $email_settings = get_option( 'woocommerce_customer_verify_email_settings', array() ); $email_on = ! ( is_array( $email_settings ) && isset( $email_settings['enabled'] ) && 'yes' !== $email_settings['enabled'] ); $guest = ( 'yes' === get_option( 'woocommerce_enable_guest_checkout', 'no' ) ); $prompt_active = $email_on && $guest; $can_hide = defined( 'WC_VERSION' ) && version_compare( WC_VERSION, '11.1', '>=' ); $ours_on = (bool) $this->settings->get( 'enabled' ); if ( $ours_on && $prompt_active && ! $can_hide ) { return array( array( 'code' => 'wc_builtin_verification_prompt', 'severity' => 'medium', 'title' => __( 'WooCommerce\'s own email verification prompt is also showing.', 'wpcoretools-email-verify' ), 'detail' => __( 'WooCommerce 11 asks customers to confirm their email on My Account > Orders, with its own email. Customers may see two prompts for the same thing. WooCommerce 11.1 and later lets this plugin hide its prompt; update WooCommerce, or turn off the "Customer verify email" email under WooCommerce > Settings > Emails.', 'wpcoretools-email-verify' ), 'fix_url' => admin_url( 'admin.php?page=wc-settings&tab=email' ), 'fix_label' => __( 'Open WooCommerce email settings', 'wpcoretools-email-verify' ), ), ); } if ( $ours_on ) { $detail = $prompt_active ? __( 'WooCommerce 11 has its own email verification, and it is switched on. While this plugin is enabled its prompt is hidden so customers see only one, and confirming in either place counts in both. Earlier guest orders are linked to the account when the address is confirmed here too.', 'wpcoretools-email-verify' ) : __( 'WooCommerce 11 has its own email verification, but its prompt is off on this site (it needs guest checkout and its "Customer verify email" email). Nothing to do.', 'wpcoretools-email-verify' ); } else { $detail = $prompt_active ? __( 'WooCommerce 11\'s own email verification prompt is active. This plugin is switched off, so it is the only one customers see.', 'wpcoretools-email-verify' ) : __( 'WooCommerce 11\'s own email verification prompt is off on this site.', 'wpcoretools-email-verify' ); } return array( array( 'code' => 'wc_builtin_verification', 'severity' => 'info', 'title' => __( 'WooCommerce built-in email verification', 'wpcoretools-email-verify' ), 'detail' => $detail, ), ); } /** * WooCommerce "Allow customers to create an account after checkout" (the * Create account block on the order confirmation page). * * WooCommerce creates that account and signs the customer in itself. The * verification mail goes out, but the customer is logged in straight away, * whatever "Block login" says. */ private function detect_wc_delayed_account_creation() { if ( ! class_exists( 'WooCommerce' ) || ! $this->settings->get( 'enabled' ) ) { return array(); } if ( 'yes' !== get_option( 'woocommerce_enable_delayed_account_creation', 'yes' ) || 'yes' !== get_option( 'woocommerce_enable_guest_checkout', 'no' ) ) { return array(); } return array( array( 'code' => 'wc_delayed_account_creation', 'severity' => 'medium', 'title' => __( 'WooCommerce creates accounts on the order confirmation page.', 'wpcoretools-email-verify' ), 'detail' => __( 'Customers who tick "create an account" after placing a guest order are signed in by WooCommerce straight away, before they confirm their email (the verification mail is still sent, and their next order waits for it). "Block login" does not apply to this one sign-in. Turn the option off under WooCommerce > Settings > Accounts & Privacy if that matters.', 'wpcoretools-email-verify' ), 'fix_url' => admin_url( 'admin.php?page=wc-settings&tab=account' ), 'fix_label' => __( 'Open WooCommerce Accounts settings', 'wpcoretools-email-verify' ), ), ); } private function detect_wc_signup_at_checkout() { if ( ! class_exists( 'WooCommerce' ) ) { return array(); } if ( ! $this->settings->get( 'block_checkout' ) ) { return array(); } if ( 'yes' !== get_option( 'woocommerce_enable_signup_and_login_from_checkout' ) ) { return array(); } if ( $this->settings->get( 'block_registration_at_checkout' ) ) { // Already mitigated by our own setting. return array(); } return array( array( 'code' => 'wc_signup_at_checkout', 'severity' => 'medium', 'title' => __( 'WooCommerce allows account creation during checkout.', 'wpcoretools-email-verify' ), 'detail' => __( 'When customers create their account on the checkout page, the first order is placed before our verification email arrives. Either disable "Allow customers to create an account during checkout" in WooCommerce → Settings → Accounts & Privacy, or enable "Block registration during checkout" in our settings.', 'wpcoretools-email-verify' ), 'fix_url' => admin_url( 'admin.php?page=wc-settings&tab=account' ), 'fix_label' => __( 'Open WooCommerce Accounts settings', 'wpcoretools-email-verify' ), ), ); } /** * Warn when other code hooks `wp_authenticate_user`. Uses Reflection * to identify each competing callback by its owning plugin, callable * name, and priority — so the admin sees *what* is competing, not * just a raw count. * * Sample output detail: * "Other plugins hooking this filter: * • Wordfence Login Security (priority 10) — wfWAFLogin::filter * • miniOrange 2-Factor Authentication (priority 20) — MO2F_Login::challenge" */ private function detect_competing_auth_filters() { global $wp_filter; if ( empty( $wp_filter['wp_authenticate_user'] ) || ! ( $wp_filter['wp_authenticate_user'] instanceof WP_Hook ) ) { return array(); } $competing = array(); foreach ( $wp_filter['wp_authenticate_user']->callbacks as $priority => $callbacks ) { foreach ( $callbacks as $cb ) { $fn = isset( $cb['function'] ) ? $cb['function'] : null; // Skip our own callback. if ( is_array( $fn ) && isset( $fn[0] ) && is_object( $fn[0] ) && $fn[0] instanceof WPCEV_Integrations ) { continue; } $info = $this->describe_callback( $fn ); if ( null === $info ) { continue; } $info['priority'] = (int) $priority; $competing[] = $info; } } if ( empty( $competing ) ) { return array(); } // Sort: by priority ASC (callbacks earliest-firing first). usort( $competing, function ( $a, $b ) { return $a['priority'] <=> $b['priority']; } ); // One bullet per competing callback. Plugin name preferred when we // can resolve it; otherwise we fall back to the callable name + // the file path's last two segments (which is enough to identify // theme functions.php / mu-plugin / unknown source). $lines = array(); foreach ( $competing as $c ) { $source = ''; if ( '' !== $c['source_label'] ) { $source = $c['source_label']; } $lines[] = sprintf( /* translators: 1: source/plugin name, 2: priority, 3: callable name */ __( '%1$s (priority %2$d) — %3$s', 'wpcoretools-email-verify' ), $source, $c['priority'], $c['name'] ); } $detail = __( 'We hook the same login filter at priority 5, so we run first under normal registration order — but a 2FA / login-security plugin that also runs at priority 5 may interleave. Test the login flow end-to-end after activating any of these.', 'wpcoretools-email-verify' ) . "\n\n" . __( 'Other callbacks on wp_authenticate_user:', 'wpcoretools-email-verify' ) . "\n• " . implode( "\n• ", $lines ); return array( array( 'code' => 'competing_auth_filters', 'severity' => 'low', 'title' => sprintf( /* translators: %d: number of other callbacks */ _n( 'Another plugin also restricts logins (%d callback).', 'Other plugins also restrict logins (%d callbacks).', count( $competing ), 'wpcoretools-email-verify' ), count( $competing ) ), 'detail' => $detail, ), ); } /** * Reflect on a hooked callable and return a human-readable description. * Returns null when the callable can't be resolved (rare but possible * with non-callable values that somehow ended up in `$wp_filter`). * * @param mixed $cb * @return array{name:string,file:string,source_label:string}|null */ private function describe_callback( $cb ) { $info = array( 'name' => '', 'file' => '', 'source_label' => '', ); try { if ( is_string( $cb ) ) { if ( ! function_exists( $cb ) ) { return null; } $reflection = new ReflectionFunction( $cb ); $info['name'] = $cb . '()'; } elseif ( is_array( $cb ) && isset( $cb[0], $cb[1] ) ) { $class = is_object( $cb[0] ) ? get_class( $cb[0] ) : $cb[0]; $method = (string) $cb[1]; $reflection = new ReflectionMethod( $class, $method ); $info['name'] = $class . '::' . $method . '()'; } elseif ( $cb instanceof Closure ) { $reflection = new ReflectionFunction( $cb ); $info['name'] = 'Closure'; } elseif ( is_object( $cb ) && method_exists( $cb, '__invoke' ) ) { $reflection = new ReflectionMethod( $cb, '__invoke' ); $info['name'] = get_class( $cb ) . '->__invoke()'; } else { return null; } $info['file'] = (string) $reflection->getFileName(); } catch ( Throwable $e ) { return null; } $info['source_label'] = $this->resolve_source_label( $info['file'] ); return $info; } /** * Map a PHP file path to a human-readable origin label: * - "Wordfence Login Security" if it's inside a known plugin * - "{slug} (mu-plugin)" if it's in WPMU_PLUGIN_DIR * - "Active theme (functions.php)" if it's a theme file * - "WP core" if it lives in wp-includes / wp-admin * - "" (empty string) when none match — caller renders a fallback */ private function resolve_source_label( $file ) { if ( '' === $file ) { return ''; } $file = wp_normalize_path( $file ); // Plugin? if ( defined( 'WP_PLUGIN_DIR' ) ) { $plugin_dir = wp_normalize_path( WP_PLUGIN_DIR ); if ( 0 === strpos( $file, $plugin_dir . '/' ) ) { $rel = substr( $file, strlen( $plugin_dir ) + 1 ); $parts = explode( '/', $rel ); $slug = isset( $parts[0] ) ? $parts[0] : ''; if ( '' !== $slug ) { return self::plugin_label( $slug ); } } } // MU plugin? if ( defined( 'WPMU_PLUGIN_DIR' ) ) { $mu_dir = wp_normalize_path( WPMU_PLUGIN_DIR ); if ( 0 === strpos( $file, $mu_dir . '/' ) ) { $rel = substr( $file, strlen( $mu_dir ) + 1 ); return basename( $rel ) . ' (mu-plugin)'; } } // Theme? $theme_root = wp_normalize_path( get_theme_root() ); if ( 0 === strpos( $file, $theme_root . '/' ) ) { return __( 'active theme', 'wpcoretools-email-verify' ); } // WP core? $abspath = wp_normalize_path( ABSPATH ); if ( 0 === strpos( $file, $abspath ) ) { return __( 'WP core', 'wpcoretools-email-verify' ); } return ''; } /** * Look up the human-readable plugin name from its folder name, by scanning * `get_plugins()`. Cached statically for the request because * `get_plugins()` is mildly expensive and we may call it once per * competing callback. * * @param string $slug Plugin folder name. * @return string Plugin Name header value, or the folder name if not found. */ public static function plugin_label( $slug ) { static $by_slug = null; if ( null === $by_slug ) { if ( ! function_exists( 'get_plugins' ) ) { require_once ABSPATH . 'wp-admin/includes/plugin.php'; } $by_slug = array(); foreach ( get_plugins() as $file => $data ) { $plugin_slug = strtok( $file, '/' ); if ( ! isset( $by_slug[ $plugin_slug ] ) ) { $by_slug[ $plugin_slug ] = $data['Name']; } } } return isset( $by_slug[ $slug ] ) && '' !== $by_slug[ $slug ] ? (string) $by_slug[ $slug ] : (string) $slug; } private function detect_mail_layer() { // If the site is using the default PHP mailer with no SMTP plugin and // no MU plugin overriding wp_mail, deliverability tends to be poor. // We can't reliably detect SMTP plugins by class, but a site with a // known SMTP plugin shows up here as "fine". $known_smtp = array( 'wp-mail-smtp/wp_mail_smtp.php', 'easy-wp-smtp/easy-wp-smtp.php', 'post-smtp/postman-smtp.php', 'fluent-smtp/fluent-smtp.php', 'gmail-smtp/main.php', 'wp-ses/wp-ses.php', ); if ( ! function_exists( 'is_plugin_active' ) ) { require_once ABSPATH . 'wp-admin/includes/plugin.php'; } foreach ( $known_smtp as $slug ) { if ( is_plugin_active( $slug ) ) { return array(); } } // Fallback: check whether *some* plugin is overriding phpmailer. if ( has_action( 'phpmailer_init' ) ) { return array(); } return array( array( 'code' => 'no_smtp_plugin', 'severity' => 'low', 'title' => __( 'No SMTP plugin detected.', 'wpcoretools-email-verify' ), 'detail' => __( 'WordPress will fall back to the server\'s sendmail, which often lands in spam folders or is blocked entirely on shared hosting. Consider installing WP Mail SMTP or another transactional-mail plugin so verification emails reach customers reliably.', 'wpcoretools-email-verify' ), ), ); } private function detect_users_can_register() { if ( get_option( 'users_can_register' ) ) { return array(); } // On WC sites, customers can still register via the My Account page // even if `users_can_register` is off, so this only matters for // non-WC installs. if ( class_exists( 'WooCommerce' ) ) { return array(); } return array( array( 'code' => 'registration_disabled', 'severity' => 'low', 'title' => __( 'Public registration is disabled.', 'wpcoretools-email-verify' ), 'detail' => __( 'Settings → General has "Anyone can register" turned off. Email verification is still active for any user created programmatically or via wp-admin, but no one can sign up from the front-end. This may be intentional.', 'wpcoretools-email-verify' ), ), ); } /** * Informational card describing how the plugin's restrictions apply under the * current WooCommerce checkout configuration. Severity is `info` — * never a warning, just a "here's what's happening on your site" * explainer that respects the four guest/signup-at-checkout states. * * Only WC sites get this; non-WC sites have a much simpler model. */ private function detect_guest_checkout_mode() { if ( ! class_exists( 'WooCommerce' ) ) { return array(); } $guest = ( 'yes' === get_option( 'woocommerce_enable_guest_checkout', 'no' ) ); $signup = ( 'yes' === get_option( 'woocommerce_enable_signup_and_login_from_checkout', 'no' ) ); $we_block_signup = (bool) $this->settings->get( 'block_registration_at_checkout' ); // Phrase varies by quadrant. We refer to the existing medium warning // rather than restate it, so the admin reads "warning + info card" // as one coherent story instead of two competing recommendations. if ( $guest && $signup ) { $detail = $we_block_signup ? __( 'WooCommerce allows guest checkout AND in-checkout account signup. Guests pass through unverified — verification only applies to registered accounts. With "Block signup at checkout" on (✓ enabled), customers must register separately and verify before checkout. This is the strictest configuration you can run while keeping guest checkout open.', 'wpcoretools-email-verify' ) : __( 'WooCommerce allows guest checkout AND in-checkout account signup. Guests pass through unverified (intentional). Customers who tick "Create an account" during checkout are verified after the order — the first order may bypass verification. See the related warning above to close that gap.', 'wpcoretools-email-verify' ); } elseif ( $guest && ! $signup ) { $detail = __( 'WooCommerce allows guest checkout but does not let customers create accounts at the checkout step. Guests check out unverified (intentional). Customers who register from My Account or the registration page must verify before their next checkout.', 'wpcoretools-email-verify' ); } elseif ( ! $guest && $signup ) { $detail = $we_block_signup ? __( 'WooCommerce requires a logged-in account for checkout, but lets customers create one at checkout. With "Block signup at checkout" on (✓ enabled), customers must register and verify separately before placing their first order — equivalent to "all orders verified."', 'wpcoretools-email-verify' ) : __( 'WooCommerce requires a logged-in account for checkout AND lets customers create one at checkout. Their first order is placed before our verification email arrives. See the related warning above — turning on "Block signup at checkout" closes this completely.', 'wpcoretools-email-verify' ); } else { $detail = __( 'WooCommerce requires customers to register and log in before checkout. Combined with this plugin\'s restrictions, every order is placed by a verified account — strictest configuration.', 'wpcoretools-email-verify' ); } return array( array( 'code' => 'wc_checkout_mode', 'severity' => 'info', 'title' => __( 'Checkout configuration', 'wpcoretools-email-verify' ), 'detail' => $detail, ), ); } /* ─── Admin notice ───────────────────────────────────────────────────── */ /** * Screens the conflict notices may appear on: the plugin's own pages, the * Dashboard and the Plugins screen. Nowhere else. * * @return string[] Screen ids. */ private static function notice_screens() { return array( 'dashboard', 'plugins', 'settings_page_' . WPCEV_Admin::PAGE_SLUG, WPCEV_Wizard::SCREEN_ID, 'users_page_' . WPCEV_Verifications_Page::PAGE_SLUG ); } public function maybe_render_admin_notice() { if ( ! current_user_can( 'manage_options' ) ) { return; } // Only render on plugin screens + the dashboard, to avoid notice spam. $screen = get_current_screen(); if ( ! $screen ) { return; } if ( ! in_array( $screen->id, self::notice_screens(), true ) ) { return; } $issues = $this->detect(); if ( empty( $issues ) ) { return; } // `info` severity is descriptive ("here's what's happening on your // site"), not advisory. Surface it only on our own settings + wizard // pages, never on the dashboard or plugins screen — that would just // be noise on every admin pageload. $on_our_screen = ( 'settings_page_' . WPCEV_Admin::PAGE_SLUG === $screen->id || WPCEV_Wizard::SCREEN_ID === $screen->id || 'users_page_' . WPCEV_Verifications_Page::PAGE_SLUG === $screen->id ); $user_id = get_current_user_id(); foreach ( $issues as $issue ) { if ( 'info' === $issue['severity'] && ! $on_our_screen ) { continue; } $dismiss_key = self::DISMISS_META_PREFIX . md5( $issue['code'] ); if ( get_user_meta( $user_id, $dismiss_key, true ) ) { continue; } switch ( $issue['severity'] ) { case 'high': $class = 'notice-error'; break; case 'medium': $class = 'notice-warning'; break; case 'info': $class = 'notice-info wpcev-info-notice'; break; default: $class = 'notice-info'; } ?>

array() ) ); ?>

. */ public static function format_detail( $detail ) { return nl2br( esc_html( (string) $detail ) ); } /** * Findings that mean "another verifier is active": what the notices, the * Site Health test and the wizard single out. * * @param array $issues Result of detect(). * @return array */ public static function duplicate_verifier_issues( $issues ) { $out = array(); foreach ( (array) $issues as $issue ) { if ( 0 === strpos( $issue['code'], 'duplicate_verifier:' ) ) { $out[] = $issue; } } return $out; } private function dismiss_url( $code ) { return wp_nonce_url( add_query_arg( array( 'action' => 'wpcev_dismiss_conflict', 'code' => rawurlencode( $code ), ), admin_url( 'admin-post.php' ) ), 'wpcev_dismiss_conflict' ); } /** * The scan control: when it last ran, what it covered and a button that * runs it again. Used by the wizard's safety step and the Tools tab. * * @param string $back Where to come back to: 'wizard' | 'tools'. */ public function render_scan_panel( $back ) { $scan = WPCEV_Scanner::last_result(); ?>

run(); $this->bust_cache(); $return = isset( $_POST['return'] ) ? sanitize_key( wp_unslash( $_POST['return'] ) ) : ''; if ( 'wizard' === $return ) { $url = add_query_arg( array( 'page' => WPCEV_Wizard::PAGE_SLUG, 'step' => WPCEV_Wizard::STEP_CONFLICTS, 'wpcev_scanned' => 1, ), admin_url( 'admin.php' ) ); } else { $url = add_query_arg( array( 'page' => WPCEV_Admin::PAGE_SLUG, 'tab' => 'tools', 'wpcev_scanned' => 1, ), admin_url( 'options-general.php' ) ); } wp_safe_redirect( $url ); exit; } /** * The script that makes the notice's X dismiss the finding for good (the * same user meta as the "Dismiss permanently" link). Loaded only on the * screens that can show a notice, and only for users who can see one. * * @param string $hook Current admin page hook. */ public function enqueue_dismiss_script( $hook ) { if ( ! current_user_can( 'manage_options' ) || ! in_array( $hook, array_merge( array( 'index.php', 'plugins.php' ), self::notice_screens() ), true ) ) { return; } wp_enqueue_script( 'wpcev-conflict-dismiss', WPCEV_PLUGIN_URL . 'assets/js/conflict-dismiss.js', array(), WPCEV_VERSION, true ); wp_add_inline_script( 'wpcev-conflict-dismiss', 'var wpcevConflictDismiss = ' . wp_json_encode( array( 'ajaxUrl' => admin_url( 'admin-ajax.php' ), 'nonce' => wp_create_nonce( 'wpcev_dismiss_conflict' ), ) ) . ';', 'before' ); } /** * Remember a dismissed finding for the current user. * * @param string $code Finding code. */ private function dismiss( $code ) { if ( '' !== $code ) { update_user_meta( get_current_user_id(), self::DISMISS_META_PREFIX . md5( $code ), 1 ); } } public function handle_dismiss() { if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'You do not have permission to do this.', 'wpcoretools-email-verify' ) ); } check_admin_referer( 'wpcev_dismiss_conflict' ); $this->dismiss( isset( $_GET['code'] ) ? sanitize_text_field( wp_unslash( $_GET['code'] ) ) : '' ); wp_safe_redirect( wp_get_referer() ? wp_get_referer() : admin_url() ); exit; } /** * The notice's X button (admin-ajax). Same check, same meta as the link. */ public function handle_dismiss_ajax() { if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( null, 403 ); } check_ajax_referer( 'wpcev_dismiss_conflict', 'nonce' ); $this->dismiss( isset( $_POST['code'] ) ? sanitize_text_field( wp_unslash( $_POST['code'] ) ) : '' ); wp_send_json_success(); } }