This commit is contained in:
Javier Casares 2025-11-26 08:24:18 +00:00
commit c98dcb7b50
9 changed files with 5952 additions and 0 deletions

664
includes/class-plugin.php Normal file
View file

@ -0,0 +1,664 @@
<?php
/**
* Main plugin bootstrap file.
*
* @package Robotstxt_SMTP
*/
namespace Robotstxt_SMTP;
use PHPMailer\PHPMailer\PHPMailer;
use PHPMailer\PHPMailer\SMTP;
use Robotstxt_SMTP\Admin\Settings_Page;
use WP_Error;
/**
* Main plugin class.
*/
class Plugin {
/**
* Custom post type used to store email logs.
*/
private const LOG_POST_TYPE = 'robotstxt_smtp_log';
/**
* Cron hook used to trigger the log cleanup.
*/
private const CRON_HOOK = 'robotstxt_smtp_cleanup_logs';
/**
* Holds the class instance.
*
* @var Plugin|null
*/
private static ?Plugin $instance = null;
/**
* Settings page handler.
*
* @var Settings_Page|null
*/
private ?Settings_Page $settings_page = null;
/**
* Captures the SMTP debug output for the current email.
*
* @var array<int, string>
*/
private array $current_debug_log = array();
/**
* Retrieves the plugin instance.
*
* @return Plugin Plugin instance.
*/
public static function get_instance(): Plugin {
if ( null === self::$instance ) {
self::$instance = new self();
}
return self::$instance;
}
/**
* Determines whether the Amazon SES integration plugin is active.
*
* @return bool
*/
public static function is_amazon_ses_integration_active(): bool {
/**
* Filters whether the Amazon SES integration should be considered active.
*
* @since 1.0.1
*
* @param bool $is_active True when the Amazon SES add-on is active.
*/
return (bool) \apply_filters( 'robotstxt_smtp_amazon_ses_active', false );
}
/**
* Sets up the plugin.
*
* @return void
*/
public function run(): void {
add_action( 'phpmailer_init', array( $this, 'configure_phpmailer' ) );
add_filter( 'wp_mail_from', array( $this, 'filter_mail_from' ) );
add_filter( 'wp_mail_from_name', array( $this, 'filter_mail_from_name' ) );
add_action( 'init', array( $this, 'register_log_post_type' ) );
add_action( 'init', array( $this, 'maybe_schedule_cleanup' ) );
add_action( 'wp_mail_succeeded', array( $this, 'handle_wp_mail_succeeded' ) );
add_action( 'wp_mail_failed', array( $this, 'handle_wp_mail_failed' ) );
add_action( self::CRON_HOOK, array( $this, 'cleanup_logs' ) );
if ( is_admin() ) {
$this->register_admin();
}
}
/**
* Registers the admin functionality.
*
* @return void
*/
private function register_admin(): void {
if ( null === $this->settings_page ) {
$this->settings_page = new Settings_Page();
}
$this->settings_page->register_hooks();
}
/**
* Registers the custom post type used to store email logs.
*
* @return void
*/
public function register_log_post_type(): void {
register_post_type(
self::LOG_POST_TYPE,
array(
'labels' => array(
'name' => esc_html__( 'SMTP Logs', 'robotstxt-smtp' ),
'singular_name' => esc_html__( 'SMTP Log', 'robotstxt-smtp' ),
),
'public' => false,
'show_ui' => false,
'show_in_menu' => false,
'show_in_nav_menus' => false,
'exclude_from_search' => true,
'publicly_queryable' => false,
'supports' => array( 'title', 'editor' ),
'capability_type' => 'post',
'map_meta_cap' => true,
)
);
}
/**
* Schedules or clears the automatic log cleanup task based on settings.
*
* @return void
*/
public function maybe_schedule_cleanup(): void {
$logging = $this->get_logging_settings();
if ( ! $logging['enabled'] ) {
wp_clear_scheduled_hook( self::CRON_HOOK );
return;
}
if ( false === wp_next_scheduled( self::CRON_HOOK ) ) {
wp_schedule_event( time() + HOUR_IN_SECONDS, 'daily', self::CRON_HOOK );
}
}
/**
* Filters the "From" email address used by WordPress.
*
* @param string $original_email Original email address provided by WordPress.
*
* @return string
*/
public function filter_mail_from( string $original_email ): string {
$settings = $this->get_mailer_settings();
$from_email = isset( $settings['from_email'] ) ? sanitize_email( $settings['from_email'] ) : '';
if ( empty( $from_email ) ) {
return sanitize_email( $original_email );
}
return $from_email;
}
/**
* Filters the "From" name used by WordPress.
*
* @param string $original_name Original name provided by WordPress.
*
* @return string
*/
public function filter_mail_from_name( string $original_name ): string {
$settings = $this->get_mailer_settings();
$from_name = isset( $settings['from_name'] ) ? sanitize_text_field( $settings['from_name'] ) : '';
if ( '' === $from_name ) {
return sanitize_text_field( $original_name );
}
return $from_name;
}
/**
* Retrieves the stored mailer settings merged with defaults.
*
* @return array<string, mixed>
*/
private function get_mailer_settings(): array {
$defaults = Settings_Page::get_default_settings();
if ( Settings_Page::is_network_mode_enabled() ) {
$settings = get_site_option( Settings_Page::NETWORK_OPTION_NAME, array() );
} else {
$settings = get_option( Settings_Page::OPTION_NAME, array() );
}
if ( ! is_array( $settings ) ) {
$settings = array();
}
$settings = wp_parse_args( $settings, $defaults );
/**
* Filters the SMTP settings before they are consumed by the mailer.
*
* @since 1.1.1
*
* @param array<string, mixed> $settings Sanitized settings merged with defaults.
*/
return (array) \apply_filters( 'robotstxt_smtp_mailer_settings', $settings );
}
/**
* Configures PHPMailer to use the stored SMTP settings.
*
* @param PHPMailer $phpmailer Mailer instance.
*
* @return void
*/
public function configure_phpmailer( PHPMailer $phpmailer ): void {
if ( self::is_amazon_ses_integration_active() ) {
return;
}
$this->current_debug_log = array();
$settings = $this->get_mailer_settings();
if ( empty( $settings['host'] ) ) {
return;
}
$phpmailer->isSMTP();
// phpcs:disable WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
$phpmailer->Host = $settings['host'];
$phpmailer->Port = (int) $settings['port'];
$phpmailer->SMTPAuth = ! empty( $settings['username'] ) || ! empty( $settings['password'] );
$phpmailer->Username = $phpmailer->SMTPAuth ? $settings['username'] : '';
$phpmailer->Password = $phpmailer->SMTPAuth ? $settings['password'] : '';
$phpmailer->SMTPAutoTLS = false;
$phpmailer->SMTPSecure = '';
// phpcs:enable WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
if ( 'ssl' === $settings['security'] ) {
// phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
$phpmailer->SMTPSecure = PHPMailer::ENCRYPTION_SMTPS;
} elseif ( 'tls' === $settings['security'] ) {
// phpcs:disable WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
$phpmailer->SMTPSecure = PHPMailer::ENCRYPTION_STARTTLS;
$phpmailer->SMTPAutoTLS = true;
// phpcs:enable WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
}
$this->register_debug_logger( $phpmailer );
}
/**
* Enables SMTP debug logging for the current email.
*
* @param PHPMailer $phpmailer Mailer instance.
*
* @return void
*/
private function register_debug_logger( PHPMailer $phpmailer ): void {
$logging = $this->get_logging_settings();
if ( ! $logging['enabled'] ) {
return;
}
// phpcs:disable WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
$phpmailer->SMTPDebug = SMTP::DEBUG_SERVER;
$phpmailer->Debugoutput = function ( string $message, int $level ): void {
$this->current_debug_log[] = '[' . $level . '] ' . $message;
};
// phpcs:enable WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
}
/**
* Stores the email data when WordPress reports a successful delivery.
*
* @param array<string, mixed> $mail_data Email data provided by wp_mail.
*
* @return void
*/
public function handle_wp_mail_succeeded( array $mail_data ): void {
$logging = $this->get_logging_settings();
if ( ! $logging['enabled'] ) {
return;
}
$settings = $this->get_mailer_settings();
$subject = isset( $mail_data['subject'] ) ? sanitize_text_field( (string) $mail_data['subject'] ) : '';
$message = isset( $mail_data['message'] ) ? (string) $mail_data['message'] : '';
$headers = $this->normalize_headers( $mail_data['headers'] ?? array() );
$to = $this->normalize_recipients( $mail_data['to'] ?? array() );
$from = $this->determine_from_header( $headers, $settings );
$post_id = wp_insert_post(
array(
'post_type' => self::LOG_POST_TYPE,
'post_status' => 'publish',
'post_title' => $subject,
'post_content' => $message,
'post_date' => current_time( 'mysql' ),
),
true
);
if ( is_wp_error( $post_id ) || 0 === $post_id ) {
return;
}
update_post_meta( $post_id, '_robotstxt_smtp_to', implode( ', ', $to ) );
update_post_meta( $post_id, '_robotstxt_smtp_from', $from );
update_post_meta( $post_id, '_robotstxt_smtp_headers', implode( "\n", $headers ) );
update_post_meta( $post_id, '_robotstxt_smtp_status', 'sent' );
update_post_meta( $post_id, '_robotstxt_smtp_error', '' );
if ( isset( $mail_data['attachments'] ) ) {
update_post_meta( $post_id, '_robotstxt_smtp_attachments', maybe_serialize( $mail_data['attachments'] ) );
}
$debug_log = $this->get_sanitized_debug_log();
if ( $debug_log ) {
update_post_meta( $post_id, '_robotstxt_smtp_debug_log', maybe_serialize( $debug_log ) );
}
if ( 'count' === $logging['mode'] ) {
$this->cleanup_logs_by_count( $logging['count'] );
return;
}
$this->cleanup_logs_by_days( $logging['days'] );
}
/**
* Stores the email data when WordPress reports a failed delivery.
*
* @param WP_Error $error Error object reported by wp_mail.
*
* @return void
*/
public function handle_wp_mail_failed( WP_Error $error ): void {
$logging = $this->get_logging_settings();
if ( ! $logging['enabled'] ) {
return;
}
$settings = $this->get_mailer_settings();
$data = $error->get_error_data();
if ( ! is_array( $data ) ) {
$data = array();
}
$subject = isset( $data['subject'] ) ? sanitize_text_field( (string) $data['subject'] ) : '';
$message = isset( $data['message'] ) ? (string) $data['message'] : '';
$headers = $this->normalize_headers( $data['headers'] ?? array() );
$to = $this->normalize_recipients( $data['to'] ?? array() );
$from = $this->determine_from_header( $headers, $settings );
$error_msg = sanitize_text_field( $error->get_error_message() );
$post_id = wp_insert_post(
array(
'post_type' => self::LOG_POST_TYPE,
'post_status' => 'publish',
'post_title' => $subject,
'post_content' => $message,
'post_date' => current_time( 'mysql' ),
),
true
);
if ( is_wp_error( $post_id ) || 0 === $post_id ) {
return;
}
update_post_meta( $post_id, '_robotstxt_smtp_to', implode( ', ', $to ) );
update_post_meta( $post_id, '_robotstxt_smtp_from', $from );
update_post_meta( $post_id, '_robotstxt_smtp_headers', implode( "\n", $headers ) );
update_post_meta( $post_id, '_robotstxt_smtp_status', 'error' );
update_post_meta( $post_id, '_robotstxt_smtp_error', $error_msg );
if ( isset( $data['attachments'] ) ) {
update_post_meta( $post_id, '_robotstxt_smtp_attachments', maybe_serialize( $data['attachments'] ) );
}
$debug_log = $this->get_sanitized_debug_log();
if ( $debug_log ) {
update_post_meta( $post_id, '_robotstxt_smtp_debug_log', maybe_serialize( $debug_log ) );
}
if ( 'count' === $logging['mode'] ) {
$this->cleanup_logs_by_count( $logging['count'] );
return;
}
$this->cleanup_logs_by_days( $logging['days'] );
}
/**
* Retrieves the sanitized SMTP debug output captured for the current email.
*
* @return array<int, string>
*/
private function get_sanitized_debug_log(): array {
if ( empty( $this->current_debug_log ) ) {
return array();
}
$sanitized = array_map(
static function ( $entry ): string {
return sanitize_textarea_field( (string) $entry );
},
$this->current_debug_log
);
$sanitized = array_filter(
$sanitized,
static function ( string $line ): bool {
return '' !== trim( $line );
}
);
return array_values( $sanitized );
}
/**
* Performs log cleanup when triggered by cron.
*
* @return void
*/
public function cleanup_logs(): void {
$logging = $this->get_logging_settings();
if ( ! $logging['enabled'] ) {
return;
}
if ( 'count' === $logging['mode'] ) {
$this->cleanup_logs_by_count( $logging['count'] );
return;
}
$this->cleanup_logs_by_days( $logging['days'] );
}
/**
* Deletes all stored logs.
*
* @return void
*/
public function clear_all_logs(): void {
do {
$posts = get_posts(
array(
'post_type' => self::LOG_POST_TYPE,
'post_status' => 'any',
'fields' => 'ids',
'posts_per_page' => 100,
'orderby' => 'ID',
'order' => 'ASC',
)
);
foreach ( $posts as $post_id ) {
wp_delete_post( (int) $post_id, true );
}
} while ( ! empty( $posts ) );
}
/**
* Cleans up logs by enforcing a maximum number of stored records.
*
* @param int $limit Maximum number of logs to keep.
*
* @return void
*/
private function cleanup_logs_by_count( int $limit ): void {
if ( $limit <= 0 ) {
$this->clear_all_logs();
return;
}
$logs_to_delete = get_posts(
array(
'post_type' => self::LOG_POST_TYPE,
'post_status' => 'any',
'fields' => 'ids',
'orderby' => 'date',
'order' => 'DESC',
'posts_per_page' => -1,
'offset' => $limit,
'no_found_rows' => true,
)
);
foreach ( $logs_to_delete as $post_id ) {
wp_delete_post( (int) $post_id, true );
}
}
/**
* Cleans up logs based on the maximum number of days to retain them.
*
* @param int $days Number of days to keep.
*
* @return void
*/
private function cleanup_logs_by_days( int $days ): void {
if ( $days <= 0 ) {
$this->clear_all_logs();
return;
}
$cutoff = time() - ( $days * DAY_IN_SECONDS );
$logs_to_delete = get_posts(
array(
'post_type' => self::LOG_POST_TYPE,
'post_status' => 'any',
'fields' => 'ids',
'date_query' => array(
array(
'column' => 'post_date_gmt',
'before' => gmdate( 'Y-m-d H:i:s', $cutoff ),
),
),
'posts_per_page' => -1,
'no_found_rows' => true,
)
);
foreach ( $logs_to_delete as $post_id ) {
wp_delete_post( (int) $post_id, true );
}
}
/**
* Normalizes the headers array into a list of strings.
*
* @param mixed $headers Headers provided by wp_mail.
*
* @return array<int, string>
*/
private function normalize_headers( $headers ): array {
if ( empty( $headers ) ) {
return array();
}
if ( is_string( $headers ) ) {
$headers = preg_split( "/\r\n|\r|\n/", $headers );
}
if ( ! is_array( $headers ) ) {
return array();
}
$normalized = array();
foreach ( $headers as $header ) {
$line = trim( (string) $header );
if ( '' !== $line ) {
$normalized[] = $line;
}
}
return $normalized;
}
/**
* Normalizes the recipient list into an array of strings.
*
* @param mixed $recipients Recipient information from wp_mail.
*
* @return array<int, string>
*/
private function normalize_recipients( $recipients ): array {
if ( empty( $recipients ) ) {
return array();
}
if ( is_string( $recipients ) ) {
$recipients = explode( ',', $recipients );
}
if ( ! is_array( $recipients ) ) {
return array();
}
$normalized = array();
foreach ( $recipients as $recipient ) {
$normalized[] = sanitize_text_field( trim( (string) $recipient ) );
}
return array_filter( $normalized );
}
/**
* Determines the "From" header for the logged message.
*
* @param array<int, string> $headers Normalized header list.
* @param array<string, mixed> $settings Plugin settings.
*
* @return string
*/
private function determine_from_header( array $headers, array $settings ): string {
foreach ( $headers as $header ) {
if ( 0 === stripos( $header, 'from:' ) ) {
return sanitize_text_field( trim( substr( $header, 5 ) ) );
}
}
$from_email = isset( $settings['from_email'] ) ? sanitize_email( $settings['from_email'] ) : '';
$from_name = isset( $settings['from_name'] ) ? sanitize_text_field( $settings['from_name'] ) : '';
if ( $from_email && $from_name ) {
return $from_name . ' <' . $from_email . '>';
}
if ( $from_email ) {
return $from_email;
}
return get_bloginfo( 'name', 'display' );
}
/**
* Retrieves the logging-related settings from the option store.
*
* @return array<string, mixed>
*/
private function get_logging_settings(): array {
$settings = $this->get_mailer_settings();
return array(
'enabled' => ! empty( $settings['logs_enabled'] ),
'mode' => in_array( $settings['logs_retention_mode'], array( 'count', 'days' ), true )
? $settings['logs_retention_mode']
: 'count',
'count' => isset( $settings['logs_retention_count'] ) ? (int) $settings['logs_retention_count'] : 0,
'days' => isset( $settings['logs_retention_days'] ) ? (int) $settings['logs_retention_days'] : 0,
);
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,107 @@
<?php
/**
* SMTP diagnostics client.
*
* @package Robotstxt_SMTP
*/
namespace Robotstxt_SMTP\Admin;
use PHPMailer\PHPMailer\SMTP;
/**
* SMTP client wrapper used for diagnostics.
*/
class SMTP_Diagnostics_Client extends SMTP {
/**
* Tracks whether the most recent command timed out.
*
* @var bool
*/
private bool $last_command_timed_out = false;
/**
* Executes an SMTP command and returns the raw reply.
*
* @param string $name Command name.
* @param string $command_string Full command string.
* @param array<int,int> $expected_codes Expected success codes.
*
* @return array<string, mixed>
*/
public function execute_command( string $name, string $command_string, array $expected_codes = array( 250 ) ): array {
$this->last_command_timed_out = false;
$this->reset_error_state();
$expected = ! empty( $expected_codes ) ? array_map( 'intval', $expected_codes ) : array( 250 );
$success = parent::sendCommand( $name, $command_string, $expected );
if ( ! $success ) {
$this->last_command_timed_out = $this->error_indicates_timeout( parent::getError() );
}
return array(
'success' => $success,
'reply' => $this->getLastReply(),
'timed_out' => $this->last_command_timed_out,
);
}
/**
* Indicates whether the last command timed out.
*
* @return bool
*/
public function did_last_command_timeout(): bool {
return $this->last_command_timed_out;
}
/**
* Initiates STARTTLS while tracking timeout state.
*
* @return bool
*/
public function startTLS(): bool {
$this->last_command_timed_out = false;
$this->reset_error_state();
$result = parent::startTLS();
if ( ! $result ) {
$this->last_command_timed_out = $this->error_indicates_timeout( parent::getError() );
}
return $result;
}
/**
* Clears the stored error state.
*/
private function reset_error_state(): void {
parent::setError( '' );
}
/**
* Determines whether an error array represents a timeout.
*
* @param array<string, mixed> $error Error details.
*
* @return bool
*/
private function error_indicates_timeout( array $error ): bool {
foreach ( array( 'error', 'detail', 'smtp_code_ex' ) as $key ) {
if ( empty( $error[ $key ] ) ) {
continue;
}
$value = (string) $error[ $key ];
if ( false !== stripos( $value, 'timed out' ) || false !== stripos( $value, 'timed-out' ) ) {
return true;
}
}
return false;
}
}