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();
+
+ ?>
+
+ 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' );
+
+ ?>
+
+ |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 );
+
+ ?>
+
+
+
+
+
+
+ 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();
+ ?>
+
+ $label ) : ?>
+ >
+
+
+
+ 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();
+}