<?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; } }