File "ErrorStats.php"

Full Path: /home/ccipcixf/public_html/beta/wp-content/plugins/wp-mail-smtp/src/UsageTracking/ErrorStats.php
File size: 8.63 KB
MIME-type: text/x-php
Charset: utf-8

<?php
/**
 * Error statistics tracking.
 *
 * Handles tracking of email sending errors for usage stats.
 *
 * @since 4.8.0
 */

namespace WPMailSMTP\UsageTracking;

use WPMailSMTP\Helpers\Helpers;
use WPMailSMTP\MailCatcherInterface;
use WPMailSMTP\Options;

/**
 * Class ErrorStats.
 *
 * Tracks email sending errors in memory during a request,
 * then flushes to the database on shutdown if any errors were recorded.
 *
 * @since 4.8.0
 */
class ErrorStats {

	/**
	 * Option name to store error stats.
	 *
	 * @since 4.8.0
	 *
	 * @var string
	 */
	const OPTION_NAME = 'wp_mail_smtp_email_sending_errors_stat';

	/**
	 * Max length for error code string.
	 *
	 * @since 4.8.0
	 *
	 * @var int
	 */
	const MAX_ERROR_CODE_LENGTH = 50;

	/**
	 * Error stats accumulated during the current request.
	 *
	 * @since 4.8.0
	 *
	 * @var array
	 */
	private $pending_stats = [];

	/**
	 * Whether the shutdown flush hook has been registered.
	 *
	 * @since 4.8.0
	 *
	 * @var bool
	 */
	private $shutdown_registered = false;

	/**
	 * Register hooks.
	 *
	 * @since 4.8.0
	 */
	public function hooks() {

		// Track email sending errors.
		add_action( 'wp_mail_smtp_mailcatcher_send_failed', [ $this, 'track_send_failed' ], 10, 5 );
		// Track email delivery failures (webhooks, delivery verification).
		add_action( 'wp_mail_smtp_email_delivery_failed', [ $this, 'track_delivery_failed' ], 10, 3 );
		// Add data to usage tracking.
		add_filter( 'wp_mail_smtp_usage_tracking_get_data', [ $this, 'add_usage_stats' ] );
	}

	/**
	 * Track email sending error from MailCatcher.
	 *
	 * @since 4.8.0
	 *
	 * @param string               $error_message Error message.
	 * @param MailCatcherInterface $mailcatcher   The MailCatcher object.
	 * @param string               $mailer_slug   Current mailer name.
	 * @param string               $error_code    Error code.
	 * @param int                  $response_code HTTP response code.
	 */
	public function track_send_failed( $error_message, $mailcatcher, $mailer_slug, $error_code = '', $response_code = 0 ) {

		$mailer_slug = $this->resolve_mailer_slug( $mailer_slug );
		$prefix      = $response_code > 0 ? (string) $response_code : '';
		$error_key   = $this->build_error_key( $prefix, (string) $error_code, (string) $error_message );

		$this->track_error( $mailer_slug, $error_key );
	}

	/**
	 * Track email delivery failure from webhooks or delivery verification.
	 *
	 * @since 4.8.0
	 *
	 * @param string $mailer_slug   Current mailer name.
	 * @param string $error_code    Error code.
	 * @param string $error_message Error message.
	 */
	public function track_delivery_failed( $mailer_slug, $error_code, $error_message = '' ) {

		$error_key = $this->build_error_key( 'delivery', (string) $error_code, (string) $error_message );

		$this->track_error( $mailer_slug, $error_key );
	}

	/**
	 * Track email sending error.
	 *
	 * Accumulates error in memory. All accumulated stats are flushed
	 * to the database on shutdown.
	 *
	 * @since 4.8.0
	 *
	 * @param string $mailer_slug Current mailer name.
	 * @param string $error_key   Normalized error key.
	 */
	private function track_error( $mailer_slug, $error_key ) { // phpcs:ignore WPForms.PHP.HooksMethod.InvalidPlaceForAddingHooks

		if ( ! isset( $this->pending_stats[ $mailer_slug ] ) ) {
			$this->pending_stats[ $mailer_slug ] = [];
		}

		if ( ! isset( $this->pending_stats[ $mailer_slug ][ $error_key ] ) ) {
			$this->pending_stats[ $mailer_slug ][ $error_key ] = 0;
		}

		$this->pending_stats[ $mailer_slug ][ $error_key ]++;

		// Register shutdown flush lazily on first tracked error.
		if ( ! $this->shutdown_registered ) {
			add_action( 'shutdown', [ $this, 'flush' ] );
			$this->shutdown_registered = true;
		}
	}

	/**
	 * Build error key in format: {prefix}:{error_code}:{sanitized_message}.
	 *
	 * Uses "-" for missing parts to keep the format parseable.
	 *
	 * @since 4.8.0
	 *
	 * @param string $prefix        First segment — HTTP response code or category (e.g. "401", "delivery").
	 * @param string $error_code    API-specific error code.
	 * @param string $error_message Error message text.
	 *
	 * @return string Normalized error key.
	 */
	private function build_error_key( $prefix, $error_code, $error_message ) { // phpcs:ignore Generic.Metrics.CyclomaticComplexity.TooHigh

		$part_response = ! empty( $prefix ) ? $prefix : '-';
		$part_code     = ! empty( $error_code ) && $error_code !== 'unknown' && (string) $error_code !== $part_response ? $error_code : '-';
		$part_message  = ! empty( $error_message ) ? $this->sanitize_message( $error_message ) : '-';

		// Remove response code and error code from message to save space.
		if ( $part_response !== '-' ) {
			$part_message = str_replace( sanitize_title( $part_response ), '', $part_message );
		}

		if ( $part_code !== '-' ) {
			$part_message = str_replace( sanitize_title( $part_code ), '', $part_message );
		}

		$part_message = preg_replace( '/-{2,}/', '-', $part_message );
		$part_message = trim( $part_message, '-' );

		if ( empty( $part_message ) ) {
			$part_message = '-';
		}

		$key = $part_response . ':' . $part_code . ':' . $part_message;

		if ( ! function_exists( 'mb_strlen' ) ) {
			Helpers::include_mbstring_polyfill();
		}

		if ( mb_strlen( $key ) > self::MAX_ERROR_CODE_LENGTH ) {
			$key = mb_substr( $key, 0, self::MAX_ERROR_CODE_LENGTH );

			// Cut at last hyphen to avoid partial words.
			$last_hyphen = strrpos( $key, '-' );

			if ( $last_hyphen !== false && $last_hyphen > strrpos( $key, ':' ) ) {
				$key = substr( $key, 0, $last_hyphen );
			}
		}

		return $key;
	}

	/**
	 * Sanitize error message into a short aggregatable slug.
	 *
	 * Strips dynamic content (emails, domains, URLs, UUIDs, quoted strings),
	 * takes first ~5 words, and slugifies.
	 *
	 * @since 4.8.0
	 *
	 * @param string $message Error message.
	 *
	 * @return string Sanitized message slug.
	 */
	private function sanitize_message( $message ) {

		// Strip HTML entities.
		$message = html_entity_decode( $message, ENT_QUOTES, 'UTF-8' );

		// Strip emails.
		$message = preg_replace( '/\S+@\S+\.\S+/', '', $message );

		// Strip URLs.
		$message = preg_replace( '#https?://\S+#i', '', $message );

		// Strip dot-separated strings (domains, namespaces, etc.).
		$message = preg_replace( '/\b\S+\.\S+\b/', '', $message );

		// Strip UUIDs.
		$message = preg_replace( '/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i', '', $message );

		// Strip quoted strings.
		$message = preg_replace( '/["\'][^"\']*["\']/', '', $message );

		// Collapse whitespace and trim.
		$message = trim( preg_replace( '/\s+/', ' ', $message ) );

		return sanitize_title( $message );
	}

	/**
	 * Resolve mailer slug, distinguishing one-click setups.
	 *
	 * @since 4.8.0
	 *
	 * @param string $mailer_slug Original mailer slug.
	 *
	 * @return string Resolved mailer slug.
	 */
	private function resolve_mailer_slug( $mailer_slug ) {

		if ( $mailer_slug === 'gmail' && Options::init()->get( 'gmail', 'one_click_setup_enabled' ) ) {
			return 'gmail_one_click';
		}

		if ( $mailer_slug === 'outlook' && Options::init()->get( 'outlook', 'one_click_setup_enabled' ) ) {
			return 'outlook_one_click';
		}

		return $mailer_slug;
	}

	/**
	 * Flush accumulated stats to the database.
	 *
	 * Reads current stored stats, merges with pending stats, and saves.
	 * Bails early if no errors were tracked during this request.
	 *
	 * @since 4.8.0
	 */
	public function flush() { // phpcs:ignore Generic.Metrics.CyclomaticComplexity.TooHigh

		if ( empty( $this->pending_stats ) ) {
			return;
		}

		$stored = get_option( self::OPTION_NAME, [] );

		if ( ! is_array( $stored ) ) {
			$stored = [];
		}

		foreach ( $this->pending_stats as $mailer_slug => $error_codes ) {
			if ( ! isset( $stored[ $mailer_slug ] ) ) {
				$stored[ $mailer_slug ] = [];
			}

			foreach ( $error_codes as $error_code => $count ) {
				// Apply overflow limit against the merged state.
				if (
					count( $stored[ $mailer_slug ] ) >= 50 &&
					! isset( $stored[ $mailer_slug ][ $error_code ] )
				) {
					$error_code = 'overflow';
				}

				if ( ! isset( $stored[ $mailer_slug ][ $error_code ] ) ) {
					$stored[ $mailer_slug ][ $error_code ] = 0;
				}

				$stored[ $mailer_slug ][ $error_code ] += $count;
			}
		}

		update_option( self::OPTION_NAME, $stored, false );

		$this->pending_stats = [];
	}

	/**
	 * Add usage stats data.
	 *
	 * @since 4.8.0
	 *
	 * @param array $data Usage data.
	 *
	 * @return array
	 */
	public function add_usage_stats( $data ) {

		// Flush any pending stats before reading.
		$this->flush();

		$stats = get_option( self::OPTION_NAME, [] );

		$data['email_sending_errors_stat'] = is_array( $stats ) ? $stats : [];

		// Reset after collecting.
		delete_option( self::OPTION_NAME );

		return $data;
	}
}