robotstxt-smtp/includes/class-settings-page.php
2025-11-26 08:24:18 +00:00

4729 lines
154 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
/**
* Settings page handler.
*
* @package Robotstxt_SMTP
*/
namespace Robotstxt_SMTP\Admin;
use PHPMailer\PHPMailer\PHPMailer;
use PHPMailer\PHPMailer\SMTP;
use Robotstxt_SMTP\Plugin;
use WP_Error;
use WP_User;
/**
* Handles the SMTP admin pages in the WordPress dashboard.
*/
class Settings_Page {
/**
* Option name used to store plugin settings.
*/
public const OPTION_NAME = 'robotstxt_smtp_options';
/**
* Option name used to store network-wide settings.
*/
public const NETWORK_OPTION_NAME = 'robotstxt_smtp_network_options';
/**
* Capability required to manage the settings on a single site.
*/
private const CAPABILITY = 'manage_options';
/**
* Capability required to manage the settings network-wide.
*/
private const NETWORK_CAPABILITY = 'manage_network_options';
/**
* Menu slug used for the top-level page.
*/
private const MENU_SLUG = 'robotstxt-smtp';
/**
* Menu slug used for the network-level page.
*/
private const NETWORK_MENU_SLUG = 'robotstxt-smtp-network';
/**
* Page slug used for the settings page.
*/
private const PAGE_SLUG = 'robotstxt-smtp';
/**
* Page slug used for the network settings page.
*/
private const NETWORK_PAGE_SLUG = 'robotstxt-smtp-network';
/**
* Page slug used for the test page.
*/
private const TEST_PAGE_SLUG = 'robotstxt-smtp-test';
/**
* Page slug used for the logs page.
*/
private const LOGS_PAGE_SLUG = 'robotstxt-smtp-logs';
/**
* Page slug used for the tools page.
*/
private const TOOLS_PAGE_SLUG = 'robotstxt-smtp-tools';
/**
* Lifetime (in seconds) for cached tool results.
*/
private const TOOLS_CACHE_LIFETIME = DAY_IN_SECONDS;
/**
* Prefix used for tool cache transient names.
*/
private const TOOLS_TRANSIENT_PREFIX = 'robotstxt_smtp_tool_';
/**
* Option used to determine whether the configuration is global or per-site.
*/
private const NETWORK_MODE_OPTION_NAME = 'robotstxt_smtp_configuration_scope';
/**
* Settings group used for the single-site settings API.
*/
private const SITE_SETTINGS_GROUP = 'robotstxt_smtp_settings';
/**
* Identifier for the single-site scope.
*/
private const SCOPE_SITE = 'site';
/**
* Identifier for the network scope.
*/
private const SCOPE_NETWORK = 'network';
/**
* Identifier for per-site configuration mode.
*/
private const MODE_SITE = 'site';
/**
* Identifier for network-wide configuration mode.
*/
private const MODE_NETWORK = 'network';
/**
* Cached settings to avoid repeated lookups during a single request.
*
* @var array<string, mixed>|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<int, array<string, mixed>>
*/
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<int, array<string, mixed>> $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<int, array<string, mixed>>
*/
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<int, array<string, mixed>> $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'] : '';
?>
<tr>
<th scope="row">
<?php if ( '' !== $label_for ) : ?>
<label for="<?php echo esc_attr( $label_for ); ?>"><?php echo esc_html( $field['label'] ); ?></label>
<?php else : ?>
<?php echo esc_html( $field['label'] ); ?>
<?php endif; ?>
</th>
<td>
<?php call_user_func( $field['callback'] ); ?>
</td>
</tr>
<?php
}
}
/**
* Outputs the section description.
*
* @return void
*/
public function render_connection_section_description(): void {
if ( Plugin::is_amazon_ses_integration_active() ) {
echo '<p>' . esc_html__( 'Amazon SES integration is active. To configure SMTP manually, deactivate the Amazon SES add-on.', 'robotstxt-smtp' ) . '</p>';
return;
}
echo '<p>' . esc_html__( 'Configure the SMTP server used to deliver outgoing emails.', 'robotstxt-smtp' ) . '</p>';
}
/**
* Outputs the logs section description.
*
* @return void
*/
public function render_logs_section_description(): void {
echo '<p>' . esc_html__( 'Control how email delivery logs are stored and pruned.', 'robotstxt-smtp' ) . '</p>';
}
/**
* 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 );
?>
<div class="wrap">
<h1><?php echo esc_html__( 'SMTP (by ROBOTSTXT)', 'robotstxt-smtp' ); ?></h1>
<form action="<?php echo esc_url( admin_url( 'options.php' ) ); ?>" method="post">
<?php
settings_fields( self::SITE_SETTINGS_GROUP );
do_settings_sections( self::PAGE_SLUG );
submit_button( esc_html__( 'Save Changes', 'robotstxt-smtp' ) );
?>
</form>
</div>
<?php
}
/**
* Renders the settings page shown in the network admin area.
*
* @return void
*/
public function render_network_settings_page(): void {
$this->ensure_capability( self::SCOPE_NETWORK );
$this->set_scope( self::SCOPE_NETWORK );
$mode = self::get_configuration_mode();
?>
<div class="wrap">
<h1><?php echo esc_html__( 'SMTP (by ROBOTSTXT)', 'robotstxt-smtp' ); ?></h1>
<?php
$saved_notice = isset( $_GET['robotstxt_smtp_saved'] ) ? sanitize_text_field( wp_unslash( (string) $_GET['robotstxt_smtp_saved'] ) ) : '';
$notice_nonce = isset( $_GET['_wpnonce'] ) ? sanitize_text_field( wp_unslash( (string) $_GET['_wpnonce'] ) ) : '';
$should_display = false;
if ( '' !== $saved_notice && '' !== $notice_nonce ) {
$should_display = wp_verify_nonce( $notice_nonce, 'robotstxt_smtp_saved_notice' ) && wp_validate_boolean( $saved_notice );
}
?>
<?php if ( $should_display ) : ?>
<div class="notice notice-success is-dismissible">
<p><?php esc_html_e( 'Settings saved.', 'robotstxt-smtp' ); ?></p>
</div>
<?php endif; ?>
<form action="<?php echo esc_url( $this->get_admin_post_url_for_scope( self::SCOPE_NETWORK ) ); ?>" method="post">
<?php wp_nonce_field( 'robotstxt_smtp_save_network_settings' ); ?>
<input type="hidden" name="action" value="robotstxt_smtp_save_network_settings" />
<input type="hidden" name="robotstxt_smtp_context" value="<?php echo esc_attr( self::SCOPE_NETWORK ); ?>" />
<h2 class="title"><?php esc_html_e( 'Configuration scope', 'robotstxt-smtp' ); ?></h2>
<p><?php esc_html_e( 'Choose whether to manage the SMTP credentials globally or per individual site.', 'robotstxt-smtp' ); ?></p>
<table class="form-table" role="presentation">
<tbody>
<tr>
<th scope="row"><?php esc_html_e( 'Mode', 'robotstxt-smtp' ); ?></th>
<td>
<fieldset>
<label>
<input type="radio" name="robotstxt_smtp_mode" value="<?php echo esc_attr( self::MODE_NETWORK ); ?>" <?php checked( $mode, self::MODE_NETWORK ); ?> />
<?php esc_html_e( 'Global network configuration', 'robotstxt-smtp' ); ?>
</label>
<br />
<label>
<input type="radio" name="robotstxt_smtp_mode" value="<?php echo esc_attr( self::MODE_SITE ); ?>" <?php checked( $mode, self::MODE_SITE ); ?> />
<?php esc_html_e( 'Configure each site individually', 'robotstxt-smtp' ); ?>
</label>
<p class="description">
<?php esc_html_e( 'When configured globally, all sites in the network will reuse the settings below.', 'robotstxt-smtp' ); ?>
</p>
</fieldset>
</td>
</tr>
</tbody>
</table>
<h2 class="title"><?php esc_html_e( 'Global SMTP settings', 'robotstxt-smtp' ); ?></h2>
<?php if ( Plugin::is_amazon_ses_integration_active() ) : ?>
<div class="notice notice-info inline">
<p><?php esc_html_e( 'Amazon SES integration is active. To configure SMTP manually, deactivate the Amazon SES add-on.', 'robotstxt-smtp' ); ?></p>
</div>
<?php endif; ?>
<?php if ( self::MODE_SITE === $mode ) : ?>
<div class="notice notice-info inline">
<p><?php esc_html_e( 'These credentials are stored for later use but are only applied when the global configuration mode is enabled.', 'robotstxt-smtp' ); ?></p>
</div>
<?php endif; ?>
<table class="form-table" role="presentation">
<tbody>
<?php $this->render_field_rows( $this->get_connection_field_definitions() ); ?>
</tbody>
</table>
<h2 class="title"><?php esc_html_e( 'Email Log Settings', 'robotstxt-smtp' ); ?></h2>
<table class="form-table" role="presentation">
<tbody>
<?php $this->render_field_rows( $this->get_logging_field_definitions() ); ?>
</tbody>
</table>
<?php submit_button( esc_html__( 'Save Changes', 'robotstxt-smtp' ) ); ?>
</form>
</div>
<?php
}
/**
* Handles saving the network-wide configuration settings.
*
* @return void
*/
public function handle_save_network_settings(): void {
$scope = self::SCOPE_NETWORK;
if ( ! is_multisite() ) {
wp_die( esc_html__( 'Network settings are only available on multisite installations.', 'robotstxt-smtp' ) );
}
$this->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 );
?>
<div class="wrap">
<h1><?php echo esc_html__( 'SMTP Test', 'robotstxt-smtp' ); ?></h1>
<?php
if ( self::SCOPE_NETWORK === $scope && self::MODE_SITE === self::get_configuration_mode() ) {
echo '<div class="notice notice-info inline"><p>' . esc_html__( 'The network configuration is currently disabled. Tests will use the site-specific settings stored below.', 'robotstxt-smtp' ) . '</p></div>';
}
$this->render_test_email_form( $scope );
?>
</div>
<?php
}
/**
* Renders the tools page.
*
* @return void
*/
public function render_tools_page(): void {
$scope = $this->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 providers terms of use.', 'robotstxt-smtp' ),
'callback' => array( $this, 'get_tools_blacklist_section_html' ),
),
);
$base_url = $this->get_admin_url_for_scope( $scope, 'admin.php' );
?>
<div class="wrap">
<h1><?php echo esc_html__( 'SMTP Tools', 'robotstxt-smtp' ); ?></h1>
<?php if ( self::SCOPE_NETWORK === $scope && self::MODE_SITE === self::get_configuration_mode() ) : ?>
<div class="notice notice-info inline">
<p><?php esc_html_e( 'The network configuration is currently disabled. Tools will use the site-specific settings stored below.', 'robotstxt-smtp' ); ?></p>
</div>
<?php endif; ?>
<div id="robotstxt-smtp-tools" class="robotstxt-smtp-tools">
<?php foreach ( $tools as $tool_key => $tool_definition ) : ?>
<?php
$run_now = ( $force_refresh && $requested_tool === $tool_key );
$result = $this->get_tool_result_for_display( $scope, $tool_key, $tool_definition['callback'], $run_now );
$has_result = null !== $result;
$refresh_url = add_query_arg(
array(
'page' => self::TOOLS_PAGE_SLUG,
'robotstxt_smtp_tool' => $tool_key,
'robotstxt_smtp_refresh' => '1',
),
$base_url
);
$refresh_url = wp_nonce_url( $refresh_url, 'robotstxt_smtp_refresh_tool_' . $tool_key );
?>
<div class="card robotstxt-smtp-tool-card" data-tool="<?php echo esc_attr( $tool_key ); ?>">
<h2><?php echo esc_html( $tool_definition['title'] ); ?></h2>
<p><?php echo esc_html( $tool_definition['description'] ); ?></p>
<?php if ( $has_result ) : ?>
<?php
$formatted_timestamp = $this->format_tool_timestamp( isset( $result['timestamp'] ) ? (int) $result['timestamp'] : 0 );
?>
<?php if ( '' !== $formatted_timestamp ) : ?>
<p class="robotstxt-smtp-tool-meta">
<?php
printf(
/* translators: %s: Human-readable timestamp of the last check. */
esc_html__( 'Last checked: %s', 'robotstxt-smtp' ),
esc_html( $formatted_timestamp )
);
?>
</p>
<?php endif; ?>
<p class="robotstxt-smtp-tool-actions">
<a class="button" href="<?php echo esc_url( $refresh_url ); ?>">
<?php esc_html_e( 'Run checks again', 'robotstxt-smtp' ); ?>
</a>
</p>
<div class="robotstxt-smtp-tool-result">
<?php echo isset( $result['html'] ) ? $result['html'] : ''; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
</div>
<?php else : ?>
<p class="robotstxt-smtp-tool-empty">
<?php esc_html_e( 'There are no results yet. Click the button below to run this check.', 'robotstxt-smtp' ); ?>
</p>
<p class="robotstxt-smtp-tool-actions">
<a class="button button-primary" href="<?php echo esc_url( $refresh_url ); ?>">
<?php esc_html_e( 'Run checks', 'robotstxt-smtp' ); ?>
</a>
</p>
<?php endif; ?>
</div>
<?php endforeach; ?>
</div>
</div>
<?php
}
/**
* Retrieves cached tool results or refreshes them when requested.
*
* @param string $scope Scope identifier.
* @param string $tool Tool identifier.
* @param callable $callback Callback used to build the tool markup.
* @param bool $refresh Whether the cache should be bypassed and regenerated.
*
* @return array<string, mixed>|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<string, mixed>|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<string, mixed> $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 ) :
?>
<div class="notice notice-info inline">
<p><?php esc_html_e( 'Configure a "From Email" address in the settings to analyze the MX records.', 'robotstxt-smtp' ); ?></p>
</div>
<?php
else :
?>
<p>
<strong><?php esc_html_e( 'Domain:', 'robotstxt-smtp' ); ?></strong>
<?php echo esc_html( $mx_analysis['domain'] ); ?>
<br />
<strong><?php esc_html_e( 'Configured SMTP host:', 'robotstxt-smtp' ); ?></strong>
<?php echo '' !== $normalized_smtp_host ? esc_html( $normalized_smtp_host ) : esc_html__( 'Not configured', 'robotstxt-smtp' ); ?>
</p>
<?php if ( ! empty( $mx_analysis['errors'] ) ) : ?>
<div class="notice notice-error">
<p><?php esc_html_e( 'The MX lookup could not be completed:', 'robotstxt-smtp' ); ?></p>
<ul>
<?php foreach ( $mx_analysis['errors'] as $error ) : ?>
<li><?php echo esc_html( $error ); ?></li>
<?php endforeach; ?>
</ul>
</div>
<?php elseif ( empty( $mx_analysis['records'] ) ) : ?>
<div class="notice notice-warning">
<p>
<?php
printf(
/* translators: %s: Domain name extracted from the sender email address. */
esc_html__( 'No MX records were found for %s.', 'robotstxt-smtp' ),
esc_html( $mx_analysis['domain'] )
);
?>
</p>
</div>
<?php else : ?>
<table class="widefat striped">
<thead>
<tr>
<th scope="col"><?php esc_html_e( 'Priority', 'robotstxt-smtp' ); ?></th>
<th scope="col"><?php esc_html_e( 'Host', 'robotstxt-smtp' ); ?></th>
<th scope="col"><?php esc_html_e( 'Resolves', 'robotstxt-smtp' ); ?></th>
<th scope="col"><?php esc_html_e( 'Matches SMTP host', 'robotstxt-smtp' ); ?></th>
</tr>
</thead>
<tbody>
<?php foreach ( $mx_analysis['records'] as $record ) : ?>
<tr>
<td><?php echo null === $record['priority'] ? '&mdash;' : esc_html( (string) $record['priority'] ); ?></td>
<td><?php echo esc_html( $record['display_target'] ); ?></td>
<td>
<?php
if ( true === $record['resolves'] ) {
esc_html_e( 'Yes', 'robotstxt-smtp' );
} elseif ( false === $record['resolves'] ) {
esc_html_e( 'No', 'robotstxt-smtp' );
} else {
esc_html_e( 'Unknown', 'robotstxt-smtp' );
}
?>
</td>
<td>
<?php
if ( '' === $normalized_smtp_host ) {
echo '&mdash;';
} elseif ( $record['matches_host'] ) {
esc_html_e( 'Yes', 'robotstxt-smtp' );
} else {
esc_html_e( 'No', 'robotstxt-smtp' );
}
?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php
$non_resolving = array();
foreach ( $mx_analysis['records'] as $record ) {
if ( false === $record['resolves'] ) {
$non_resolving[] = $record['display_target'];
}
}
if ( ! empty( $non_resolving ) ) :
?>
<div class="notice notice-warning inline">
<p><?php esc_html_e( 'Some MX records could not be resolved to an A or AAAA record:', 'robotstxt-smtp' ); ?></p>
<ul>
<?php foreach ( $non_resolving as $host ) : ?>
<li><?php echo esc_html( $host ); ?></li>
<?php endforeach; ?>
</ul>
</div>
<?php endif; ?>
<?php if ( '' !== $normalized_smtp_host ) : ?>
<?php if ( $mx_analysis['matches_smtp_host'] ) : ?>
<div class="notice notice-success inline">
<p>
<?php
printf(
/* translators: %s: Configured SMTP host. */
esc_html__( 'At least one MX record points to %s, which matches the configured SMTP host.', 'robotstxt-smtp' ),
esc_html( $normalized_smtp_host )
);
?>
</p>
</div>
<?php else : ?>
<div class="notice notice-warning inline">
<p>
<?php
printf(
/* translators: %s: Configured SMTP host. */
esc_html__( 'None of the MX records point to %s, which is the configured SMTP host.', 'robotstxt-smtp' ),
esc_html( $normalized_smtp_host )
);
?>
</p>
</div>
<?php endif; ?>
<?php else : ?>
<div class="notice notice-info inline">
<p><?php esc_html_e( 'Configure an SMTP host to compare it against the MX records.', 'robotstxt-smtp' ); ?></p>
</div>
<?php endif; ?>
<?php endif; ?>
<?php
endif;
return (string) ob_get_clean();
}
/**
* Generates the markup for the SMTP diagnostics tool.
*
* @param string $scope Scope identifier.
*
* @return string
*/
private function get_tools_smtp_diagnostics_section_html( string $scope ): string {
$this->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 ) :
?>
<div class="notice notice-info inline">
<p><?php esc_html_e( 'Configure an SMTP host in the settings to run the diagnostics.', 'robotstxt-smtp' ); ?></p>
</div>
<?php
return (string) ob_get_clean();
endif;
$diagnostics = $this->run_smtp_diagnostics( $settings );
if ( ! empty( $diagnostics['errors'] ) ) :
?>
<div class="notice notice-error inline">
<ul>
<?php foreach ( $diagnostics['errors'] as $error ) : ?>
<li><?php echo esc_html( $error ); ?></li>
<?php endforeach; ?>
</ul>
</div>
<?php
endif;
if ( ! empty( $diagnostics['warnings'] ) ) :
?>
<div class="notice notice-warning inline">
<ul>
<?php foreach ( $diagnostics['warnings'] as $warning ) : ?>
<li><?php echo esc_html( $warning ); ?></li>
<?php endforeach; ?>
</ul>
</div>
<?php
endif;
?>
<p>
<strong><?php esc_html_e( 'Server:', 'robotstxt-smtp' ); ?></strong>
<?php echo esc_html( $diagnostics['host'] . ':' . $diagnostics['port'] ); ?>
<br />
<strong><?php esc_html_e( 'Configured security:', 'robotstxt-smtp' ); ?></strong>
<?php echo esc_html( $security_label ); ?>
</p>
<?php if ( '' !== $diagnostics['banner'] ) : ?>
<p>
<strong><?php esc_html_e( 'Server banner:', 'robotstxt-smtp' ); ?></strong>
<code><?php echo esc_html( $diagnostics['banner'] ); ?></code>
<?php if ( '' !== $diagnostics['banner_code'] ) : ?>
<span class="robotstxt-smtp-diagnostics-code">
(
<?php
printf(
/* translators: %s: SMTP server response code. */
esc_html__( 'code %s', 'robotstxt-smtp' ),
esc_html( $diagnostics['banner_code'] )
);
?>
)
</span>
<?php endif; ?>
</p>
<?php endif; ?>
<h3><?php esc_html_e( 'TLS status', 'robotstxt-smtp' ); ?></h3>
<p>
<?php
if ( ! empty( $diagnostics['tls']['negotiated'] ) ) {
if ( 'ssl' === $diagnostics['tls']['mode'] ) {
esc_html_e( 'The connection uses implicit TLS (SMTPS).', 'robotstxt-smtp' );
} elseif ( 'tls' === $diagnostics['tls']['mode'] ) {
esc_html_e( 'The STARTTLS negotiation succeeded and the connection is encrypted.', 'robotstxt-smtp' );
} else {
esc_html_e( 'The server accepted the TLS negotiation.', 'robotstxt-smtp' );
}
} else {
esc_html_e( 'TLS is not active for this connection.', 'robotstxt-smtp' );
}
?>
</p>
<?php if ( ! empty( $diagnostics['tls']['upgrade_attempted'] ) ) : ?>
<p>
<strong><?php esc_html_e( 'STARTTLS response:', 'robotstxt-smtp' ); ?></strong>
<?php if ( '' !== $diagnostics['tls']['upgrade_response']['code'] ) : ?>
<?php echo esc_html( $diagnostics['tls']['upgrade_response']['code'] ); ?>
<?php endif; ?>
</p>
<?php if ( ! empty( $diagnostics['tls']['upgrade_response']['lines'] ) ) : ?>
<ul>
<?php foreach ( $diagnostics['tls']['upgrade_response']['lines'] as $line ) : ?>
<li><code><?php echo esc_html( $line ); ?></code></li>
<?php endforeach; ?>
</ul>
<?php endif; ?>
<?php endif; ?>
<h3><?php esc_html_e( 'Authentication mechanisms', 'robotstxt-smtp' ); ?></h3>
<?php if ( ! empty( $diagnostics['auth_mechanisms'] ) ) : ?>
<ul>
<?php foreach ( $diagnostics['auth_mechanisms'] as $mechanism ) : ?>
<li><code><?php echo esc_html( $mechanism ); ?></code></li>
<?php endforeach; ?>
</ul>
<?php else : ?>
<p><?php esc_html_e( 'The server did not announce authentication mechanisms.', 'robotstxt-smtp' ); ?></p>
<?php endif; ?>
<h3><?php esc_html_e( 'EHLO response', 'robotstxt-smtp' ); ?></h3>
<?php if ( '' !== $diagnostics['ehlo']['code'] ) : ?>
<p>
<strong><?php esc_html_e( 'Status code:', 'robotstxt-smtp' ); ?></strong>
<?php echo esc_html( $diagnostics['ehlo']['code'] ); ?>
</p>
<?php endif; ?>
<?php if ( ! empty( $diagnostics['ehlo']['lines'] ) ) : ?>
<ul>
<?php foreach ( $diagnostics['ehlo']['lines'] as $line ) : ?>
<li><code><?php echo esc_html( $line ); ?></code></li>
<?php endforeach; ?>
</ul>
<?php endif; ?>
<?php if ( isset( $diagnostics['ehlo_post_tls'] ) && is_array( $diagnostics['ehlo_post_tls'] ) && ! empty( $diagnostics['ehlo_post_tls']['lines'] ) ) : ?>
<h4><?php esc_html_e( 'EHLO after STARTTLS', 'robotstxt-smtp' ); ?></h4>
<?php if ( '' !== $diagnostics['ehlo_post_tls']['code'] ) : ?>
<p>
<strong><?php esc_html_e( 'Status code:', 'robotstxt-smtp' ); ?></strong>
<?php echo esc_html( $diagnostics['ehlo_post_tls']['code'] ); ?>
</p>
<?php endif; ?>
<ul>
<?php foreach ( $diagnostics['ehlo_post_tls']['lines'] as $line ) : ?>
<li><code><?php echo esc_html( $line ); ?></code></li>
<?php endforeach; ?>
</ul>
<?php endif; ?>
<?php if ( ! empty( $diagnostics['helo']['attempted'] ) ) : ?>
<h3><?php esc_html_e( 'HELO response', 'robotstxt-smtp' ); ?></h3>
<?php if ( '' !== $diagnostics['helo']['code'] ) : ?>
<p>
<strong><?php esc_html_e( 'Status code:', 'robotstxt-smtp' ); ?></strong>
<?php echo esc_html( $diagnostics['helo']['code'] ); ?>
</p>
<?php endif; ?>
<?php if ( ! empty( $diagnostics['helo']['lines'] ) ) : ?>
<ul>
<?php foreach ( $diagnostics['helo']['lines'] as $line ) : ?>
<li><code><?php echo esc_html( $line ); ?></code></li>
<?php endforeach; ?>
</ul>
<?php endif; ?>
<?php endif; ?>
<?php if ( ! empty( $diagnostics['transcript'] ) ) : ?>
<details>
<summary><?php esc_html_e( 'View raw transcript', 'robotstxt-smtp' ); ?></summary>
<pre><code><?php echo esc_html( implode( "\n", $diagnostics['transcript'] ) ); ?></code></pre>
</details>
<?php endif; ?>
<?php
return (string) ob_get_clean();
}
/**
* Executes the SMTP diagnostics using the configured settings.
*
* @param array<string, mixed> $settings SMTP settings.
*
* @return array<string, mixed>
*/
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<string, mixed>
*/
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<int, string> $lines Response lines.
*
* @return array<string, mixed>
*/
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 ) :
?>
<div class="notice notice-info inline">
<p><?php esc_html_e( 'Configure a "From Email" address in the settings to analyze SPF, DKIM, and DMARC records.', 'robotstxt-smtp' ); ?></p>
</div>
<?php
else :
$auth_analysis = $this->analyze_domain_authentication( $sender_domain, $normalized_smtp_host, $mx_analysis, $host_ips );
if ( ! empty( $auth_analysis['errors'] ) ) :
?>
<div class="notice notice-error inline">
<ul>
<?php foreach ( $auth_analysis['errors'] as $error ) : ?>
<li><?php echo esc_html( $error ); ?></li>
<?php endforeach; ?>
</ul>
</div>
<?php
else :
?>
<h3><?php esc_html_e( 'SPF', 'robotstxt-smtp' ); ?></h3>
<?php if ( ! empty( $auth_analysis['spf']['errors'] ) ) : ?>
<div class="notice notice-error inline">
<ul>
<?php foreach ( $auth_analysis['spf']['errors'] as $error ) : ?>
<li><?php echo esc_html( $error ); ?></li>
<?php endforeach; ?>
</ul>
</div>
<?php elseif ( empty( $auth_analysis['spf']['records'] ) ) : ?>
<div class="notice notice-warning inline">
<p><?php esc_html_e( 'No SPF record was found for the sender domain.', 'robotstxt-smtp' ); ?></p>
</div>
<?php else : ?>
<ul>
<?php foreach ( $auth_analysis['spf']['records'] as $record ) : ?>
<li><code><?php echo esc_html( $record ); ?></code></li>
<?php endforeach; ?>
</ul>
<?php if ( true === $auth_analysis['spf']['authorized'] ) : ?>
<div class="notice notice-success inline">
<p><?php echo esc_html( $auth_analysis['spf']['message'] ); ?></p>
</div>
<?php elseif ( false === $auth_analysis['spf']['authorized'] ) : ?>
<div class="notice notice-warning inline">
<p><?php echo esc_html( $auth_analysis['spf']['message'] ); ?></p>
</div>
<?php else : ?>
<div class="notice notice-info inline">
<p><?php echo esc_html( $auth_analysis['spf']['message'] ); ?></p>
</div>
<?php endif; ?>
<?php endif; ?>
<h3><?php esc_html_e( 'DKIM', 'robotstxt-smtp' ); ?></h3>
<?php if ( ! empty( $auth_analysis['dkim']['errors'] ) ) : ?>
<div class="notice notice-error inline">
<ul>
<?php foreach ( $auth_analysis['dkim']['errors'] as $error ) : ?>
<li><?php echo esc_html( $error ); ?></li>
<?php endforeach; ?>
</ul>
</div>
<?php elseif ( true === $auth_analysis['dkim']['found'] ) : ?>
<div class="notice notice-success inline">
<p><?php esc_html_e( 'A DKIM record was found for the default selector.', 'robotstxt-smtp' ); ?></p>
<?php if ( '' !== $auth_analysis['dkim']['record'] ) : ?>
<p><code><?php echo esc_html( $auth_analysis['dkim']['record'] ); ?></code></p>
<?php endif; ?>
</div>
<?php else : ?>
<div class="notice notice-warning inline">
<p><?php esc_html_e( 'No DKIM record was found for the default selector. Confirm the selector provided by your email service and publish the corresponding TXT record.', 'robotstxt-smtp' ); ?></p>
</div>
<?php endif; ?>
<h3><?php esc_html_e( 'DMARC', 'robotstxt-smtp' ); ?></h3>
<?php if ( ! empty( $auth_analysis['dmarc']['errors'] ) ) : ?>
<div class="notice notice-error inline">
<ul>
<?php foreach ( $auth_analysis['dmarc']['errors'] as $error ) : ?>
<li><?php echo esc_html( $error ); ?></li>
<?php endforeach; ?>
</ul>
</div>
<?php elseif ( '' === $auth_analysis['dmarc']['record'] ) : ?>
<div class="notice notice-warning inline">
<p><?php esc_html_e( 'No DMARC record was found for the sender domain.', 'robotstxt-smtp' ); ?></p>
</div>
<?php else : ?>
<p><code><?php echo esc_html( $auth_analysis['dmarc']['record'] ); ?></code></p>
<?php if ( '' !== $auth_analysis['dmarc']['policy'] ) : ?>
<p>
<?php
printf(
/* translators: %s: DMARC policy value. */
esc_html__( 'Published policy: %s', 'robotstxt-smtp' ),
esc_html( $auth_analysis['dmarc']['policy'] )
);
?>
</p>
<?php endif; ?>
<?php if ( ! empty( $auth_analysis['dmarc']['recommendations'] ) ) : ?>
<div class="notice notice-warning inline">
<ul>
<?php foreach ( $auth_analysis['dmarc']['recommendations'] as $recommendation ) : ?>
<li><?php echo esc_html( $recommendation ); ?></li>
<?php endforeach; ?>
</ul>
</div>
<?php else : ?>
<div class="notice notice-success inline">
<p><?php esc_html_e( 'The DMARC policy enforces protection for the domain.', 'robotstxt-smtp' ); ?></p>
</div>
<?php endif; ?>
<?php endif; ?>
<?php
endif;
endif;
return (string) ob_get_clean();
}
/**
* Generates the markup for the blacklist lookup tool.
*
* @param string $scope Scope identifier.
* @return string
*/
private function get_tools_blacklist_section_html( string $scope ): string {
$this->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'] ) ) :
?>
<div class="notice notice-error inline">
<ul>
<?php foreach ( $analysis['errors'] as $error ) : ?>
<li><?php echo esc_html( $error ); ?></li>
<?php endforeach; ?>
</ul>
</div>
<?php
endif;
if ( ! empty( $analysis['ipv6'] ) ) :
?>
<div class="notice notice-info inline">
<p><?php esc_html_e( 'IPv6 addresses are currently not checked because most DNSBL providers only publish IPv4 data. Verify IPv6 reputation manually with your email service.', 'robotstxt-smtp' ); ?></p>
</div>
<?php
endif;
if ( empty( $analysis['ips'] ) ) :
?>
<div class="notice notice-info inline">
<p><?php esc_html_e( 'The SMTP host did not resolve to any IPv4 addresses. Configure a valid host or allow DNS lookups on the server to perform blacklist checks.', 'robotstxt-smtp' ); ?></p>
</div>
<?php
else :
foreach ( $analysis['ips'] as $ip_result ) :
?>
<h3>
<?php
printf(
/* translators: %s: IP address used for the blacklist lookup. */
esc_html__( 'IP address: %s', 'robotstxt-smtp' ),
esc_html( $ip_result['ip'] )
);
?>
</h3>
<?php if ( ! empty( $ip_result['errors'] ) ) : ?>
<div class="notice notice-error inline">
<ul>
<?php foreach ( $ip_result['errors'] as $error ) : ?>
<li><?php echo esc_html( $error ); ?></li>
<?php endforeach; ?>
</ul>
</div>
<?php endif; ?>
<table class="widefat striped">
<thead>
<tr>
<th scope="col"><?php esc_html_e( 'Provider', 'robotstxt-smtp' ); ?></th>
<th scope="col"><?php esc_html_e( 'Status', 'robotstxt-smtp' ); ?></th>
<th scope="col"><?php esc_html_e( 'Impact', 'robotstxt-smtp' ); ?></th>
<th scope="col"><?php esc_html_e( 'Details', 'robotstxt-smtp' ); ?></th>
<th scope="col"><?php esc_html_e( 'Removal guidance', 'robotstxt-smtp' ); ?></th>
</tr>
</thead>
<tbody>
<?php foreach ( $ip_result['listings'] as $listing ) : ?>
<tr>
<td>
<strong><?php echo esc_html( $listing['provider']['label'] ); ?></strong><br />
<code><?php echo esc_html( $listing['provider']['zone'] ); ?></code>
</td>
<td>
<?php
if ( '' !== $listing['error'] ) {
esc_html_e( 'Unavailable', 'robotstxt-smtp' );
} elseif ( true === $listing['listed'] ) {
esc_html_e( 'Listed', 'robotstxt-smtp' );
} else {
esc_html_e( 'Not listed', 'robotstxt-smtp' );
}
?>
</td>
<td><?php echo esc_html( $listing['provider']['impact'] ); ?></td>
<td>
<?php if ( '' !== $listing['error'] ) : ?>
<p><?php echo esc_html( $listing['error'] ); ?></p>
<?php elseif ( true === $listing['listed'] ) : ?>
<?php if ( ! empty( $listing['response_ips'] ) ) : ?>
<p>
<?php esc_html_e( 'Response:', 'robotstxt-smtp' ); ?>
<code><?php echo esc_html( implode( ', ', $listing['response_ips'] ) ); ?></code>
</p>
<?php endif; ?>
<?php if ( ! empty( $listing['txt_records'] ) ) : ?>
<ul>
<?php foreach ( $listing['txt_records'] as $txt_record ) : ?>
<li><code><?php echo esc_html( $txt_record ); ?></code></li>
<?php endforeach; ?>
</ul>
<?php else : ?>
<p><?php esc_html_e( 'No additional TXT details were returned by the provider.', 'robotstxt-smtp' ); ?></p>
<?php endif; ?>
<?php else : ?>
<p><?php esc_html_e( 'The IP address is not currently listed by this provider.', 'robotstxt-smtp' ); ?></p>
<?php endif; ?>
</td>
<td>
<p><?php echo esc_html( $listing['provider']['removal_notes'] ); ?></p>
<?php if ( '' !== $listing['provider']['removal_url'] ) : ?>
<p>
<a href="<?php echo esc_url( $listing['provider']['removal_url'] ); ?>" target="_blank" rel="noopener noreferrer">
<?php esc_html_e( 'Open removal request page', 'robotstxt-smtp' ); ?>
</a>
</p>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php
endforeach;
endif;
return (string) ob_get_clean();
}
/**
* Generates the markup for the MX lookup tool.
*
* @param string $scope Scope identifier.
* @return string
*/
/**
* Generates the markup for the SPF, DKIM, and DMARC checks.
*
* @param string $scope Scope identifier.
* @return string
*/
/**
* Generates the markup for the blacklist lookup tool.
*
* @param string $scope Scope identifier.
* @return string
*/
/**
* Renders the log list page.
*
* @return void
*/
public function render_logs_page(): void {
$scope = $this->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 );
?>
<div class="wrap">
<h1><?php echo esc_html__( 'SMTP Logs', 'robotstxt-smtp' ); ?></h1>
<?php if ( $nonce_required && ! $nonce_valid ) : ?>
<div class="notice notice-error">
<p><?php esc_html_e( 'The link you followed has expired. Please try again.', 'robotstxt-smtp' ); ?></p>
</div>
<?php endif; ?>
<?php if ( isset( $_GET['robotstxt_smtp_cleared'] ) ) : ?>
<div class="notice notice-success is-dismissible">
<p><?php esc_html_e( 'All log entries were removed.', 'robotstxt-smtp' ); ?></p>
</div>
<?php endif; ?>
<?php if ( self::SCOPE_NETWORK === $scope && self::MODE_SITE === self::get_configuration_mode() ) : ?>
<div class="notice notice-info inline">
<p><?php esc_html_e( 'Log entries are stored per site. The table below reflects the currently selected site.', 'robotstxt-smtp' ); ?></p>
</div>
<?php endif; ?>
<?php if ( ! $logging_enabled ) : ?>
<div class="notice notice-warning">
<p><?php esc_html_e( 'Logging is currently disabled. Enable it in the settings page to store new emails.', 'robotstxt-smtp' ); ?></p>
</div>
<?php endif; ?>
<?php
if ( $log_id > 0 ) {
$this->render_log_details( $log_id, $scope, $filters, $logs_nonce );
} else {
$this->render_logs_list( $paged, $scope, $filters, $logs_nonce );
}
?>
</div>
<?php
}
/**
* Retrieves the current filters applied to the logs list.
*
* @return array<string, mixed> 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<string, mixed>
*/
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<string, mixed> $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<string, mixed> $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<string, mixed>
*/
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<string, mixed> $request Request arguments.
*
* @return array<string, mixed> 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<string, mixed> $filters Filter values.
*
* @return array<string, string> 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<string, mixed> $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 );
?>
<form method="get" action="<?php echo esc_url( $this->get_admin_url_for_scope( $scope, 'admin.php' ) ); ?>" class="robotstxt-smtp-log-filters" style="margin: 1.5em 0;">
<input type="hidden" name="page" value="<?php echo esc_attr( self::LOGS_PAGE_SLUG ); ?>" />
<?php
// Output a nonce so filter requests can be verified before reading user-supplied query vars.
wp_nonce_field( $this->get_logs_nonce_action(), 'robotstxt_smtp_logs_nonce', false );
?>
<div style="display: flex; flex-wrap: wrap; gap: 1em; align-items: flex-end;">
<p style="margin: 0;">
<label for="robotstxt-smtp-filter-date-from" style="display: block;"><?php esc_html_e( 'From date', 'robotstxt-smtp' ); ?></label>
<input id="robotstxt-smtp-filter-date-from" type="date" name="robotstxt_smtp_date_from" value="<?php echo esc_attr( $filters['date_from'] ); ?>" />
</p>
<p style="margin: 0;">
<label for="robotstxt-smtp-filter-date-to" style="display: block;"><?php esc_html_e( 'To date', 'robotstxt-smtp' ); ?></label>
<input id="robotstxt-smtp-filter-date-to" type="date" name="robotstxt_smtp_date_to" value="<?php echo esc_attr( $filters['date_to'] ); ?>" />
</p>
<p style="margin: 0;">
<label for="robotstxt-smtp-filter-subject" style="display: block;"><?php esc_html_e( 'Subject', 'robotstxt-smtp' ); ?></label>
<input id="robotstxt-smtp-filter-subject" type="search" name="robotstxt_smtp_subject" value="<?php echo esc_attr( $filters['subject'] ); ?>" />
</p>
<p style="margin: 0;">
<label for="robotstxt-smtp-filter-to" style="display: block;"><?php esc_html_e( 'To', 'robotstxt-smtp' ); ?></label>
<input id="robotstxt-smtp-filter-to" type="search" name="robotstxt_smtp_to" value="<?php echo esc_attr( $filters['to'] ); ?>" />
</p>
<p style="margin: 0;">
<label for="robotstxt-smtp-filter-per-page" style="display: block;"><?php esc_html_e( 'Results per page', 'robotstxt-smtp' ); ?></label>
<select id="robotstxt-smtp-filter-per-page" name="robotstxt_smtp_per_page">
<?php foreach ( array( 10, 25, 50, 100, 250, 500 ) as $option ) : ?>
<option value="<?php echo esc_attr( $option ); ?>" <?php selected( $filters['per_page'], $option ); ?>><?php echo esc_html( $option ); ?></option>
<?php endforeach; ?>
</select>
</p>
<p style="margin: 0;">
<button type="submit" class="button button-primary"><?php esc_html_e( 'Apply filters', 'robotstxt-smtp' ); ?></button>
</p>
<p style="margin: 0;">
<a class="button" href="<?php echo esc_url( add_query_arg( 'robotstxt_smtp_logs_nonce', $nonce, $reset_url ) ); ?>"><?php esc_html_e( 'Reset', 'robotstxt-smtp' ); ?></a>
</p>
</div>
</form>
<div class="robotstxt-smtp-log-actions" style="margin: 1.5em 0;">
<form method="post" action="<?php echo esc_url( $this->get_admin_post_url_for_scope( $scope ) ); ?>">
<?php wp_nonce_field( 'robotstxt_smtp_clear_logs' ); ?>
<input type="hidden" name="action" value="robotstxt_smtp_clear_logs" />
<input type="hidden" name="robotstxt_smtp_context" value="<?php echo esc_attr( $scope ); ?>" />
<?php
$confirmation = esc_js( __( 'Are you sure you want to delete all log entries?', 'robotstxt-smtp' ) );
submit_button(
esc_html__( 'Clear Logs', 'robotstxt-smtp' ),
'delete',
'robotstxt_smtp_clear_logs_button',
false,
array(
'onclick' => "return confirm('" . $confirmation . "');",
)
);
?>
</form>
</div>
<?php if ( empty( $query->posts ) ) : ?>
<p><?php esc_html_e( 'No email logs have been stored yet.', 'robotstxt-smtp' ); ?></p>
<?php return; ?>
<?php endif; ?>
<table class="wp-list-table widefat fixed striped">
<thead>
<tr>
<th scope="col"><?php esc_html_e( 'Date', 'robotstxt-smtp' ); ?></th>
<th scope="col"><?php esc_html_e( 'Status', 'robotstxt-smtp' ); ?></th>
<th scope="col"><?php esc_html_e( 'From', 'robotstxt-smtp' ); ?></th>
<th scope="col"><?php esc_html_e( 'To', 'robotstxt-smtp' ); ?></th>
<th scope="col"><?php esc_html_e( 'Subject', 'robotstxt-smtp' ); ?></th>
<th scope="col" class="column-actions"><?php esc_html_e( 'Actions', 'robotstxt-smtp' ); ?></th>
</tr>
</thead>
<tbody>
<?php foreach ( $query->posts as $log ) : ?>
<?php
$from = get_post_meta( $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' )
);
?>
<tr>
<td><?php echo esc_html( $date ); ?></td>
<td>
<span class="robotstxt-smtp-log-status <?php echo esc_attr( $status_display['class'] ); ?>">
<?php echo esc_html( $status_display['label'] ); ?>
</span>
</td>
<td><?php echo esc_html( (string) $from ); ?></td>
<td><?php echo esc_html( (string) $to ); ?></td>
<td><?php echo esc_html( $subject ); ?></td>
<td>
<a class="button button-secondary" href="<?php echo esc_url( $view_url ); ?>">
<?php esc_html_e( 'View details', 'robotstxt-smtp' ); ?>
</a>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php
$pagination = paginate_links(
array(
'base' => add_query_arg( 'paged', '%#%', $base_url ),
'format' => '',
'current' => max( 1, $paged ),
'total' => max( 1, (int) $query->max_num_pages ),
'prev_text' => __( '&laquo;', 'robotstxt-smtp' ),
'next_text' => __( '&raquo;', 'robotstxt-smtp' ),
)
);
if ( $pagination ) {
echo '<div class="tablenav"><div class="tablenav-pages">' . wp_kses_post( $pagination ) . '</div></div>';
}
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<string, mixed> $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 '<div class="notice notice-error"><p>' . esc_html__( 'The requested log entry could not be found.', 'robotstxt-smtp' ) . '</p></div>';
$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;
}
?>
<p><a href="<?php echo esc_url( $back_url ); ?>" class="button">&larr; <?php esc_html_e( 'Back to list', 'robotstxt-smtp' ); ?></a></p>
<table class="widefat fixed striped">
<tbody>
<tr>
<th scope="row"><?php esc_html_e( 'Date', 'robotstxt-smtp' ); ?></th>
<td><?php echo esc_html( $date ); ?></td>
</tr>
<tr>
<th scope="row"><?php esc_html_e( 'Status', 'robotstxt-smtp' ); ?></th>
<td>
<span class="robotstxt-smtp-log-status <?php echo esc_attr( $status['class'] ); ?>">
<?php echo esc_html( $status['label'] ); ?>
</span>
</td>
</tr>
<tr>
<th scope="row"><?php esc_html_e( 'From', 'robotstxt-smtp' ); ?></th>
<td><?php echo esc_html( (string) $from ); ?></td>
</tr>
<tr>
<th scope="row"><?php esc_html_e( 'To', 'robotstxt-smtp' ); ?></th>
<td><?php echo esc_html( (string) $to ); ?></td>
</tr>
<tr>
<th scope="row"><?php esc_html_e( 'Subject', 'robotstxt-smtp' ); ?></th>
<td><?php echo esc_html( $subject ); ?></td>
</tr>
<tr>
<th scope="row"><?php esc_html_e( 'Error message', 'robotstxt-smtp' ); ?></th>
<td>
<?php
if ( $error ) {
echo esc_html( $error );
} else {
esc_html_e( 'No error recorded.', 'robotstxt-smtp' );
}
?>
</td>
</tr>
</tbody>
</table>
<h2><?php esc_html_e( 'Headers', 'robotstxt-smtp' ); ?></h2>
<pre><?php echo esc_html( $headers ? $headers : __( 'No headers stored for this email.', 'robotstxt-smtp' ) ); ?></pre>
<h2><?php esc_html_e( 'Attachments', 'robotstxt-smtp' ); ?></h2>
<?php if ( $normalized_attachments ) : ?>
<ul class="robotstxt-smtp-log-attachments">
<?php foreach ( $normalized_attachments as $attachment_name ) : ?>
<li><?php echo esc_html( $attachment_name ); ?></li>
<?php endforeach; ?>
</ul>
<?php else : ?>
<p><?php esc_html_e( 'No attachments were logged for this email.', 'robotstxt-smtp' ); ?></p>
<?php endif; ?>
<h2><?php esc_html_e( 'Message content', 'robotstxt-smtp' ); ?></h2>
<?php if ( $log->post_content ) : ?>
<div class="robotstxt-smtp-log-content"><?php echo wp_kses_post( wpautop( $log->post_content ) ); ?></div>
<?php else : ?>
<p><?php esc_html_e( 'This email did not include any content.', 'robotstxt-smtp' ); ?></p>
<?php endif; ?>
<h2><?php esc_html_e( 'SMTP debug log', 'robotstxt-smtp' ); ?></h2>
<?php if ( $normalized_debug_log ) : ?>
<details class="robotstxt-smtp-log-debug">
<summary><?php esc_html_e( 'Show SMTP conversation', 'robotstxt-smtp' ); ?></summary>
<pre><?php echo esc_html( implode( "\n", $normalized_debug_log ) ); ?></pre>
</details>
<?php else : ?>
<p><?php esc_html_e( 'No SMTP debug output was stored for this email.', 'robotstxt-smtp' ); ?></p>
<?php endif; ?>
<?php
}
/**
* Retrieves the normalized status data for a log entry.
*
* @param int $log_id Log post ID.
*
* @return array<string, string>
*/
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();
?>
<input
name="<?php echo esc_attr( $this->get_settings_field_name( 'host' ) ); ?>"
type="text"
id="robotstxt_smtp_host"
class="regular-text"
value="<?php echo esc_attr( $settings['host'] ); ?>"
placeholder="smtp.example.com"
/>
<p class="description">
<?php
printf(
/* translators: 1: Example SMTP host. 2: Example SMTP host. */
esc_html__( 'Use the fully qualified address of your SMTP server, for example %1$s or %2$s.', 'robotstxt-smtp' ),
'mail.example.com',
'smtp.gmail.com'
);
?>
</p>
<?php
}
/**
* Renders the username field.
*
* @return void
*/
public function render_username_field(): void {
$settings = $this->get_settings();
?>
<input
name="<?php echo esc_attr( $this->get_settings_field_name( 'username' ) ); ?>"
type="text"
id="robotstxt_smtp_username"
class="regular-text"
value="<?php echo esc_attr( $settings['username'] ); ?>"
autocomplete="username"
/>
<p class="description"><?php esc_html_e( 'Usually this is the full email address or dedicated SMTP user provided by your email service.', 'robotstxt-smtp' ); ?></p>
<?php
}
/**
* Renders the password field.
*
* @return void
*/
public function render_password_field(): void {
$settings = $this->get_settings();
?>
<input
name="<?php echo esc_attr( $this->get_settings_field_name( 'password' ) ); ?>"
type="password"
id="robotstxt_smtp_password"
class="regular-text"
value="<?php echo esc_attr( $settings['password'] ); ?>"
autocomplete="current-password"
/>
<p class="description"><?php esc_html_e( 'Enter the password or app-specific password for the SMTP user. Consider creating an application password as the value is stored in WordPress.', 'robotstxt-smtp' ); ?></p>
<?php
}
/**
* Renders the from email field.
*
* @return void
*/
public function render_from_email_field(): void {
$settings = $this->get_settings();
?>
<input
name="<?php echo esc_attr( $this->get_settings_field_name( 'from_email' ) ); ?>"
type="email"
id="robotstxt_smtp_from_email"
class="regular-text"
value="<?php echo esc_attr( $settings['from_email'] ); ?>"
placeholder="noreply@example.com"
/>
<p class="description"><?php esc_html_e( 'This address will be used as the default sender for emails sent by WordPress, for example notifications@example.com.', 'robotstxt-smtp' ); ?></p>
<?php
}
/**
* Renders the from name field.
*
* @return void
*/
public function render_from_name_field(): void {
$settings = $this->get_settings();
?>
<input
name="<?php echo esc_attr( $this->get_settings_field_name( 'from_name' ) ); ?>"
type="text"
id="robotstxt_smtp_from_name"
class="regular-text"
value="<?php echo esc_attr( $settings['from_name'] ); ?>"
/>
<p class="description"><?php esc_html_e( 'Recipients will see this name alongside the email address, such as "Support Team" or the site title.', 'robotstxt-smtp' ); ?></p>
<?php
}
/**
* Renders the security type field.
*
* @return void
*/
public function render_security_field(): void {
$settings = $this->get_settings();
$options = $this->get_security_options();
?>
<select name="<?php echo esc_attr( $this->get_settings_field_name( 'security' ) ); ?>" id="robotstxt_smtp_security">
<?php foreach ( $options as $value => $label ) : ?>
<option value="<?php echo esc_attr( $value ); ?>" <?php selected( $settings['security'], $value ); ?>><?php echo esc_html( $label ); ?></option>
<?php endforeach; ?>
</select>
<p class="description"><?php esc_html_e( 'Choose the encryption method recommended by your provider. The default ports are 25 for None, 465 for SSL, and 587 for TLS.', 'robotstxt-smtp' ); ?></p>
<?php
}
/**
* Renders the port field.
*
* @return void
*/
public function render_port_field(): void {
$settings = $this->get_settings();
?>
<input
name="<?php echo esc_attr( $this->get_settings_field_name( 'port' ) ); ?>"
type="number"
id="robotstxt_smtp_port"
class="small-text"
value="<?php echo esc_attr( (string) $settings['port'] ); ?>"
min="1"
max="65535"
/>
<p class="description"><?php esc_html_e( 'Set the port required by your SMTP server. Common values are 25 (None), 465 (SSL), and 587 (TLS).', 'robotstxt-smtp' ); ?></p>
<?php
}
/**
* Renders the toggle that enables or disables email logging.
*
* @return void
*/
public function render_logs_enabled_field(): void {
$settings = $this->get_settings();
?>
<label for="robotstxt_smtp_logs_enabled">
<input
name="<?php echo esc_attr( $this->get_settings_field_name( 'logs_enabled' ) ); ?>"
type="checkbox"
id="robotstxt_smtp_logs_enabled"
value="1"
<?php checked( ! empty( $settings['logs_enabled'] ) ); ?>
/>
<?php esc_html_e( 'Store a copy of every sent email in the logs.', 'robotstxt-smtp' ); ?>
</label>
<p class="description"><?php esc_html_e( 'Logged emails are saved as private posts under SMTP Logs so you can review the content or clear them at any time.', 'robotstxt-smtp' ); ?></p>
<?php
}
/**
* Renders the retention mode selector.
*
* @return void
*/
public function render_logs_retention_mode_field(): void {
$settings = $this->get_settings();
$mode = isset( $settings['logs_retention_mode'] ) ? $settings['logs_retention_mode'] : 'count';
?>
<fieldset>
<label>
<input
type="radio"
name="<?php echo esc_attr( $this->get_settings_field_name( 'logs_retention_mode' ) ); ?>"
value="count"
<?php checked( 'days' !== $mode ); ?>
/>
<?php esc_html_e( 'Limit by number of emails', 'robotstxt-smtp' ); ?>
</label>
<br />
<label>
<input
type="radio"
name="<?php echo esc_attr( $this->get_settings_field_name( 'logs_retention_mode' ) ); ?>"
value="days"
<?php checked( 'days' === $mode ); ?>
/>
<?php esc_html_e( 'Limit by age (in days)', 'robotstxt-smtp' ); ?>
</label>
</fieldset>
<p class="description"><?php esc_html_e( 'Select how the log cleaner should decide which entries to remove: by keeping a fixed number or by removing items older than a number of days.', 'robotstxt-smtp' ); ?></p>
<?php
}
/**
* Renders the numeric field that limits logs by count.
*
* @return void
*/
public function render_logs_retention_count_field(): void {
$settings = $this->get_settings();
?>
<input
name="<?php echo esc_attr( $this->get_settings_field_name( 'logs_retention_count' ) ); ?>"
type="number"
id="robotstxt_smtp_logs_retention_count"
class="small-text"
value="<?php echo esc_attr( (string) $settings['logs_retention_count'] ); ?>"
min="0"
/>
<p class="description"><?php esc_html_e( 'Maximum number of emails to keep when using the count-based retention mode.', 'robotstxt-smtp' ); ?></p>
<?php
}
/**
* Renders the numeric field that limits logs by age.
*
* @return void
*/
public function render_logs_retention_days_field(): void {
$settings = $this->get_settings();
?>
<input
name="<?php echo esc_attr( $this->get_settings_field_name( 'logs_retention_days' ) ); ?>"
type="number"
id="robotstxt_smtp_logs_retention_days"
class="small-text"
value="<?php echo esc_attr( (string) $settings['logs_retention_days'] ); ?>"
min="0"
/>
<p class="description"><?php esc_html_e( 'Number of days to keep log entries when using the time-based retention mode.', 'robotstxt-smtp' ); ?></p>
<?php
}
/**
* Sanitizes and validates the submitted settings.
*
* @param array<string, mixed> $options Raw option values.
*
* @return array<string, mixed> 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<string, mixed> $clean Sanitized option values.
* @param array<string, mixed> $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<string, mixed> 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<string, mixed> $mx_analysis MX lookup results.
* @param array<string, array<int, string>>|null $host_ips Optional pre-resolved SMTP host IP addresses.
*
* @return array<string, mixed>
*/
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<string, array<int, string>> $host_ips Resolved IP addresses for the SMTP host.
*
* @return array<string, mixed>
*/
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<int, array<string, string>>
*/
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<string, string> $provider Provider definition.
*
* @return array<string, mixed>
*/
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<string, mixed> $mx_analysis MX lookup results.
* @param array<string, array> $host_ips Resolved IP addresses for the SMTP host.
*
* @return array<string, mixed>
*/
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<string, array<int, string>>
*/
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<string, mixed>
*/
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<string, mixed>
*/
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<string, mixed>
*/
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<string, mixed> 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<string, string> 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<string, mixed> 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' );
}
?>
<div class="<?php echo esc_attr( $class ); ?>">
<p><strong><?php echo esc_html( $status ); ?></strong></p>
<?php if ( ! empty( $result['recipient'] ) ) : ?>
<p>
<?php
printf(
/* translators: %s: Recipient email address. */
esc_html__( 'Recipient: %s', 'robotstxt-smtp' ),
esc_html( $result['recipient'] )
);
?>
</p>
<?php endif; ?>
<?php if ( ! empty( $result['timestamp'] ) ) : ?>
<p>
<?php
printf(
/* translators: %s: Timestamp of the test. */
esc_html__( 'Timestamp: %s', 'robotstxt-smtp' ),
esc_html( $result['timestamp'] )
);
?>
</p>
<?php endif; ?>
<?php if ( ! empty( $result['error'] ) ) : ?>
<p><?php echo esc_html( $result['error'] ); ?></p>
<?php endif; ?>
<?php if ( array_key_exists( 'error_data', $result ) && ! empty( $result['error_data'] ) ) : ?>
<details>
<summary><?php esc_html_e( 'Error details', 'robotstxt-smtp' ); ?></summary>
<pre><?php echo esc_html( wp_json_encode( $result['error_data'], JSON_PRETTY_PRINT ) ); ?></pre>
</details>
<?php endif; ?>
<?php if ( ! empty( $result['settings'] ) ) : ?>
<details>
<summary><?php esc_html_e( 'Settings used', 'robotstxt-smtp' ); ?></summary>
<pre><?php echo esc_html( wp_json_encode( $result['settings'], JSON_PRETTY_PRINT ) ); ?></pre>
</details>
<?php endif; ?>
<?php if ( ! empty( $result['mail_data'] ) ) : ?>
<details>
<summary><?php esc_html_e( 'Email payload', 'robotstxt-smtp' ); ?></summary>
<pre><?php echo esc_html( wp_json_encode( $result['mail_data'], JSON_PRETTY_PRINT ) ); ?></pre>
</details>
<?php endif; ?>
<?php if ( 'manual' === $context && ! empty( $result['debug_log'] ) ) : ?>
<details open>
<summary><?php esc_html_e( 'SMTP debug output', 'robotstxt-smtp' ); ?></summary>
<pre>
<?php
echo esc_html(
implode(
"\n",
array_map(
static function ( $line ) {
return sanitize_textarea_field( (string) $line );
},
$result['debug_log']
)
)
);
?>
</pre>
</details>
<?php endif; ?>
</div>
<?php
}
/**
* Renders the test email form.
*
* @param string $scope Scope identifier (site or network).
*
* @return void
*/
private function render_test_email_form( string $scope ): void {
$recipient = '';
$nonce_value = filter_input( INPUT_GET, 'robotstxt_smtp_result_nonce', FILTER_UNSAFE_RAW );
$recipient_raw = filter_input( INPUT_GET, 'robotstxt_smtp_recipient', FILTER_UNSAFE_RAW );
if ( is_string( $nonce_value ) && is_string( $recipient_raw )
&& wp_verify_nonce( wp_unslash( $nonce_value ), 'robotstxt_smtp_test_result' ) ) {
$recipient = sanitize_email( wp_unslash( $recipient_raw ) );
}
if ( empty( $recipient ) ) {
$current_user = wp_get_current_user();
if ( $current_user instanceof WP_User && ! empty( $current_user->user_email ) ) {
$recipient = $current_user->user_email;
}
}
if ( empty( $recipient ) ) {
$recipient = sanitize_email( get_bloginfo( 'admin_email' ) );
}
?>
<hr />
<h2><?php esc_html_e( 'Send Test Email', 'robotstxt-smtp' ); ?></h2>
<form action="<?php echo esc_url( $this->get_admin_post_url_for_scope( $scope ) ); ?>" method="post">
<?php wp_nonce_field( 'robotstxt_smtp_test_email', 'robotstxt_smtp_test_email_nonce' ); ?>
<input type="hidden" name="action" value="robotstxt_smtp_test_email" />
<input type="hidden" name="robotstxt_smtp_context" value="<?php echo esc_attr( $scope ); ?>" />
<table class="form-table" role="presentation">
<tbody>
<tr>
<th scope="row">
<label for="robotstxt_smtp_test_email"><?php esc_html_e( 'Recipient email address', 'robotstxt-smtp' ); ?></label>
</th>
<td>
<input
type="email"
name="robotstxt_smtp_test_email"
id="robotstxt_smtp_test_email"
class="regular-text"
value="<?php echo esc_attr( $recipient ); ?>"
placeholder="you@example.com"
/>
<p class="description"><?php esc_html_e( 'Leave blank to send the test email to your account email address.', 'robotstxt-smtp' ); ?></p>
</td>
</tr>
</tbody>
</table>
<?php submit_button( esc_html__( 'Send Test Email', 'robotstxt-smtp' ), 'secondary', 'robotstxt_smtp_send_test_email' ); ?>
</form>
<?php
}
/**
* Prepares the stored settings for display within the debug output.
*
* @return array<string, mixed>
*/
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<string, mixed> $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;
}
}