|null */ private ?array $cached_settings = null; /** * Tracks whether the site settings form was submitted in the current request. * * @var bool */ private bool $site_settings_submitted = false; /** * Tracks whether the site settings option was updated in the current request. * * @var bool */ private bool $site_settings_option_updated = false; /** * Tracks the settings scope used for the current request. * * @var string */ private string $current_scope = self::SCOPE_SITE; /** * Retrieves the current configuration mode. * * @return string */ public static function get_configuration_mode(): string { if ( ! is_multisite() ) { return self::MODE_SITE; } $mode = get_site_option( self::NETWORK_MODE_OPTION_NAME, self::MODE_NETWORK ); return self::MODE_NETWORK === $mode ? self::MODE_NETWORK : self::MODE_SITE; } /** * Determines whether the plugin operates in network-wide configuration mode. * * @return bool */ public static function is_network_mode_enabled(): bool { return self::MODE_NETWORK === self::get_configuration_mode(); } /** * Updates the internal scope tracker and resets caches when the scope changes. * * @param string $scope Scope identifier. * * @return void */ private function set_scope( string $scope ): void { $normalized = self::SCOPE_NETWORK === $scope ? self::SCOPE_NETWORK : self::SCOPE_SITE; if ( $normalized !== $this->current_scope ) { $this->current_scope = $normalized; $this->cached_settings = null; } } /** * Retrieves the scope currently in use. * * @return string */ private function get_scope(): string { return $this->current_scope; } /** * Determines the settings option name for a given scope. * * @param string|null $scope Optional scope identifier. * * @return string */ private function get_option_name_for_scope( ?string $scope = null ): string { $scope = $scope ?? $this->get_scope(); return self::SCOPE_NETWORK === $scope ? self::NETWORK_OPTION_NAME : self::OPTION_NAME; } /** * Builds the input name for a settings field based on the active scope. * * @param string $field Field identifier. * * @return string */ private function get_settings_field_name( string $field ): string { return $this->get_option_name_for_scope() . '[' . $field . ']'; } /** * Retrieves the capability required to manage settings for a scope. * * @param string $scope Scope identifier. * * @return string */ private function get_scope_capability( string $scope ): string { return self::SCOPE_NETWORK === $scope ? self::NETWORK_CAPABILITY : self::CAPABILITY; } /** * Ensures the current user can manage the provided scope. * * @param string $scope Scope identifier. * * @return void */ private function ensure_capability( string $scope ): void { if ( ! current_user_can( $this->get_scope_capability( $scope ) ) ) { wp_die( esc_html__( 'You do not have permission to access this page.', 'robotstxt-smtp' ) ); } } /** * Determines the scope associated with the current admin area. * * @return string */ private function get_current_admin_scope(): string { return is_network_admin() ? self::SCOPE_NETWORK : self::SCOPE_SITE; } /** * Retrieves the admin URL for the provided scope. * * @param string $scope Scope identifier. * @param string $path Path appended to the admin URL. * * @return string */ private function get_admin_url_for_scope( string $scope, string $path ): string { return self::SCOPE_NETWORK === $scope ? network_admin_url( $path ) : admin_url( $path ); } /** * Retrieves the admin-post URL for a scope. * * @param string $scope Scope identifier. * * @return string */ private function get_admin_post_url_for_scope( string $scope ): string { unset( $scope ); return admin_url( 'admin-post.php' ); } /** * Registers WordPress hooks. * * @return void */ public function register_hooks(): void { if ( ! is_multisite() || ! self::is_network_mode_enabled() ) { add_action( 'admin_menu', array( $this, 'register_site_menu' ) ); } if ( is_multisite() ) { add_action( 'network_admin_menu', array( $this, 'register_network_menu' ) ); } add_action( 'admin_init', array( $this, 'register_settings' ) ); add_action( 'admin_post_robotstxt_smtp_test_email', array( $this, 'handle_test_email' ) ); add_action( 'admin_post_robotstxt_smtp_clear_logs', array( $this, 'handle_clear_logs' ) ); add_action( 'admin_post_robotstxt_smtp_save_network_settings', array( $this, 'handle_save_network_settings' ) ); add_action( 'admin_notices', array( $this, 'display_test_email_notice' ) ); add_action( 'network_admin_notices', array( $this, 'display_test_email_notice' ) ); add_action( 'shutdown', array( $this, 'maybe_send_pending_site_test' ) ); add_action( 'updated_option', array( $this, 'handle_settings_option_updated' ), 10, 3 ); add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_assets' ) ); } /** * Enqueues JavaScript assets for the admin pages. * * @param string $hook_suffix Current admin page hook suffix. * * @return void */ public function enqueue_assets( string $hook_suffix ): void { if ( false === strpos( $hook_suffix, 'robotstxt-smtp' ) ) { return; } wp_enqueue_style( 'robotstxt-smtp-admin-style', ROBOTSTXT_SMTP_URL . 'assets/css/admin.css', array(), ROBOTSTXT_SMTP_VERSION ); wp_enqueue_script( 'robotstxt-smtp-admin', ROBOTSTXT_SMTP_URL . 'assets/js/admin.js', array(), ROBOTSTXT_SMTP_VERSION, true ); } /** * Registers the admin menu for the single-site plugin pages. * * @return void */ public function register_site_menu(): void { add_menu_page( esc_html__( 'SMTP (by ROBOTSTXT)', 'robotstxt-smtp' ), esc_html__( 'SMTP', 'robotstxt-smtp' ), self::CAPABILITY, self::MENU_SLUG, array( $this, 'render_site_settings_page' ), 'dashicons-email-alt2' ); add_submenu_page( self::MENU_SLUG, esc_html__( 'SMTP (by ROBOTSTXT)', 'robotstxt-smtp' ), esc_html__( 'Settings', 'robotstxt-smtp' ), self::CAPABILITY, self::MENU_SLUG, array( $this, 'render_site_settings_page' ) ); add_submenu_page( self::MENU_SLUG, esc_html__( 'SMTP Test', 'robotstxt-smtp' ), esc_html__( 'Test', 'robotstxt-smtp' ), self::CAPABILITY, self::TEST_PAGE_SLUG, array( $this, 'render_test_page' ) ); add_submenu_page( self::MENU_SLUG, esc_html__( 'SMTP Tools', 'robotstxt-smtp' ), esc_html__( 'Tools', 'robotstxt-smtp' ), self::CAPABILITY, self::TOOLS_PAGE_SLUG, array( $this, 'render_tools_page' ) ); add_submenu_page( self::MENU_SLUG, esc_html__( 'SMTP Logs', 'robotstxt-smtp' ), esc_html__( 'Logs', 'robotstxt-smtp' ), self::CAPABILITY, self::LOGS_PAGE_SLUG, array( $this, 'render_logs_page' ) ); } /** * Registers the admin menu for the network-level pages when multisite is enabled. * * @return void */ public function register_network_menu(): void { if ( ! current_user_can( self::NETWORK_CAPABILITY ) ) { return; } add_menu_page( esc_html__( 'SMTP (by ROBOTSTXT)', 'robotstxt-smtp' ), esc_html__( 'SMTP', 'robotstxt-smtp' ), self::NETWORK_CAPABILITY, self::NETWORK_MENU_SLUG, array( $this, 'render_network_settings_page' ), 'dashicons-email-alt2' ); add_submenu_page( self::NETWORK_MENU_SLUG, esc_html__( 'SMTP (by ROBOTSTXT)', 'robotstxt-smtp' ), esc_html__( 'Settings', 'robotstxt-smtp' ), self::NETWORK_CAPABILITY, self::NETWORK_PAGE_SLUG, array( $this, 'render_network_settings_page' ) ); add_submenu_page( self::NETWORK_MENU_SLUG, esc_html__( 'SMTP Test', 'robotstxt-smtp' ), esc_html__( 'Test', 'robotstxt-smtp' ), self::NETWORK_CAPABILITY, self::TEST_PAGE_SLUG, array( $this, 'render_test_page' ) ); add_submenu_page( self::NETWORK_MENU_SLUG, esc_html__( 'SMTP Tools', 'robotstxt-smtp' ), esc_html__( 'Tools', 'robotstxt-smtp' ), self::NETWORK_CAPABILITY, self::TOOLS_PAGE_SLUG, array( $this, 'render_tools_page' ) ); add_submenu_page( self::NETWORK_MENU_SLUG, esc_html__( 'SMTP Logs', 'robotstxt-smtp' ), esc_html__( 'Logs', 'robotstxt-smtp' ), self::NETWORK_CAPABILITY, self::LOGS_PAGE_SLUG, array( $this, 'render_logs_page' ) ); } /** * Registers plugin settings, sections, and fields. * * @return void */ public function register_settings(): void { if ( is_multisite() && self::is_network_mode_enabled() && ! is_network_admin() ) { return; } if ( is_network_admin() ) { // Network settings are handled manually via the admin-post handler. return; } $this->set_scope( self::SCOPE_SITE ); register_setting( self::SITE_SETTINGS_GROUP, self::OPTION_NAME, array( 'sanitize_callback' => array( $this, 'sanitize_options' ), ) ); add_settings_section( 'robotstxt_smtp_connection_section', esc_html__( 'SMTP Server Configuration', 'robotstxt-smtp' ), array( $this, 'render_connection_section_description' ), self::PAGE_SLUG ); $this->register_connection_fields(); add_settings_section( 'robotstxt_smtp_logs_section', esc_html__( 'Email Log Settings', 'robotstxt-smtp' ), array( $this, 'render_logs_section_description' ), self::PAGE_SLUG ); $this->register_logging_fields(); } /** * Registers fields related to the SMTP connection. * * @return void */ private function register_connection_fields(): void { foreach ( $this->get_connection_field_definitions() as $field ) { $args = array(); if ( ! empty( $field['label_for'] ) ) { $args['label_for'] = $field['label_for']; } add_settings_field( $field['id'], $field['label'], $field['callback'], self::PAGE_SLUG, 'robotstxt_smtp_connection_section', $args ); } } /** * Registers the logging configuration fields. * * @return void */ private function register_logging_fields(): void { foreach ( $this->get_logging_field_definitions() as $field ) { $args = array(); if ( ! empty( $field['label_for'] ) ) { $args['label_for'] = $field['label_for']; } add_settings_field( $field['id'], $field['label'], $field['callback'], self::PAGE_SLUG, 'robotstxt_smtp_logs_section', $args ); } } /** * Provides the field definitions for the SMTP connection settings. * * @return array> */ private function get_connection_field_definitions(): array { if ( Plugin::is_amazon_ses_integration_active() ) { $fields = array(); } else { $fields = array( array( 'id' => 'robotstxt_smtp_host', 'label' => esc_html__( 'Host', 'robotstxt-smtp' ), 'callback' => array( $this, 'render_host_field' ), 'label_for' => 'robotstxt_smtp_host', ), array( 'id' => 'robotstxt_smtp_username', 'label' => esc_html__( 'Username', 'robotstxt-smtp' ), 'callback' => array( $this, 'render_username_field' ), 'label_for' => 'robotstxt_smtp_username', ), array( 'id' => 'robotstxt_smtp_password', 'label' => esc_html__( 'Password', 'robotstxt-smtp' ), 'callback' => array( $this, 'render_password_field' ), 'label_for' => 'robotstxt_smtp_password', ), array( 'id' => 'robotstxt_smtp_from_email', 'label' => esc_html__( 'From Email', 'robotstxt-smtp' ), 'callback' => array( $this, 'render_from_email_field' ), 'label_for' => 'robotstxt_smtp_from_email', ), array( 'id' => 'robotstxt_smtp_from_name', 'label' => esc_html__( 'From Name', 'robotstxt-smtp' ), 'callback' => array( $this, 'render_from_name_field' ), 'label_for' => 'robotstxt_smtp_from_name', ), array( 'id' => 'robotstxt_smtp_security', 'label' => esc_html__( 'Security Type', 'robotstxt-smtp' ), 'callback' => array( $this, 'render_security_field' ), 'label_for' => 'robotstxt_smtp_security', ), array( 'id' => 'robotstxt_smtp_port', 'label' => esc_html__( 'Port', 'robotstxt-smtp' ), 'callback' => array( $this, 'render_port_field' ), 'label_for' => 'robotstxt_smtp_port', ), ); } /** * Filters the SMTP connection field definitions. * * @since 1.0.1 * * @param array> $fields Field definitions. * @param Settings_Page $settings_page Settings page instance. */ return (array) \apply_filters( 'robotstxt_smtp_connection_field_definitions', $fields, $this ); } /** * Provides the field definitions for the logging settings. * * @return array> */ private function get_logging_field_definitions(): array { return array( array( 'id' => 'robotstxt_smtp_logs_enabled', 'label' => esc_html__( 'Enable logging', 'robotstxt-smtp' ), 'callback' => array( $this, 'render_logs_enabled_field' ), 'label_for' => 'robotstxt_smtp_logs_enabled', ), array( 'id' => 'robotstxt_smtp_logs_retention_mode', 'label' => esc_html__( 'Retention mode', 'robotstxt-smtp' ), 'callback' => array( $this, 'render_logs_retention_mode_field' ), ), array( 'id' => 'robotstxt_smtp_logs_retention_count', 'label' => esc_html__( 'Maximum stored emails', 'robotstxt-smtp' ), 'callback' => array( $this, 'render_logs_retention_count_field' ), 'label_for' => 'robotstxt_smtp_logs_retention_count', ), array( 'id' => 'robotstxt_smtp_logs_retention_days', 'label' => esc_html__( 'Retention in days', 'robotstxt-smtp' ), 'callback' => array( $this, 'render_logs_retention_days_field' ), 'label_for' => 'robotstxt_smtp_logs_retention_days', ), ); } /** * Outputs the provided fields in a table layout. * * @param array> $fields Field definitions. * * @return void */ private function render_field_rows( array $fields ): void { foreach ( $fields as $field ) { $label_for = isset( $field['label_for'] ) ? (string) $field['label_for'] : ''; ?> ' . esc_html__( 'Amazon SES integration is active. To configure SMTP manually, deactivate the Amazon SES add-on.', 'robotstxt-smtp' ) . '

'; return; } echo '

' . esc_html__( 'Configure the SMTP server used to deliver outgoing emails.', 'robotstxt-smtp' ) . '

'; } /** * Outputs the logs section description. * * @return void */ public function render_logs_section_description(): void { echo '

' . esc_html__( 'Control how email delivery logs are stored and pruned.', 'robotstxt-smtp' ) . '

'; } /** * Renders the settings page. * * @return void */ public function render_site_settings_page(): void { $this->ensure_capability( self::SCOPE_SITE ); $this->set_scope( self::SCOPE_SITE ); ?>

ensure_capability( self::SCOPE_NETWORK ); $this->set_scope( self::SCOPE_NETWORK ); $mode = self::get_configuration_mode(); ?>

render_field_rows( $this->get_connection_field_definitions() ); ?>

render_field_rows( $this->get_logging_field_definitions() ); ?>
ensure_capability( $scope ); $nonce_value = filter_input( INPUT_POST, '_wpnonce', FILTER_UNSAFE_RAW ); // Abort when the security nonce is missing or invalid to block forged submissions. if ( ! is_string( $nonce_value ) || ! wp_verify_nonce( wp_unslash( $nonce_value ), 'robotstxt_smtp_save_network_settings' ) ) { wp_die( esc_html__( 'The link you followed has expired.', 'robotstxt-smtp' ) ); } $mode = self::MODE_SITE; $submitted_mode = filter_input( INPUT_POST, 'robotstxt_smtp_mode', FILTER_UNSAFE_RAW ); if ( is_string( $submitted_mode ) ) { $submitted_mode = sanitize_key( wp_unslash( $submitted_mode ) ); if ( self::MODE_NETWORK === $submitted_mode ) { $mode = self::MODE_NETWORK; } } update_site_option( self::NETWORK_MODE_OPTION_NAME, $mode ); $options = array(); $raw_options = filter_input( INPUT_POST, self::NETWORK_OPTION_NAME, FILTER_UNSAFE_RAW, FILTER_REQUIRE_ARRAY ); if ( is_array( $raw_options ) ) { $options = map_deep( wp_unslash( $raw_options ), 'sanitize_text_field' ); } $this->set_scope( self::SCOPE_NETWORK ); $clean_options = $this->sanitize_options( $options ); update_site_option( self::NETWORK_OPTION_NAME, $clean_options ); $result = $this->send_test_email_for_scope( self::SCOPE_NETWORK, null, 'auto' ); $this->persist_test_result( $result ); $redirect = add_query_arg( array( 'page' => self::NETWORK_PAGE_SLUG, 'robotstxt_smtp_saved' => 1, 'robotstxt_smtp_test' => ! empty( $result['success'] ) ? 'success' : 'error', ), $this->get_admin_url_for_scope( $scope, 'admin.php' ) ); $redirect = add_query_arg( '_wpnonce', wp_create_nonce( 'robotstxt_smtp_saved_notice' ), $redirect ); wp_safe_redirect( $redirect ); exit; } /** * Renders the test page. * * @return void */ public function render_test_page(): void { $scope = $this->get_current_admin_scope(); $this->ensure_capability( $scope ); $this->set_scope( $scope ); ?>

' . esc_html__( 'The network configuration is currently disabled. Tests will use the site-specific settings stored below.', 'robotstxt-smtp' ) . '

'; } $this->render_test_email_form( $scope ); ?> get_current_admin_scope(); $this->ensure_capability( $scope ); $this->set_scope( $scope ); $requested_tool = ''; if ( isset( $_GET['robotstxt_smtp_tool'] ) ) { $requested_tool = sanitize_key( wp_unslash( (string) $_GET['robotstxt_smtp_tool'] ) ); } $force_refresh = false; if ( isset( $_GET['robotstxt_smtp_refresh'] ) && '' !== $requested_tool ) { $nonce = isset( $_GET['_wpnonce'] ) ? sanitize_text_field( wp_unslash( (string) $_GET['_wpnonce'] ) ) : ''; if ( wp_verify_nonce( $nonce, 'robotstxt_smtp_refresh_tool_' . $requested_tool ) ) { $force_refresh = true; } } $tools = array( 'mx' => array( 'title' => esc_html__( 'Automatic MX Lookup', 'robotstxt-smtp' ), 'description' => esc_html__( 'Detects the domain from the configured sender address, retrieves its MX records, and compares them with the SMTP host.', 'robotstxt-smtp' ), 'callback' => array( $this, 'get_tools_mx_section_html' ), ), 'authentication' => array( 'title' => esc_html__( 'SPF, DKIM, and DMARC Validation', 'robotstxt-smtp' ), 'description' => esc_html__( 'Checks common DNS TXT records for the sender domain to help you verify email authentication.', 'robotstxt-smtp' ), 'callback' => array( $this, 'get_tools_authentication_section_html' ), ), 'diagnostics' => array( 'title' => esc_html__( 'Extended SMTP Diagnostics', 'robotstxt-smtp' ), 'description' => esc_html__( 'Reuses the configured SMTP connection to inspect the server greeting, TLS support, available authentication mechanisms, and EHLO/HELO response codes.', 'robotstxt-smtp' ), 'callback' => array( $this, 'get_tools_smtp_diagnostics_section_html' ), ), 'blacklist' => array( 'title' => esc_html__( 'Blacklist Lookup', 'robotstxt-smtp' ), 'description' => esc_html__( 'Checks the SMTP host IP address against well-known DNS-based reputation lists. Use the feature responsibly and respect each provider’s terms of use.', 'robotstxt-smtp' ), 'callback' => array( $this, 'get_tools_blacklist_section_html' ), ), ); $base_url = $this->get_admin_url_for_scope( $scope, 'admin.php' ); ?>

$tool_definition ) : ?> get_tool_result_for_display( $scope, $tool_key, $tool_definition['callback'], $run_now ); $has_result = null !== $result; $refresh_url = add_query_arg( array( 'page' => self::TOOLS_PAGE_SLUG, 'robotstxt_smtp_tool' => $tool_key, 'robotstxt_smtp_refresh' => '1', ), $base_url ); $refresh_url = wp_nonce_url( $refresh_url, 'robotstxt_smtp_refresh_tool_' . $tool_key ); ?>

format_tool_timestamp( isset( $result['timestamp'] ) ? (int) $result['timestamp'] : 0 ); ?>

|null */ private function get_tool_result_for_display( string $scope, string $tool, callable $callback, bool $refresh ): ?array { $cached = $this->get_cached_tool_result( $scope, $tool ); if ( $refresh ) { $html = (string) call_user_func( $callback, $scope ); $data = array( 'html' => $html, 'timestamp' => time(), ); $this->set_tool_result_cache( $scope, $tool, $data ); return $data; } return $cached; } /** * Retrieves the cached results for a tool. * * @param string $scope Scope identifier. * @param string $tool Tool identifier. * * @return array|null */ private function get_cached_tool_result( string $scope, string $tool ): ?array { $key = $this->get_tool_transient_key( $scope, $tool ); $value = self::SCOPE_NETWORK === $scope ? get_site_transient( $key ) : get_transient( $key ); if ( false === $value ) { return null; } if ( ! is_array( $value ) || ! isset( $value['html'], $value['timestamp'] ) ) { $this->delete_tool_result_cache( $scope, $tool ); return null; } return array( 'html' => (string) $value['html'], 'timestamp' => (int) $value['timestamp'], ); } /** * Stores the cached results for a tool. * * @param string $scope Scope identifier. * @param string $tool Tool identifier. * @param array $data Cached data. * * @return void */ private function set_tool_result_cache( string $scope, string $tool, array $data ): void { $payload = array( 'html' => isset( $data['html'] ) ? (string) $data['html'] : '', 'timestamp' => isset( $data['timestamp'] ) ? (int) $data['timestamp'] : time(), ); $key = $this->get_tool_transient_key( $scope, $tool ); if ( self::SCOPE_NETWORK === $scope ) { set_site_transient( $key, $payload, self::TOOLS_CACHE_LIFETIME ); return; } set_transient( $key, $payload, self::TOOLS_CACHE_LIFETIME ); } /** * Removes the cached results for a tool. * * @param string $scope Scope identifier. * @param string $tool Tool identifier. * * @return void */ private function delete_tool_result_cache( string $scope, string $tool ): void { $key = $this->get_tool_transient_key( $scope, $tool ); if ( self::SCOPE_NETWORK === $scope ) { delete_site_transient( $key ); return; } delete_transient( $key ); } /** * Builds the transient key for a tool and scope combination. * * @param string $scope Scope identifier. * @param string $tool Tool identifier. * * @return string */ private function get_tool_transient_key( string $scope, string $tool ): string { return self::TOOLS_TRANSIENT_PREFIX . $scope . '_' . $tool; } /** * Formats the last checked timestamp for display. * * @param int $timestamp Unix timestamp. * * @return string */ private function format_tool_timestamp( int $timestamp ): string { $timestamp = max( 0, $timestamp ); if ( 0 === $timestamp ) { return ''; } $date_format = (string) get_option( 'date_format', 'F j, Y' ); $time_format = (string) get_option( 'time_format', 'g:i a' ); $format = trim( $date_format . ' ' . $time_format ); if ( '' === $format ) { $format = 'F j, Y g:i a'; } return date_i18n( $format, $timestamp ); } /** * Generates the markup for the MX lookup tool. * * @param string $scope Scope identifier. * @return string */ private function get_tools_mx_section_html( string $scope ): string { $this->set_scope( $scope ); $settings = $this->get_settings(); $smtp_host = isset( $settings['host'] ) ? (string) $settings['host'] : ''; $from_email = isset( $settings['from_email'] ) ? (string) $settings['from_email'] : ''; $sender_domain = $this->extract_domain_from_email( $from_email ); $normalized_smtp_host = $this->normalize_hostname( $smtp_host ); $mx_analysis = array( 'domain' => $sender_domain, 'records' => array(), 'errors' => array(), 'matches_smtp_host' => false, ); if ( '' !== $sender_domain ) { $mx_analysis = $this->analyze_mx_records( $sender_domain, $normalized_smtp_host ); } ob_start(); if ( '' === $sender_domain ) : ?>


set_scope( $scope ); $settings = $this->get_settings(); $smtp_host = isset( $settings['host'] ) ? (string) $settings['host'] : ''; $smtp_port = isset( $settings['port'] ) ? (int) $settings['port'] : 0; $security = isset( $settings['security'] ) ? (string) $settings['security'] : 'none'; $normalized_host = $this->normalize_hostname( $smtp_host ); $security_options = $this->get_security_options(); $security_label = isset( $security_options[ $security ] ) ? $security_options[ $security ] : $security; ob_start(); if ( '' === $normalized_host ) : ?>

run_smtp_diagnostics( $settings ); if ( ! empty( $diagnostics['errors'] ) ) : ?>


( )

$settings SMTP settings. * * @return array */ private function run_smtp_diagnostics( array $settings ): array { $defaults = array( 'host' => '', 'port' => 25, 'security' => 'none', ); $settings = wp_parse_args( $settings, $defaults ); $host = $this->normalize_hostname( (string) $settings['host'] ); $port = (int) $settings['port']; $security = in_array( (string) $settings['security'], array( 'none', 'ssl', 'tls' ), true ) ? (string) $settings['security'] : 'none'; if ( $port <= 0 || $port > 65535 ) { $port = 25; } $result = array( 'success' => false, 'host' => $host, 'port' => $port, 'security' => $security, 'errors' => array(), 'warnings' => array(), 'banner' => '', 'banner_code' => '', 'ehlo' => array( 'code' => '', 'lines' => array(), 'timed_out' => false, ), 'ehlo_post_tls' => null, 'helo' => array( 'attempted' => false, 'code' => '', 'lines' => array(), 'timed_out' => false, ), 'tls' => array( 'mode' => $security, 'offered' => null, 'upgrade_attempted' => false, 'upgrade_response' => array( 'code' => '', 'lines' => array(), 'timed_out' => false, ), 'negotiated' => ( 'ssl' === $security ), ), 'auth_mechanisms' => array(), 'transcript' => array(), ); if ( '' === $host ) { $result['errors'][] = __( 'A valid SMTP host is required to run diagnostics.', 'robotstxt-smtp' ); return $result; } $client = $this->create_smtp_client(); $timeout = 15; $transport = 'ssl' === $security ? 'ssl://' . $host : $host; $client->setDebugOutput( static function ( $message, $level ) use ( &$result ) { unset( $level ); if ( ! is_string( $message ) ) { return; } $message = trim( $message ); if ( '' === $message ) { return; } if ( str_starts_with( $message, 'CLIENT -> SERVER:' ) ) { $result['transcript'][] = 'C: ' . trim( substr( $message, strlen( 'CLIENT -> SERVER:' ) ) ); return; } if ( str_starts_with( $message, 'SERVER -> CLIENT:' ) ) { $result['transcript'][] = 'S: ' . trim( substr( $message, strlen( 'SERVER -> CLIENT:' ) ) ); } } ); // phpcs:disable WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase $client->do_debug = SMTP::DEBUG_SERVER; $client->Timeout = $timeout; $client->Timelimit = $timeout; // phpcs:enable WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase $connected = $client->connect( $transport, $port, $timeout ); if ( ! $connected ) { $error = $client->getError(); $error_code = isset( $error['smtp_code'] ) && is_numeric( $error['smtp_code'] ) ? (int) $error['smtp_code'] : 0; $message = ''; foreach ( array( 'smtp_code_ex', 'detail', 'error' ) as $key ) { if ( ! empty( $error[ $key ] ) ) { $message = (string) $error[ $key ]; break; } } if ( '' === $message ) { $message = __( 'Unknown error', 'robotstxt-smtp' ); } $result['errors'][] = sprintf( /* translators: 1: Error message. 2: Error code. */ __( 'Connection failed: %1$s (%2$d).', 'robotstxt-smtp' ), $message, $error_code ); $client->close(); return $result; } $banner = $this->parse_smtp_reply( $client->getLastReply() ); if ( '' !== $banner['code'] ) { $result['banner_code'] = $banner['code']; } if ( ! empty( $banner['lines'] ) ) { $first_line = preg_replace( '/^\d{3}[ \-]/', '', (string) $banner['lines'][0] ); $result['banner'] = is_string( $first_line ) ? trim( $first_line ) : ''; } if ( '' !== $result['banner_code'] && '220' !== $result['banner_code'] ) { $result['warnings'][] = __( 'The server greeting returned an unexpected status code.', 'robotstxt-smtp' ); } $ehlo_domain = $this->get_ehlo_hostname(); $ehlo_command = $client->execute_command( 'EHLO', 'EHLO ' . $ehlo_domain ); $ehlo_response = $this->parse_smtp_reply( $ehlo_command['reply'], $ehlo_command['timed_out'] ); $result['ehlo'] = $ehlo_response; $capabilities = $this->parse_ehlo_capabilities( $ehlo_response['lines'] ); $result['auth_mechanisms'] = $capabilities['auth_mechanisms']; $result['tls']['offered'] = $capabilities['has_starttls']; if ( '' === $ehlo_response['code'] ) { $result['warnings'][] = __( 'The EHLO response could not be parsed.', 'robotstxt-smtp' ); } elseif ( '250' !== $ehlo_response['code'] ) { $result['warnings'][] = __( 'The server did not accept the EHLO command.', 'robotstxt-smtp' ); } if ( 'tls' === $security ) { if ( empty( $capabilities['has_starttls'] ) ) { $result['warnings'][] = __( 'The server did not advertise STARTTLS support.', 'robotstxt-smtp' ); } else { $result['tls']['upgrade_attempted'] = true; $tls_negotiated = $client->startTLS(); $starttls_reply = $this->parse_smtp_reply( $client->getLastReply(), $client->did_last_command_timeout() ); $result['tls']['upgrade_response'] = $starttls_reply; if ( $tls_negotiated ) { $result['tls']['negotiated'] = true; $post_tls_command = $client->execute_command( 'EHLO', 'EHLO ' . $ehlo_domain ); $post_tls_response = $this->parse_smtp_reply( $post_tls_command['reply'], $post_tls_command['timed_out'] ); $result['ehlo_post_tls'] = $post_tls_response; $post_tls_capabilities = $this->parse_ehlo_capabilities( $post_tls_response['lines'] ); if ( ! empty( $post_tls_capabilities['auth_mechanisms'] ) ) { $result['auth_mechanisms'] = $post_tls_capabilities['auth_mechanisms']; } } else { $result['warnings'][] = __( 'The TLS negotiation failed after STARTTLS.', 'robotstxt-smtp' ); } } } if ( 'none' === $security && ! empty( $capabilities['has_starttls'] ) ) { $result['warnings'][] = __( 'The server supports STARTTLS. Enable TLS in the configuration to protect the connection.', 'robotstxt-smtp' ); } if ( empty( $result['auth_mechanisms'] ) ) { $result['warnings'][] = __( 'No authentication mechanisms were advertised by the server.', 'robotstxt-smtp' ); } if ( '250' !== $ehlo_response['code'] ) { $result['helo']['attempted'] = true; $helo_command = $client->execute_command( 'HELO', 'HELO ' . $ehlo_domain ); $helo_response = $this->parse_smtp_reply( $helo_command['reply'], $helo_command['timed_out'] ); $result['helo']['code'] = $helo_response['code']; $result['helo']['lines'] = $helo_response['lines']; if ( '' === $helo_response['code'] || '250' !== $helo_response['code'] ) { $result['warnings'][] = __( 'The legacy HELO command did not return a successful status code.', 'robotstxt-smtp' ); } } $client->execute_command( 'QUIT', 'QUIT', array( 221 ) ); $client->close(); if ( ! $result['tls']['negotiated'] ) { $result['warnings'][] = __( 'The connection is not encrypted. Credentials may travel in plain text.', 'robotstxt-smtp' ); } $result['auth_mechanisms'] = array_values( array_unique( array_map( 'strtoupper', $result['auth_mechanisms'] ) ) ); $result['warnings'] = array_values( array_unique( $result['warnings'] ) ); $result['errors'] = array_values( array_unique( $result['errors'] ) ); $result['success'] = empty( $result['errors'] ); return $result; } /** * Determines the hostname used when sending EHLO/HELO commands. * * @return string */ private function get_ehlo_hostname(): string { $home = home_url( '/' ); if ( is_string( $home ) ) { $parsed = wp_parse_url( $home ); if ( is_array( $parsed ) && isset( $parsed['host'] ) && '' !== $parsed['host'] ) { return strtolower( rtrim( (string) $parsed['host'], '.' ) ); } } if ( function_exists( 'gethostname' ) ) { $hostname = gethostname(); if ( is_string( $hostname ) && '' !== $hostname ) { return strtolower( rtrim( $hostname, '.' ) ); } } return 'localhost'; } /** * Parses a raw SMTP reply string. * * @param string $reply Raw reply returned by the server. * @param bool $timed_out Whether the request timed out. * * @return array */ private function parse_smtp_reply( string $reply, bool $timed_out = false ): array { $lines = array(); $code = ''; if ( '' !== $reply ) { $normalized = str_replace( array( "\r\n", "\r" ), "\n", $reply ); $parts = array_filter( array_map( 'trim', explode( "\n", trim( $normalized ) ) ), 'strlen' ); foreach ( $parts as $line ) { $lines[] = $line; if ( '' === $code && preg_match( '/^(\d{3})/', $line, $matches ) ) { $code = $matches[1]; } } } return array( 'code' => $code, 'lines' => $lines, 'timed_out' => $timed_out, ); } /** * Creates an SMTP diagnostics client instance. * * @return SMTP_Diagnostics_Client */ private function create_smtp_client(): SMTP_Diagnostics_Client { if ( ! class_exists( SMTP::class ) ) { require_once ABSPATH . WPINC . '/PHPMailer/SMTP.php'; } return new SMTP_Diagnostics_Client(); } /** * Parses the EHLO response to extract advertised capabilities. * * @param array $lines Response lines. * * @return array */ private function parse_ehlo_capabilities( array $lines ): array { $result = array( 'raw' => array(), 'has_starttls' => false, 'auth_mechanisms' => array(), ); foreach ( $lines as $line ) { $text = (string) $line; if ( preg_match( '/^\d{3}[ \-](.*)$/', $text, $matches ) ) { $text = trim( (string) $matches[1] ); } else { $text = trim( $text ); } if ( '' === $text ) { continue; } $result['raw'][] = $text; $upper = strtoupper( $text ); if ( 0 === strpos( $upper, 'STARTTLS' ) ) { $result['has_starttls'] = true; } if ( 0 === strpos( $upper, 'AUTH' ) ) { $after_auth = trim( substr( $text, 4 ) ); $after_auth = ltrim( $after_auth, '= ' ); if ( '' !== $after_auth ) { $mechanisms = preg_split( '/\s+/', $after_auth ); if ( is_array( $mechanisms ) ) { foreach ( $mechanisms as $mechanism ) { $mechanism = strtoupper( trim( (string) $mechanism ) ); if ( '' !== $mechanism ) { $result['auth_mechanisms'][] = $mechanism; } } } } } } $result['auth_mechanisms'] = array_values( array_unique( $result['auth_mechanisms'] ) ); return $result; } /** * Generates the markup for the SPF, DKIM, and DMARC checks. * * @param string $scope Scope identifier. * @return string */ private function get_tools_authentication_section_html( string $scope ): string { $this->set_scope( $scope ); $settings = $this->get_settings(); $smtp_host = isset( $settings['host'] ) ? (string) $settings['host'] : ''; $from_email = isset( $settings['from_email'] ) ? (string) $settings['from_email'] : ''; $sender_domain = $this->extract_domain_from_email( $from_email ); $normalized_smtp_host = $this->normalize_hostname( $smtp_host ); $mx_analysis = array( 'domain' => $sender_domain, 'records' => array(), 'errors' => array(), 'matches_smtp_host' => false, ); $host_ips = $this->get_hostname_ip_addresses( $normalized_smtp_host ); if ( '' !== $sender_domain ) { $mx_analysis = $this->analyze_mx_records( $sender_domain, $normalized_smtp_host ); } ob_start(); if ( '' === $sender_domain ) : ?>

analyze_domain_authentication( $sender_domain, $normalized_smtp_host, $mx_analysis, $host_ips ); if ( ! empty( $auth_analysis['errors'] ) ) : ?>

set_scope( $scope ); $settings = $this->get_settings(); $smtp_host = isset( $settings['host'] ) ? (string) $settings['host'] : ''; $normalized_smtp_host = $this->normalize_hostname( $smtp_host ); $host_ips = $this->get_hostname_ip_addresses( $normalized_smtp_host ); $analysis = $this->analyze_blacklist_status( $host_ips ); ob_start(); if ( ! empty( $analysis['errors'] ) ) : ?>


get_current_admin_scope(); $this->ensure_capability( $scope ); $this->set_scope( $scope ); $settings = $this->get_settings(); $logging_enabled = ! empty( $settings['logs_enabled'] ); $nonce_action = $this->get_logs_nonce_action(); $nonce_value = filter_input( INPUT_GET, 'robotstxt_smtp_logs_nonce', FILTER_UNSAFE_RAW ); // Only honor filter arguments after confirming that the nonce matches the filter form submission. $nonce_valid = is_string( $nonce_value ) && '' !== $nonce_value && wp_verify_nonce( wp_unslash( $nonce_value ), $nonce_action ); $nonce_required = $this->is_logs_request_using_user_input(); $request_args = array(); if ( $nonce_required && ! $nonce_valid ) { $log_id = 0; $paged = 1; $filters = $this->get_default_log_filters(); } else { if ( $nonce_valid ) { $request_args = $this->get_sanitized_logs_request_args(); } $log_id = $this->get_requested_log_id( $request_args ); $paged = $this->get_requested_paged( $request_args ); $filters = $this->get_log_filters( $request_args ); } $logs_nonce = wp_create_nonce( $nonce_action ); ?>

0 ) { $this->render_log_details( $log_id, $scope, $filters, $logs_nonce ); } else { $this->render_logs_list( $paged, $scope, $filters, $logs_nonce ); } ?>
Filter values. */ private function get_logs_nonce_action(): string { return 'robotstxt_smtp_manage_logs'; } /** * Determines whether the current request attempts to filter or paginate the logs list. * * @return bool */ private function is_logs_request_using_user_input(): bool { if ( filter_has_var( INPUT_GET, 'paged' ) ) { return true; } $keys = array( 'robotstxt_smtp_subject', 'robotstxt_smtp_to', 'robotstxt_smtp_date_from', 'robotstxt_smtp_date_to', 'robotstxt_smtp_per_page', 'log_id', ); foreach ( $keys as $key ) { if ( filter_has_var( INPUT_GET, $key ) ) { return true; } } return false; } /** * Retrieves the sanitized request parameters used to filter the logs list. * * @return array */ private function get_sanitized_logs_request_args(): array { $request = array(); $log_id = filter_input( INPUT_GET, 'log_id', FILTER_UNSAFE_RAW ); if ( null !== $log_id && '' !== $log_id ) { // Cast to an absolute integer before using the requested log identifier. $request['log_id'] = absint( wp_unslash( (string) $log_id ) ); } $paged = filter_input( INPUT_GET, 'paged', FILTER_UNSAFE_RAW ); if ( null !== $paged && '' !== $paged ) { // Force pagination to a positive integer to avoid loading invalid pages. $request['paged'] = max( 1, absint( wp_unslash( (string) $paged ) ) ); } $subject = filter_input( INPUT_GET, 'robotstxt_smtp_subject', FILTER_UNSAFE_RAW ); if ( is_string( $subject ) && '' !== $subject ) { // Sanitize the email subject search term before passing it to WP_Query. $request['robotstxt_smtp_subject'] = sanitize_text_field( wp_unslash( $subject ) ); } $recipient = filter_input( INPUT_GET, 'robotstxt_smtp_to', FILTER_UNSAFE_RAW ); if ( is_string( $recipient ) && '' !== $recipient ) { // Normalize the recipient filter to plain text for safe database queries. $request['robotstxt_smtp_to'] = sanitize_text_field( wp_unslash( $recipient ) ); } $date_from = filter_input( INPUT_GET, 'robotstxt_smtp_date_from', FILTER_UNSAFE_RAW ); if ( is_string( $date_from ) && '' !== $date_from ) { // Validate the start date to guard against malformed date strings. $request['robotstxt_smtp_date_from'] = $this->sanitize_log_date( $date_from ); } $date_to = filter_input( INPUT_GET, 'robotstxt_smtp_date_to', FILTER_UNSAFE_RAW ); if ( is_string( $date_to ) && '' !== $date_to ) { // Validate the end date to guard against malformed date strings. $request['robotstxt_smtp_date_to'] = $this->sanitize_log_date( $date_to ); } $per_page = filter_input( INPUT_GET, 'robotstxt_smtp_per_page', FILTER_UNSAFE_RAW ); if ( null !== $per_page && '' !== $per_page ) { // Clamp the per-page setting to a non-negative integer before building the query. $request['robotstxt_smtp_per_page'] = absint( wp_unslash( (string) $per_page ) ); } return $request; } /** * Retrieves the requested log identifier from the current request. * * @param array $request Request arguments. * * @return int Log identifier if provided, otherwise 0. */ private function get_requested_log_id( array $request ): int { if ( ! isset( $request['log_id'] ) ) { return 0; } return (int) $request['log_id']; } /** * Retrieves the requested page number when the nonce is valid. * * @param array $request Request arguments. * * @return int */ private function get_requested_paged( array $request ): int { if ( ! isset( $request['paged'] ) ) { return 1; } return (int) $request['paged']; } /** * Returns the default filter values for the logs list. * * @return array */ private function get_default_log_filters(): array { return array( 'subject' => '', 'to' => '', 'date_from' => '', 'date_to' => '', 'per_page' => 100, ); } /** * Retrieves the current filters applied to the logs list. * * @param array $request Request arguments. * * @return array Filter values. */ private function get_log_filters( array $request ): array { $filters = $this->get_default_log_filters(); if ( isset( $request['robotstxt_smtp_subject'] ) ) { $filters['subject'] = $request['robotstxt_smtp_subject']; } if ( isset( $request['robotstxt_smtp_to'] ) ) { $filters['to'] = $request['robotstxt_smtp_to']; } if ( isset( $request['robotstxt_smtp_date_from'] ) ) { $filters['date_from'] = $request['robotstxt_smtp_date_from']; } if ( isset( $request['robotstxt_smtp_date_to'] ) ) { $filters['date_to'] = $request['robotstxt_smtp_date_to']; } $allowed_per_page = array( 10, 25, 50, 100, 250, 500 ); if ( isset( $request['robotstxt_smtp_per_page'] ) ) { $requested_per_page = (int) $request['robotstxt_smtp_per_page']; if ( in_array( $requested_per_page, $allowed_per_page, true ) ) { $filters['per_page'] = $requested_per_page; } } return $filters; } /** * Builds the query arguments that represent the current log filters. * * @param array $filters Filter values. * * @return array URL query arguments. */ private function get_log_filter_query_args( array $filters ): array { $query_args = array(); if ( '' !== $filters['subject'] ) { $query_args['robotstxt_smtp_subject'] = $filters['subject']; } if ( '' !== $filters['to'] ) { $query_args['robotstxt_smtp_to'] = $filters['to']; } if ( '' !== $filters['date_from'] ) { $query_args['robotstxt_smtp_date_from'] = $filters['date_from']; } if ( '' !== $filters['date_to'] ) { $query_args['robotstxt_smtp_date_to'] = $filters['date_to']; } if ( 100 !== $filters['per_page'] ) { $query_args['robotstxt_smtp_per_page'] = (string) $filters['per_page']; } return $query_args; } /** * Sanitizes a date string coming from the logs filters. * * @param string $value Raw date value. * * @return string Sanitized date in `Y-m-d` format or an empty string when invalid. */ private function sanitize_log_date( string $value ): string { $value = sanitize_text_field( $value ); if ( '' === $value ) { return ''; } $date = \DateTime::createFromFormat( 'Y-m-d', $value ); if ( ! $date ) { return ''; } $errors = \DateTime::getLastErrors(); if ( is_array( $errors ) && ( $errors['warning_count'] > 0 || $errors['error_count'] > 0 ) ) { return ''; } return $date->format( 'Y-m-d' ); } /** * Renders the table listing of log entries. * * @param int $paged Current page number. * @param string $scope Scope identifier (site or network). * @param array $filters Current filter values. * @param string $nonce Logs nonce value. * * @return void */ private function render_logs_list( int $paged, string $scope, array $filters, string $nonce ): void { $per_page = $filters['per_page']; $filter_query_args = $this->get_log_filter_query_args( $filters ); $query_args = array( 'post_type' => Plugin::get_log_post_type(), 'post_status' => 'publish', 'posts_per_page' => $per_page, 'paged' => $paged, 'orderby' => 'date', 'order' => 'DESC', ); if ( '' !== $filters['subject'] ) { $query_args['s'] = $filters['subject']; } if ( '' !== $filters['to'] ) { // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- Recipient filter requires meta_query. $query_args['meta_query'] = array( array( 'key' => '_robotstxt_smtp_to', 'value' => $filters['to'], 'compare' => 'LIKE', ), ); } if ( '' !== $filters['date_from'] || '' !== $filters['date_to'] ) { $date_query = array( 'inclusive' => true, ); if ( '' !== $filters['date_from'] ) { $date_query['after'] = $filters['date_from']; } if ( '' !== $filters['date_to'] ) { $date_query['before'] = $filters['date_to']; } $query_args['date_query'] = array( $date_query ); } $query = new \WP_Query( $query_args ); $base_url = remove_query_arg( array( 'paged', 'log_id', 'robotstxt_smtp_cleared' ), add_query_arg( array_merge( array( 'page' => self::LOGS_PAGE_SLUG, 'robotstxt_smtp_logs_nonce' => $nonce, ), $filter_query_args ), $this->get_admin_url_for_scope( $scope, 'admin.php' ) ) ); $reset_url = empty( $filter_query_args ) ? $base_url : remove_query_arg( array_keys( $filter_query_args ), $base_url ); ?>
get_logs_nonce_action(), 'robotstxt_smtp_logs_nonce', false ); ?>

"return confirm('" . $confirmation . "');", ) ); ?>
posts ) ) : ?>

posts as $log ) : ?> ID, '_robotstxt_smtp_from', true ); $to = get_post_meta( $log->ID, '_robotstxt_smtp_to', true ); $subject = $log->post_title ? $log->post_title : __( '(No subject)', 'robotstxt-smtp' ); $status_display = $this->get_log_status_display( (int) $log->ID ); $view_url = add_query_arg( array_merge( array( 'page' => self::LOGS_PAGE_SLUG, 'log_id' => $log->ID, 'robotstxt_smtp_logs_nonce' => $nonce, ), $filter_query_args ), $this->get_admin_url_for_scope( $scope, 'admin.php' ) ); $date = get_date_from_gmt( $log->post_date_gmt, get_option( 'date_format' ) . ' ' . get_option( 'time_format' ) ); ?>
add_query_arg( 'paged', '%#%', $base_url ), 'format' => '', 'current' => max( 1, $paged ), 'total' => max( 1, (int) $query->max_num_pages ), 'prev_text' => __( '«', 'robotstxt-smtp' ), 'next_text' => __( '»', 'robotstxt-smtp' ), ) ); if ( $pagination ) { echo '
' . wp_kses_post( $pagination ) . '
'; } wp_reset_postdata(); } /** * Displays the details of a single log entry. * * @param int $log_id Log post ID. * @param string $scope Scope identifier (site or network). * @param array $filters Current filter values. * @param string $nonce Logs nonce value. * * @return void */ private function render_log_details( int $log_id, string $scope, array $filters, string $nonce ): void { $log = get_post( $log_id ); if ( ! $log || Plugin::get_log_post_type() !== $log->post_type ) { echo '

' . esc_html__( 'The requested log entry could not be found.', 'robotstxt-smtp' ) . '

'; $this->render_logs_list( 1, $scope, $filters, $nonce ); return; } $from = get_post_meta( $log->ID, '_robotstxt_smtp_from', true ); $to = get_post_meta( $log->ID, '_robotstxt_smtp_to', true ); $headers = (string) get_post_meta( $log->ID, '_robotstxt_smtp_headers', true ); $attachments = maybe_unserialize( get_post_meta( $log->ID, '_robotstxt_smtp_attachments', true ) ); $debug_log = maybe_unserialize( get_post_meta( $log->ID, '_robotstxt_smtp_debug_log', true ) ); $date = get_date_from_gmt( $log->post_date_gmt, get_option( 'date_format' ) . ' ' . get_option( 'time_format' ) ); $subject = $log->post_title ? $log->post_title : __( '(No subject)', 'robotstxt-smtp' ); $status = $this->get_log_status_display( (int) $log->ID ); $error = (string) get_post_meta( $log->ID, '_robotstxt_smtp_error', true ); if ( ! is_array( $attachments ) ) { $attachments = array(); } if ( ! is_array( $debug_log ) ) { $debug_log = array(); } $normalized_attachments = array(); $normalized_debug_log = array(); foreach ( $attachments as $attachment ) { if ( ! is_scalar( $attachment ) ) { continue; } $attachment_name = trim( (string) $attachment ); if ( '' === $attachment_name ) { continue; } $normalized_attachments[] = $attachment_name; } foreach ( $debug_log as $debug_line ) { if ( ! is_scalar( $debug_line ) ) { continue; } $clean_line = sanitize_textarea_field( (string) $debug_line ); if ( '' === $clean_line ) { continue; } $normalized_debug_log[] = $clean_line; } ?>

post_content ) : ?>
post_content ) ); ?>

*/ private function get_log_status_display( int $log_id ): array { $status = get_post_meta( $log_id, '_robotstxt_smtp_status', true ); if ( 'error' === $status ) { return array( 'status' => 'error', 'label' => __( 'Failed', 'robotstxt-smtp' ), 'class' => 'robotstxt-smtp-log-status-error', ); } return array( 'status' => 'sent', 'label' => __( 'Sent', 'robotstxt-smtp' ), 'class' => 'robotstxt-smtp-log-status-sent', ); } /** * Handles the request to clear all stored logs. * * @return void */ public function handle_clear_logs(): void { $nonce_value = filter_input( INPUT_POST, '_wpnonce', FILTER_UNSAFE_RAW ); // Block log deletions unless the nonce matches the action button submission. if ( ! is_string( $nonce_value ) || ! wp_verify_nonce( wp_unslash( $nonce_value ), 'robotstxt_smtp_clear_logs' ) ) { wp_die( esc_html__( 'The link you followed has expired.', 'robotstxt-smtp' ) ); } $scope = self::SCOPE_SITE; $context_raw = filter_input( INPUT_POST, 'robotstxt_smtp_context', FILTER_UNSAFE_RAW ); if ( is_string( $context_raw ) && '' !== $context_raw ) { // Sanitize the submitted scope before checking the requested context. $context = sanitize_text_field( wp_unslash( $context_raw ) ); if ( self::SCOPE_NETWORK === $context ) { $scope = self::SCOPE_NETWORK; } } $this->ensure_capability( $scope ); $this->set_scope( $scope ); Plugin::get_instance()->clear_all_logs(); $redirect = add_query_arg( array( 'page' => self::LOGS_PAGE_SLUG, 'robotstxt_smtp_cleared' => 1, ), $this->get_admin_url_for_scope( $scope, 'admin.php' ) ); wp_safe_redirect( $redirect ); exit; } /** * Renders the host field. * * @return void */ public function render_host_field(): void { $settings = $this->get_settings(); ?>

get_settings(); ?>

get_settings(); ?>

get_settings(); ?>

get_settings(); ?>

get_settings(); $options = $this->get_security_options(); ?>

get_settings(); ?>

get_settings(); ?>

get_settings(); $mode = isset( $settings['logs_retention_mode'] ) ? $settings['logs_retention_mode'] : 'count'; ?>

get_settings(); ?>

get_settings(); ?>

$options Raw option values. * * @return array Sanitized option values. */ public function sanitize_options( array $options ): array { $this->cached_settings = null; if ( self::SCOPE_SITE === $this->get_scope() && is_admin() && isset( $_SERVER['REQUEST_METHOD'] ) && 'POST' === $_SERVER['REQUEST_METHOD'] ) { $this->site_settings_submitted = true; $this->site_settings_option_updated = false; } $clean = self::get_default_settings(); $options = wp_unslash( $options ); $security = $this->get_security_options(); if ( isset( $options['host'] ) ) { $clean['host'] = sanitize_text_field( $options['host'] ); } if ( isset( $options['username'] ) ) { $clean['username'] = sanitize_text_field( $options['username'] ); } if ( isset( $options['password'] ) ) { $clean['password'] = sanitize_text_field( $options['password'] ); } if ( isset( $options['from_email'] ) ) { $clean['from_email'] = sanitize_email( $options['from_email'] ); } if ( isset( $options['from_name'] ) ) { $clean['from_name'] = sanitize_text_field( $options['from_name'] ); } if ( isset( $options['amazon_ses_access_key'] ) ) { $clean['amazon_ses_access_key'] = sanitize_text_field( $options['amazon_ses_access_key'] ); } if ( isset( $options['amazon_ses_secret_key'] ) ) { $clean['amazon_ses_secret_key'] = sanitize_text_field( $options['amazon_ses_secret_key'] ); } if ( isset( $options['amazon_ses_region'] ) ) { $clean['amazon_ses_region'] = sanitize_text_field( $options['amazon_ses_region'] ); } if ( isset( $options['security'] ) && array_key_exists( $options['security'], $security ) ) { $clean['security'] = $options['security']; } if ( isset( $options['port'] ) ) { $port = absint( $options['port'] ); if ( $port > 0 && $port <= 65535 ) { $clean['port'] = $port; } } $clean['logs_enabled'] = ! empty( $options['logs_enabled'] ); if ( isset( $options['logs_retention_mode'] ) && in_array( $options['logs_retention_mode'], array( 'count', 'days' ), true ) ) { $clean['logs_retention_mode'] = $options['logs_retention_mode']; } if ( isset( $options['logs_retention_count'] ) ) { $clean['logs_retention_count'] = absint( $options['logs_retention_count'] ); } if ( isset( $options['logs_retention_days'] ) ) { $clean['logs_retention_days'] = absint( $options['logs_retention_days'] ); } /** * Filters the sanitized SMTP options before they are persisted. * * This hook allows integrations to adjust the sanitized values or * perform additional validation based on the submitted form data. * * @since 1.1.0 * * @param array $clean Sanitized option values. * @param array $options Raw submitted values after `wp_unslash()`. * @param Settings_Page $settings Settings page instance. */ $clean = (array) \apply_filters( 'robotstxt_smtp_sanitized_options', $clean, $options, $this ); return $clean; } /** * Retrieves the stored settings merged with defaults. * * @return array Stored settings. */ private function get_settings(): array { if ( null !== $this->cached_settings ) { return $this->cached_settings; } $defaults = self::get_default_settings(); $option_name = $this->get_option_name_for_scope(); if ( self::SCOPE_NETWORK === $this->get_scope() ) { $settings = get_site_option( $option_name, array() ); } else { $settings = get_option( $option_name, array() ); } if ( ! is_array( $settings ) ) { $settings = array(); } $this->cached_settings = wp_parse_args( $settings, $defaults ); return $this->cached_settings; } /** * Extracts the domain portion from an email address. * * @param string $email Email address. * * @return string */ private function extract_domain_from_email( string $email ): string { $sanitized = sanitize_email( $email ); if ( empty( $sanitized ) || false === strpos( $sanitized, '@' ) ) { return ''; } $parts = explode( '@', $sanitized ); $domain = array_pop( $parts ); if ( ! is_string( $domain ) ) { return ''; } $domain = strtolower( $domain ); $domain = rtrim( $domain, '.' ); return $domain; } /** * Normalizes a hostname for comparison. * * @param string $hostname Hostname to normalize. * * @return string */ private function normalize_hostname( string $hostname ): string { $normalized = strtolower( trim( $hostname ) ); if ( '' === $normalized ) { return ''; } if ( false !== strpos( $normalized, '://' ) ) { $parsed = wp_parse_url( $normalized ); if ( is_array( $parsed ) && isset( $parsed['host'] ) ) { $normalized = strtolower( (string) $parsed['host'] ); } } $normalized = ltrim( $normalized, '/\\' ); $normalized = preg_replace( '/:\d+$/', '', $normalized ); if ( ! is_string( $normalized ) ) { $normalized = ''; } $normalized = rtrim( $normalized, '.' ); return $normalized; } /** * Analyzes SPF, DKIM, and DMARC records for the provided domain. * * @param string $domain Domain to analyze. * @param string $smtp_host Normalized SMTP host. * @param array $mx_analysis MX lookup results. * @param array>|null $host_ips Optional pre-resolved SMTP host IP addresses. * * @return array */ private function analyze_domain_authentication( string $domain, string $smtp_host, array $mx_analysis, ?array $host_ips = null ): array { $result = array( 'domain' => $domain, 'errors' => array(), 'spf' => array( 'records' => array(), 'errors' => array(), 'authorized' => null, 'message' => '', ), 'dkim' => array( 'found' => null, 'errors' => array(), 'record' => '', ), 'dmarc' => array( 'record' => '', 'policy' => '', 'errors' => array(), 'recommendations' => array(), ), ); if ( '' === $domain ) { $result['errors'][] = __( 'The sender domain could not be determined.', 'robotstxt-smtp' ); return $result; } if ( ! function_exists( 'dns_get_record' ) ) { $result['errors'][] = __( 'DNS lookups are not available because the DNS functions are disabled on this server.', 'robotstxt-smtp' ); return $result; } if ( null === $host_ips ) { $host_ips = $this->get_hostname_ip_addresses( $smtp_host ); } $result['spf'] = $this->analyze_spf_records( $domain, $smtp_host, $mx_analysis, $host_ips ); $result['dkim'] = $this->analyze_dkim_record( $domain ); $result['dmarc'] = $this->analyze_dmarc_record( $domain ); return $result; } /** * Checks the resolved SMTP host IPs against DNSBL providers. * * @param array> $host_ips Resolved IP addresses for the SMTP host. * * @return array */ private function analyze_blacklist_status( array $host_ips ): array { $result = array( 'ips' => array(), 'ipv6' => array(), 'errors' => array(), ); if ( ! function_exists( 'dns_get_record' ) ) { $result['errors'][] = __( 'Blacklist lookups are not available because the DNS functions are disabled on this server.', 'robotstxt-smtp' ); return $result; } if ( ! empty( $host_ips['ipv6'] ) ) { $result['ipv6'] = $host_ips['ipv6']; } if ( empty( $host_ips['ipv4'] ) ) { return $result; } $providers = $this->get_dnsbl_providers(); foreach ( $host_ips['ipv4'] as $ip ) { if ( false === filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 ) ) { continue; } $ip_result = array( 'ip' => $ip, 'errors' => array(), 'listings' => array(), ); foreach ( $providers as $provider ) { $ip_result['listings'][] = $this->query_dnsbl_listing( $ip, $provider ); } $result['ips'][] = $ip_result; } return $result; } /** * Provides the DNSBL providers used for blacklist checks. * * @return array> */ private function get_dnsbl_providers(): array { return array( array( 'id' => 'spamhaus_zen', 'label' => __( 'Spamhaus ZEN', 'robotstxt-smtp' ), 'zone' => 'zen.spamhaus.org', 'impact' => __( 'High — major inbox providers rely on Spamhaus and a listing can block most traffic.', 'robotstxt-smtp' ), 'removal_url' => 'https://check.spamhaus.org/', 'removal_notes' => __( 'Investigate and resolve the root cause, then use the Spamhaus Blocklist Removal Center to request delisting.', 'robotstxt-smtp' ), ), array( 'id' => 'spamcop', 'label' => __( 'SpamCop', 'robotstxt-smtp' ), 'zone' => 'bl.spamcop.net', 'impact' => __( 'Medium — many shared hosts and spam filters consult SpamCop when scoring messages.', 'robotstxt-smtp' ), 'removal_url' => 'https://www.spamcop.net/w3m?action=checkblock', 'removal_notes' => __( 'Ensure no unsolicited traffic is sent and follow the SpamCop blocklist removal instructions.', 'robotstxt-smtp' ), ), array( 'id' => 'barracuda', 'label' => __( 'Barracuda Reputation Block List', 'robotstxt-smtp' ), 'zone' => 'b.barracudacentral.org', 'impact' => __( 'Medium — Barracuda appliances and some hosted filters can defer or reject messages.', 'robotstxt-smtp' ), 'removal_url' => 'https://www.barracudacentral.org/lookups/lookup-reputation', 'removal_notes' => __( 'Review the Barracuda reputation lookup and submit a delisting request after addressing any spam complaints.', 'robotstxt-smtp' ), ), ); } /** * Queries a DNSBL provider for a specific IPv4 address. * * @param string $ip IPv4 address. * @param array $provider Provider definition. * * @return array */ private function query_dnsbl_listing( string $ip, array $provider ): array { $result = array( 'provider' => $provider, 'listed' => false, 'response_ips' => array(), 'txt_records' => array(), 'error' => '', ); $reverse = $this->reverse_ipv4_for_dnsbl( $ip ); if ( '' === $reverse ) { $result['error'] = __( 'The IP address could not be normalized for the DNSBL lookup.', 'robotstxt-smtp' ); return $result; } $hostname = $reverse . '.' . $provider['zone']; $a_records = dns_get_record( $hostname, DNS_A ); if ( false === $a_records ) { /* translators: %s: DNSBL provider name. */ $result['error'] = sprintf( __( 'The DNS lookup for %s failed. Please try again later.', 'robotstxt-smtp' ), $provider['label'] ); return $result; } if ( empty( $a_records ) ) { return $result; } $result['listed'] = true; foreach ( $a_records as $record ) { if ( isset( $record['ip'] ) && filter_var( $record['ip'], FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 ) ) { $result['response_ips'][] = (string) $record['ip']; } } $txt_records = dns_get_record( $hostname, DNS_TXT ); if ( false !== $txt_records && ! empty( $txt_records ) ) { foreach ( $txt_records as $txt_record ) { if ( isset( $txt_record['txt'] ) && is_string( $txt_record['txt'] ) ) { $value = trim( (string) $txt_record['txt'] ); if ( '' !== $value ) { $result['txt_records'][] = $value; } } } } return $result; } /** * Normalizes an IPv4 address for DNSBL queries. * * @param string $ip IPv4 address. * * @return string */ private function reverse_ipv4_for_dnsbl( string $ip ): string { if ( false === filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 ) ) { return ''; } $parts = explode( '.', $ip ); if ( count( $parts ) !== 4 ) { return ''; } return implode( '.', array_reverse( $parts ) ); } /** * Inspects SPF records for the sender domain. * * @param string $domain Sender domain. * @param string $smtp_host Normalized SMTP host. * @param array $mx_analysis MX lookup results. * @param array $host_ips Resolved IP addresses for the SMTP host. * * @return array */ private function analyze_spf_records( string $domain, string $smtp_host, array $mx_analysis, array $host_ips ): array { $result = array( 'records' => array(), 'errors' => array(), 'authorized' => null, 'message' => __( 'Unable to determine whether the SMTP host is authorized in the SPF record.', 'robotstxt-smtp' ), ); if ( ! function_exists( 'dns_get_record' ) ) { $result['errors'][] = __( 'DNS lookups are not available because the DNS functions are disabled on this server.', 'robotstxt-smtp' ); return $result; } $txt_records = dns_get_record( $domain, DNS_TXT ); if ( false === $txt_records ) { $result['errors'][] = __( 'The DNS lookup for TXT records failed. Please try again later.', 'robotstxt-smtp' ); return $result; } foreach ( $txt_records as $record ) { if ( empty( $record['txt'] ) || ! is_string( $record['txt'] ) ) { continue; } $value = trim( (string) $record['txt'] ); if ( preg_match( '/^v=spf1\b/i', $value ) ) { $result['records'][] = $value; } } if ( empty( $result['records'] ) ) { $result['message'] = __( 'The domain does not publish an SPF record.', 'robotstxt-smtp' ); $result['authorized'] = null; return $result; } if ( '' === $smtp_host ) { $result['message'] = __( 'Configure an SMTP host to verify its presence in the SPF record.', 'robotstxt-smtp' ); $result['authorized'] = null; return $result; } $normalized_host = strtolower( $smtp_host ); $mx_hosts = array(); if ( isset( $mx_analysis['records'] ) && is_array( $mx_analysis['records'] ) ) { foreach ( $mx_analysis['records'] as $mx_record ) { if ( isset( $mx_record['normalized_target'] ) && is_string( $mx_record['normalized_target'] ) ) { $mx_hosts[] = strtolower( $mx_record['normalized_target'] ); } } } $authorized = false; $authorization_msg = ''; $could_evaluate = false; foreach ( $result['records'] as $spf_record ) { $tokens = preg_split( '/\s+/', strtolower( $spf_record ) ); if ( ! is_array( $tokens ) ) { continue; } foreach ( $tokens as $token ) { if ( '' === $token ) { continue; } $qualifier = ''; if ( in_array( $token[0], array( '+', '-', '~', '?' ), true ) ) { $qualifier = $token[0]; $token = substr( $token, 1 ); } if ( '' === $token || '-' === $qualifier ) { continue; } $token = preg_replace( '/\/\d+$/', '', $token ); if ( ! is_string( $token ) || '' === $token || 'v=spf1' === $token ) { continue; } if ( str_starts_with( $token, 'include:' ) || str_starts_with( $token, 'redirect=' ) ) { $target = str_replace( array( 'include:', 'redirect=' ), '', $token ); if ( $this->hostname_matches_domain( $normalized_host, $target ) ) { $authorized = true; /* translators: %s: Included SPF domain. */ $authorization_msg = sprintf( __( 'The SPF record includes %s, which matches the configured SMTP host.', 'robotstxt-smtp' ), $target ); break 2; } continue; } if ( str_starts_with( $token, 'a:' ) ) { $target = substr( $token, 2 ); if ( $this->hostname_matches_domain( $normalized_host, $target ) ) { $authorized = true; /* translators: %s: Hostname authorized via the SPF "a" mechanism. */ $authorization_msg = sprintf( __( 'The SPF record authorizes %s via the "a" mechanism.', 'robotstxt-smtp' ), $normalized_host ); break 2; } continue; } if ( 'a' === $token ) { if ( $this->hostname_matches_domain( $normalized_host, $domain ) ) { $authorized = true; /* translators: %s: Hostname authorized via the SPF "a" mechanism. */ $authorization_msg = sprintf( __( 'The SPF record authorizes %s via the "a" mechanism.', 'robotstxt-smtp' ), $normalized_host ); break 2; } continue; } if ( str_starts_with( $token, 'mx:' ) ) { $target = substr( $token, 3 ); if ( $this->hostname_matches_domain( $normalized_host, $target ) ) { $authorized = true; /* translators: %s: Hostname authorized via the SPF "mx" mechanism. */ $authorization_msg = sprintf( __( 'The SPF record authorizes %s via the "mx" mechanism.', 'robotstxt-smtp' ), $normalized_host ); break 2; } continue; } if ( 'mx' === $token && in_array( $normalized_host, $mx_hosts, true ) ) { $authorized = true; /* translators: %s: Hostname authorized via the SPF "mx" mechanism. */ $authorization_msg = sprintf( __( 'The SPF record authorizes %s via the "mx" mechanism.', 'robotstxt-smtp' ), $normalized_host ); break 2; } if ( str_starts_with( $token, 'ip4:' ) ) { $cidr = substr( $token, 4 ); if ( '' === $cidr || ! is_array( $host_ips['ipv4'] ) ) { continue; } foreach ( $host_ips['ipv4'] as $ip ) { if ( $this->ipv4_in_cidr( $ip, $cidr ) ) { $authorized = true; /* translators: %1$s: IP address. %2$s: SPF mechanism. */ $authorization_msg = sprintf( __( 'The SPF record authorizes the SMTP host IP %1$s via %2$s.', 'robotstxt-smtp' ), $ip, 'ip4' ); break 3; } } $could_evaluate = true; continue; } if ( str_starts_with( $token, 'ip6:' ) ) { $cidr = substr( $token, 4 ); if ( '' === $cidr || ! is_array( $host_ips['ipv6'] ) ) { continue; } foreach ( $host_ips['ipv6'] as $ip ) { if ( $this->ipv6_in_cidr( $ip, $cidr ) ) { $authorized = true; /* translators: %1$s: IP address. %2$s: SPF mechanism. */ $authorization_msg = sprintf( __( 'The SPF record authorizes the SMTP host IP %1$s via %2$s.', 'robotstxt-smtp' ), $ip, 'ip6' ); break 3; } } $could_evaluate = true; } } } if ( $authorized ) { $result['authorized'] = true; $result['message'] = $authorization_msg; return $result; } if ( ! empty( $host_ips['ipv4'] ) || ! empty( $host_ips['ipv6'] ) ) { $result['authorized'] = false; $result['message'] = __( 'The SMTP host was not found in the SPF record. Add it to avoid delivery issues.', 'robotstxt-smtp' ); } elseif ( $could_evaluate ) { $result['authorized'] = null; $result['message'] = __( 'The SPF record contains IP ranges, but the SMTP host IP could not be resolved.', 'robotstxt-smtp' ); } else { $result['authorized'] = null; $result['message'] = __( 'The SPF record does not explicitly reference the configured SMTP host.', 'robotstxt-smtp' ); } return $result; } /** * Retrieves IPv4 and IPv6 addresses for the provided hostname. * * @param string $hostname Hostname to inspect. * * @return array> */ private function get_hostname_ip_addresses( string $hostname ): array { $ips = array( 'ipv4' => array(), 'ipv6' => array(), ); if ( '' === $hostname || ! function_exists( 'dns_get_record' ) ) { return $ips; } $types = DNS_A; if ( defined( 'DNS_AAAA' ) ) { $types |= DNS_AAAA; } $candidates = array( $hostname, $hostname . '.' ); foreach ( $candidates as $candidate ) { $records = dns_get_record( $candidate, $types ); if ( empty( $records ) || false === $records ) { continue; } foreach ( $records as $record ) { if ( isset( $record['type'], $record['ip'] ) && 'A' === $record['type'] ) { $ip = (string) $record['ip']; if ( filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 ) ) { $ips['ipv4'][ $ip ] = $ip; } } if ( isset( $record['type'], $record['ipv6'] ) && 'AAAA' === $record['type'] ) { $ip = (string) $record['ipv6']; if ( filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6 ) ) { $ips['ipv6'][ $ip ] = $ip; } } } } $ips['ipv4'] = array_values( $ips['ipv4'] ); $ips['ipv6'] = array_values( $ips['ipv6'] ); return $ips; } /** * Determines whether the provided hostname belongs to the given domain. * * @param string $hostname Hostname to compare. * @param string $domain Domain to check against. * * @return bool */ private function hostname_matches_domain( string $hostname, string $domain ): bool { $hostname = strtolower( rtrim( $hostname, '.' ) ); $domain = strtolower( rtrim( $domain, '.' ) ); if ( '' === $hostname || '' === $domain ) { return false; } if ( $hostname === $domain ) { return true; } return str_ends_with( $hostname, '.' . $domain ); } /** * Determines whether an IPv4 address belongs to the provided CIDR. * * @param string $ip IPv4 address. * @param string $cidr CIDR expression. * * @return bool */ private function ipv4_in_cidr( string $ip, string $cidr ): bool { if ( false === filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 ) ) { return false; } $parts = explode( '/', $cidr, 2 ); $net = $parts[0]; $bits = isset( $parts[1] ) ? (int) $parts[1] : 32; if ( false === filter_var( $net, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 ) ) { return false; } if ( $bits < 0 || $bits > 32 ) { return false; } $ip_long = unpack( 'N', inet_pton( $ip ) ); $net_long = unpack( 'N', inet_pton( $net ) ); if ( ! is_array( $ip_long ) || ! is_array( $net_long ) ) { return false; } $mask = 0; if ( 0 !== $bits ) { $mask = ( 0xFFFFFFFF << ( 32 - $bits ) ) & 0xFFFFFFFF; } return ( ( $ip_long[0] & $mask ) === ( $net_long[0] & $mask ) ); } /** * Determines whether an IPv6 address belongs to the provided CIDR. * * @param string $ip IPv6 address. * @param string $cidr CIDR expression. * * @return bool */ private function ipv6_in_cidr( string $ip, string $cidr ): bool { if ( false === filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6 ) ) { return false; } $parts = explode( '/', $cidr, 2 ); $net = $parts[0]; $bits = isset( $parts[1] ) ? (int) $parts[1] : 128; if ( false === filter_var( $net, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6 ) ) { return false; } if ( $bits < 0 || $bits > 128 ) { return false; } $ip_bin = inet_pton( $ip ); $net_bin = inet_pton( $net ); if ( false === $ip_bin || false === $net_bin ) { return false; } $remaining = $bits; for ( $i = 0; $i < 16; $i++ ) { if ( $remaining <= 0 ) { break; } $mask = 0xFF; if ( $remaining < 8 ) { $mask = ( 0xFF << ( 8 - $remaining ) ) & 0xFF; } if ( ( ord( $ip_bin[ $i ] ) & $mask ) !== ( ord( $net_bin[ $i ] ) & $mask ) ) { return false; } $remaining -= 8; } return true; } /** * Looks up a DKIM record using the default selector. * * @param string $domain Domain to inspect. * * @return array */ private function analyze_dkim_record( string $domain ): array { $result = array( 'found' => false, 'errors' => array(), 'record' => '', ); if ( ! function_exists( 'dns_get_record' ) ) { $result['errors'][] = __( 'DNS lookups are not available because the DNS functions are disabled on this server.', 'robotstxt-smtp' ); return $result; } $selector_domain = 'default._domainkey.' . $domain; $records = dns_get_record( $selector_domain, DNS_TXT ); if ( false === $records ) { /* translators: %s: Domain queried for the DKIM record lookup. */ $result['errors'][] = sprintf( __( 'The DNS lookup for %s failed. Please try again later.', 'robotstxt-smtp' ), $selector_domain ); return $result; } foreach ( $records as $record ) { if ( empty( $record['txt'] ) || ! is_string( $record['txt'] ) ) { continue; } $value = trim( (string) $record['txt'] ); if ( preg_match( '/^v=DKIM1/i', $value ) ) { $result['found'] = true; $result['record'] = $value; break; } } return $result; } /** * Looks up the DMARC policy for the sender domain. * * @param string $domain Domain to inspect. * * @return array */ private function analyze_dmarc_record( string $domain ): array { $result = array( 'record' => '', 'policy' => '', 'errors' => array(), 'recommendations' => array(), ); if ( ! function_exists( 'dns_get_record' ) ) { $result['errors'][] = __( 'DNS lookups are not available because the DNS functions are disabled on this server.', 'robotstxt-smtp' ); return $result; } $dmarc_domain = '_dmarc.' . $domain; $records = dns_get_record( $dmarc_domain, DNS_TXT ); if ( false === $records ) { /* translators: %s: Domain queried for the DMARC record lookup. */ $result['errors'][] = sprintf( __( 'The DNS lookup for %s failed. Please try again later.', 'robotstxt-smtp' ), $dmarc_domain ); return $result; } foreach ( $records as $record ) { if ( empty( $record['txt'] ) || ! is_string( $record['txt'] ) ) { continue; } $value = trim( (string) $record['txt'] ); if ( preg_match( '/^v=DMARC1/i', $value ) ) { $result['record'] = $value; if ( preg_match( '/\bp=([a-z]+)/i', $value, $matches ) ) { $result['policy'] = strtolower( $matches[1] ); } break; } } if ( '' === $result['record'] ) { return $result; } if ( 'none' === $result['policy'] ) { $result['recommendations'][] = __( 'The DMARC policy is set to "p=none". Increase it to "p=quarantine" or "p=reject" to protect the domain.', 'robotstxt-smtp' ); } elseif ( 'quarantine' === $result['policy'] ) { $result['recommendations'][] = __( 'Consider moving the DMARC policy to "p=reject" once you have validated that legitimate messages pass authentication.', 'robotstxt-smtp' ); } return $result; } /** * Performs an MX lookup for the provided domain and compares the results with the SMTP host. * * @param string $domain Domain to inspect. * @param string $smtp_host Normalized SMTP host. * * @return array */ private function analyze_mx_records( string $domain, string $smtp_host ): array { $result = array( 'domain' => $domain, 'records' => array(), 'errors' => array(), 'matches_smtp_host' => false, ); if ( '' === $domain ) { return $result; } if ( ! function_exists( 'dns_get_record' ) ) { $result['errors'][] = __( 'MX lookups are not available because the DNS functions are disabled on this server.', 'robotstxt-smtp' ); return $result; } $records = dns_get_record( $domain, DNS_MX ); if ( false === $records ) { $result['errors'][] = __( 'The DNS lookup for MX records failed. Please try again later.', 'robotstxt-smtp' ); return $result; } foreach ( $records as $record ) { if ( empty( $record['target'] ) ) { continue; } $priority = isset( $record['pri'] ) ? (int) $record['pri'] : null; $display_target = rtrim( (string) $record['target'], '.' ); $normalized = $this->normalize_hostname( $display_target ); $resolves = $this->hostname_resolves( $normalized ); $matches_host = '' !== $smtp_host && $normalized === $smtp_host; $result['records'][] = array( 'priority' => $priority, 'display_target' => $display_target, 'normalized_target' => $normalized, 'resolves' => $resolves, 'matches_host' => $matches_host, ); if ( $matches_host ) { $result['matches_smtp_host'] = true; } } usort( $result['records'], static function ( array $a, array $b ): int { $priority_a = $a['priority'] ?? PHP_INT_MAX; $priority_b = $b['priority'] ?? PHP_INT_MAX; if ( $priority_a === $priority_b ) { return strcmp( $a['display_target'], $b['display_target'] ); } return $priority_a <=> $priority_b; } ); return $result; } /** * Determines whether a hostname resolves to an address record. * * @param string $hostname Hostname to inspect. * * @return bool|null True when it resolves, false when it does not, null when unknown. */ private function hostname_resolves( string $hostname ): ?bool { if ( '' === $hostname ) { return null; } $candidates = array( $hostname, $hostname . '.' ); if ( function_exists( 'checkdnsrr' ) ) { foreach ( $candidates as $candidate ) { if ( checkdnsrr( $candidate, 'A' ) || checkdnsrr( $candidate, 'AAAA' ) ) { return true; } } } if ( function_exists( 'dns_get_record' ) ) { $types = DNS_A; if ( defined( 'DNS_AAAA' ) ) { $types |= DNS_AAAA; } $had_error = false; foreach ( $candidates as $candidate ) { $records = dns_get_record( $candidate, $types ); if ( false === $records ) { $had_error = true; continue; } if ( ! empty( $records ) ) { return true; } } if ( $had_error ) { return null; } return false; } return null; } /** * Provides the default settings. * * @return array Default settings. */ public static function get_default_settings(): array { return array( 'host' => '', 'username' => '', 'password' => '', 'from_email' => '', 'from_name' => get_bloginfo( 'name', 'display' ), 'security' => 'none', 'port' => 25, 'amazon_ses_access_key' => '', 'amazon_ses_secret_key' => '', 'amazon_ses_region' => '', 'logs_enabled' => false, 'logs_retention_mode' => 'count', 'logs_retention_count' => 1024, 'logs_retention_days' => 28, ); } /** * Returns the available security types. * * @return array Security type labels keyed by value. */ private function get_security_options(): array { return array( 'none' => esc_html__( 'None', 'robotstxt-smtp' ), 'ssl' => esc_html__( 'SSL', 'robotstxt-smtp' ), 'tls' => esc_html__( 'TLS', 'robotstxt-smtp' ), ); } /** * Handles the submission of the test email form. * * @return void */ public function handle_test_email(): void { $nonce_value = filter_input( INPUT_POST, 'robotstxt_smtp_test_email_nonce', FILTER_UNSAFE_RAW ); // Halt the request when the nonce is missing or invalid to stop forged test submissions. if ( ! is_string( $nonce_value ) || ! wp_verify_nonce( wp_unslash( $nonce_value ), 'robotstxt_smtp_test_email' ) ) { wp_die( esc_html__( 'The link you followed has expired.', 'robotstxt-smtp' ) ); } $scope = self::SCOPE_SITE; $context_raw = filter_input( INPUT_POST, 'robotstxt_smtp_context', FILTER_UNSAFE_RAW ); if ( is_string( $context_raw ) && '' !== $context_raw ) { // Sanitize the submitted scope before using it to determine capabilities. $context = sanitize_text_field( wp_unslash( $context_raw ) ); if ( self::SCOPE_NETWORK === $context ) { $scope = self::SCOPE_NETWORK; } } $this->ensure_capability( $scope ); $this->set_scope( $scope ); $recipient = ''; $recipient_raw = filter_input( INPUT_POST, 'robotstxt_smtp_test_email', FILTER_UNSAFE_RAW ); if ( is_string( $recipient_raw ) && '' !== $recipient_raw ) { // Sanitize the recipient email address before attempting to send mail. $recipient = sanitize_email( wp_unslash( $recipient_raw ) ); } $result = $this->send_test_email_for_scope( $scope, $recipient, 'manual' ); $this->persist_test_result( $result ); $this->redirect_after_test( ! empty( $result['success'] ) ? 'success' : 'error', (string) $result['recipient'], $scope ); } /** * Handles the updated_option hook for the site-level configuration. * * @param string $option Option name that was updated. * @param mixed $old_value Previous option value. * @param mixed $value New option value. * * @return void */ public function handle_settings_option_updated( string $option, $old_value, $value ): void { unset( $old_value, $value ); if ( self::OPTION_NAME !== $option ) { return; } if ( ! is_admin() ) { return; } $nonce = filter_input( INPUT_POST, '_wpnonce', FILTER_UNSAFE_RAW ); if ( ! is_string( $nonce ) ) { return; } $nonce = sanitize_text_field( wp_unslash( $nonce ) ); if ( ! wp_verify_nonce( $nonce, self::SITE_SETTINGS_GROUP . '-options' ) ) { return; } $option_page = filter_input( INPUT_POST, 'option_page', FILTER_UNSAFE_RAW ); if ( ! is_string( $option_page ) ) { return; } $option_page = sanitize_key( wp_unslash( $option_page ) ); if ( self::SITE_SETTINGS_GROUP !== $option_page ) { return; } if ( ! current_user_can( self::CAPABILITY ) ) { return; } $this->cached_settings = null; $this->site_settings_option_updated = true; $result = $this->send_test_email_for_scope( self::SCOPE_SITE, null, 'auto' ); $this->persist_test_result( $result ); } /** * Sends the automatic test email when the site settings were submitted but * the stored option value did not change. * * @return void */ public function maybe_send_pending_site_test(): void { if ( ! $this->site_settings_submitted || $this->site_settings_option_updated ) { return; } if ( ! is_admin() ) { return; } $this->cached_settings = null; $result = $this->send_test_email_for_scope( self::SCOPE_SITE, null, 'auto' ); $this->persist_test_result( $result ); $this->site_settings_submitted = false; $this->site_settings_option_updated = false; } /** * Sends a test email using the stored configuration for the provided scope. * * @param string $scope Scope identifier (site or network). * @param string|null $recipient Optional recipient email address. * @param string $context Context identifier (manual|auto). * * @return array Result data for the attempted email. */ private function send_test_email_for_scope( string $scope, ?string $recipient = null, string $context = 'manual' ): array { $this->ensure_capability( $scope ); $this->set_scope( $scope ); $resolved_recipient = $this->resolve_test_recipient( $recipient ); $result = array( 'success' => false, 'recipient' => $resolved_recipient, 'timestamp' => current_time( 'mysql' ), 'settings' => $this->get_settings_for_display(), 'debug_log' => array(), 'mail_data' => array(), 'error' => '', 'error_data' => null, 'context' => $context, ); if ( empty( $resolved_recipient ) ) { $result['error'] = __( 'A valid recipient email address is required.', 'robotstxt-smtp' ); return $result; } $debug_log = array(); $failure = null; $debug_action = static function ( PHPMailer $phpmailer ) use ( &$debug_log ): void { // phpcs:disable WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase $phpmailer->SMTPDebug = 2; $phpmailer->Debugoutput = static function ( string $message, int $level ) use ( &$debug_log ): void { $debug_log[] = '[' . $level . '] ' . $message; }; // phpcs:enable WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase }; $failure_filter = static function ( WP_Error $error ) use ( &$failure ) { $failure = $error; return $error; }; add_action( 'phpmailer_init', $debug_action, 20 ); add_filter( 'wp_mail_failed', $failure_filter ); $subject = __( 'ROBOTSTXT SMTP Test Email', 'robotstxt-smtp' ); $body = sprintf( /* translators: %s: Site name. */ __( 'This is a test email sent from %s using the ROBOTSTXT SMTP settings.', 'robotstxt-smtp' ), get_bloginfo( 'name', 'display' ) ); $headers = array( 'Content-Type: text/plain; charset=UTF-8' ); $sent = wp_mail( $resolved_recipient, $subject, $body, $headers ); remove_action( 'phpmailer_init', $debug_action, 20 ); remove_filter( 'wp_mail_failed', $failure_filter ); if ( is_wp_error( $sent ) ) { $result['success'] = false; if ( empty( $result['error'] ) ) { $result['error'] = $sent->get_error_message(); } if ( null === $result['error_data'] ) { $result['error_data'] = $sent->get_error_data(); } } else { $result['success'] = (bool) $sent; } $result['debug_log'] = $debug_log; $result['mail_data'] = array( 'subject' => $subject, 'body' => $body, 'headers' => $headers, ); if ( $failure instanceof WP_Error ) { $result['error'] = $failure->get_error_message(); $result['error_data'] = $failure->get_error_data(); } if ( ! $result['success'] && empty( $result['error'] ) ) { $result['error'] = __( 'The test email could not be sent.', 'robotstxt-smtp' ); } return $result; } /** * Determines the recipient address to use for a test email. * * @param string|null $recipient Recipient provided by the user. * * @return string Sanitized recipient email address or an empty string when unavailable. */ private function resolve_test_recipient( ?string $recipient ): string { if ( ! empty( $recipient ) ) { $recipient = sanitize_email( $recipient ); if ( ! empty( $recipient ) ) { return $recipient; } } $current_user = wp_get_current_user(); if ( $current_user instanceof WP_User && ! empty( $current_user->user_email ) ) { $candidate = sanitize_email( $current_user->user_email ); if ( ! empty( $candidate ) ) { return $candidate; } } return sanitize_email( get_bloginfo( 'admin_email' ) ); } /** * Displays the admin notice after sending a test email. * * @return void */ public function display_test_email_notice(): void { if ( ! is_admin() ) { return; } $page = filter_input( INPUT_GET, 'page', FILTER_UNSAFE_RAW ); if ( null === $page ) { return; } $page = sanitize_key( $page ); if ( '' === $page || ! in_array( $page, array( self::TEST_PAGE_SLUG, self::PAGE_SLUG, self::NETWORK_PAGE_SLUG ), true ) ) { return; } $result = get_transient( $this->get_test_result_transient_key() ); if ( false === $result ) { return; } delete_transient( $this->get_test_result_transient_key() ); $class = ! empty( $result['success'] ) ? 'notice notice-success' : 'notice notice-error'; $context = isset( $result['context'] ) ? (string) $result['context'] : 'manual'; if ( 'auto' === $context ) { $status = ! empty( $result['success'] ) ? __( 'The SMTP configuration appears to be working correctly.', 'robotstxt-smtp' ) : __( 'The SMTP configuration could not be verified.', 'robotstxt-smtp' ); } else { $status = ! empty( $result['success'] ) ? __( 'Test email sent successfully.', 'robotstxt-smtp' ) : __( 'Test email failed to send.', 'robotstxt-smtp' ); } ?>

										
										
user_email ) ) { $recipient = $current_user->user_email; } } if ( empty( $recipient ) ) { $recipient = sanitize_email( get_bloginfo( 'admin_email' ) ); } ?>

*/ private function get_settings_for_display(): array { $settings = $this->get_settings(); if ( ! empty( $settings['password'] ) ) { $settings['password'] = str_repeat( '\u2022', 8 ); } return $settings; } /** * Persists the test result for later display. * * @param array $result Result data. * * @return void */ private function persist_test_result( array $result ): void { set_transient( $this->get_test_result_transient_key(), $result, MINUTE_IN_SECONDS * 10 ); } /** * Builds the transient key for the current user. * * @return string */ private function get_test_result_transient_key(): string { return 'robotstxt_smtp_test_result_' . get_current_user_id(); } /** * Redirects the user back to the settings page after sending the test email. * * @param string $status The status string (success|error). * @param string $recipient The recipient email address. * @param string $scope Scope identifier (site or network). * * @return void */ private function redirect_after_test( string $status, string $recipient, string $scope ): void { $redirect = add_query_arg( array( 'page' => self::TEST_PAGE_SLUG, 'robotstxt_smtp_test' => $status, 'robotstxt_smtp_recipient' => $recipient, 'robotstxt_smtp_result_nonce' => wp_create_nonce( 'robotstxt_smtp_test_result' ), ), $this->get_admin_url_for_scope( $scope, 'admin.php' ) ); wp_safe_redirect( $redirect ); exit; } }