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