From c98dcb7b50161eaac555e54a034c7c0d725a4404 Mon Sep 17 00:00:00 2001 From: Javier Casares Date: Wed, 26 Nov 2025 08:24:18 +0000 Subject: [PATCH] v1.2.0 --- assets/css/admin.css | 22 + assets/js/admin.js | 52 + changelog.txt | 19 + includes/class-plugin.php | 664 +++ includes/class-settings-page.php | 4729 ++++++++++++++++++++ includes/class-smtp-diagnostics-client.php | 107 + readme.txt | 84 + robotstxt-smtp.php | 120 + uninstall.php | 155 + 9 files changed, 5952 insertions(+) create mode 100644 assets/css/admin.css create mode 100644 assets/js/admin.js create mode 100644 changelog.txt create mode 100644 includes/class-plugin.php create mode 100644 includes/class-settings-page.php create mode 100644 includes/class-smtp-diagnostics-client.php create mode 100644 readme.txt create mode 100644 robotstxt-smtp.php create mode 100644 uninstall.php diff --git a/assets/css/admin.css b/assets/css/admin.css new file mode 100644 index 0000000..cf82263 --- /dev/null +++ b/assets/css/admin.css @@ -0,0 +1,22 @@ +.robotstxt-smtp-tools .card { + width: 100%; + max-width: 100%; + box-sizing: border-box; +} + +.robotstxt-smtp-tools .robotstxt-smtp-tool-empty { + margin-top: 1em; +} + +.robotstxt-smtp-tools .robotstxt-smtp-tool-meta { + margin: 1em 0; + font-style: italic; +} + +.robotstxt-smtp-tools .robotstxt-smtp-tool-actions { + margin: 1em 0; +} + +.robotstxt-smtp-tools .robotstxt-smtp-tool-result { + margin-top: 1.5em; +} diff --git a/assets/js/admin.js b/assets/js/admin.js new file mode 100644 index 0000000..4f380ee --- /dev/null +++ b/assets/js/admin.js @@ -0,0 +1,52 @@ +/** + * Admin UI behavior for Robotstxt SMTP settings. + * + * @package RobotstxtSMTP + */ + +(function () { + 'use strict'; + + var initializeAdmin = function () { + var security = document.getElementById( 'robotstxt_smtp_security' ); + var port = document.getElementById( 'robotstxt_smtp_port' ); + + if ( ! security || ! port) { + return; + } + + var defaultPorts = { + none: 25, + ssl: 465, + tls: 587 + }; + + var previousValue = security.value || 'none'; + + security.addEventListener( + 'change', + function () { + var newValue = security.value || 'none'; + var previousDefault = Object.prototype.hasOwnProperty.call( defaultPorts, previousValue ) + ? defaultPorts[previousValue] + : null; + var newDefault = Object.prototype.hasOwnProperty.call( defaultPorts, newValue ) + ? defaultPorts[newValue] + : null; + var currentPort = parseInt( port.value, 10 ); + + if ( ! Number.isNaN( currentPort ) && null !== previousDefault && currentPort === previousDefault && null !== newDefault) { + port.value = newDefault; + } + + previousValue = newValue; + } + ); + }; + + if ('loading' !== document.readyState) { + initializeAdmin(); + } else { + document.addEventListener( 'DOMContentLoaded', initializeAdmin ); + } +})(); diff --git a/changelog.txt b/changelog.txt new file mode 100644 index 0000000..7e0f46b --- /dev/null +++ b/changelog.txt @@ -0,0 +1,19 @@ += 1.2.0 = + +* Logged failed email deliveries with status and error details in the log list and detail views. +* Captured the SMTP debug conversation for each email and surfaced it inside the log detail view. +* Fixed fatal errors in the SMTP bootstrap by restoring configuration and log cleanup hooks. + += 1.1.0 = + +* Added Amazon SES credential fields, regional selection, and live validation helpers that appear when the Amazon SES add-on is active. +* Introduced the `robotstxt_smtp_sanitized_options` filter so add-ons can adjust sanitized settings before they are stored. +* Routed SMTP test messages and regular WordPress emails through Amazon SES whenever the add-on supplies valid credentials. + += 1.0.0 = + +* Contextual help guidance in every SMTP configuration field. +* Automatic port updates when selecting an encryption method with standard values. +* Advanced tools: MX analysis, SPF/DKIM/DMARC validation, extended SMTP diagnostics, and blacklist monitoring. +* Enhanced logging with automatic cleanup by limit or age. +* Updated documentation for the 1.0.0 release with WordPress 6.7 and PHP 8.2 support. diff --git a/includes/class-plugin.php b/includes/class-plugin.php new file mode 100644 index 0000000..2dbe43b --- /dev/null +++ b/includes/class-plugin.php @@ -0,0 +1,664 @@ + + */ + private array $current_debug_log = array(); + + /** + * Retrieves the plugin instance. + * + * @return Plugin Plugin instance. + */ + public static function get_instance(): Plugin { + if ( null === self::$instance ) { + self::$instance = new self(); + } + + return self::$instance; + } + + /** + * Determines whether the Amazon SES integration plugin is active. + * + * @return bool + */ + public static function is_amazon_ses_integration_active(): bool { + /** + * Filters whether the Amazon SES integration should be considered active. + * + * @since 1.0.1 + * + * @param bool $is_active True when the Amazon SES add-on is active. + */ + return (bool) \apply_filters( 'robotstxt_smtp_amazon_ses_active', false ); + } + + /** + * Sets up the plugin. + * + * @return void + */ + public function run(): void { + add_action( 'phpmailer_init', array( $this, 'configure_phpmailer' ) ); + add_filter( 'wp_mail_from', array( $this, 'filter_mail_from' ) ); + add_filter( 'wp_mail_from_name', array( $this, 'filter_mail_from_name' ) ); + + add_action( 'init', array( $this, 'register_log_post_type' ) ); + add_action( 'init', array( $this, 'maybe_schedule_cleanup' ) ); + add_action( 'wp_mail_succeeded', array( $this, 'handle_wp_mail_succeeded' ) ); + add_action( 'wp_mail_failed', array( $this, 'handle_wp_mail_failed' ) ); + add_action( self::CRON_HOOK, array( $this, 'cleanup_logs' ) ); + + if ( is_admin() ) { + $this->register_admin(); + } + } + + /** + * Registers the admin functionality. + * + * @return void + */ + private function register_admin(): void { + if ( null === $this->settings_page ) { + $this->settings_page = new Settings_Page(); + } + + $this->settings_page->register_hooks(); + } + + /** + * Registers the custom post type used to store email logs. + * + * @return void + */ + public function register_log_post_type(): void { + register_post_type( + self::LOG_POST_TYPE, + array( + 'labels' => array( + 'name' => esc_html__( 'SMTP Logs', 'robotstxt-smtp' ), + 'singular_name' => esc_html__( 'SMTP Log', 'robotstxt-smtp' ), + ), + 'public' => false, + 'show_ui' => false, + 'show_in_menu' => false, + 'show_in_nav_menus' => false, + 'exclude_from_search' => true, + 'publicly_queryable' => false, + 'supports' => array( 'title', 'editor' ), + 'capability_type' => 'post', + 'map_meta_cap' => true, + ) + ); + } + + /** + * Schedules or clears the automatic log cleanup task based on settings. + * + * @return void + */ + public function maybe_schedule_cleanup(): void { + $logging = $this->get_logging_settings(); + + if ( ! $logging['enabled'] ) { + wp_clear_scheduled_hook( self::CRON_HOOK ); + return; + } + + if ( false === wp_next_scheduled( self::CRON_HOOK ) ) { + wp_schedule_event( time() + HOUR_IN_SECONDS, 'daily', self::CRON_HOOK ); + } + } + + /** + * Filters the "From" email address used by WordPress. + * + * @param string $original_email Original email address provided by WordPress. + * + * @return string + */ + public function filter_mail_from( string $original_email ): string { + $settings = $this->get_mailer_settings(); + $from_email = isset( $settings['from_email'] ) ? sanitize_email( $settings['from_email'] ) : ''; + + if ( empty( $from_email ) ) { + return sanitize_email( $original_email ); + } + + return $from_email; + } + + /** + * Filters the "From" name used by WordPress. + * + * @param string $original_name Original name provided by WordPress. + * + * @return string + */ + public function filter_mail_from_name( string $original_name ): string { + $settings = $this->get_mailer_settings(); + $from_name = isset( $settings['from_name'] ) ? sanitize_text_field( $settings['from_name'] ) : ''; + + if ( '' === $from_name ) { + return sanitize_text_field( $original_name ); + } + + return $from_name; + } + + /** + * Retrieves the stored mailer settings merged with defaults. + * + * @return array + */ + private function get_mailer_settings(): array { + $defaults = Settings_Page::get_default_settings(); + + if ( Settings_Page::is_network_mode_enabled() ) { + $settings = get_site_option( Settings_Page::NETWORK_OPTION_NAME, array() ); + } else { + $settings = get_option( Settings_Page::OPTION_NAME, array() ); + } + + if ( ! is_array( $settings ) ) { + $settings = array(); + } + + $settings = wp_parse_args( $settings, $defaults ); + + /** + * Filters the SMTP settings before they are consumed by the mailer. + * + * @since 1.1.1 + * + * @param array $settings Sanitized settings merged with defaults. + */ + return (array) \apply_filters( 'robotstxt_smtp_mailer_settings', $settings ); + } + + /** + * Configures PHPMailer to use the stored SMTP settings. + * + * @param PHPMailer $phpmailer Mailer instance. + * + * @return void + */ + public function configure_phpmailer( PHPMailer $phpmailer ): void { + if ( self::is_amazon_ses_integration_active() ) { + return; + } + + $this->current_debug_log = array(); + + $settings = $this->get_mailer_settings(); + + if ( empty( $settings['host'] ) ) { + return; + } + + $phpmailer->isSMTP(); + + // phpcs:disable WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase + $phpmailer->Host = $settings['host']; + $phpmailer->Port = (int) $settings['port']; + $phpmailer->SMTPAuth = ! empty( $settings['username'] ) || ! empty( $settings['password'] ); + $phpmailer->Username = $phpmailer->SMTPAuth ? $settings['username'] : ''; + $phpmailer->Password = $phpmailer->SMTPAuth ? $settings['password'] : ''; + $phpmailer->SMTPAutoTLS = false; + $phpmailer->SMTPSecure = ''; + // phpcs:enable WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase + + if ( 'ssl' === $settings['security'] ) { + // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase + $phpmailer->SMTPSecure = PHPMailer::ENCRYPTION_SMTPS; + } elseif ( 'tls' === $settings['security'] ) { + // phpcs:disable WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase + $phpmailer->SMTPSecure = PHPMailer::ENCRYPTION_STARTTLS; + $phpmailer->SMTPAutoTLS = true; + // phpcs:enable WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase + } + + $this->register_debug_logger( $phpmailer ); + } + + /** + * Enables SMTP debug logging for the current email. + * + * @param PHPMailer $phpmailer Mailer instance. + * + * @return void + */ + private function register_debug_logger( PHPMailer $phpmailer ): void { + $logging = $this->get_logging_settings(); + + if ( ! $logging['enabled'] ) { + return; + } + + // phpcs:disable WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase + $phpmailer->SMTPDebug = SMTP::DEBUG_SERVER; + $phpmailer->Debugoutput = function ( string $message, int $level ): void { + $this->current_debug_log[] = '[' . $level . '] ' . $message; + }; + // phpcs:enable WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase + } + + + /** + * Stores the email data when WordPress reports a successful delivery. + * + * @param array $mail_data Email data provided by wp_mail. + * + * @return void + */ + public function handle_wp_mail_succeeded( array $mail_data ): void { + $logging = $this->get_logging_settings(); + + if ( ! $logging['enabled'] ) { + return; + } + + $settings = $this->get_mailer_settings(); + + $subject = isset( $mail_data['subject'] ) ? sanitize_text_field( (string) $mail_data['subject'] ) : ''; + $message = isset( $mail_data['message'] ) ? (string) $mail_data['message'] : ''; + $headers = $this->normalize_headers( $mail_data['headers'] ?? array() ); + $to = $this->normalize_recipients( $mail_data['to'] ?? array() ); + $from = $this->determine_from_header( $headers, $settings ); + + $post_id = wp_insert_post( + array( + 'post_type' => self::LOG_POST_TYPE, + 'post_status' => 'publish', + 'post_title' => $subject, + 'post_content' => $message, + 'post_date' => current_time( 'mysql' ), + ), + true + ); + + if ( is_wp_error( $post_id ) || 0 === $post_id ) { + return; + } + + update_post_meta( $post_id, '_robotstxt_smtp_to', implode( ', ', $to ) ); + update_post_meta( $post_id, '_robotstxt_smtp_from', $from ); + update_post_meta( $post_id, '_robotstxt_smtp_headers', implode( "\n", $headers ) ); + update_post_meta( $post_id, '_robotstxt_smtp_status', 'sent' ); + update_post_meta( $post_id, '_robotstxt_smtp_error', '' ); + + if ( isset( $mail_data['attachments'] ) ) { + update_post_meta( $post_id, '_robotstxt_smtp_attachments', maybe_serialize( $mail_data['attachments'] ) ); + } + + $debug_log = $this->get_sanitized_debug_log(); + + if ( $debug_log ) { + update_post_meta( $post_id, '_robotstxt_smtp_debug_log', maybe_serialize( $debug_log ) ); + } + + if ( 'count' === $logging['mode'] ) { + $this->cleanup_logs_by_count( $logging['count'] ); + return; + } + + $this->cleanup_logs_by_days( $logging['days'] ); + } + + /** + * Stores the email data when WordPress reports a failed delivery. + * + * @param WP_Error $error Error object reported by wp_mail. + * + * @return void + */ + public function handle_wp_mail_failed( WP_Error $error ): void { + $logging = $this->get_logging_settings(); + + if ( ! $logging['enabled'] ) { + return; + } + + $settings = $this->get_mailer_settings(); + $data = $error->get_error_data(); + + if ( ! is_array( $data ) ) { + $data = array(); + } + + $subject = isset( $data['subject'] ) ? sanitize_text_field( (string) $data['subject'] ) : ''; + $message = isset( $data['message'] ) ? (string) $data['message'] : ''; + $headers = $this->normalize_headers( $data['headers'] ?? array() ); + $to = $this->normalize_recipients( $data['to'] ?? array() ); + $from = $this->determine_from_header( $headers, $settings ); + $error_msg = sanitize_text_field( $error->get_error_message() ); + + $post_id = wp_insert_post( + array( + 'post_type' => self::LOG_POST_TYPE, + 'post_status' => 'publish', + 'post_title' => $subject, + 'post_content' => $message, + 'post_date' => current_time( 'mysql' ), + ), + true + ); + + if ( is_wp_error( $post_id ) || 0 === $post_id ) { + return; + } + + update_post_meta( $post_id, '_robotstxt_smtp_to', implode( ', ', $to ) ); + update_post_meta( $post_id, '_robotstxt_smtp_from', $from ); + update_post_meta( $post_id, '_robotstxt_smtp_headers', implode( "\n", $headers ) ); + update_post_meta( $post_id, '_robotstxt_smtp_status', 'error' ); + update_post_meta( $post_id, '_robotstxt_smtp_error', $error_msg ); + + if ( isset( $data['attachments'] ) ) { + update_post_meta( $post_id, '_robotstxt_smtp_attachments', maybe_serialize( $data['attachments'] ) ); + } + + $debug_log = $this->get_sanitized_debug_log(); + + if ( $debug_log ) { + update_post_meta( $post_id, '_robotstxt_smtp_debug_log', maybe_serialize( $debug_log ) ); + } + + if ( 'count' === $logging['mode'] ) { + $this->cleanup_logs_by_count( $logging['count'] ); + return; + } + + $this->cleanup_logs_by_days( $logging['days'] ); + } + /** + * Retrieves the sanitized SMTP debug output captured for the current email. + * + * @return array + */ + private function get_sanitized_debug_log(): array { + if ( empty( $this->current_debug_log ) ) { + return array(); + } + + $sanitized = array_map( + static function ( $entry ): string { + return sanitize_textarea_field( (string) $entry ); + }, + $this->current_debug_log + ); + + $sanitized = array_filter( + $sanitized, + static function ( string $line ): bool { + return '' !== trim( $line ); + } + ); + + return array_values( $sanitized ); + } + + /** + * Performs log cleanup when triggered by cron. + * + * @return void + */ + public function cleanup_logs(): void { + $logging = $this->get_logging_settings(); + + if ( ! $logging['enabled'] ) { + return; + } + + if ( 'count' === $logging['mode'] ) { + $this->cleanup_logs_by_count( $logging['count'] ); + return; + } + + $this->cleanup_logs_by_days( $logging['days'] ); + } + + /** + * Deletes all stored logs. + * + * @return void + */ + public function clear_all_logs(): void { + do { + $posts = get_posts( + array( + 'post_type' => self::LOG_POST_TYPE, + 'post_status' => 'any', + 'fields' => 'ids', + 'posts_per_page' => 100, + 'orderby' => 'ID', + 'order' => 'ASC', + ) + ); + + foreach ( $posts as $post_id ) { + wp_delete_post( (int) $post_id, true ); + } + } while ( ! empty( $posts ) ); + } + + /** + * Cleans up logs by enforcing a maximum number of stored records. + * + * @param int $limit Maximum number of logs to keep. + * + * @return void + */ + private function cleanup_logs_by_count( int $limit ): void { + if ( $limit <= 0 ) { + $this->clear_all_logs(); + return; + } + + $logs_to_delete = get_posts( + array( + 'post_type' => self::LOG_POST_TYPE, + 'post_status' => 'any', + 'fields' => 'ids', + 'orderby' => 'date', + 'order' => 'DESC', + 'posts_per_page' => -1, + 'offset' => $limit, + 'no_found_rows' => true, + ) + ); + + foreach ( $logs_to_delete as $post_id ) { + wp_delete_post( (int) $post_id, true ); + } + } + + /** + * Cleans up logs based on the maximum number of days to retain them. + * + * @param int $days Number of days to keep. + * + * @return void + */ + private function cleanup_logs_by_days( int $days ): void { + if ( $days <= 0 ) { + $this->clear_all_logs(); + return; + } + + $cutoff = time() - ( $days * DAY_IN_SECONDS ); + + $logs_to_delete = get_posts( + array( + 'post_type' => self::LOG_POST_TYPE, + 'post_status' => 'any', + 'fields' => 'ids', + 'date_query' => array( + array( + 'column' => 'post_date_gmt', + 'before' => gmdate( 'Y-m-d H:i:s', $cutoff ), + ), + ), + 'posts_per_page' => -1, + 'no_found_rows' => true, + ) + ); + + foreach ( $logs_to_delete as $post_id ) { + wp_delete_post( (int) $post_id, true ); + } + } + + /** + * Normalizes the headers array into a list of strings. + * + * @param mixed $headers Headers provided by wp_mail. + * + * @return array + */ + private function normalize_headers( $headers ): array { + if ( empty( $headers ) ) { + return array(); + } + + if ( is_string( $headers ) ) { + $headers = preg_split( "/\r\n|\r|\n/", $headers ); + } + + if ( ! is_array( $headers ) ) { + return array(); + } + + $normalized = array(); + + foreach ( $headers as $header ) { + $line = trim( (string) $header ); + + if ( '' !== $line ) { + $normalized[] = $line; + } + } + + return $normalized; + } + + /** + * Normalizes the recipient list into an array of strings. + * + * @param mixed $recipients Recipient information from wp_mail. + * + * @return array + */ + private function normalize_recipients( $recipients ): array { + if ( empty( $recipients ) ) { + return array(); + } + + if ( is_string( $recipients ) ) { + $recipients = explode( ',', $recipients ); + } + + if ( ! is_array( $recipients ) ) { + return array(); + } + + $normalized = array(); + + foreach ( $recipients as $recipient ) { + $normalized[] = sanitize_text_field( trim( (string) $recipient ) ); + } + + return array_filter( $normalized ); + } + + /** + * Determines the "From" header for the logged message. + * + * @param array $headers Normalized header list. + * @param array $settings Plugin settings. + * + * @return string + */ + private function determine_from_header( array $headers, array $settings ): string { + foreach ( $headers as $header ) { + if ( 0 === stripos( $header, 'from:' ) ) { + return sanitize_text_field( trim( substr( $header, 5 ) ) ); + } + } + + $from_email = isset( $settings['from_email'] ) ? sanitize_email( $settings['from_email'] ) : ''; + $from_name = isset( $settings['from_name'] ) ? sanitize_text_field( $settings['from_name'] ) : ''; + + if ( $from_email && $from_name ) { + return $from_name . ' <' . $from_email . '>'; + } + + if ( $from_email ) { + return $from_email; + } + + return get_bloginfo( 'name', 'display' ); + } + + /** + * Retrieves the logging-related settings from the option store. + * + * @return array + */ + private function get_logging_settings(): array { + $settings = $this->get_mailer_settings(); + + return array( + 'enabled' => ! empty( $settings['logs_enabled'] ), + 'mode' => in_array( $settings['logs_retention_mode'], array( 'count', 'days' ), true ) + ? $settings['logs_retention_mode'] + : 'count', + 'count' => isset( $settings['logs_retention_count'] ) ? (int) $settings['logs_retention_count'] : 0, + 'days' => isset( $settings['logs_retention_days'] ) ? (int) $settings['logs_retention_days'] : 0, + ); + } +} diff --git a/includes/class-settings-page.php b/includes/class-settings-page.php new file mode 100644 index 0000000..1788fb5 --- /dev/null +++ b/includes/class-settings-page.php @@ -0,0 +1,4729 @@ +|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; + } +} diff --git a/includes/class-smtp-diagnostics-client.php b/includes/class-smtp-diagnostics-client.php new file mode 100644 index 0000000..38ae318 --- /dev/null +++ b/includes/class-smtp-diagnostics-client.php @@ -0,0 +1,107 @@ + $expected_codes Expected success codes. + * + * @return array + */ + public function execute_command( string $name, string $command_string, array $expected_codes = array( 250 ) ): array { + $this->last_command_timed_out = false; + $this->reset_error_state(); + + $expected = ! empty( $expected_codes ) ? array_map( 'intval', $expected_codes ) : array( 250 ); + + $success = parent::sendCommand( $name, $command_string, $expected ); + + if ( ! $success ) { + $this->last_command_timed_out = $this->error_indicates_timeout( parent::getError() ); + } + + return array( + 'success' => $success, + 'reply' => $this->getLastReply(), + 'timed_out' => $this->last_command_timed_out, + ); + } + + /** + * Indicates whether the last command timed out. + * + * @return bool + */ + public function did_last_command_timeout(): bool { + return $this->last_command_timed_out; + } + + /** + * Initiates STARTTLS while tracking timeout state. + * + * @return bool + */ + public function startTLS(): bool { + $this->last_command_timed_out = false; + $this->reset_error_state(); + + $result = parent::startTLS(); + + if ( ! $result ) { + $this->last_command_timed_out = $this->error_indicates_timeout( parent::getError() ); + } + + return $result; + } + + /** + * Clears the stored error state. + */ + private function reset_error_state(): void { + parent::setError( '' ); + } + + /** + * Determines whether an error array represents a timeout. + * + * @param array $error Error details. + * + * @return bool + */ + private function error_indicates_timeout( array $error ): bool { + foreach ( array( 'error', 'detail', 'smtp_code_ex' ) as $key ) { + if ( empty( $error[ $key ] ) ) { + continue; + } + + $value = (string) $error[ $key ]; + + if ( false !== stripos( $value, 'timed out' ) || false !== stripos( $value, 'timed-out' ) ) { + return true; + } + } + + return false; + } +} diff --git a/readme.txt b/readme.txt new file mode 100644 index 0000000..846650b --- /dev/null +++ b/readme.txt @@ -0,0 +1,84 @@ +=== SMTP (by ROBOTSTXT) === +Contributors: robotstxt +Tags: smtp, email, mail +Requires at least: 6.0 +Tested up to: 6.9 +Requires PHP: 8.2 +Stable tag: 1.2.0 +License: GPLv3 or later +License URI: https://www.gnu.org/licenses/gpl-3.0.html + +Send every site email through a fully configurable SMTP server managed from the WordPress dashboard. + +== Description == + +SMTP (by ROBOTSTXT) replaces WordPress' native mail delivery with a secure SMTP connection and detailed guidance. The plugin is built for marketing teams, agencies, and technical departments that need a clear, complete solution ready for multisite installations. + += Guided PHPMailer configuration = + +* Routes `wp_mail()` through PHPMailer in SMTP mode with support for credentials, custom ports, and the `None`, `SSL`, or `TLS` encryption methods. +* Adds inline help text with practical examples in every field: host, port, username, password, sender email, and sender name. +* Automatically changes the port when it matches the default value for the selected encryption type (25, 465, or 587). +* Lets you set default sender information for the site to keep a consistent identity across outgoing messages. + += Tools for support and marketing teams = + +* **Settings → SMTP → Test** screen for sending manual test emails and confirming the connection to the server. +* **Tools** panel packed with diagnostics: automatic MX lookup, SPF/DKIM/DMARC checks, extended SMTP server diagnostics, and DNS reputation monitoring. +* Caches results from each tool for 24 hours (with a manual refresh option) to streamline recurring tasks for your team. + += Comprehensive delivery logs = + +* Saves the subject, recipients, headers, content, and attachments for every email sent. +* Browse logs from **Settings → SMTP → Logs** with pagination and access to the details of each delivery. +* Configure automatic cleanup by maximum entries or age in days, plus an instant "Clear all" button to wipe the history. + += Multisite-ready configuration = + +* Choose whether the configuration applies to the entire network or individually per site. +* Dedicated forms in both the network dashboard and each site to edit, test, and share credentials securely. + +== Installation == + +1. Upload the plugin folder to `wp-content/plugins/`. +2. Activate **SMTP (by ROBOTSTXT)** from the **Plugins** menu in the WordPress dashboard. +3. Open **Settings → SMTP** (or **Network Settings → SMTP** in multisite) to enter your connection details. + +== Frequently Asked Questions == + += Do I need credentials to send email? = + +Enter a username and password only if your provider requires them. If the server accepts unauthenticated delivery, leave the fields blank and the plugin will send without credentials. + += Which encryption should I use? = + +Select `None`, `SSL`, or `TLS` according to your provider's documentation. When you switch encryption, the plugin will automatically suggest the recommended port if you are using one of the standard values. + += How can I review the emails that were sent? = + +Enable logging on the settings page and visit **Settings → SMTP → Logs** to open the paginated table of saved emails. From there you can inspect each entry, download attachments, and delete records. + += Does it work on a multisite network? = + +Yes. From the network dashboard you can decide whether the configuration is global or site-specific. You can also run the tools and send test emails from the network or from each individual site. + +== Changelog == + += 1.2.0 = + +* Logged failed email deliveries with status and error details in the log list and detail views. +* Captured the SMTP debug conversation for each email and surfaced it inside the log detail view. + += 1.1.0 = + +* Added Amazon SES credential fields, regional selection, and live validation helpers that appear when the Amazon SES add-on is active. +* Introduced the `robotstxt_smtp_sanitized_options` filter so add-ons can adjust sanitized settings before they are stored. +* Routed SMTP test messages and regular WordPress emails through Amazon SES whenever the add-on supplies valid credentials. + += 1.0.0 = + +* Contextual help guidance in every SMTP configuration field. +* Automatic port updates when selecting an encryption method with standard values. +* Advanced tools: MX analysis, SPF/DKIM/DMARC validation, extended SMTP diagnostics, and blacklist monitoring. +* Enhanced logging with automatic cleanup by limit or age. +* Updated documentation for the 1.0.0 release with WordPress 6.7 and PHP 8.2 support. diff --git a/robotstxt-smtp.php b/robotstxt-smtp.php new file mode 100644 index 0000000..230bd6d --- /dev/null +++ b/robotstxt-smtp.php @@ -0,0 +1,120 @@ +id && 'plugins-network' !== $screen->id ) { + return; + } + + $message = sprintf( + /* translators: %s: plugin name. */ + esc_html__( 'The %s plugin requires the PHPMailer library to be available.', 'robotstxt-smtp' ), + esc_html__( 'ROBOTSTXT SMTP', 'robotstxt-smtp' ) + ); + ?> +
+

+
+ run(); +} else { + robotstxt_smtp_register_dependency_notice(); +} diff --git a/uninstall.php b/uninstall.php new file mode 100644 index 0000000..39915c0 --- /dev/null +++ b/uninstall.php @@ -0,0 +1,155 @@ +get_col( + $wpdb->prepare( + "SELECT meta_key FROM {$wpdb->sitemeta} WHERE meta_key LIKE %s OR meta_key LIKE %s", + $wpdb->esc_like( $stored_prefix ) . '%', + $wpdb->esc_like( $timeout_prefix ) . '%' + ) + ); + } else { + $stored_prefix = '_transient_' . $prefix; + $timeout_prefix = '_transient_timeout_' . $prefix; + $delete_callback = 'delete_transient'; + $timeout_prefix_length = strlen( '_transient_timeout_' ); + $stored_prefix_length = strlen( '_transient_' ); + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching + $matches = $wpdb->get_col( + $wpdb->prepare( + "SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE %s OR option_name LIKE %s", + $wpdb->esc_like( $stored_prefix ) . '%', + $wpdb->esc_like( $timeout_prefix ) . '%' + ) + ); + } + + if ( empty( $matches ) ) { + return; + } + + foreach ( $matches as $option_name ) { + if ( ! is_string( $option_name ) ) { + continue; + } + + if ( str_starts_with( $option_name, $is_network ? '_site_transient_timeout_' : '_transient_timeout_' ) ) { + $transient_key = substr( $option_name, $timeout_prefix_length ); + } else { + $transient_key = substr( $option_name, $stored_prefix_length ); + } + + if ( '' === $transient_key ) { + continue; + } + + call_user_func( $delete_callback, $transient_key ); + } + } +} + +if ( ! function_exists( 'robotstxt_smtp_uninstall_delete_logs' ) ) { + /** + * Deletes all stored SMTP logs for the current site. + * + * @since 1.1.1 + * + * @return void + */ + function robotstxt_smtp_uninstall_delete_logs(): void { + do { + $logs = get_posts( + array( + 'post_type' => 'robotstxt_smtp_log', + 'post_status' => 'any', + 'fields' => 'ids', + 'posts_per_page' => 100, + 'orderby' => 'ID', + 'order' => 'ASC', + ) + ); + + foreach ( $logs as $log_id ) { + wp_delete_post( (int) $log_id, true ); + } + } while ( ! empty( $logs ) ); + } +} + +robotstxt_smtp_uninstall_cleanup_network(); + +if ( function_exists( 'is_multisite' ) && is_multisite() ) { + $sites = get_sites( array( 'fields' => 'ids' ) ); + + foreach ( $sites as $site_id ) { + switch_to_blog( (int) $site_id ); + robotstxt_smtp_uninstall_cleanup_site(); + restore_current_blog(); + } +} else { + robotstxt_smtp_uninstall_cleanup_site(); +}