4729 lines
154 KiB
PHP
4729 lines
154 KiB
PHP
<?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 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' );
|
||
|
||
?>
|
||
<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'] ? '—' : 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 '—';
|
||
} 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' => __( '«', 'robotstxt-smtp' ),
|
||
'next_text' => __( '»', '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">← <?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;
|
||
}
|
||
}
|