|null
*/
private ?array $cached_settings = null;
/**
* Tracks whether the site settings form was submitted in the current request.
*
* @var bool
*/
private bool $site_settings_submitted = false;
/**
* Tracks whether the site settings option was updated in the current request.
*
* @var bool
*/
private bool $site_settings_option_updated = false;
/**
* Tracks the settings scope used for the current request.
*
* @var string
*/
private string $current_scope = self::SCOPE_SITE;
/**
* Retrieves the current configuration mode.
*
* @return string
*/
public static function get_configuration_mode(): string {
if ( ! is_multisite() ) {
return self::MODE_SITE;
}
$mode = get_site_option( self::NETWORK_MODE_OPTION_NAME, self::MODE_NETWORK );
return self::MODE_NETWORK === $mode ? self::MODE_NETWORK : self::MODE_SITE;
}
/**
* Determines whether the plugin operates in network-wide configuration mode.
*
* @return bool
*/
public static function is_network_mode_enabled(): bool {
return self::MODE_NETWORK === self::get_configuration_mode();
}
/**
* Updates the internal scope tracker and resets caches when the scope changes.
*
* @param string $scope Scope identifier.
*
* @return void
*/
private function set_scope( string $scope ): void {
$normalized = self::SCOPE_NETWORK === $scope ? self::SCOPE_NETWORK : self::SCOPE_SITE;
if ( $normalized !== $this->current_scope ) {
$this->current_scope = $normalized;
$this->cached_settings = null;
}
}
/**
* Retrieves the scope currently in use.
*
* @return string
*/
private function get_scope(): string {
return $this->current_scope;
}
/**
* Determines the settings option name for a given scope.
*
* @param string|null $scope Optional scope identifier.
*
* @return string
*/
private function get_option_name_for_scope( ?string $scope = null ): string {
$scope = $scope ?? $this->get_scope();
return self::SCOPE_NETWORK === $scope ? self::NETWORK_OPTION_NAME : self::OPTION_NAME;
}
/**
* Builds the input name for a settings field based on the active scope.
*
* @param string $field Field identifier.
*
* @return string
*/
private function get_settings_field_name( string $field ): string {
return $this->get_option_name_for_scope() . '[' . $field . ']';
}
/**
* Retrieves the capability required to manage settings for a scope.
*
* @param string $scope Scope identifier.
*
* @return string
*/
private function get_scope_capability( string $scope ): string {
return self::SCOPE_NETWORK === $scope ? self::NETWORK_CAPABILITY : self::CAPABILITY;
}
/**
* Ensures the current user can manage the provided scope.
*
* @param string $scope Scope identifier.
*
* @return void
*/
private function ensure_capability( string $scope ): void {
if ( ! current_user_can( $this->get_scope_capability( $scope ) ) ) {
wp_die( esc_html__( 'You do not have permission to access this page.', 'robotstxt-smtp' ) );
}
}
/**
* Determines the scope associated with the current admin area.
*
* @return string
*/
private function get_current_admin_scope(): string {
return is_network_admin() ? self::SCOPE_NETWORK : self::SCOPE_SITE;
}
/**
* Retrieves the admin URL for the provided scope.
*
* @param string $scope Scope identifier.
* @param string $path Path appended to the admin URL.
*
* @return string
*/
private function get_admin_url_for_scope( string $scope, string $path ): string {
return self::SCOPE_NETWORK === $scope ? network_admin_url( $path ) : admin_url( $path );
}
/**
* Retrieves the admin-post URL for a scope.
*
* @param string $scope Scope identifier.
*
* @return string
*/
private function get_admin_post_url_for_scope( string $scope ): string {
unset( $scope );
return admin_url( 'admin-post.php' );
}
/**
* Registers WordPress hooks.
*
* @return void
*/
public function register_hooks(): void {
if ( ! is_multisite() || ! self::is_network_mode_enabled() ) {
add_action( 'admin_menu', array( $this, 'register_site_menu' ) );
}
if ( is_multisite() ) {
add_action( 'network_admin_menu', array( $this, 'register_network_menu' ) );
}
add_action( 'admin_init', array( $this, 'register_settings' ) );
add_action( 'admin_post_robotstxt_smtp_test_email', array( $this, 'handle_test_email' ) );
add_action( 'admin_post_robotstxt_smtp_clear_logs', array( $this, 'handle_clear_logs' ) );
add_action( 'admin_post_robotstxt_smtp_save_network_settings', array( $this, 'handle_save_network_settings' ) );
add_action( 'admin_notices', array( $this, 'display_test_email_notice' ) );
add_action( 'network_admin_notices', array( $this, 'display_test_email_notice' ) );
add_action( 'shutdown', array( $this, 'maybe_send_pending_site_test' ) );
add_action( 'updated_option', array( $this, 'handle_settings_option_updated' ), 10, 3 );
add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_assets' ) );
}
/**
* Enqueues JavaScript assets for the admin pages.
*
* @param string $hook_suffix Current admin page hook suffix.
*
* @return void
*/
public function enqueue_assets( string $hook_suffix ): void {
if ( false === strpos( $hook_suffix, 'robotstxt-smtp' ) ) {
return;
}
wp_enqueue_style(
'robotstxt-smtp-admin-style',
ROBOTSTXT_SMTP_URL . 'assets/css/admin.css',
array(),
ROBOTSTXT_SMTP_VERSION
);
wp_enqueue_script(
'robotstxt-smtp-admin',
ROBOTSTXT_SMTP_URL . 'assets/js/admin.js',
array(),
ROBOTSTXT_SMTP_VERSION,
true
);
}
/**
* Registers the admin menu for the single-site plugin pages.
*
* @return void
*/
public function register_site_menu(): void {
add_menu_page(
esc_html__( 'SMTP (by ROBOTSTXT)', 'robotstxt-smtp' ),
esc_html__( 'SMTP', 'robotstxt-smtp' ),
self::CAPABILITY,
self::MENU_SLUG,
array( $this, 'render_site_settings_page' ),
'dashicons-email-alt2'
);
add_submenu_page(
self::MENU_SLUG,
esc_html__( 'SMTP (by ROBOTSTXT)', 'robotstxt-smtp' ),
esc_html__( 'Settings', 'robotstxt-smtp' ),
self::CAPABILITY,
self::MENU_SLUG,
array( $this, 'render_site_settings_page' )
);
add_submenu_page(
self::MENU_SLUG,
esc_html__( 'SMTP Test', 'robotstxt-smtp' ),
esc_html__( 'Test', 'robotstxt-smtp' ),
self::CAPABILITY,
self::TEST_PAGE_SLUG,
array( $this, 'render_test_page' )
);
add_submenu_page(
self::MENU_SLUG,
esc_html__( 'SMTP Tools', 'robotstxt-smtp' ),
esc_html__( 'Tools', 'robotstxt-smtp' ),
self::CAPABILITY,
self::TOOLS_PAGE_SLUG,
array( $this, 'render_tools_page' )
);
add_submenu_page(
self::MENU_SLUG,
esc_html__( 'SMTP Logs', 'robotstxt-smtp' ),
esc_html__( 'Logs', 'robotstxt-smtp' ),
self::CAPABILITY,
self::LOGS_PAGE_SLUG,
array( $this, 'render_logs_page' )
);
}
/**
* Registers the admin menu for the network-level pages when multisite is enabled.
*
* @return void
*/
public function register_network_menu(): void {
if ( ! current_user_can( self::NETWORK_CAPABILITY ) ) {
return;
}
add_menu_page(
esc_html__( 'SMTP (by ROBOTSTXT)', 'robotstxt-smtp' ),
esc_html__( 'SMTP', 'robotstxt-smtp' ),
self::NETWORK_CAPABILITY,
self::NETWORK_MENU_SLUG,
array( $this, 'render_network_settings_page' ),
'dashicons-email-alt2'
);
add_submenu_page(
self::NETWORK_MENU_SLUG,
esc_html__( 'SMTP (by ROBOTSTXT)', 'robotstxt-smtp' ),
esc_html__( 'Settings', 'robotstxt-smtp' ),
self::NETWORK_CAPABILITY,
self::NETWORK_PAGE_SLUG,
array( $this, 'render_network_settings_page' )
);
add_submenu_page(
self::NETWORK_MENU_SLUG,
esc_html__( 'SMTP Test', 'robotstxt-smtp' ),
esc_html__( 'Test', 'robotstxt-smtp' ),
self::NETWORK_CAPABILITY,
self::TEST_PAGE_SLUG,
array( $this, 'render_test_page' )
);
add_submenu_page(
self::NETWORK_MENU_SLUG,
esc_html__( 'SMTP Tools', 'robotstxt-smtp' ),
esc_html__( 'Tools', 'robotstxt-smtp' ),
self::NETWORK_CAPABILITY,
self::TOOLS_PAGE_SLUG,
array( $this, 'render_tools_page' )
);
add_submenu_page(
self::NETWORK_MENU_SLUG,
esc_html__( 'SMTP Logs', 'robotstxt-smtp' ),
esc_html__( 'Logs', 'robotstxt-smtp' ),
self::NETWORK_CAPABILITY,
self::LOGS_PAGE_SLUG,
array( $this, 'render_logs_page' )
);
}
/**
* Registers plugin settings, sections, and fields.
*
* @return void
*/
public function register_settings(): void {
if ( is_multisite() && self::is_network_mode_enabled() && ! is_network_admin() ) {
return;
}
if ( is_network_admin() ) {
// Network settings are handled manually via the admin-post handler.
return;
}
$this->set_scope( self::SCOPE_SITE );
register_setting(
self::SITE_SETTINGS_GROUP,
self::OPTION_NAME,
array(
'sanitize_callback' => array( $this, 'sanitize_options' ),
)
);
add_settings_section(
'robotstxt_smtp_connection_section',
esc_html__( 'SMTP Server Configuration', 'robotstxt-smtp' ),
array( $this, 'render_connection_section_description' ),
self::PAGE_SLUG
);
$this->register_connection_fields();
add_settings_section(
'robotstxt_smtp_logs_section',
esc_html__( 'Email Log Settings', 'robotstxt-smtp' ),
array( $this, 'render_logs_section_description' ),
self::PAGE_SLUG
);
$this->register_logging_fields();
}
/**
* Registers fields related to the SMTP connection.
*
* @return void
*/
private function register_connection_fields(): void {
foreach ( $this->get_connection_field_definitions() as $field ) {
$args = array();
if ( ! empty( $field['label_for'] ) ) {
$args['label_for'] = $field['label_for'];
}
add_settings_field(
$field['id'],
$field['label'],
$field['callback'],
self::PAGE_SLUG,
'robotstxt_smtp_connection_section',
$args
);
}
}
/**
* Registers the logging configuration fields.
*
* @return void
*/
private function register_logging_fields(): void {
foreach ( $this->get_logging_field_definitions() as $field ) {
$args = array();
if ( ! empty( $field['label_for'] ) ) {
$args['label_for'] = $field['label_for'];
}
add_settings_field(
$field['id'],
$field['label'],
$field['callback'],
self::PAGE_SLUG,
'robotstxt_smtp_logs_section',
$args
);
}
}
/**
* Provides the field definitions for the SMTP connection settings.
*
* @return array>
*/
private function get_connection_field_definitions(): array {
if ( Plugin::is_amazon_ses_integration_active() ) {
$fields = array();
} else {
$fields = array(
array(
'id' => 'robotstxt_smtp_host',
'label' => esc_html__( 'Host', 'robotstxt-smtp' ),
'callback' => array( $this, 'render_host_field' ),
'label_for' => 'robotstxt_smtp_host',
),
array(
'id' => 'robotstxt_smtp_username',
'label' => esc_html__( 'Username', 'robotstxt-smtp' ),
'callback' => array( $this, 'render_username_field' ),
'label_for' => 'robotstxt_smtp_username',
),
array(
'id' => 'robotstxt_smtp_password',
'label' => esc_html__( 'Password', 'robotstxt-smtp' ),
'callback' => array( $this, 'render_password_field' ),
'label_for' => 'robotstxt_smtp_password',
),
array(
'id' => 'robotstxt_smtp_from_email',
'label' => esc_html__( 'From Email', 'robotstxt-smtp' ),
'callback' => array( $this, 'render_from_email_field' ),
'label_for' => 'robotstxt_smtp_from_email',
),
array(
'id' => 'robotstxt_smtp_from_name',
'label' => esc_html__( 'From Name', 'robotstxt-smtp' ),
'callback' => array( $this, 'render_from_name_field' ),
'label_for' => 'robotstxt_smtp_from_name',
),
array(
'id' => 'robotstxt_smtp_security',
'label' => esc_html__( 'Security Type', 'robotstxt-smtp' ),
'callback' => array( $this, 'render_security_field' ),
'label_for' => 'robotstxt_smtp_security',
),
array(
'id' => 'robotstxt_smtp_port',
'label' => esc_html__( 'Port', 'robotstxt-smtp' ),
'callback' => array( $this, 'render_port_field' ),
'label_for' => 'robotstxt_smtp_port',
),
);
}
/**
* Filters the SMTP connection field definitions.
*
* @since 1.0.1
*
* @param array> $fields Field definitions.
* @param Settings_Page $settings_page Settings page instance.
*/
return (array) \apply_filters( 'robotstxt_smtp_connection_field_definitions', $fields, $this );
}
/**
* Provides the field definitions for the logging settings.
*
* @return array>
*/
private function get_logging_field_definitions(): array {
return array(
array(
'id' => 'robotstxt_smtp_logs_enabled',
'label' => esc_html__( 'Enable logging', 'robotstxt-smtp' ),
'callback' => array( $this, 'render_logs_enabled_field' ),
'label_for' => 'robotstxt_smtp_logs_enabled',
),
array(
'id' => 'robotstxt_smtp_logs_retention_mode',
'label' => esc_html__( 'Retention mode', 'robotstxt-smtp' ),
'callback' => array( $this, 'render_logs_retention_mode_field' ),
),
array(
'id' => 'robotstxt_smtp_logs_retention_count',
'label' => esc_html__( 'Maximum stored emails', 'robotstxt-smtp' ),
'callback' => array( $this, 'render_logs_retention_count_field' ),
'label_for' => 'robotstxt_smtp_logs_retention_count',
),
array(
'id' => 'robotstxt_smtp_logs_retention_days',
'label' => esc_html__( 'Retention in days', 'robotstxt-smtp' ),
'callback' => array( $this, 'render_logs_retention_days_field' ),
'label_for' => 'robotstxt_smtp_logs_retention_days',
),
);
}
/**
* Outputs the provided fields in a table layout.
*
* @param array> $fields Field definitions.
*
* @return void
*/
private function render_field_rows( array $fields ): void {
foreach ( $fields as $field ) {
$label_for = isset( $field['label_for'] ) ? (string) $field['label_for'] : '';
?>
|
|
|
' . esc_html__( 'Amazon SES integration is active. To configure SMTP manually, deactivate the Amazon SES add-on.', 'robotstxt-smtp' ) . '
';
return;
}
echo '' . esc_html__( 'Configure the SMTP server used to deliver outgoing emails.', 'robotstxt-smtp' ) . '
';
}
/**
* Outputs the logs section description.
*
* @return void
*/
public function render_logs_section_description(): void {
echo '' . esc_html__( 'Control how email delivery logs are stored and pruned.', 'robotstxt-smtp' ) . '
';
}
/**
* Renders the settings page.
*
* @return void
*/
public function render_site_settings_page(): void {
$this->ensure_capability( self::SCOPE_SITE );
$this->set_scope( self::SCOPE_SITE );
?>
ensure_capability( self::SCOPE_NETWORK );
$this->set_scope( self::SCOPE_NETWORK );
$mode = self::get_configuration_mode();
?>
ensure_capability( $scope );
$nonce_value = filter_input( INPUT_POST, '_wpnonce', FILTER_UNSAFE_RAW );
// Abort when the security nonce is missing or invalid to block forged submissions.
if ( ! is_string( $nonce_value ) || ! wp_verify_nonce( wp_unslash( $nonce_value ), 'robotstxt_smtp_save_network_settings' ) ) {
wp_die( esc_html__( 'The link you followed has expired.', 'robotstxt-smtp' ) );
}
$mode = self::MODE_SITE;
$submitted_mode = filter_input( INPUT_POST, 'robotstxt_smtp_mode', FILTER_UNSAFE_RAW );
if ( is_string( $submitted_mode ) ) {
$submitted_mode = sanitize_key( wp_unslash( $submitted_mode ) );
if ( self::MODE_NETWORK === $submitted_mode ) {
$mode = self::MODE_NETWORK;
}
}
update_site_option( self::NETWORK_MODE_OPTION_NAME, $mode );
$options = array();
$raw_options = filter_input( INPUT_POST, self::NETWORK_OPTION_NAME, FILTER_UNSAFE_RAW, FILTER_REQUIRE_ARRAY );
if ( is_array( $raw_options ) ) {
$options = map_deep( wp_unslash( $raw_options ), 'sanitize_text_field' );
}
$this->set_scope( self::SCOPE_NETWORK );
$clean_options = $this->sanitize_options( $options );
update_site_option( self::NETWORK_OPTION_NAME, $clean_options );
$result = $this->send_test_email_for_scope( self::SCOPE_NETWORK, null, 'auto' );
$this->persist_test_result( $result );
$redirect = add_query_arg(
array(
'page' => self::NETWORK_PAGE_SLUG,
'robotstxt_smtp_saved' => 1,
'robotstxt_smtp_test' => ! empty( $result['success'] ) ? 'success' : 'error',
),
$this->get_admin_url_for_scope( $scope, 'admin.php' )
);
$redirect = add_query_arg(
'_wpnonce',
wp_create_nonce( 'robotstxt_smtp_saved_notice' ),
$redirect
);
wp_safe_redirect( $redirect );
exit;
}
/**
* Renders the test page.
*
* @return void
*/
public function render_test_page(): void {
$scope = $this->get_current_admin_scope();
$this->ensure_capability( $scope );
$this->set_scope( $scope );
?>
' . esc_html__( 'The network configuration is currently disabled. Tests will use the site-specific settings stored below.', 'robotstxt-smtp' ) . '
';
}
$this->render_test_email_form( $scope );
?>
get_current_admin_scope();
$this->ensure_capability( $scope );
$this->set_scope( $scope );
$requested_tool = '';
if ( isset( $_GET['robotstxt_smtp_tool'] ) ) {
$requested_tool = sanitize_key( wp_unslash( (string) $_GET['robotstxt_smtp_tool'] ) );
}
$force_refresh = false;
if ( isset( $_GET['robotstxt_smtp_refresh'] ) && '' !== $requested_tool ) {
$nonce = isset( $_GET['_wpnonce'] ) ? sanitize_text_field( wp_unslash( (string) $_GET['_wpnonce'] ) ) : '';
if ( wp_verify_nonce( $nonce, 'robotstxt_smtp_refresh_tool_' . $requested_tool ) ) {
$force_refresh = true;
}
}
$tools = array(
'mx' => array(
'title' => esc_html__( 'Automatic MX Lookup', 'robotstxt-smtp' ),
'description' => esc_html__( 'Detects the domain from the configured sender address, retrieves its MX records, and compares them with the SMTP host.', 'robotstxt-smtp' ),
'callback' => array( $this, 'get_tools_mx_section_html' ),
),
'authentication' => array(
'title' => esc_html__( 'SPF, DKIM, and DMARC Validation', 'robotstxt-smtp' ),
'description' => esc_html__( 'Checks common DNS TXT records for the sender domain to help you verify email authentication.', 'robotstxt-smtp' ),
'callback' => array( $this, 'get_tools_authentication_section_html' ),
),
'diagnostics' => array(
'title' => esc_html__( 'Extended SMTP Diagnostics', 'robotstxt-smtp' ),
'description' => esc_html__( 'Reuses the configured SMTP connection to inspect the server greeting, TLS support, available authentication mechanisms, and EHLO/HELO response codes.', 'robotstxt-smtp' ),
'callback' => array( $this, 'get_tools_smtp_diagnostics_section_html' ),
),
'blacklist' => array(
'title' => esc_html__( 'Blacklist Lookup', 'robotstxt-smtp' ),
'description' => esc_html__( 'Checks the SMTP host IP address against well-known DNS-based reputation lists. Use the feature responsibly and respect each provider’s terms of use.', 'robotstxt-smtp' ),
'callback' => array( $this, 'get_tools_blacklist_section_html' ),
),
);
$base_url = $this->get_admin_url_for_scope( $scope, 'admin.php' );
?>
|null
*/
private function get_tool_result_for_display( string $scope, string $tool, callable $callback, bool $refresh ): ?array {
$cached = $this->get_cached_tool_result( $scope, $tool );
if ( $refresh ) {
$html = (string) call_user_func( $callback, $scope );
$data = array(
'html' => $html,
'timestamp' => time(),
);
$this->set_tool_result_cache( $scope, $tool, $data );
return $data;
}
return $cached;
}
/**
* Retrieves the cached results for a tool.
*
* @param string $scope Scope identifier.
* @param string $tool Tool identifier.
*
* @return array|null
*/
private function get_cached_tool_result( string $scope, string $tool ): ?array {
$key = $this->get_tool_transient_key( $scope, $tool );
$value = self::SCOPE_NETWORK === $scope ? get_site_transient( $key ) : get_transient( $key );
if ( false === $value ) {
return null;
}
if ( ! is_array( $value ) || ! isset( $value['html'], $value['timestamp'] ) ) {
$this->delete_tool_result_cache( $scope, $tool );
return null;
}
return array(
'html' => (string) $value['html'],
'timestamp' => (int) $value['timestamp'],
);
}
/**
* Stores the cached results for a tool.
*
* @param string $scope Scope identifier.
* @param string $tool Tool identifier.
* @param array $data Cached data.
*
* @return void
*/
private function set_tool_result_cache( string $scope, string $tool, array $data ): void {
$payload = array(
'html' => isset( $data['html'] ) ? (string) $data['html'] : '',
'timestamp' => isset( $data['timestamp'] ) ? (int) $data['timestamp'] : time(),
);
$key = $this->get_tool_transient_key( $scope, $tool );
if ( self::SCOPE_NETWORK === $scope ) {
set_site_transient( $key, $payload, self::TOOLS_CACHE_LIFETIME );
return;
}
set_transient( $key, $payload, self::TOOLS_CACHE_LIFETIME );
}
/**
* Removes the cached results for a tool.
*
* @param string $scope Scope identifier.
* @param string $tool Tool identifier.
*
* @return void
*/
private function delete_tool_result_cache( string $scope, string $tool ): void {
$key = $this->get_tool_transient_key( $scope, $tool );
if ( self::SCOPE_NETWORK === $scope ) {
delete_site_transient( $key );
return;
}
delete_transient( $key );
}
/**
* Builds the transient key for a tool and scope combination.
*
* @param string $scope Scope identifier.
* @param string $tool Tool identifier.
*
* @return string
*/
private function get_tool_transient_key( string $scope, string $tool ): string {
return self::TOOLS_TRANSIENT_PREFIX . $scope . '_' . $tool;
}
/**
* Formats the last checked timestamp for display.
*
* @param int $timestamp Unix timestamp.
*
* @return string
*/
private function format_tool_timestamp( int $timestamp ): string {
$timestamp = max( 0, $timestamp );
if ( 0 === $timestamp ) {
return '';
}
$date_format = (string) get_option( 'date_format', 'F j, Y' );
$time_format = (string) get_option( 'time_format', 'g:i a' );
$format = trim( $date_format . ' ' . $time_format );
if ( '' === $format ) {
$format = 'F j, Y g:i a';
}
return date_i18n( $format, $timestamp );
}
/**
* Generates the markup for the MX lookup tool.
*
* @param string $scope Scope identifier.
* @return string
*/
private function get_tools_mx_section_html( string $scope ): string {
$this->set_scope( $scope );
$settings = $this->get_settings();
$smtp_host = isset( $settings['host'] ) ? (string) $settings['host'] : '';
$from_email = isset( $settings['from_email'] ) ? (string) $settings['from_email'] : '';
$sender_domain = $this->extract_domain_from_email( $from_email );
$normalized_smtp_host = $this->normalize_hostname( $smtp_host );
$mx_analysis = array(
'domain' => $sender_domain,
'records' => array(),
'errors' => array(),
'matches_smtp_host' => false,
);
if ( '' !== $sender_domain ) {
$mx_analysis = $this->analyze_mx_records( $sender_domain, $normalized_smtp_host );
}
ob_start();
if ( '' === $sender_domain ) :
?>
set_scope( $scope );
$settings = $this->get_settings();
$smtp_host = isset( $settings['host'] ) ? (string) $settings['host'] : '';
$smtp_port = isset( $settings['port'] ) ? (int) $settings['port'] : 0;
$security = isset( $settings['security'] ) ? (string) $settings['security'] : 'none';
$normalized_host = $this->normalize_hostname( $smtp_host );
$security_options = $this->get_security_options();
$security_label = isset( $security_options[ $security ] ) ? $security_options[ $security ] : $security;
ob_start();
if ( '' === $normalized_host ) :
?>
run_smtp_diagnostics( $settings );
if ( ! empty( $diagnostics['errors'] ) ) :
?>
(
)
$settings SMTP settings.
*
* @return array
*/
private function run_smtp_diagnostics( array $settings ): array {
$defaults = array(
'host' => '',
'port' => 25,
'security' => 'none',
);
$settings = wp_parse_args( $settings, $defaults );
$host = $this->normalize_hostname( (string) $settings['host'] );
$port = (int) $settings['port'];
$security = in_array( (string) $settings['security'], array( 'none', 'ssl', 'tls' ), true ) ? (string) $settings['security'] : 'none';
if ( $port <= 0 || $port > 65535 ) {
$port = 25;
}
$result = array(
'success' => false,
'host' => $host,
'port' => $port,
'security' => $security,
'errors' => array(),
'warnings' => array(),
'banner' => '',
'banner_code' => '',
'ehlo' => array(
'code' => '',
'lines' => array(),
'timed_out' => false,
),
'ehlo_post_tls' => null,
'helo' => array(
'attempted' => false,
'code' => '',
'lines' => array(),
'timed_out' => false,
),
'tls' => array(
'mode' => $security,
'offered' => null,
'upgrade_attempted' => false,
'upgrade_response' => array(
'code' => '',
'lines' => array(),
'timed_out' => false,
),
'negotiated' => ( 'ssl' === $security ),
),
'auth_mechanisms' => array(),
'transcript' => array(),
);
if ( '' === $host ) {
$result['errors'][] = __( 'A valid SMTP host is required to run diagnostics.', 'robotstxt-smtp' );
return $result;
}
$client = $this->create_smtp_client();
$timeout = 15;
$transport = 'ssl' === $security ? 'ssl://' . $host : $host;
$client->setDebugOutput(
static function ( $message, $level ) use ( &$result ) {
unset( $level );
if ( ! is_string( $message ) ) {
return;
}
$message = trim( $message );
if ( '' === $message ) {
return;
}
if ( str_starts_with( $message, 'CLIENT -> SERVER:' ) ) {
$result['transcript'][] = 'C: ' . trim( substr( $message, strlen( 'CLIENT -> SERVER:' ) ) );
return;
}
if ( str_starts_with( $message, 'SERVER -> CLIENT:' ) ) {
$result['transcript'][] = 'S: ' . trim( substr( $message, strlen( 'SERVER -> CLIENT:' ) ) );
}
}
);
// phpcs:disable WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
$client->do_debug = SMTP::DEBUG_SERVER;
$client->Timeout = $timeout;
$client->Timelimit = $timeout;
// phpcs:enable WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
$connected = $client->connect( $transport, $port, $timeout );
if ( ! $connected ) {
$error = $client->getError();
$error_code = isset( $error['smtp_code'] ) && is_numeric( $error['smtp_code'] ) ? (int) $error['smtp_code'] : 0;
$message = '';
foreach ( array( 'smtp_code_ex', 'detail', 'error' ) as $key ) {
if ( ! empty( $error[ $key ] ) ) {
$message = (string) $error[ $key ];
break;
}
}
if ( '' === $message ) {
$message = __( 'Unknown error', 'robotstxt-smtp' );
}
$result['errors'][] = sprintf(
/* translators: 1: Error message. 2: Error code. */
__( 'Connection failed: %1$s (%2$d).', 'robotstxt-smtp' ),
$message,
$error_code
);
$client->close();
return $result;
}
$banner = $this->parse_smtp_reply( $client->getLastReply() );
if ( '' !== $banner['code'] ) {
$result['banner_code'] = $banner['code'];
}
if ( ! empty( $banner['lines'] ) ) {
$first_line = preg_replace( '/^\d{3}[ \-]/', '', (string) $banner['lines'][0] );
$result['banner'] = is_string( $first_line ) ? trim( $first_line ) : '';
}
if ( '' !== $result['banner_code'] && '220' !== $result['banner_code'] ) {
$result['warnings'][] = __( 'The server greeting returned an unexpected status code.', 'robotstxt-smtp' );
}
$ehlo_domain = $this->get_ehlo_hostname();
$ehlo_command = $client->execute_command( 'EHLO', 'EHLO ' . $ehlo_domain );
$ehlo_response = $this->parse_smtp_reply( $ehlo_command['reply'], $ehlo_command['timed_out'] );
$result['ehlo'] = $ehlo_response;
$capabilities = $this->parse_ehlo_capabilities( $ehlo_response['lines'] );
$result['auth_mechanisms'] = $capabilities['auth_mechanisms'];
$result['tls']['offered'] = $capabilities['has_starttls'];
if ( '' === $ehlo_response['code'] ) {
$result['warnings'][] = __( 'The EHLO response could not be parsed.', 'robotstxt-smtp' );
} elseif ( '250' !== $ehlo_response['code'] ) {
$result['warnings'][] = __( 'The server did not accept the EHLO command.', 'robotstxt-smtp' );
}
if ( 'tls' === $security ) {
if ( empty( $capabilities['has_starttls'] ) ) {
$result['warnings'][] = __( 'The server did not advertise STARTTLS support.', 'robotstxt-smtp' );
} else {
$result['tls']['upgrade_attempted'] = true;
$tls_negotiated = $client->startTLS();
$starttls_reply = $this->parse_smtp_reply( $client->getLastReply(), $client->did_last_command_timeout() );
$result['tls']['upgrade_response'] = $starttls_reply;
if ( $tls_negotiated ) {
$result['tls']['negotiated'] = true;
$post_tls_command = $client->execute_command( 'EHLO', 'EHLO ' . $ehlo_domain );
$post_tls_response = $this->parse_smtp_reply( $post_tls_command['reply'], $post_tls_command['timed_out'] );
$result['ehlo_post_tls'] = $post_tls_response;
$post_tls_capabilities = $this->parse_ehlo_capabilities( $post_tls_response['lines'] );
if ( ! empty( $post_tls_capabilities['auth_mechanisms'] ) ) {
$result['auth_mechanisms'] = $post_tls_capabilities['auth_mechanisms'];
}
} else {
$result['warnings'][] = __( 'The TLS negotiation failed after STARTTLS.', 'robotstxt-smtp' );
}
}
}
if ( 'none' === $security && ! empty( $capabilities['has_starttls'] ) ) {
$result['warnings'][] = __( 'The server supports STARTTLS. Enable TLS in the configuration to protect the connection.', 'robotstxt-smtp' );
}
if ( empty( $result['auth_mechanisms'] ) ) {
$result['warnings'][] = __( 'No authentication mechanisms were advertised by the server.', 'robotstxt-smtp' );
}
if ( '250' !== $ehlo_response['code'] ) {
$result['helo']['attempted'] = true;
$helo_command = $client->execute_command( 'HELO', 'HELO ' . $ehlo_domain );
$helo_response = $this->parse_smtp_reply( $helo_command['reply'], $helo_command['timed_out'] );
$result['helo']['code'] = $helo_response['code'];
$result['helo']['lines'] = $helo_response['lines'];
if ( '' === $helo_response['code'] || '250' !== $helo_response['code'] ) {
$result['warnings'][] = __( 'The legacy HELO command did not return a successful status code.', 'robotstxt-smtp' );
}
}
$client->execute_command( 'QUIT', 'QUIT', array( 221 ) );
$client->close();
if ( ! $result['tls']['negotiated'] ) {
$result['warnings'][] = __( 'The connection is not encrypted. Credentials may travel in plain text.', 'robotstxt-smtp' );
}
$result['auth_mechanisms'] = array_values( array_unique( array_map( 'strtoupper', $result['auth_mechanisms'] ) ) );
$result['warnings'] = array_values( array_unique( $result['warnings'] ) );
$result['errors'] = array_values( array_unique( $result['errors'] ) );
$result['success'] = empty( $result['errors'] );
return $result;
}
/**
* Determines the hostname used when sending EHLO/HELO commands.
*
* @return string
*/
private function get_ehlo_hostname(): string {
$home = home_url( '/' );
if ( is_string( $home ) ) {
$parsed = wp_parse_url( $home );
if ( is_array( $parsed ) && isset( $parsed['host'] ) && '' !== $parsed['host'] ) {
return strtolower( rtrim( (string) $parsed['host'], '.' ) );
}
}
if ( function_exists( 'gethostname' ) ) {
$hostname = gethostname();
if ( is_string( $hostname ) && '' !== $hostname ) {
return strtolower( rtrim( $hostname, '.' ) );
}
}
return 'localhost';
}
/**
* Parses a raw SMTP reply string.
*
* @param string $reply Raw reply returned by the server.
* @param bool $timed_out Whether the request timed out.
*
* @return array
*/
private function parse_smtp_reply( string $reply, bool $timed_out = false ): array {
$lines = array();
$code = '';
if ( '' !== $reply ) {
$normalized = str_replace( array( "\r\n", "\r" ), "\n", $reply );
$parts = array_filter( array_map( 'trim', explode( "\n", trim( $normalized ) ) ), 'strlen' );
foreach ( $parts as $line ) {
$lines[] = $line;
if ( '' === $code && preg_match( '/^(\d{3})/', $line, $matches ) ) {
$code = $matches[1];
}
}
}
return array(
'code' => $code,
'lines' => $lines,
'timed_out' => $timed_out,
);
}
/**
* Creates an SMTP diagnostics client instance.
*
* @return SMTP_Diagnostics_Client
*/
private function create_smtp_client(): SMTP_Diagnostics_Client {
if ( ! class_exists( SMTP::class ) ) {
require_once ABSPATH . WPINC . '/PHPMailer/SMTP.php';
}
return new SMTP_Diagnostics_Client();
}
/**
* Parses the EHLO response to extract advertised capabilities.
*
* @param array $lines Response lines.
*
* @return array
*/
private function parse_ehlo_capabilities( array $lines ): array {
$result = array(
'raw' => array(),
'has_starttls' => false,
'auth_mechanisms' => array(),
);
foreach ( $lines as $line ) {
$text = (string) $line;
if ( preg_match( '/^\d{3}[ \-](.*)$/', $text, $matches ) ) {
$text = trim( (string) $matches[1] );
} else {
$text = trim( $text );
}
if ( '' === $text ) {
continue;
}
$result['raw'][] = $text;
$upper = strtoupper( $text );
if ( 0 === strpos( $upper, 'STARTTLS' ) ) {
$result['has_starttls'] = true;
}
if ( 0 === strpos( $upper, 'AUTH' ) ) {
$after_auth = trim( substr( $text, 4 ) );
$after_auth = ltrim( $after_auth, '= ' );
if ( '' !== $after_auth ) {
$mechanisms = preg_split( '/\s+/', $after_auth );
if ( is_array( $mechanisms ) ) {
foreach ( $mechanisms as $mechanism ) {
$mechanism = strtoupper( trim( (string) $mechanism ) );
if ( '' !== $mechanism ) {
$result['auth_mechanisms'][] = $mechanism;
}
}
}
}
}
}
$result['auth_mechanisms'] = array_values( array_unique( $result['auth_mechanisms'] ) );
return $result;
}
/**
* Generates the markup for the SPF, DKIM, and DMARC checks.
*
* @param string $scope Scope identifier.
* @return string
*/
private function get_tools_authentication_section_html( string $scope ): string {
$this->set_scope( $scope );
$settings = $this->get_settings();
$smtp_host = isset( $settings['host'] ) ? (string) $settings['host'] : '';
$from_email = isset( $settings['from_email'] ) ? (string) $settings['from_email'] : '';
$sender_domain = $this->extract_domain_from_email( $from_email );
$normalized_smtp_host = $this->normalize_hostname( $smtp_host );
$mx_analysis = array(
'domain' => $sender_domain,
'records' => array(),
'errors' => array(),
'matches_smtp_host' => false,
);
$host_ips = $this->get_hostname_ip_addresses( $normalized_smtp_host );
if ( '' !== $sender_domain ) {
$mx_analysis = $this->analyze_mx_records( $sender_domain, $normalized_smtp_host );
}
ob_start();
if ( '' === $sender_domain ) :
?>
analyze_domain_authentication( $sender_domain, $normalized_smtp_host, $mx_analysis, $host_ips );
if ( ! empty( $auth_analysis['errors'] ) ) :
?>
set_scope( $scope );
$settings = $this->get_settings();
$smtp_host = isset( $settings['host'] ) ? (string) $settings['host'] : '';
$normalized_smtp_host = $this->normalize_hostname( $smtp_host );
$host_ips = $this->get_hostname_ip_addresses( $normalized_smtp_host );
$analysis = $this->analyze_blacklist_status( $host_ips );
ob_start();
if ( ! empty( $analysis['errors'] ) ) :
?>
get_current_admin_scope();
$this->ensure_capability( $scope );
$this->set_scope( $scope );
$settings = $this->get_settings();
$logging_enabled = ! empty( $settings['logs_enabled'] );
$nonce_action = $this->get_logs_nonce_action();
$nonce_value = filter_input( INPUT_GET, 'robotstxt_smtp_logs_nonce', FILTER_UNSAFE_RAW );
// Only honor filter arguments after confirming that the nonce matches the filter form submission.
$nonce_valid = is_string( $nonce_value ) && '' !== $nonce_value && wp_verify_nonce( wp_unslash( $nonce_value ), $nonce_action );
$nonce_required = $this->is_logs_request_using_user_input();
$request_args = array();
if ( $nonce_required && ! $nonce_valid ) {
$log_id = 0;
$paged = 1;
$filters = $this->get_default_log_filters();
} else {
if ( $nonce_valid ) {
$request_args = $this->get_sanitized_logs_request_args();
}
$log_id = $this->get_requested_log_id( $request_args );
$paged = $this->get_requested_paged( $request_args );
$filters = $this->get_log_filters( $request_args );
}
$logs_nonce = wp_create_nonce( $nonce_action );
?>
0 ) {
$this->render_log_details( $log_id, $scope, $filters, $logs_nonce );
} else {
$this->render_logs_list( $paged, $scope, $filters, $logs_nonce );
}
?>
Filter values.
*/
private function get_logs_nonce_action(): string {
return 'robotstxt_smtp_manage_logs';
}
/**
* Determines whether the current request attempts to filter or paginate the logs list.
*
* @return bool
*/
private function is_logs_request_using_user_input(): bool {
if ( filter_has_var( INPUT_GET, 'paged' ) ) {
return true;
}
$keys = array(
'robotstxt_smtp_subject',
'robotstxt_smtp_to',
'robotstxt_smtp_date_from',
'robotstxt_smtp_date_to',
'robotstxt_smtp_per_page',
'log_id',
);
foreach ( $keys as $key ) {
if ( filter_has_var( INPUT_GET, $key ) ) {
return true;
}
}
return false;
}
/**
* Retrieves the sanitized request parameters used to filter the logs list.
*
* @return array
*/
private function get_sanitized_logs_request_args(): array {
$request = array();
$log_id = filter_input( INPUT_GET, 'log_id', FILTER_UNSAFE_RAW );
if ( null !== $log_id && '' !== $log_id ) {
// Cast to an absolute integer before using the requested log identifier.
$request['log_id'] = absint( wp_unslash( (string) $log_id ) );
}
$paged = filter_input( INPUT_GET, 'paged', FILTER_UNSAFE_RAW );
if ( null !== $paged && '' !== $paged ) {
// Force pagination to a positive integer to avoid loading invalid pages.
$request['paged'] = max( 1, absint( wp_unslash( (string) $paged ) ) );
}
$subject = filter_input( INPUT_GET, 'robotstxt_smtp_subject', FILTER_UNSAFE_RAW );
if ( is_string( $subject ) && '' !== $subject ) {
// Sanitize the email subject search term before passing it to WP_Query.
$request['robotstxt_smtp_subject'] = sanitize_text_field( wp_unslash( $subject ) );
}
$recipient = filter_input( INPUT_GET, 'robotstxt_smtp_to', FILTER_UNSAFE_RAW );
if ( is_string( $recipient ) && '' !== $recipient ) {
// Normalize the recipient filter to plain text for safe database queries.
$request['robotstxt_smtp_to'] = sanitize_text_field( wp_unslash( $recipient ) );
}
$date_from = filter_input( INPUT_GET, 'robotstxt_smtp_date_from', FILTER_UNSAFE_RAW );
if ( is_string( $date_from ) && '' !== $date_from ) {
// Validate the start date to guard against malformed date strings.
$request['robotstxt_smtp_date_from'] = $this->sanitize_log_date( $date_from );
}
$date_to = filter_input( INPUT_GET, 'robotstxt_smtp_date_to', FILTER_UNSAFE_RAW );
if ( is_string( $date_to ) && '' !== $date_to ) {
// Validate the end date to guard against malformed date strings.
$request['robotstxt_smtp_date_to'] = $this->sanitize_log_date( $date_to );
}
$per_page = filter_input( INPUT_GET, 'robotstxt_smtp_per_page', FILTER_UNSAFE_RAW );
if ( null !== $per_page && '' !== $per_page ) {
// Clamp the per-page setting to a non-negative integer before building the query.
$request['robotstxt_smtp_per_page'] = absint( wp_unslash( (string) $per_page ) );
}
return $request;
}
/**
* Retrieves the requested log identifier from the current request.
*
* @param array $request Request arguments.
*
* @return int Log identifier if provided, otherwise 0.
*/
private function get_requested_log_id( array $request ): int {
if ( ! isset( $request['log_id'] ) ) {
return 0;
}
return (int) $request['log_id'];
}
/**
* Retrieves the requested page number when the nonce is valid.
*
* @param array $request Request arguments.
*
* @return int
*/
private function get_requested_paged( array $request ): int {
if ( ! isset( $request['paged'] ) ) {
return 1;
}
return (int) $request['paged'];
}
/**
* Returns the default filter values for the logs list.
*
* @return array
*/
private function get_default_log_filters(): array {
return array(
'subject' => '',
'to' => '',
'date_from' => '',
'date_to' => '',
'per_page' => 100,
);
}
/**
* Retrieves the current filters applied to the logs list.
*
* @param array $request Request arguments.
*
* @return array Filter values.
*/
private function get_log_filters( array $request ): array {
$filters = $this->get_default_log_filters();
if ( isset( $request['robotstxt_smtp_subject'] ) ) {
$filters['subject'] = $request['robotstxt_smtp_subject'];
}
if ( isset( $request['robotstxt_smtp_to'] ) ) {
$filters['to'] = $request['robotstxt_smtp_to'];
}
if ( isset( $request['robotstxt_smtp_date_from'] ) ) {
$filters['date_from'] = $request['robotstxt_smtp_date_from'];
}
if ( isset( $request['robotstxt_smtp_date_to'] ) ) {
$filters['date_to'] = $request['robotstxt_smtp_date_to'];
}
$allowed_per_page = array( 10, 25, 50, 100, 250, 500 );
if ( isset( $request['robotstxt_smtp_per_page'] ) ) {
$requested_per_page = (int) $request['robotstxt_smtp_per_page'];
if ( in_array( $requested_per_page, $allowed_per_page, true ) ) {
$filters['per_page'] = $requested_per_page;
}
}
return $filters;
}
/**
* Builds the query arguments that represent the current log filters.
*
* @param array $filters Filter values.
*
* @return array URL query arguments.
*/
private function get_log_filter_query_args( array $filters ): array {
$query_args = array();
if ( '' !== $filters['subject'] ) {
$query_args['robotstxt_smtp_subject'] = $filters['subject'];
}
if ( '' !== $filters['to'] ) {
$query_args['robotstxt_smtp_to'] = $filters['to'];
}
if ( '' !== $filters['date_from'] ) {
$query_args['robotstxt_smtp_date_from'] = $filters['date_from'];
}
if ( '' !== $filters['date_to'] ) {
$query_args['robotstxt_smtp_date_to'] = $filters['date_to'];
}
if ( 100 !== $filters['per_page'] ) {
$query_args['robotstxt_smtp_per_page'] = (string) $filters['per_page'];
}
return $query_args;
}
/**
* Sanitizes a date string coming from the logs filters.
*
* @param string $value Raw date value.
*
* @return string Sanitized date in `Y-m-d` format or an empty string when invalid.
*/
private function sanitize_log_date( string $value ): string {
$value = sanitize_text_field( $value );
if ( '' === $value ) {
return '';
}
$date = \DateTime::createFromFormat( 'Y-m-d', $value );
if ( ! $date ) {
return '';
}
$errors = \DateTime::getLastErrors();
if ( is_array( $errors ) && ( $errors['warning_count'] > 0 || $errors['error_count'] > 0 ) ) {
return '';
}
return $date->format( 'Y-m-d' );
}
/**
* Renders the table listing of log entries.
*
* @param int $paged Current page number.
* @param string $scope Scope identifier (site or network).
* @param array $filters Current filter values.
* @param string $nonce Logs nonce value.
*
* @return void
*/
private function render_logs_list( int $paged, string $scope, array $filters, string $nonce ): void {
$per_page = $filters['per_page'];
$filter_query_args = $this->get_log_filter_query_args( $filters );
$query_args = array(
'post_type' => Plugin::get_log_post_type(),
'post_status' => 'publish',
'posts_per_page' => $per_page,
'paged' => $paged,
'orderby' => 'date',
'order' => 'DESC',
);
if ( '' !== $filters['subject'] ) {
$query_args['s'] = $filters['subject'];
}
if ( '' !== $filters['to'] ) {
// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- Recipient filter requires meta_query.
$query_args['meta_query'] = array(
array(
'key' => '_robotstxt_smtp_to',
'value' => $filters['to'],
'compare' => 'LIKE',
),
);
}
if ( '' !== $filters['date_from'] || '' !== $filters['date_to'] ) {
$date_query = array(
'inclusive' => true,
);
if ( '' !== $filters['date_from'] ) {
$date_query['after'] = $filters['date_from'];
}
if ( '' !== $filters['date_to'] ) {
$date_query['before'] = $filters['date_to'];
}
$query_args['date_query'] = array( $date_query );
}
$query = new \WP_Query( $query_args );
$base_url = remove_query_arg(
array( 'paged', 'log_id', 'robotstxt_smtp_cleared' ),
add_query_arg(
array_merge(
array(
'page' => self::LOGS_PAGE_SLUG,
'robotstxt_smtp_logs_nonce' => $nonce,
),
$filter_query_args
),
$this->get_admin_url_for_scope( $scope, 'admin.php' )
)
);
$reset_url = empty( $filter_query_args )
? $base_url
: remove_query_arg( array_keys( $filter_query_args ), $base_url );
?>
posts ) ) : ?>
|
|
|
|
|
|
posts as $log ) : ?>
ID, '_robotstxt_smtp_from', true );
$to = get_post_meta( $log->ID, '_robotstxt_smtp_to', true );
$subject = $log->post_title ? $log->post_title : __( '(No subject)', 'robotstxt-smtp' );
$status_display = $this->get_log_status_display( (int) $log->ID );
$view_url = add_query_arg(
array_merge(
array(
'page' => self::LOGS_PAGE_SLUG,
'log_id' => $log->ID,
'robotstxt_smtp_logs_nonce' => $nonce,
),
$filter_query_args
),
$this->get_admin_url_for_scope( $scope, 'admin.php' )
);
$date = get_date_from_gmt(
$log->post_date_gmt,
get_option( 'date_format' ) . ' ' . get_option( 'time_format' )
);
?>
|
|
|
|
|
|
add_query_arg( 'paged', '%#%', $base_url ),
'format' => '',
'current' => max( 1, $paged ),
'total' => max( 1, (int) $query->max_num_pages ),
'prev_text' => __( '«', 'robotstxt-smtp' ),
'next_text' => __( '»', 'robotstxt-smtp' ),
)
);
if ( $pagination ) {
echo '' . wp_kses_post( $pagination ) . '
';
}
wp_reset_postdata();
}
/**
* Displays the details of a single log entry.
*
* @param int $log_id Log post ID.
* @param string $scope Scope identifier (site or network).
* @param array $filters Current filter values.
* @param string $nonce Logs nonce value.
*
* @return void
*/
private function render_log_details( int $log_id, string $scope, array $filters, string $nonce ): void {
$log = get_post( $log_id );
if ( ! $log || Plugin::get_log_post_type() !== $log->post_type ) {
echo '' . esc_html__( 'The requested log entry could not be found.', 'robotstxt-smtp' ) . '
';
$this->render_logs_list( 1, $scope, $filters, $nonce );
return;
}
$from = get_post_meta( $log->ID, '_robotstxt_smtp_from', true );
$to = get_post_meta( $log->ID, '_robotstxt_smtp_to', true );
$headers = (string) get_post_meta( $log->ID, '_robotstxt_smtp_headers', true );
$attachments = maybe_unserialize( get_post_meta( $log->ID, '_robotstxt_smtp_attachments', true ) );
$debug_log = maybe_unserialize( get_post_meta( $log->ID, '_robotstxt_smtp_debug_log', true ) );
$date = get_date_from_gmt( $log->post_date_gmt, get_option( 'date_format' ) . ' ' . get_option( 'time_format' ) );
$subject = $log->post_title ? $log->post_title : __( '(No subject)', 'robotstxt-smtp' );
$status = $this->get_log_status_display( (int) $log->ID );
$error = (string) get_post_meta( $log->ID, '_robotstxt_smtp_error', true );
if ( ! is_array( $attachments ) ) {
$attachments = array();
}
if ( ! is_array( $debug_log ) ) {
$debug_log = array();
}
$normalized_attachments = array();
$normalized_debug_log = array();
foreach ( $attachments as $attachment ) {
if ( ! is_scalar( $attachment ) ) {
continue;
}
$attachment_name = trim( (string) $attachment );
if ( '' === $attachment_name ) {
continue;
}
$normalized_attachments[] = $attachment_name;
}
foreach ( $debug_log as $debug_line ) {
if ( ! is_scalar( $debug_line ) ) {
continue;
}
$clean_line = sanitize_textarea_field( (string) $debug_line );
if ( '' === $clean_line ) {
continue;
}
$normalized_debug_log[] = $clean_line;
}
?>
←
post_content ) : ?>
post_content ) ); ?>
*/
private function get_log_status_display( int $log_id ): array {
$status = get_post_meta( $log_id, '_robotstxt_smtp_status', true );
if ( 'error' === $status ) {
return array(
'status' => 'error',
'label' => __( 'Failed', 'robotstxt-smtp' ),
'class' => 'robotstxt-smtp-log-status-error',
);
}
return array(
'status' => 'sent',
'label' => __( 'Sent', 'robotstxt-smtp' ),
'class' => 'robotstxt-smtp-log-status-sent',
);
}
/**
* Handles the request to clear all stored logs.
*
* @return void
*/
public function handle_clear_logs(): void {
$nonce_value = filter_input( INPUT_POST, '_wpnonce', FILTER_UNSAFE_RAW );
// Block log deletions unless the nonce matches the action button submission.
if ( ! is_string( $nonce_value ) || ! wp_verify_nonce( wp_unslash( $nonce_value ), 'robotstxt_smtp_clear_logs' ) ) {
wp_die( esc_html__( 'The link you followed has expired.', 'robotstxt-smtp' ) );
}
$scope = self::SCOPE_SITE;
$context_raw = filter_input( INPUT_POST, 'robotstxt_smtp_context', FILTER_UNSAFE_RAW );
if ( is_string( $context_raw ) && '' !== $context_raw ) {
// Sanitize the submitted scope before checking the requested context.
$context = sanitize_text_field( wp_unslash( $context_raw ) );
if ( self::SCOPE_NETWORK === $context ) {
$scope = self::SCOPE_NETWORK;
}
}
$this->ensure_capability( $scope );
$this->set_scope( $scope );
Plugin::get_instance()->clear_all_logs();
$redirect = add_query_arg(
array(
'page' => self::LOGS_PAGE_SLUG,
'robotstxt_smtp_cleared' => 1,
),
$this->get_admin_url_for_scope( $scope, 'admin.php' )
);
wp_safe_redirect( $redirect );
exit;
}
/**
* Renders the host field.
*
* @return void
*/
public function render_host_field(): void {
$settings = $this->get_settings();
?>
get_settings();
?>
get_settings();
?>
get_settings();
?>
get_settings();
?>
get_settings();
$options = $this->get_security_options();
?>
get_settings();
?>
get_settings();
?>
get_settings();
$mode = isset( $settings['logs_retention_mode'] ) ? $settings['logs_retention_mode'] : 'count';
?>
get_settings();
?>
get_settings();
?>
$options Raw option values.
*
* @return array Sanitized option values.
*/
public function sanitize_options( array $options ): array {
$this->cached_settings = null;
if ( self::SCOPE_SITE === $this->get_scope() && is_admin() && isset( $_SERVER['REQUEST_METHOD'] ) && 'POST' === $_SERVER['REQUEST_METHOD'] ) {
$this->site_settings_submitted = true;
$this->site_settings_option_updated = false;
}
$clean = self::get_default_settings();
$options = wp_unslash( $options );
$security = $this->get_security_options();
if ( isset( $options['host'] ) ) {
$clean['host'] = sanitize_text_field( $options['host'] );
}
if ( isset( $options['username'] ) ) {
$clean['username'] = sanitize_text_field( $options['username'] );
}
if ( isset( $options['password'] ) ) {
$clean['password'] = sanitize_text_field( $options['password'] );
}
if ( isset( $options['from_email'] ) ) {
$clean['from_email'] = sanitize_email( $options['from_email'] );
}
if ( isset( $options['from_name'] ) ) {
$clean['from_name'] = sanitize_text_field( $options['from_name'] );
}
if ( isset( $options['amazon_ses_access_key'] ) ) {
$clean['amazon_ses_access_key'] = sanitize_text_field( $options['amazon_ses_access_key'] );
}
if ( isset( $options['amazon_ses_secret_key'] ) ) {
$clean['amazon_ses_secret_key'] = sanitize_text_field( $options['amazon_ses_secret_key'] );
}
if ( isset( $options['amazon_ses_region'] ) ) {
$clean['amazon_ses_region'] = sanitize_text_field( $options['amazon_ses_region'] );
}
if ( isset( $options['security'] ) && array_key_exists( $options['security'], $security ) ) {
$clean['security'] = $options['security'];
}
if ( isset( $options['port'] ) ) {
$port = absint( $options['port'] );
if ( $port > 0 && $port <= 65535 ) {
$clean['port'] = $port;
}
}
$clean['logs_enabled'] = ! empty( $options['logs_enabled'] );
if ( isset( $options['logs_retention_mode'] ) && in_array( $options['logs_retention_mode'], array( 'count', 'days' ), true ) ) {
$clean['logs_retention_mode'] = $options['logs_retention_mode'];
}
if ( isset( $options['logs_retention_count'] ) ) {
$clean['logs_retention_count'] = absint( $options['logs_retention_count'] );
}
if ( isset( $options['logs_retention_days'] ) ) {
$clean['logs_retention_days'] = absint( $options['logs_retention_days'] );
}
/**
* Filters the sanitized SMTP options before they are persisted.
*
* This hook allows integrations to adjust the sanitized values or
* perform additional validation based on the submitted form data.
*
* @since 1.1.0
*
* @param array $clean Sanitized option values.
* @param array $options Raw submitted values after `wp_unslash()`.
* @param Settings_Page $settings Settings page instance.
*/
$clean = (array) \apply_filters( 'robotstxt_smtp_sanitized_options', $clean, $options, $this );
return $clean;
}
/**
* Retrieves the stored settings merged with defaults.
*
* @return array Stored settings.
*/
private function get_settings(): array {
if ( null !== $this->cached_settings ) {
return $this->cached_settings;
}
$defaults = self::get_default_settings();
$option_name = $this->get_option_name_for_scope();
if ( self::SCOPE_NETWORK === $this->get_scope() ) {
$settings = get_site_option( $option_name, array() );
} else {
$settings = get_option( $option_name, array() );
}
if ( ! is_array( $settings ) ) {
$settings = array();
}
$this->cached_settings = wp_parse_args( $settings, $defaults );
return $this->cached_settings;
}
/**
* Extracts the domain portion from an email address.
*
* @param string $email Email address.
*
* @return string
*/
private function extract_domain_from_email( string $email ): string {
$sanitized = sanitize_email( $email );
if ( empty( $sanitized ) || false === strpos( $sanitized, '@' ) ) {
return '';
}
$parts = explode( '@', $sanitized );
$domain = array_pop( $parts );
if ( ! is_string( $domain ) ) {
return '';
}
$domain = strtolower( $domain );
$domain = rtrim( $domain, '.' );
return $domain;
}
/**
* Normalizes a hostname for comparison.
*
* @param string $hostname Hostname to normalize.
*
* @return string
*/
private function normalize_hostname( string $hostname ): string {
$normalized = strtolower( trim( $hostname ) );
if ( '' === $normalized ) {
return '';
}
if ( false !== strpos( $normalized, '://' ) ) {
$parsed = wp_parse_url( $normalized );
if ( is_array( $parsed ) && isset( $parsed['host'] ) ) {
$normalized = strtolower( (string) $parsed['host'] );
}
}
$normalized = ltrim( $normalized, '/\\' );
$normalized = preg_replace( '/:\d+$/', '', $normalized );
if ( ! is_string( $normalized ) ) {
$normalized = '';
}
$normalized = rtrim( $normalized, '.' );
return $normalized;
}
/**
* Analyzes SPF, DKIM, and DMARC records for the provided domain.
*
* @param string $domain Domain to analyze.
* @param string $smtp_host Normalized SMTP host.
* @param array $mx_analysis MX lookup results.
* @param array>|null $host_ips Optional pre-resolved SMTP host IP addresses.
*
* @return array
*/
private function analyze_domain_authentication( string $domain, string $smtp_host, array $mx_analysis, ?array $host_ips = null ): array {
$result = array(
'domain' => $domain,
'errors' => array(),
'spf' => array(
'records' => array(),
'errors' => array(),
'authorized' => null,
'message' => '',
),
'dkim' => array(
'found' => null,
'errors' => array(),
'record' => '',
),
'dmarc' => array(
'record' => '',
'policy' => '',
'errors' => array(),
'recommendations' => array(),
),
);
if ( '' === $domain ) {
$result['errors'][] = __( 'The sender domain could not be determined.', 'robotstxt-smtp' );
return $result;
}
if ( ! function_exists( 'dns_get_record' ) ) {
$result['errors'][] = __( 'DNS lookups are not available because the DNS functions are disabled on this server.', 'robotstxt-smtp' );
return $result;
}
if ( null === $host_ips ) {
$host_ips = $this->get_hostname_ip_addresses( $smtp_host );
}
$result['spf'] = $this->analyze_spf_records( $domain, $smtp_host, $mx_analysis, $host_ips );
$result['dkim'] = $this->analyze_dkim_record( $domain );
$result['dmarc'] = $this->analyze_dmarc_record( $domain );
return $result;
}
/**
* Checks the resolved SMTP host IPs against DNSBL providers.
*
* @param array> $host_ips Resolved IP addresses for the SMTP host.
*
* @return array
*/
private function analyze_blacklist_status( array $host_ips ): array {
$result = array(
'ips' => array(),
'ipv6' => array(),
'errors' => array(),
);
if ( ! function_exists( 'dns_get_record' ) ) {
$result['errors'][] = __( 'Blacklist lookups are not available because the DNS functions are disabled on this server.', 'robotstxt-smtp' );
return $result;
}
if ( ! empty( $host_ips['ipv6'] ) ) {
$result['ipv6'] = $host_ips['ipv6'];
}
if ( empty( $host_ips['ipv4'] ) ) {
return $result;
}
$providers = $this->get_dnsbl_providers();
foreach ( $host_ips['ipv4'] as $ip ) {
if ( false === filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 ) ) {
continue;
}
$ip_result = array(
'ip' => $ip,
'errors' => array(),
'listings' => array(),
);
foreach ( $providers as $provider ) {
$ip_result['listings'][] = $this->query_dnsbl_listing( $ip, $provider );
}
$result['ips'][] = $ip_result;
}
return $result;
}
/**
* Provides the DNSBL providers used for blacklist checks.
*
* @return array>
*/
private function get_dnsbl_providers(): array {
return array(
array(
'id' => 'spamhaus_zen',
'label' => __( 'Spamhaus ZEN', 'robotstxt-smtp' ),
'zone' => 'zen.spamhaus.org',
'impact' => __( 'High — major inbox providers rely on Spamhaus and a listing can block most traffic.', 'robotstxt-smtp' ),
'removal_url' => 'https://check.spamhaus.org/',
'removal_notes' => __( 'Investigate and resolve the root cause, then use the Spamhaus Blocklist Removal Center to request delisting.', 'robotstxt-smtp' ),
),
array(
'id' => 'spamcop',
'label' => __( 'SpamCop', 'robotstxt-smtp' ),
'zone' => 'bl.spamcop.net',
'impact' => __( 'Medium — many shared hosts and spam filters consult SpamCop when scoring messages.', 'robotstxt-smtp' ),
'removal_url' => 'https://www.spamcop.net/w3m?action=checkblock',
'removal_notes' => __( 'Ensure no unsolicited traffic is sent and follow the SpamCop blocklist removal instructions.', 'robotstxt-smtp' ),
),
array(
'id' => 'barracuda',
'label' => __( 'Barracuda Reputation Block List', 'robotstxt-smtp' ),
'zone' => 'b.barracudacentral.org',
'impact' => __( 'Medium — Barracuda appliances and some hosted filters can defer or reject messages.', 'robotstxt-smtp' ),
'removal_url' => 'https://www.barracudacentral.org/lookups/lookup-reputation',
'removal_notes' => __( 'Review the Barracuda reputation lookup and submit a delisting request after addressing any spam complaints.', 'robotstxt-smtp' ),
),
);
}
/**
* Queries a DNSBL provider for a specific IPv4 address.
*
* @param string $ip IPv4 address.
* @param array $provider Provider definition.
*
* @return array
*/
private function query_dnsbl_listing( string $ip, array $provider ): array {
$result = array(
'provider' => $provider,
'listed' => false,
'response_ips' => array(),
'txt_records' => array(),
'error' => '',
);
$reverse = $this->reverse_ipv4_for_dnsbl( $ip );
if ( '' === $reverse ) {
$result['error'] = __( 'The IP address could not be normalized for the DNSBL lookup.', 'robotstxt-smtp' );
return $result;
}
$hostname = $reverse . '.' . $provider['zone'];
$a_records = dns_get_record( $hostname, DNS_A );
if ( false === $a_records ) {
/* translators: %s: DNSBL provider name. */
$result['error'] = sprintf( __( 'The DNS lookup for %s failed. Please try again later.', 'robotstxt-smtp' ), $provider['label'] );
return $result;
}
if ( empty( $a_records ) ) {
return $result;
}
$result['listed'] = true;
foreach ( $a_records as $record ) {
if ( isset( $record['ip'] ) && filter_var( $record['ip'], FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 ) ) {
$result['response_ips'][] = (string) $record['ip'];
}
}
$txt_records = dns_get_record( $hostname, DNS_TXT );
if ( false !== $txt_records && ! empty( $txt_records ) ) {
foreach ( $txt_records as $txt_record ) {
if ( isset( $txt_record['txt'] ) && is_string( $txt_record['txt'] ) ) {
$value = trim( (string) $txt_record['txt'] );
if ( '' !== $value ) {
$result['txt_records'][] = $value;
}
}
}
}
return $result;
}
/**
* Normalizes an IPv4 address for DNSBL queries.
*
* @param string $ip IPv4 address.
*
* @return string
*/
private function reverse_ipv4_for_dnsbl( string $ip ): string {
if ( false === filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 ) ) {
return '';
}
$parts = explode( '.', $ip );
if ( count( $parts ) !== 4 ) {
return '';
}
return implode( '.', array_reverse( $parts ) );
}
/**
* Inspects SPF records for the sender domain.
*
* @param string $domain Sender domain.
* @param string $smtp_host Normalized SMTP host.
* @param array $mx_analysis MX lookup results.
* @param array $host_ips Resolved IP addresses for the SMTP host.
*
* @return array
*/
private function analyze_spf_records( string $domain, string $smtp_host, array $mx_analysis, array $host_ips ): array {
$result = array(
'records' => array(),
'errors' => array(),
'authorized' => null,
'message' => __( 'Unable to determine whether the SMTP host is authorized in the SPF record.', 'robotstxt-smtp' ),
);
if ( ! function_exists( 'dns_get_record' ) ) {
$result['errors'][] = __( 'DNS lookups are not available because the DNS functions are disabled on this server.', 'robotstxt-smtp' );
return $result;
}
$txt_records = dns_get_record( $domain, DNS_TXT );
if ( false === $txt_records ) {
$result['errors'][] = __( 'The DNS lookup for TXT records failed. Please try again later.', 'robotstxt-smtp' );
return $result;
}
foreach ( $txt_records as $record ) {
if ( empty( $record['txt'] ) || ! is_string( $record['txt'] ) ) {
continue;
}
$value = trim( (string) $record['txt'] );
if ( preg_match( '/^v=spf1\b/i', $value ) ) {
$result['records'][] = $value;
}
}
if ( empty( $result['records'] ) ) {
$result['message'] = __( 'The domain does not publish an SPF record.', 'robotstxt-smtp' );
$result['authorized'] = null;
return $result;
}
if ( '' === $smtp_host ) {
$result['message'] = __( 'Configure an SMTP host to verify its presence in the SPF record.', 'robotstxt-smtp' );
$result['authorized'] = null;
return $result;
}
$normalized_host = strtolower( $smtp_host );
$mx_hosts = array();
if ( isset( $mx_analysis['records'] ) && is_array( $mx_analysis['records'] ) ) {
foreach ( $mx_analysis['records'] as $mx_record ) {
if ( isset( $mx_record['normalized_target'] ) && is_string( $mx_record['normalized_target'] ) ) {
$mx_hosts[] = strtolower( $mx_record['normalized_target'] );
}
}
}
$authorized = false;
$authorization_msg = '';
$could_evaluate = false;
foreach ( $result['records'] as $spf_record ) {
$tokens = preg_split( '/\s+/', strtolower( $spf_record ) );
if ( ! is_array( $tokens ) ) {
continue;
}
foreach ( $tokens as $token ) {
if ( '' === $token ) {
continue;
}
$qualifier = '';
if ( in_array( $token[0], array( '+', '-', '~', '?' ), true ) ) {
$qualifier = $token[0];
$token = substr( $token, 1 );
}
if ( '' === $token || '-' === $qualifier ) {
continue;
}
$token = preg_replace( '/\/\d+$/', '', $token );
if ( ! is_string( $token ) || '' === $token || 'v=spf1' === $token ) {
continue;
}
if ( str_starts_with( $token, 'include:' ) || str_starts_with( $token, 'redirect=' ) ) {
$target = str_replace( array( 'include:', 'redirect=' ), '', $token );
if ( $this->hostname_matches_domain( $normalized_host, $target ) ) {
$authorized = true;
/* translators: %s: Included SPF domain. */
$authorization_msg = sprintf( __( 'The SPF record includes %s, which matches the configured SMTP host.', 'robotstxt-smtp' ), $target );
break 2;
}
continue;
}
if ( str_starts_with( $token, 'a:' ) ) {
$target = substr( $token, 2 );
if ( $this->hostname_matches_domain( $normalized_host, $target ) ) {
$authorized = true;
/* translators: %s: Hostname authorized via the SPF "a" mechanism. */
$authorization_msg = sprintf( __( 'The SPF record authorizes %s via the "a" mechanism.', 'robotstxt-smtp' ), $normalized_host );
break 2;
}
continue;
}
if ( 'a' === $token ) {
if ( $this->hostname_matches_domain( $normalized_host, $domain ) ) {
$authorized = true;
/* translators: %s: Hostname authorized via the SPF "a" mechanism. */
$authorization_msg = sprintf( __( 'The SPF record authorizes %s via the "a" mechanism.', 'robotstxt-smtp' ), $normalized_host );
break 2;
}
continue;
}
if ( str_starts_with( $token, 'mx:' ) ) {
$target = substr( $token, 3 );
if ( $this->hostname_matches_domain( $normalized_host, $target ) ) {
$authorized = true;
/* translators: %s: Hostname authorized via the SPF "mx" mechanism. */
$authorization_msg = sprintf( __( 'The SPF record authorizes %s via the "mx" mechanism.', 'robotstxt-smtp' ), $normalized_host );
break 2;
}
continue;
}
if ( 'mx' === $token && in_array( $normalized_host, $mx_hosts, true ) ) {
$authorized = true;
/* translators: %s: Hostname authorized via the SPF "mx" mechanism. */
$authorization_msg = sprintf( __( 'The SPF record authorizes %s via the "mx" mechanism.', 'robotstxt-smtp' ), $normalized_host );
break 2;
}
if ( str_starts_with( $token, 'ip4:' ) ) {
$cidr = substr( $token, 4 );
if ( '' === $cidr || ! is_array( $host_ips['ipv4'] ) ) {
continue;
}
foreach ( $host_ips['ipv4'] as $ip ) {
if ( $this->ipv4_in_cidr( $ip, $cidr ) ) {
$authorized = true;
/* translators: %1$s: IP address. %2$s: SPF mechanism. */
$authorization_msg = sprintf( __( 'The SPF record authorizes the SMTP host IP %1$s via %2$s.', 'robotstxt-smtp' ), $ip, 'ip4' );
break 3;
}
}
$could_evaluate = true;
continue;
}
if ( str_starts_with( $token, 'ip6:' ) ) {
$cidr = substr( $token, 4 );
if ( '' === $cidr || ! is_array( $host_ips['ipv6'] ) ) {
continue;
}
foreach ( $host_ips['ipv6'] as $ip ) {
if ( $this->ipv6_in_cidr( $ip, $cidr ) ) {
$authorized = true;
/* translators: %1$s: IP address. %2$s: SPF mechanism. */
$authorization_msg = sprintf( __( 'The SPF record authorizes the SMTP host IP %1$s via %2$s.', 'robotstxt-smtp' ), $ip, 'ip6' );
break 3;
}
}
$could_evaluate = true;
}
}
}
if ( $authorized ) {
$result['authorized'] = true;
$result['message'] = $authorization_msg;
return $result;
}
if ( ! empty( $host_ips['ipv4'] ) || ! empty( $host_ips['ipv6'] ) ) {
$result['authorized'] = false;
$result['message'] = __( 'The SMTP host was not found in the SPF record. Add it to avoid delivery issues.', 'robotstxt-smtp' );
} elseif ( $could_evaluate ) {
$result['authorized'] = null;
$result['message'] = __( 'The SPF record contains IP ranges, but the SMTP host IP could not be resolved.', 'robotstxt-smtp' );
} else {
$result['authorized'] = null;
$result['message'] = __( 'The SPF record does not explicitly reference the configured SMTP host.', 'robotstxt-smtp' );
}
return $result;
}
/**
* Retrieves IPv4 and IPv6 addresses for the provided hostname.
*
* @param string $hostname Hostname to inspect.
*
* @return array>
*/
private function get_hostname_ip_addresses( string $hostname ): array {
$ips = array(
'ipv4' => array(),
'ipv6' => array(),
);
if ( '' === $hostname || ! function_exists( 'dns_get_record' ) ) {
return $ips;
}
$types = DNS_A;
if ( defined( 'DNS_AAAA' ) ) {
$types |= DNS_AAAA;
}
$candidates = array( $hostname, $hostname . '.' );
foreach ( $candidates as $candidate ) {
$records = dns_get_record( $candidate, $types );
if ( empty( $records ) || false === $records ) {
continue;
}
foreach ( $records as $record ) {
if ( isset( $record['type'], $record['ip'] ) && 'A' === $record['type'] ) {
$ip = (string) $record['ip'];
if ( filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 ) ) {
$ips['ipv4'][ $ip ] = $ip;
}
}
if ( isset( $record['type'], $record['ipv6'] ) && 'AAAA' === $record['type'] ) {
$ip = (string) $record['ipv6'];
if ( filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6 ) ) {
$ips['ipv6'][ $ip ] = $ip;
}
}
}
}
$ips['ipv4'] = array_values( $ips['ipv4'] );
$ips['ipv6'] = array_values( $ips['ipv6'] );
return $ips;
}
/**
* Determines whether the provided hostname belongs to the given domain.
*
* @param string $hostname Hostname to compare.
* @param string $domain Domain to check against.
*
* @return bool
*/
private function hostname_matches_domain( string $hostname, string $domain ): bool {
$hostname = strtolower( rtrim( $hostname, '.' ) );
$domain = strtolower( rtrim( $domain, '.' ) );
if ( '' === $hostname || '' === $domain ) {
return false;
}
if ( $hostname === $domain ) {
return true;
}
return str_ends_with( $hostname, '.' . $domain );
}
/**
* Determines whether an IPv4 address belongs to the provided CIDR.
*
* @param string $ip IPv4 address.
* @param string $cidr CIDR expression.
*
* @return bool
*/
private function ipv4_in_cidr( string $ip, string $cidr ): bool {
if ( false === filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 ) ) {
return false;
}
$parts = explode( '/', $cidr, 2 );
$net = $parts[0];
$bits = isset( $parts[1] ) ? (int) $parts[1] : 32;
if ( false === filter_var( $net, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 ) ) {
return false;
}
if ( $bits < 0 || $bits > 32 ) {
return false;
}
$ip_long = unpack( 'N', inet_pton( $ip ) );
$net_long = unpack( 'N', inet_pton( $net ) );
if ( ! is_array( $ip_long ) || ! is_array( $net_long ) ) {
return false;
}
$mask = 0;
if ( 0 !== $bits ) {
$mask = ( 0xFFFFFFFF << ( 32 - $bits ) ) & 0xFFFFFFFF;
}
return ( ( $ip_long[0] & $mask ) === ( $net_long[0] & $mask ) );
}
/**
* Determines whether an IPv6 address belongs to the provided CIDR.
*
* @param string $ip IPv6 address.
* @param string $cidr CIDR expression.
*
* @return bool
*/
private function ipv6_in_cidr( string $ip, string $cidr ): bool {
if ( false === filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6 ) ) {
return false;
}
$parts = explode( '/', $cidr, 2 );
$net = $parts[0];
$bits = isset( $parts[1] ) ? (int) $parts[1] : 128;
if ( false === filter_var( $net, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6 ) ) {
return false;
}
if ( $bits < 0 || $bits > 128 ) {
return false;
}
$ip_bin = inet_pton( $ip );
$net_bin = inet_pton( $net );
if ( false === $ip_bin || false === $net_bin ) {
return false;
}
$remaining = $bits;
for ( $i = 0; $i < 16; $i++ ) {
if ( $remaining <= 0 ) {
break;
}
$mask = 0xFF;
if ( $remaining < 8 ) {
$mask = ( 0xFF << ( 8 - $remaining ) ) & 0xFF;
}
if ( ( ord( $ip_bin[ $i ] ) & $mask ) !== ( ord( $net_bin[ $i ] ) & $mask ) ) {
return false;
}
$remaining -= 8;
}
return true;
}
/**
* Looks up a DKIM record using the default selector.
*
* @param string $domain Domain to inspect.
*
* @return array
*/
private function analyze_dkim_record( string $domain ): array {
$result = array(
'found' => false,
'errors' => array(),
'record' => '',
);
if ( ! function_exists( 'dns_get_record' ) ) {
$result['errors'][] = __( 'DNS lookups are not available because the DNS functions are disabled on this server.', 'robotstxt-smtp' );
return $result;
}
$selector_domain = 'default._domainkey.' . $domain;
$records = dns_get_record( $selector_domain, DNS_TXT );
if ( false === $records ) {
/* translators: %s: Domain queried for the DKIM record lookup. */
$result['errors'][] = sprintf( __( 'The DNS lookup for %s failed. Please try again later.', 'robotstxt-smtp' ), $selector_domain );
return $result;
}
foreach ( $records as $record ) {
if ( empty( $record['txt'] ) || ! is_string( $record['txt'] ) ) {
continue;
}
$value = trim( (string) $record['txt'] );
if ( preg_match( '/^v=DKIM1/i', $value ) ) {
$result['found'] = true;
$result['record'] = $value;
break;
}
}
return $result;
}
/**
* Looks up the DMARC policy for the sender domain.
*
* @param string $domain Domain to inspect.
*
* @return array
*/
private function analyze_dmarc_record( string $domain ): array {
$result = array(
'record' => '',
'policy' => '',
'errors' => array(),
'recommendations' => array(),
);
if ( ! function_exists( 'dns_get_record' ) ) {
$result['errors'][] = __( 'DNS lookups are not available because the DNS functions are disabled on this server.', 'robotstxt-smtp' );
return $result;
}
$dmarc_domain = '_dmarc.' . $domain;
$records = dns_get_record( $dmarc_domain, DNS_TXT );
if ( false === $records ) {
/* translators: %s: Domain queried for the DMARC record lookup. */
$result['errors'][] = sprintf( __( 'The DNS lookup for %s failed. Please try again later.', 'robotstxt-smtp' ), $dmarc_domain );
return $result;
}
foreach ( $records as $record ) {
if ( empty( $record['txt'] ) || ! is_string( $record['txt'] ) ) {
continue;
}
$value = trim( (string) $record['txt'] );
if ( preg_match( '/^v=DMARC1/i', $value ) ) {
$result['record'] = $value;
if ( preg_match( '/\bp=([a-z]+)/i', $value, $matches ) ) {
$result['policy'] = strtolower( $matches[1] );
}
break;
}
}
if ( '' === $result['record'] ) {
return $result;
}
if ( 'none' === $result['policy'] ) {
$result['recommendations'][] = __( 'The DMARC policy is set to "p=none". Increase it to "p=quarantine" or "p=reject" to protect the domain.', 'robotstxt-smtp' );
} elseif ( 'quarantine' === $result['policy'] ) {
$result['recommendations'][] = __( 'Consider moving the DMARC policy to "p=reject" once you have validated that legitimate messages pass authentication.', 'robotstxt-smtp' );
}
return $result;
}
/**
* Performs an MX lookup for the provided domain and compares the results with the SMTP host.
*
* @param string $domain Domain to inspect.
* @param string $smtp_host Normalized SMTP host.
*
* @return array
*/
private function analyze_mx_records( string $domain, string $smtp_host ): array {
$result = array(
'domain' => $domain,
'records' => array(),
'errors' => array(),
'matches_smtp_host' => false,
);
if ( '' === $domain ) {
return $result;
}
if ( ! function_exists( 'dns_get_record' ) ) {
$result['errors'][] = __( 'MX lookups are not available because the DNS functions are disabled on this server.', 'robotstxt-smtp' );
return $result;
}
$records = dns_get_record( $domain, DNS_MX );
if ( false === $records ) {
$result['errors'][] = __( 'The DNS lookup for MX records failed. Please try again later.', 'robotstxt-smtp' );
return $result;
}
foreach ( $records as $record ) {
if ( empty( $record['target'] ) ) {
continue;
}
$priority = isset( $record['pri'] ) ? (int) $record['pri'] : null;
$display_target = rtrim( (string) $record['target'], '.' );
$normalized = $this->normalize_hostname( $display_target );
$resolves = $this->hostname_resolves( $normalized );
$matches_host = '' !== $smtp_host && $normalized === $smtp_host;
$result['records'][] = array(
'priority' => $priority,
'display_target' => $display_target,
'normalized_target' => $normalized,
'resolves' => $resolves,
'matches_host' => $matches_host,
);
if ( $matches_host ) {
$result['matches_smtp_host'] = true;
}
}
usort(
$result['records'],
static function ( array $a, array $b ): int {
$priority_a = $a['priority'] ?? PHP_INT_MAX;
$priority_b = $b['priority'] ?? PHP_INT_MAX;
if ( $priority_a === $priority_b ) {
return strcmp( $a['display_target'], $b['display_target'] );
}
return $priority_a <=> $priority_b;
}
);
return $result;
}
/**
* Determines whether a hostname resolves to an address record.
*
* @param string $hostname Hostname to inspect.
*
* @return bool|null True when it resolves, false when it does not, null when unknown.
*/
private function hostname_resolves( string $hostname ): ?bool {
if ( '' === $hostname ) {
return null;
}
$candidates = array( $hostname, $hostname . '.' );
if ( function_exists( 'checkdnsrr' ) ) {
foreach ( $candidates as $candidate ) {
if ( checkdnsrr( $candidate, 'A' ) || checkdnsrr( $candidate, 'AAAA' ) ) {
return true;
}
}
}
if ( function_exists( 'dns_get_record' ) ) {
$types = DNS_A;
if ( defined( 'DNS_AAAA' ) ) {
$types |= DNS_AAAA;
}
$had_error = false;
foreach ( $candidates as $candidate ) {
$records = dns_get_record( $candidate, $types );
if ( false === $records ) {
$had_error = true;
continue;
}
if ( ! empty( $records ) ) {
return true;
}
}
if ( $had_error ) {
return null;
}
return false;
}
return null;
}
/**
* Provides the default settings.
*
* @return array Default settings.
*/
public static function get_default_settings(): array {
return array(
'host' => '',
'username' => '',
'password' => '',
'from_email' => '',
'from_name' => get_bloginfo( 'name', 'display' ),
'security' => 'none',
'port' => 25,
'amazon_ses_access_key' => '',
'amazon_ses_secret_key' => '',
'amazon_ses_region' => '',
'logs_enabled' => false,
'logs_retention_mode' => 'count',
'logs_retention_count' => 1024,
'logs_retention_days' => 28,
);
}
/**
* Returns the available security types.
*
* @return array Security type labels keyed by value.
*/
private function get_security_options(): array {
return array(
'none' => esc_html__( 'None', 'robotstxt-smtp' ),
'ssl' => esc_html__( 'SSL', 'robotstxt-smtp' ),
'tls' => esc_html__( 'TLS', 'robotstxt-smtp' ),
);
}
/**
* Handles the submission of the test email form.
*
* @return void
*/
public function handle_test_email(): void {
$nonce_value = filter_input( INPUT_POST, 'robotstxt_smtp_test_email_nonce', FILTER_UNSAFE_RAW );
// Halt the request when the nonce is missing or invalid to stop forged test submissions.
if ( ! is_string( $nonce_value ) || ! wp_verify_nonce( wp_unslash( $nonce_value ), 'robotstxt_smtp_test_email' ) ) {
wp_die( esc_html__( 'The link you followed has expired.', 'robotstxt-smtp' ) );
}
$scope = self::SCOPE_SITE;
$context_raw = filter_input( INPUT_POST, 'robotstxt_smtp_context', FILTER_UNSAFE_RAW );
if ( is_string( $context_raw ) && '' !== $context_raw ) {
// Sanitize the submitted scope before using it to determine capabilities.
$context = sanitize_text_field( wp_unslash( $context_raw ) );
if ( self::SCOPE_NETWORK === $context ) {
$scope = self::SCOPE_NETWORK;
}
}
$this->ensure_capability( $scope );
$this->set_scope( $scope );
$recipient = '';
$recipient_raw = filter_input( INPUT_POST, 'robotstxt_smtp_test_email', FILTER_UNSAFE_RAW );
if ( is_string( $recipient_raw ) && '' !== $recipient_raw ) {
// Sanitize the recipient email address before attempting to send mail.
$recipient = sanitize_email( wp_unslash( $recipient_raw ) );
}
$result = $this->send_test_email_for_scope( $scope, $recipient, 'manual' );
$this->persist_test_result( $result );
$this->redirect_after_test( ! empty( $result['success'] ) ? 'success' : 'error', (string) $result['recipient'], $scope );
}
/**
* Handles the updated_option hook for the site-level configuration.
*
* @param string $option Option name that was updated.
* @param mixed $old_value Previous option value.
* @param mixed $value New option value.
*
* @return void
*/
public function handle_settings_option_updated( string $option, $old_value, $value ): void {
unset( $old_value, $value );
if ( self::OPTION_NAME !== $option ) {
return;
}
if ( ! is_admin() ) {
return;
}
$nonce = filter_input( INPUT_POST, '_wpnonce', FILTER_UNSAFE_RAW );
if ( ! is_string( $nonce ) ) {
return;
}
$nonce = sanitize_text_field( wp_unslash( $nonce ) );
if ( ! wp_verify_nonce( $nonce, self::SITE_SETTINGS_GROUP . '-options' ) ) {
return;
}
$option_page = filter_input( INPUT_POST, 'option_page', FILTER_UNSAFE_RAW );
if ( ! is_string( $option_page ) ) {
return;
}
$option_page = sanitize_key( wp_unslash( $option_page ) );
if ( self::SITE_SETTINGS_GROUP !== $option_page ) {
return;
}
if ( ! current_user_can( self::CAPABILITY ) ) {
return;
}
$this->cached_settings = null;
$this->site_settings_option_updated = true;
$result = $this->send_test_email_for_scope( self::SCOPE_SITE, null, 'auto' );
$this->persist_test_result( $result );
}
/**
* Sends the automatic test email when the site settings were submitted but
* the stored option value did not change.
*
* @return void
*/
public function maybe_send_pending_site_test(): void {
if ( ! $this->site_settings_submitted || $this->site_settings_option_updated ) {
return;
}
if ( ! is_admin() ) {
return;
}
$this->cached_settings = null;
$result = $this->send_test_email_for_scope( self::SCOPE_SITE, null, 'auto' );
$this->persist_test_result( $result );
$this->site_settings_submitted = false;
$this->site_settings_option_updated = false;
}
/**
* Sends a test email using the stored configuration for the provided scope.
*
* @param string $scope Scope identifier (site or network).
* @param string|null $recipient Optional recipient email address.
* @param string $context Context identifier (manual|auto).
*
* @return array Result data for the attempted email.
*/
private function send_test_email_for_scope( string $scope, ?string $recipient = null, string $context = 'manual' ): array {
$this->ensure_capability( $scope );
$this->set_scope( $scope );
$resolved_recipient = $this->resolve_test_recipient( $recipient );
$result = array(
'success' => false,
'recipient' => $resolved_recipient,
'timestamp' => current_time( 'mysql' ),
'settings' => $this->get_settings_for_display(),
'debug_log' => array(),
'mail_data' => array(),
'error' => '',
'error_data' => null,
'context' => $context,
);
if ( empty( $resolved_recipient ) ) {
$result['error'] = __( 'A valid recipient email address is required.', 'robotstxt-smtp' );
return $result;
}
$debug_log = array();
$failure = null;
$debug_action = static function ( PHPMailer $phpmailer ) use ( &$debug_log ): void {
// phpcs:disable WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
$phpmailer->SMTPDebug = 2;
$phpmailer->Debugoutput = static function ( string $message, int $level ) use ( &$debug_log ): void {
$debug_log[] = '[' . $level . '] ' . $message;
};
// phpcs:enable WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
};
$failure_filter = static function ( WP_Error $error ) use ( &$failure ) {
$failure = $error;
return $error;
};
add_action( 'phpmailer_init', $debug_action, 20 );
add_filter( 'wp_mail_failed', $failure_filter );
$subject = __( 'ROBOTSTXT SMTP Test Email', 'robotstxt-smtp' );
$body = sprintf(
/* translators: %s: Site name. */
__( 'This is a test email sent from %s using the ROBOTSTXT SMTP settings.', 'robotstxt-smtp' ),
get_bloginfo( 'name', 'display' )
);
$headers = array( 'Content-Type: text/plain; charset=UTF-8' );
$sent = wp_mail( $resolved_recipient, $subject, $body, $headers );
remove_action( 'phpmailer_init', $debug_action, 20 );
remove_filter( 'wp_mail_failed', $failure_filter );
if ( is_wp_error( $sent ) ) {
$result['success'] = false;
if ( empty( $result['error'] ) ) {
$result['error'] = $sent->get_error_message();
}
if ( null === $result['error_data'] ) {
$result['error_data'] = $sent->get_error_data();
}
} else {
$result['success'] = (bool) $sent;
}
$result['debug_log'] = $debug_log;
$result['mail_data'] = array(
'subject' => $subject,
'body' => $body,
'headers' => $headers,
);
if ( $failure instanceof WP_Error ) {
$result['error'] = $failure->get_error_message();
$result['error_data'] = $failure->get_error_data();
}
if ( ! $result['success'] && empty( $result['error'] ) ) {
$result['error'] = __( 'The test email could not be sent.', 'robotstxt-smtp' );
}
return $result;
}
/**
* Determines the recipient address to use for a test email.
*
* @param string|null $recipient Recipient provided by the user.
*
* @return string Sanitized recipient email address or an empty string when unavailable.
*/
private function resolve_test_recipient( ?string $recipient ): string {
if ( ! empty( $recipient ) ) {
$recipient = sanitize_email( $recipient );
if ( ! empty( $recipient ) ) {
return $recipient;
}
}
$current_user = wp_get_current_user();
if ( $current_user instanceof WP_User && ! empty( $current_user->user_email ) ) {
$candidate = sanitize_email( $current_user->user_email );
if ( ! empty( $candidate ) ) {
return $candidate;
}
}
return sanitize_email( get_bloginfo( 'admin_email' ) );
}
/**
* Displays the admin notice after sending a test email.
*
* @return void
*/
public function display_test_email_notice(): void {
if ( ! is_admin() ) {
return;
}
$page = filter_input( INPUT_GET, 'page', FILTER_UNSAFE_RAW );
if ( null === $page ) {
return;
}
$page = sanitize_key( $page );
if ( '' === $page || ! in_array( $page, array( self::TEST_PAGE_SLUG, self::PAGE_SLUG, self::NETWORK_PAGE_SLUG ), true ) ) {
return;
}
$result = get_transient( $this->get_test_result_transient_key() );
if ( false === $result ) {
return;
}
delete_transient( $this->get_test_result_transient_key() );
$class = ! empty( $result['success'] ) ? 'notice notice-success' : 'notice notice-error';
$context = isset( $result['context'] ) ? (string) $result['context'] : 'manual';
if ( 'auto' === $context ) {
$status = ! empty( $result['success'] )
? __( 'The SMTP configuration appears to be working correctly.', 'robotstxt-smtp' )
: __( 'The SMTP configuration could not be verified.', 'robotstxt-smtp' );
} else {
$status = ! empty( $result['success'] ) ? __( 'Test email sent successfully.', 'robotstxt-smtp' ) : __( 'Test email failed to send.', 'robotstxt-smtp' );
}
?>
user_email ) ) {
$recipient = $current_user->user_email;
}
}
if ( empty( $recipient ) ) {
$recipient = sanitize_email( get_bloginfo( 'admin_email' ) );
}
?>
*/
private function get_settings_for_display(): array {
$settings = $this->get_settings();
if ( ! empty( $settings['password'] ) ) {
$settings['password'] = str_repeat( '\u2022', 8 );
}
return $settings;
}
/**
* Persists the test result for later display.
*
* @param array $result Result data.
*
* @return void
*/
private function persist_test_result( array $result ): void {
set_transient( $this->get_test_result_transient_key(), $result, MINUTE_IN_SECONDS * 10 );
}
/**
* Builds the transient key for the current user.
*
* @return string
*/
private function get_test_result_transient_key(): string {
return 'robotstxt_smtp_test_result_' . get_current_user_id();
}
/**
* Redirects the user back to the settings page after sending the test email.
*
* @param string $status The status string (success|error).
* @param string $recipient The recipient email address.
* @param string $scope Scope identifier (site or network).
*
* @return void
*/
private function redirect_after_test( string $status, string $recipient, string $scope ): void {
$redirect = add_query_arg(
array(
'page' => self::TEST_PAGE_SLUG,
'robotstxt_smtp_test' => $status,
'robotstxt_smtp_recipient' => $recipient,
'robotstxt_smtp_result_nonce' => wp_create_nonce( 'robotstxt_smtp_test_result' ),
),
$this->get_admin_url_for_scope( $scope, 'admin.php' )
);
wp_safe_redirect( $redirect );
exit;
}
}