|null */ private ?array $region_choices = null; /** * Cached SES clients indexed by a hash of the credentials and region. * * @var array */ private array $client_cache = array(); /** * Retrieves the instance. * * @return Plugin */ public static function get_instance(): Plugin { if ( null === self::$instance ) { self::$instance = new self(); } return self::$instance; } /** * Registers WordPress hooks used by the integration. * * @return void */ public function run(): void { \add_filter( 'robotstxt_smtp_amazon_ses_active', array( $this, 'declare_active' ) ); \add_filter( 'robotstxt_smtp_connection_field_definitions', array( $this, 'provide_field_definitions' ), 10, 2 ); \add_filter( 'robotstxt_smtp_sanitized_options', array( $this, 'sanitize_settings' ), 10, 3 ); \add_filter( 'pre_wp_mail', array( $this, 'maybe_route_mail_via_amazon_ses' ), 10, 2 ); \add_action( 'init', array( $this, 'maybe_schedule_quota_update' ) ); \add_action( self::CRON_HOOK_UPDATE_QUOTA, array( $this, 'update_quota_from_ses' ) ); \add_action( 'admin_init', array( $this, 'handle_refresh_quota_request' ) ); \add_action( 'admin_notices', array( $this, 'display_admin_notices' ) ); \add_action( 'network_admin_notices', array( $this, 'display_admin_notices' ) ); } /** * Routes outgoing mail through Amazon SES when credentials are available. * * @param mixed $pre_wp_mail Short-circuit value provided by earlier filters. * @param array $mail_data Normalized email arguments from wp_mail(). * * @return mixed */ public function maybe_route_mail_via_amazon_ses( $pre_wp_mail, array $mail_data ) { if ( null !== $pre_wp_mail ) { return $pre_wp_mail; } $is_active = SMTP_Plugin::is_amazon_ses_integration_active(); if ( ! $is_active ) { return $pre_wp_mail; } $settings = $this->get_stored_settings(); $access_key = (string) ( $settings[ self::OPTION_ACCESS_KEY ] ?? '' ); $secret_key = (string) ( $settings[ self::OPTION_SECRET_KEY ] ?? '' ); $region = (string) ( $settings[ self::OPTION_REGION ] ?? '' ); if ( '' === $access_key || '' === $secret_key || '' === $region ) { return $pre_wp_mail; } $mail_data = \wp_parse_args( $mail_data, array( 'to' => array(), 'subject' => '', 'message' => '', 'headers' => array(), 'attachments' => array(), ) ); $recipients = $this->normalize_recipients( $mail_data['to'] ); if ( empty( $recipients ) ) { $error = new WP_Error( 'robotstxt_smtp_amazon_ses_missing_recipient', \esc_html__( 'Amazon SES could not send the email because no recipient was provided.', 'robotstxt-smtp-amazonses' ), array( 'to' => array(), 'subject' => (string) $mail_data['subject'], 'message' => (string) $mail_data['message'], 'headers' => $mail_data['headers'], 'attachments' => $this->normalize_attachments( $mail_data['attachments'] ), ) ); \do_action( 'wp_mail_failed', $error ); return $error; } $attachments = $this->normalize_attachments( $mail_data['attachments'] ); $headers = $this->normalize_headers( $mail_data['headers'] ); $parsed = $this->parse_headers( $headers ); $mail_context = array( 'to' => $recipients, 'subject' => (string) $mail_data['subject'], 'message' => (string) $mail_data['message'], 'headers' => $mail_data['headers'], 'attachments' => $attachments, ); $send_error = $this->deliver_via_amazon_ses( $mail_context, $settings, $parsed, $access_key, $secret_key, $region ); if ( $send_error instanceof WP_Error ) { \do_action( 'wp_mail_failed', $send_error ); return $send_error; } \do_action( 'wp_mail_succeeded', $mail_context ); return true; } /** * Sends the email data to Amazon SES using the SDK client. * * @param array{to: array, subject: string, message: string, headers: mixed, attachments: array} $mail_context Normalized mail data. * @param array{host: string, username: string, password: string, from_email: string, from_name: string, reply_to_email: string, reply_to_name: string, security: string, port: int, amazon_ses_access_key: string, amazon_ses_secret_key: string, amazon_ses_region: string, logs_enabled: bool, logs_retention_mode: string, logs_retention_count: int, logs_retention_days: int, stats_retention_days: int, rate_limit_per_second: int, rate_limit_per_hour: int, rate_limit_per_day: int, delete_data_on_uninstall: bool} $settings Stored plugin settings. * @param array{content_type: string|null, charset: string|null, cc: array, bcc: array, reply_to: array, custom: array} $parsed_headers Structured header data. * @param string $access_key AWS access key ID. * @param string $secret_key AWS secret access key. * @param string $region AWS region identifier. * * @return WP_Error|null */ private function deliver_via_amazon_ses( array $mail_context, array $settings, array $parsed_headers, string $access_key, string $secret_key, string $region ): ?WP_Error { // Prepare debug context for error reporting. $debug_context = $this->get_ses_debug_context( $access_key, $region ); $phpmailer = $this->prepare_mailer_for_amazon_ses( $mail_context, $settings, $parsed_headers ); if ( $phpmailer instanceof WP_Error ) { // Add SES debug context to existing error. $error_data = $phpmailer->get_error_data(); if ( is_array( $error_data ) ) { $error_data['ses_debug'] = $debug_context; $phpmailer->add_data( $error_data ); } return $phpmailer; } try { $phpmailer->preSend(); } catch ( PHPMailerException $exception ) { return new WP_Error( 'robotstxt_smtp_amazon_ses_presend_failed', \sprintf( /* translators: %s: Error details describing the email preparation failure. */ \esc_html__( 'Amazon SES could not prepare the email: %s', 'robotstxt-smtp-amazonses' ), \esc_html( $exception->getMessage() ) ), array( 'to' => $mail_context['to'], 'subject' => $mail_context['subject'], 'message' => $mail_context['message'], 'headers' => $mail_context['headers'], 'attachments' => $mail_context['attachments'], 'phpmailer_exception_code' => $exception->getCode(), 'phpmailer_exception_file' => $exception->getFile(), 'phpmailer_exception_line' => $exception->getLine(), 'ses_debug' => $debug_context, ) ); } $raw_message = $phpmailer->getSentMIMEMessage(); try { $client = $this->get_ses_client( $access_key, $secret_key, $region ); $client->sendEmail( array( 'Content' => array( 'Raw' => array( 'Data' => $raw_message, ), ), // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase 'FromEmailAddress' => $phpmailer->From, ) ); } catch ( AwsException $exception ) { $message = $exception->getAwsErrorMessage(); if ( ! is_string( $message ) || '' === $message ) { $message = $exception->getMessage(); } return new WP_Error( 'robotstxt_smtp_amazon_ses_send_failed', \sprintf( /* translators: %s: Error message returned by Amazon SES. */ \esc_html__( 'Amazon SES could not send the email: %s', 'robotstxt-smtp-amazonses' ), \esc_html( (string) $message ) ), array( 'to' => $mail_context['to'], 'subject' => $mail_context['subject'], 'message' => $mail_context['message'], 'headers' => $mail_context['headers'], 'attachments' => $mail_context['attachments'], 'ses_error_code' => $exception->getAwsErrorCode(), 'ses_request_id' => $exception->getAwsRequestId(), 'ses_http_status_code' => $exception->getStatusCode(), 'ses_debug' => $debug_context, ) ); } catch ( Throwable $exception ) { return new WP_Error( 'robotstxt_smtp_amazon_ses_unexpected_send_error', \sprintf( /* translators: %s: Error message describing the unexpected failure. */ \esc_html__( 'An unexpected error occurred while sending through Amazon SES: %s', 'robotstxt-smtp-amazonses' ), \esc_html( $exception->getMessage() ) ), array( 'to' => $mail_context['to'], 'subject' => $mail_context['subject'], 'message' => $mail_context['message'], 'headers' => $mail_context['headers'], 'attachments' => $mail_context['attachments'], 'ses_debug' => $debug_context, ) ); } return null; } /** * Builds a PHPMailer instance configured with the provided mail data. * * @param array{to: array, subject: string, message: string, headers: mixed, attachments: array} $mail_context Normalized mail data. * @param array{host: string, username: string, password: string, from_email: string, from_name: string, reply_to_email: string, reply_to_name: string, security: string, port: int, amazon_ses_access_key: string, amazon_ses_secret_key: string, amazon_ses_region: string, logs_enabled: bool, logs_retention_mode: string, logs_retention_count: int, logs_retention_days: int, stats_retention_days: int, rate_limit_per_second: int, rate_limit_per_hour: int, rate_limit_per_day: int, delete_data_on_uninstall: bool} $settings Stored plugin settings. * @param array{content_type: string|null, charset: string|null, cc: array, bcc: array, reply_to: array, custom: array} $parsed_headers Structured header data. * * @return PHPMailer|WP_Error */ private function prepare_mailer_for_amazon_ses( array $mail_context, array $settings, array $parsed_headers ) { $phpmailer = new PHPMailer( true ); $default_content_type = \apply_filters( 'wp_mail_content_type', 'text/plain' ); $default_charset = \apply_filters( 'wp_mail_charset', \get_bloginfo( 'charset' ) ); $content_type_header = $parsed_headers['content_type']; $content_type = ( null !== $content_type_header && '' !== $content_type_header ) ? $content_type_header : ( is_string( $default_content_type ) ? $default_content_type : 'text/plain' ); $charset_header = $parsed_headers['charset']; $charset = ( null !== $charset_header && '' !== $charset_header ) ? $charset_header : ( is_string( $default_charset ) ? $default_charset : 'utf-8' ); // Ensure charset is never empty, null, or the string "null". $charset = \trim( $charset ); if ( '' === $charset || 'null' === \strtolower( $charset ) ) { $charset = 'utf-8'; } // phpcs:disable WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase $phpmailer->CharSet = $charset; $phpmailer->Encoding = '8bit'; $from_email = $this->determine_from_email( $settings ); $from_name = $this->determine_from_name( $settings ); try { $phpmailer->setFrom( $from_email, $from_name, false ); } catch ( PHPMailerException $exception ) { return new WP_Error( 'robotstxt_smtp_amazon_ses_invalid_from', \sprintf( /* translators: %s: Error details describing why the From header is invalid. */ \esc_html__( 'Amazon SES rejected the "From" address: %s', 'robotstxt-smtp-amazonses' ), \esc_html( $exception->getMessage() ) ), array( 'to' => $mail_context['to'], 'subject' => $mail_context['subject'], 'message' => $mail_context['message'], 'headers' => $mail_context['headers'], 'attachments' => $mail_context['attachments'], ) ); } foreach ( $mail_context['to'] as $recipient ) { try { $phpmailer->addAddress( $recipient ); } catch ( PHPMailerException $exception ) { return new WP_Error( 'robotstxt_smtp_amazon_ses_invalid_recipient', \sprintf( /* translators: %s: Invalid recipient email address. */ \esc_html__( 'Amazon SES rejected the recipient address: %s', 'robotstxt-smtp-amazonses' ), \esc_html( $recipient ) ), array( 'to' => $mail_context['to'], 'subject' => $mail_context['subject'], 'message' => $mail_context['message'], 'headers' => $mail_context['headers'], 'attachments' => $mail_context['attachments'], ) ); } } foreach ( $parsed_headers['cc'] ?? array() as $cc ) { try { $phpmailer->addCC( $cc ); } catch ( PHPMailerException $exception ) { return new WP_Error( 'robotstxt_smtp_amazon_ses_invalid_cc', \sprintf( /* translators: %s: Invalid CC email address. */ \esc_html__( 'Amazon SES rejected the CC address: %s', 'robotstxt-smtp-amazonses' ), \esc_html( $cc ) ), array( 'to' => $mail_context['to'], 'subject' => $mail_context['subject'], 'message' => $mail_context['message'], 'headers' => $mail_context['headers'], 'attachments' => $mail_context['attachments'], ) ); } } foreach ( $parsed_headers['bcc'] ?? array() as $bcc ) { try { $phpmailer->addBCC( $bcc ); } catch ( PHPMailerException $exception ) { return new WP_Error( 'robotstxt_smtp_amazon_ses_invalid_bcc', \sprintf( /* translators: %s: Invalid BCC email address. */ \esc_html__( 'Amazon SES rejected the BCC address: %s', 'robotstxt-smtp-amazonses' ), \esc_html( $bcc ) ), array( 'to' => $mail_context['to'], 'subject' => $mail_context['subject'], 'message' => $mail_context['message'], 'headers' => $mail_context['headers'], 'attachments' => $mail_context['attachments'], ) ); } } foreach ( $parsed_headers['reply_to'] ?? array() as $reply_to ) { try { $phpmailer->addReplyTo( $reply_to ); } catch ( PHPMailerException $exception ) { return new WP_Error( 'robotstxt_smtp_amazon_ses_invalid_reply_to', \sprintf( /* translators: %s: Invalid reply-to email address. */ \esc_html__( 'Amazon SES rejected the reply-to address: %s', 'robotstxt-smtp-amazonses' ), \esc_html( $reply_to ) ), array( 'to' => $mail_context['to'], 'subject' => $mail_context['subject'], 'message' => $mail_context['message'], 'headers' => $mail_context['headers'], 'attachments' => $mail_context['attachments'], ) ); } } // Add Reply-To from settings if not already set by headers. // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase if ( empty( $phpmailer->getReplyToAddresses() ) ) { $reply_to_email = isset( $settings['reply_to_email'] ) ? \sanitize_email( $settings['reply_to_email'] ) : ''; if ( ! empty( $reply_to_email ) && \is_email( $reply_to_email ) ) { $reply_to_name = isset( $settings['reply_to_name'] ) ? \sanitize_text_field( $settings['reply_to_name'] ) : ''; try { $phpmailer->addReplyTo( $reply_to_email, $reply_to_name ); } catch ( PHPMailerException $exception ) { return new WP_Error( 'robotstxt_smtp_amazon_ses_invalid_reply_to_settings', \sprintf( /* translators: %s: Invalid reply-to email address from settings. */ \esc_html__( 'Amazon SES rejected the configured reply-to address: %s', 'robotstxt-smtp-amazonses' ), \esc_html( $reply_to_email ) ), array( 'to' => $mail_context['to'], 'subject' => $mail_context['subject'], 'message' => $mail_context['message'], 'headers' => $mail_context['headers'], 'attachments' => $mail_context['attachments'], ) ); } } } $phpmailer->ContentType = $content_type; if ( 'text/html' === strtolower( $content_type ) ) { $phpmailer->isHTML( true ); } else { $phpmailer->isHTML( false ); } foreach ( $parsed_headers['custom'] ?? array() as $custom_header ) { if ( isset( $custom_header['name'], $custom_header['value'] ) ) { $phpmailer->addCustomHeader( (string) $custom_header['name'], (string) $custom_header['value'] ); } } $phpmailer->Subject = \wp_specialchars_decode( $mail_context['subject'], ENT_QUOTES ); $message = (string) $mail_context['message']; if ( $phpmailer->isHTML() ) { $phpmailer->msgHTML( $message ); if ( '' === $phpmailer->AltBody ) { $phpmailer->AltBody = \wp_strip_all_tags( $message ); } } else { $phpmailer->Body = $message; } foreach ( $mail_context['attachments'] as $attachment ) { try { $phpmailer->addAttachment( $attachment ); } catch ( PHPMailerException $exception ) { return new WP_Error( 'robotstxt_smtp_amazon_ses_invalid_attachment', \sprintf( /* translators: %s: Attachment path that could not be added. */ \esc_html__( 'Amazon SES could not include the attachment: %s', 'robotstxt-smtp-amazonses' ), \esc_html( $attachment ) ), array( 'to' => $mail_context['to'], 'subject' => $mail_context['subject'], 'message' => $mail_context['message'], 'headers' => $mail_context['headers'], 'attachments' => $mail_context['attachments'], 'phpmailer_exception_code' => $exception->getCode(), ) ); } } // phpcs:enable WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase return $phpmailer; } /** * Determines the From email address based on the stored settings and filters. * * @param array{host: string, username: string, password: string, from_email: string, from_name: string, reply_to_email: string, reply_to_name: string, security: string, port: int, amazon_ses_access_key: string, amazon_ses_secret_key: string, amazon_ses_region: string, logs_enabled: bool, logs_retention_mode: string, logs_retention_count: int, logs_retention_days: int, stats_retention_days: int, rate_limit_per_second: int, rate_limit_per_hour: int, rate_limit_per_day: int, delete_data_on_uninstall: bool} $settings Stored plugin settings. * * @return string */ private function determine_from_email( array $settings ): string { $default_email = \get_bloginfo( 'admin_email' ); if ( \is_email( $settings['from_email'] ) ) { $default_email = $settings['from_email']; } $filtered = \apply_filters( 'wp_mail_from', $default_email ); if ( ! is_string( $filtered ) || ! \is_email( $filtered ) ) { return $default_email; } return $filtered; } /** * Determines the From name based on stored settings and filters. * * @param array{host: string, username: string, password: string, from_email: string, from_name: string, reply_to_email: string, reply_to_name: string, security: string, port: int, amazon_ses_access_key: string, amazon_ses_secret_key: string, amazon_ses_region: string, logs_enabled: bool, logs_retention_mode: string, logs_retention_count: int, logs_retention_days: int, stats_retention_days: int, rate_limit_per_second: int, rate_limit_per_hour: int, rate_limit_per_day: int, delete_data_on_uninstall: bool} $settings Stored plugin settings. * * @return string */ private function determine_from_name( array $settings ): string { $default_name = \get_bloginfo( 'name', 'display' ); if ( '' !== $settings['from_name'] ) { $default_name = $settings['from_name']; } $filtered = \apply_filters( 'wp_mail_from_name', $default_name ); return is_string( $filtered ) ? $filtered : $default_name; } /** * Converts different header representations into a clean array of header lines. * * @param mixed $headers Headers supplied to 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 ) { if ( ! is_string( $header ) ) { continue; } $line = trim( $header ); if ( '' !== $line ) { $normalized[] = $line; } } return $normalized; } /** * Breaks down the headers into structured data used when building the PHPMailer instance. * * @param array $headers Normalized header lines. * * @return array{ * content_type: string|null, * charset: string|null, * cc: array, * bcc: array, * reply_to: array, * custom: array * } */ private function parse_headers( array $headers ): array { $parsed = array( 'content_type' => null, 'charset' => null, 'cc' => array(), 'bcc' => array(), 'reply_to' => array(), 'custom' => array(), ); foreach ( $headers as $header ) { if ( false === strpos( $header, ':' ) ) { continue; } list( $name, $value ) = explode( ':', $header, 2 ); $normalized_name = strtolower( trim( $name ) ); $original_name = trim( $name ); $value = trim( $value ); switch ( $normalized_name ) { case 'content-type': if ( false !== strpos( $value, ';' ) ) { $parts = explode( ';', $value ); $parsed['content_type'] = trim( array_shift( $parts ) ); foreach ( $parts as $part ) { if ( false === strpos( $part, '=' ) ) { continue; } list( $param, $param_value ) = explode( '=', $part, 2 ); if ( 'charset' === strtolower( trim( $param ) ) ) { $charset_value = trim( $param_value, " \n\r\0\x0B\"'" ); // Only set charset if it has a valid non-empty value and is not "null". if ( '' !== $charset_value && 'null' !== strtolower( $charset_value ) ) { $parsed['charset'] = $charset_value; } } } } else { $parsed['content_type'] = $value; } break; case 'cc': $parsed['cc'] = array_merge( $parsed['cc'], $this->normalize_recipients( $value ) ); break; case 'bcc': $parsed['bcc'] = array_merge( $parsed['bcc'], $this->normalize_recipients( $value ) ); break; case 'reply-to': $parsed['reply_to'] = array_merge( $parsed['reply_to'], $this->normalize_recipients( $value ) ); break; default: if ( '' !== $original_name && '' !== $value ) { $parsed['custom'][] = array( 'name' => $original_name, 'value' => $value, ); } } } return $parsed; } /** * Normalizes the list of recipients into an array of individual addresses. * * @param mixed $recipients Recipient data supplied to wp_mail(). * * @return array */ private function normalize_recipients( $recipients ): array { if ( empty( $recipients ) ) { return array(); } if ( ! is_array( $recipients ) ) { $recipients = is_string( $recipients ) ? explode( ',', $recipients ) : array(); } $normalized = array(); foreach ( $recipients as $recipient ) { if ( ! is_string( $recipient ) ) { continue; } $address = trim( $recipient ); if ( '' !== $address ) { $normalized[] = $address; } } return $normalized; } /** * Ensures the attachments argument is converted into an array of paths. * * @param mixed $attachments Attachment data supplied to wp_mail(). * * @return array */ private function normalize_attachments( $attachments ): array { if ( empty( $attachments ) ) { return array(); } if ( ! is_array( $attachments ) ) { $attachments = array( $attachments ); } $normalized = array(); foreach ( $attachments as $attachment ) { if ( ! is_string( $attachment ) ) { continue; } $path = trim( $attachment ); if ( '' !== $path ) { $normalized[] = $path; } } return $normalized; } /** * Retrieves a cached Amazon SES client or creates a new instance. * * @param string $access_key AWS access key ID. * @param string $secret_key AWS secret access key. * @param string $region AWS region identifier. * * @return SesV2Client */ private function get_ses_client( string $access_key, string $secret_key, string $region ): SesV2Client { $cache_key = \md5( $access_key . '|' . $secret_key . '|' . $region ); if ( isset( $this->client_cache[ $cache_key ] ) ) { return $this->client_cache[ $cache_key ]; } $client = new SesV2Client( array( 'version' => 'latest', 'region' => $region, 'credentials' => new Credentials( $access_key, $secret_key ), ) ); $this->client_cache[ $cache_key ] = $client; return $client; } /** * Gathers Amazon SES debug context information for error reporting. * * @since 2.1.0 * * @param string $access_key AWS access key ID. * @param string $region AWS region identifier. * * @return array Debug context with region, masked access key, SDK version, and endpoint. */ private function get_ses_debug_context( string $access_key, string $region ): array { // Mask the access key for security (show first 4 and last 4 characters). $masked_key = ''; $key_length = \strlen( $access_key ); if ( $key_length > 8 ) { $masked_key = \substr( $access_key, 0, 4 ) . \str_repeat( '*', $key_length - 8 ) . \substr( $access_key, -4 ); } elseif ( $key_length > 0 ) { $masked_key = \str_repeat( '*', $key_length ); } // Get AWS SDK version. $sdk_version = \defined( 'Aws\Sdk::VERSION' ) ? \Aws\Sdk::VERSION : 'unknown'; // Build SES endpoint URL. $endpoint = \sprintf( 'https://email.%s.amazonaws.com', $region ); return array( 'region' => $region, 'access_key' => $masked_key, 'sdk_version' => $sdk_version, 'endpoint' => $endpoint, 'timestamp' => \gmdate( 'Y-m-d H:i:s' ) . ' UTC', ); } /** * Marks the Amazon SES integration as active. * * @param bool $is_active Current filter value. * * @return bool */ public function declare_active( bool $is_active ): bool { unset( $is_active ); return true; } /** * Enqueues admin scripts and styles for the settings page. * * @since 1.0.1 * * @param string $hook The current admin page hook. * @return void */ /** * Displays admin notices from transient after redirect. * * @since 1.0.1 * * @return void */ public function display_admin_notices(): void { $notices = \get_transient( 'robotstxt_smtp_admin_notices' ); if ( $notices && \is_array( $notices ) ) { \delete_transient( 'robotstxt_smtp_admin_notices' ); foreach ( $notices as $notice ) { if ( ! is_array( $notice ) ) { continue; } $type = isset( $notice['type'] ) && is_string( $notice['type'] ) ? $notice['type'] : 'info'; $message = isset( $notice['message'] ) && is_string( $notice['message'] ) ? $notice['message'] : ''; if ( $message ) { printf( '

%s

', \esc_attr( $type ), \esc_html( $message ) ); } } } } /** * Handles the refresh quota form submission. * * @since 1.0.1 * * @return void */ public function handle_refresh_quota_request(): void { // Check if this is a refresh quota request first (can come via GET or POST). $action_post = \filter_input( INPUT_POST, 'robotstxt_smtp_action', FILTER_UNSAFE_RAW ); $action_get = \filter_input( INPUT_GET, 'robotstxt_smtp_action', FILTER_UNSAFE_RAW ); $action = $action_post ?? $action_get ?? ''; if ( 'refresh_quota' !== $action ) { return; } // This is a refresh quota request - now verify nonce. $nonce_post = \filter_input( INPUT_POST, 'robotstxt_smtp_refresh_quota_nonce', FILTER_UNSAFE_RAW ); $nonce_get = \filter_input( INPUT_GET, 'robotstxt_smtp_refresh_quota_nonce', FILTER_UNSAFE_RAW ); $nonce_raw = $nonce_post ?? $nonce_get ?? ''; $nonce = is_string( $nonce_raw ) ? $nonce_raw : ''; if ( ! \wp_verify_nonce( $nonce, 'robotstxt_smtp_refresh_quota' ) ) { \wp_die( \esc_html__( 'Security check failed.', 'robotstxt-smtp-amazonses' ) ); } // Check permissions based on configuration scope. $is_network_mode = Settings_Page::is_network_mode_enabled(); $required_cap = $is_network_mode ? 'manage_network_options' : 'manage_options'; if ( ! \current_user_can( $required_cap ) ) { \wp_die( \esc_html__( 'You do not have sufficient permissions to access this page.', 'robotstxt-smtp-amazonses' ) ); } // Fetch fresh quotas. $this->update_quota_from_ses(); // Check if successful and add admin notice. $quota = \get_site_transient( self::get_quota_cache_key() ); if ( $quota && \is_array( $quota ) ) { \add_settings_error( 'robotstxt_smtp_messages', 'quota_refreshed', \__( 'Amazon SES quotas refreshed successfully.', 'robotstxt-smtp-amazonses' ), 'updated' ); } else { \add_settings_error( 'robotstxt_smtp_messages', 'quota_refresh_failed', \__( 'Unable to fetch quotas from Amazon SES. Check debug log for details.', 'robotstxt-smtp-amazonses' ), 'error' ); } // Save notices for after redirect. \set_transient( 'robotstxt_smtp_admin_notices', \get_settings_errors( 'robotstxt_smtp_messages' ), 30 ); // Redirect back to the settings page (remove the action parameters from URL). // Since the request comes with ?page= parameter, we know which page to redirect to. $page_param = \filter_input( INPUT_GET, 'page', FILTER_UNSAFE_RAW ); $page = $page_param ? \sanitize_key( $page_param ) : 'robotstxt-smtp'; if ( 'robotstxt-smtp-network' === $page ) { $redirect_url = \network_admin_url( 'admin.php?page=robotstxt-smtp-network' ); } else { $redirect_url = \admin_url( 'admin.php?page=robotstxt-smtp' ); } \wp_safe_redirect( $redirect_url ); exit; } /** * Replaces the SMTP connection fields with Amazon SES specific fields. * * @param array> $fields Registered field definitions. * @param Settings_Page $settings_page Settings page instance. * * @return array> */ public function provide_field_definitions( array $fields, Settings_Page $settings_page ): array { if ( ! SMTP_Plugin::is_amazon_ses_integration_active() ) { return $fields; } return array( array( 'id' => 'robotstxt_smtp_amazon_ses_access_key', 'label' => \esc_html__( 'Access Key ID', 'robotstxt-smtp-amazonses' ), 'callback' => array( $this, 'render_access_key_field' ), 'label_for' => 'robotstxt_smtp_amazon_ses_access_key', ), array( 'id' => 'robotstxt_smtp_amazon_ses_secret_key', 'label' => \esc_html__( 'Secret Access Key', 'robotstxt-smtp-amazonses' ), 'callback' => array( $this, 'render_secret_key_field' ), 'label_for' => 'robotstxt_smtp_amazon_ses_secret_key', ), array( 'id' => 'robotstxt_smtp_amazon_ses_region', 'label' => \esc_html__( 'Region', 'robotstxt-smtp-amazonses' ), 'callback' => array( $this, 'render_region_field' ), 'label_for' => 'robotstxt_smtp_amazon_ses_region', ), array( 'id' => 'robotstxt_smtp_from_email', 'label' => \esc_html__( 'From Email', 'robotstxt-smtp' ), 'callback' => array( $settings_page, '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( $settings_page, 'render_from_name_field' ), 'label_for' => 'robotstxt_smtp_from_name', ), array( 'id' => 'robotstxt_smtp_reply_to_email', 'label' => \esc_html__( 'Reply-To Email', 'robotstxt-smtp' ), 'callback' => array( $settings_page, 'render_reply_to_email_field' ), 'label_for' => 'robotstxt_smtp_reply_to_email', ), array( 'id' => 'robotstxt_smtp_reply_to_name', 'label' => \esc_html__( 'Reply-To Name', 'robotstxt-smtp' ), 'callback' => array( $settings_page, 'render_reply_to_name_field' ), 'label_for' => 'robotstxt_smtp_reply_to_name', ), ); } /** * Outputs the Amazon SES access key field. * * @return void */ public function render_access_key_field(): void { $settings = $this->get_stored_settings(); $has_access_key = '' !== $settings[ self::OPTION_ACCESS_KEY ]; $placeholder_label = $has_access_key ? \__( 'Leave empty to keep the stored access key.', 'robotstxt-smtp-amazonses' ) : ''; ?>

get_stored_settings(); $has_secret_key = '' !== $settings[ self::OPTION_SECRET_KEY ]; $placeholder_label = $has_secret_key ? \__( 'Leave empty to keep the stored secret access key.', 'robotstxt-smtp-amazonses' ) : ''; ?>

get_stored_settings(); $current_region = $settings[ self::OPTION_REGION ]; $regions = $this->get_region_choices(); ?>

$clean Sanitized option values prepared by the core plugin. * @param array $options Raw submitted option values after `wp_unslash()`. * @param Settings_Page $settings Settings page instance. * * @return array */ public function sanitize_settings( array $clean, array $options, Settings_Page $settings ): array { unset( $settings ); if ( ! SMTP_Plugin::is_amazon_ses_integration_active() ) { return $clean; } $stored_settings = $this->get_stored_settings(); $settings_error_slug = $this->get_settings_option_name(); $access_key = $stored_settings[ self::OPTION_ACCESS_KEY ] ?? ''; $secret_key = $stored_settings[ self::OPTION_SECRET_KEY ] ?? ''; $region = $stored_settings[ self::OPTION_REGION ] ?? ''; $regions = $this->get_region_choices(); $credentials_changed = false; if ( array_key_exists( self::OPTION_ACCESS_KEY, $options ) ) { $raw_access_key = $options[ self::OPTION_ACCESS_KEY ] ?? null; $submitted_access_key = \trim( is_string( $raw_access_key ) ? $raw_access_key : '' ); if ( '' !== $submitted_access_key ) { $sanitized_access_key = \sanitize_text_field( $submitted_access_key ); if ( $sanitized_access_key !== $access_key ) { $credentials_changed = true; } $access_key = $sanitized_access_key; } } if ( array_key_exists( self::OPTION_SECRET_KEY, $options ) ) { $raw_secret_key = $options[ self::OPTION_SECRET_KEY ] ?? null; $submitted_secret_key = \trim( is_string( $raw_secret_key ) ? $raw_secret_key : '' ); if ( '' !== $submitted_secret_key ) { $sanitized_secret_key = \sanitize_text_field( $submitted_secret_key ); if ( $sanitized_secret_key !== $secret_key ) { $credentials_changed = true; } $secret_key = $sanitized_secret_key; } } if ( array_key_exists( self::OPTION_REGION, $options ) ) { $raw_region = $options[ self::OPTION_REGION ] ?? null; $submitted_region = \sanitize_text_field( is_string( $raw_region ) ? $raw_region : '' ); if ( '' === $submitted_region ) { if ( '' !== $region ) { $credentials_changed = true; } $region = ''; } elseif ( isset( $regions[ $submitted_region ] ) ) { if ( $submitted_region !== $region ) { $credentials_changed = true; } $region = $submitted_region; } else { \add_settings_error( $settings_error_slug, self::SETTINGS_ERROR_CREDENTIALS, \esc_html__( 'The selected Amazon SES region is not available.', 'robotstxt-smtp-amazonses' ), 'error' ); return $this->restore_amazon_settings( $clean, $stored_settings ); } } $clean[ self::OPTION_ACCESS_KEY ] = $access_key; $clean[ self::OPTION_SECRET_KEY ] = $secret_key; $clean[ self::OPTION_REGION ] = $region; if ( $credentials_changed && ( '' === $access_key || '' === $secret_key || '' === $region ) ) { \add_settings_error( $settings_error_slug, self::SETTINGS_ERROR_CREDENTIALS, \esc_html__( 'Amazon SES credentials were not saved. Provide an access key, secret key, and region.', 'robotstxt-smtp-amazonses' ), 'error' ); return $this->restore_amazon_settings( $clean, $stored_settings ); } if ( $credentials_changed && '' !== $access_key && '' !== $secret_key && '' !== $region ) { $validation_error = $this->validate_credentials( $access_key, $secret_key, $region ); if ( $validation_error instanceof WP_Error ) { \add_settings_error( $settings_error_slug, self::SETTINGS_ERROR_CREDENTIALS, \esc_html( $validation_error->get_error_message() ), 'error' ); return $this->restore_amazon_settings( $clean, $stored_settings ); } \add_settings_error( $settings_error_slug, self::SETTINGS_ERROR_CREDENTIALS, \esc_html__( 'Amazon SES credentials verified successfully.', 'robotstxt-smtp-amazonses' ), 'updated' ); // Fetch SES quotas immediately after successful validation and cache them. // Note: We do NOT overwrite rate_limit settings in the database. // The UI will read directly from the transient when Amazon SES is active. $quota = $this->fetch_ses_quota_with_credentials( $access_key, $secret_key, $region ); if ( ! \is_wp_error( $quota ) ) { \set_site_transient( self::get_quota_cache_key(), $quota, DAY_IN_SECONDS ); } } return $clean; } /** * Restores the previously stored Amazon SES values in case validation fails. * * @param array $clean Current sanitized values. * @param array{amazon_ses_access_key: string, amazon_ses_secret_key: string, amazon_ses_region: string, host: string, username: string, password: string, from_email: string, from_name: string, reply_to_email: string, reply_to_name: string, security: string, port: int, logs_enabled: bool, logs_retention_mode: string, logs_retention_count: int, logs_retention_days: int, stats_retention_days: int, rate_limit_per_second: int, rate_limit_per_hour: int, rate_limit_per_day: int, delete_data_on_uninstall: bool} $stored_settings Stored settings merged with defaults. * * @return array */ private function restore_amazon_settings( array $clean, array $stored_settings ): array { $clean[ self::OPTION_ACCESS_KEY ] = $stored_settings[ self::OPTION_ACCESS_KEY ] ?? ''; $clean[ self::OPTION_SECRET_KEY ] = $stored_settings[ self::OPTION_SECRET_KEY ] ?? ''; $clean[ self::OPTION_REGION ] = $stored_settings[ self::OPTION_REGION ] ?? ''; return $clean; } /** * Retrieves the stored settings merged with defaults for the active scope. * * @return array{ * host: string, * username: string, * password: string, * from_email: string, * from_name: string, * reply_to_email: string, * reply_to_name: string, * security: string, * port: int, * amazon_ses_access_key: string, * amazon_ses_secret_key: string, * amazon_ses_region: string, * logs_enabled: bool, * logs_retention_mode: string, * logs_retention_count: int, * logs_retention_days: int, * stats_retention_days: int, * rate_limit_per_second: int, * rate_limit_per_hour: int, * rate_limit_per_day: int, * delete_data_on_uninstall: bool * } */ private function get_stored_settings(): array { $option_name = $this->get_settings_option_name(); if ( Settings_Page::NETWORK_OPTION_NAME === $option_name ) { $settings = \get_site_option( $option_name, array() ); } else { $settings = \get_option( $option_name, array() ); } if ( ! is_array( $settings ) ) { $settings = array(); } $settings = \wp_parse_args( $settings, Settings_Page::get_default_settings() ); $s = (array) $settings; $access_key = is_string( $s[ self::OPTION_ACCESS_KEY ] ?? null ) ? $s[ self::OPTION_ACCESS_KEY ] : ''; $secret_key = is_string( $s[ self::OPTION_SECRET_KEY ] ?? null ) ? $s[ self::OPTION_SECRET_KEY ] : ''; // Decrypt Amazon SES credentials after loading from database. if ( '' !== $access_key ) { $access_key = \Robotstxt_SMTP_Encryption::decrypt( $access_key ); } if ( '' !== $secret_key ) { $secret_key = \Robotstxt_SMTP_Encryption::decrypt( $secret_key ); } return array( 'host' => is_string( $s['host'] ?? null ) ? $s['host'] : '', 'username' => is_string( $s['username'] ?? null ) ? $s['username'] : '', 'password' => is_string( $s['password'] ?? null ) ? $s['password'] : '', 'from_email' => is_string( $s['from_email'] ?? null ) ? $s['from_email'] : '', 'from_name' => is_string( $s['from_name'] ?? null ) ? $s['from_name'] : '', 'reply_to_email' => is_string( $s['reply_to_email'] ?? null ) ? $s['reply_to_email'] : '', 'reply_to_name' => is_string( $s['reply_to_name'] ?? null ) ? $s['reply_to_name'] : '', 'security' => is_string( $s['security'] ?? null ) ? $s['security'] : 'none', 'port' => is_int( $s['port'] ?? null ) ? $s['port'] : 25, self::OPTION_ACCESS_KEY => $access_key, self::OPTION_SECRET_KEY => $secret_key, self::OPTION_REGION => is_string( $s[ self::OPTION_REGION ] ?? null ) ? $s[ self::OPTION_REGION ] : '', 'logs_enabled' => is_bool( $s['logs_enabled'] ?? null ) ? $s['logs_enabled'] : false, 'logs_retention_mode' => is_string( $s['logs_retention_mode'] ?? null ) ? $s['logs_retention_mode'] : 'count', 'logs_retention_count' => is_int( $s['logs_retention_count'] ?? null ) ? $s['logs_retention_count'] : 1024, 'logs_retention_days' => is_int( $s['logs_retention_days'] ?? null ) ? $s['logs_retention_days'] : 28, 'stats_retention_days' => is_int( $s['stats_retention_days'] ?? null ) ? $s['stats_retention_days'] : 28, 'rate_limit_per_second' => is_int( $s['rate_limit_per_second'] ?? null ) ? $s['rate_limit_per_second'] : 0, 'rate_limit_per_hour' => is_int( $s['rate_limit_per_hour'] ?? null ) ? $s['rate_limit_per_hour'] : 0, 'rate_limit_per_day' => is_int( $s['rate_limit_per_day'] ?? null ) ? $s['rate_limit_per_day'] : 0, 'delete_data_on_uninstall' => is_bool( $s['delete_data_on_uninstall'] ?? null ) ? $s['delete_data_on_uninstall'] : false, ); } /** * Builds the settings field name for the current configuration scope. * * @param string $field Field identifier. * * @return string */ private function get_field_name( string $field ): string { return $this->get_settings_option_name() . '[' . $field . ']'; } /** * Determines the correct option name for the current configuration scope. * * @return string */ private function get_settings_option_name(): string { // Check if network mode is enabled, regardless of whether we're in network admin context. // This is important when sending emails from frontend or non-admin contexts. if ( ROBOTSTXT_SMTP_IS_MULTISITE && Settings_Page::is_network_mode_enabled() ) { return Settings_Page::NETWORK_OPTION_NAME; } return Settings_Page::OPTION_NAME; } /** * Retrieves the list of available Amazon SES regions. * * @return array */ private function get_region_choices(): array { if ( null !== $this->region_choices ) { return $this->region_choices; } $labels = $this->get_known_region_labels(); $choices = $labels; $discovered_list = array(); try { $provider = PartitionEndpointProvider::defaultProvider(); foreach ( array( 'aws', 'aws-us-gov' ) as $partition_name ) { $partition = $provider->getPartitionByName( $partition_name ); if ( ! $partition ) { continue; } $regions = $partition->getAvailableEndpoints( 'ses' ); foreach ( $regions as $region ) { if ( '' !== $region ) { $discovered_list[ $region ] = $labels[ $region ] ?? $this->format_region_label( $region ); } } } } catch ( Throwable $exception ) { unset( $exception ); } foreach ( $discovered_list as $region => $label ) { if ( isset( $choices[ $region ] ) ) { continue; } $choices[ $region ] = $label; } if ( empty( $choices ) ) { $choices = array( 'us-east-1' => $this->format_region_label( 'us-east-1' ), 'us-west-2' => $this->format_region_label( 'us-west-2' ), ); } $this->region_choices = $choices; return $this->region_choices; } /** * Provides translated labels for known Amazon SES regions. * * @return array */ private function get_known_region_labels(): array { return array( 'us-east-2' => $this->format_named_region_label( \__( 'US East (Ohio)', 'robotstxt-smtp-amazonses' ), 'us-east-2' ), 'us-east-1' => $this->format_named_region_label( \__( 'US East (N. Virginia)', 'robotstxt-smtp-amazonses' ), 'us-east-1' ), 'us-west-1' => $this->format_named_region_label( \__( 'US West (N. California)', 'robotstxt-smtp-amazonses' ), 'us-west-1' ), 'us-west-2' => $this->format_named_region_label( \__( 'US West (Oregon)', 'robotstxt-smtp-amazonses' ), 'us-west-2' ), 'af-south-1' => $this->format_named_region_label( \__( 'Africa (Cape Town)', 'robotstxt-smtp-amazonses' ), 'af-south-1' ), 'ap-south-2' => $this->format_named_region_label( \__( 'Asia Pacific (Hyderabad)', 'robotstxt-smtp-amazonses' ), 'ap-south-2' ), 'ap-southeast-3' => $this->format_named_region_label( \__( 'Asia Pacific (Jakarta)', 'robotstxt-smtp-amazonses' ), 'ap-southeast-3' ), 'ap-south-1' => $this->format_named_region_label( \__( 'Asia Pacific (Mumbai)', 'robotstxt-smtp-amazonses' ), 'ap-south-1' ), 'ap-northeast-3' => $this->format_named_region_label( \__( 'Asia Pacific (Osaka)', 'robotstxt-smtp-amazonses' ), 'ap-northeast-3' ), 'ap-northeast-2' => $this->format_named_region_label( \__( 'Asia Pacific (Seoul)', 'robotstxt-smtp-amazonses' ), 'ap-northeast-2' ), 'ap-southeast-1' => $this->format_named_region_label( \__( 'Asia Pacific (Singapore)', 'robotstxt-smtp-amazonses' ), 'ap-southeast-1' ), 'ap-southeast-2' => $this->format_named_region_label( \__( 'Asia Pacific (Sydney)', 'robotstxt-smtp-amazonses' ), 'ap-southeast-2' ), 'ap-northeast-1' => $this->format_named_region_label( \__( 'Asia Pacific (Tokyo)', 'robotstxt-smtp-amazonses' ), 'ap-northeast-1' ), 'ca-central-1' => $this->format_named_region_label( \__( 'Canada (Central)', 'robotstxt-smtp-amazonses' ), 'ca-central-1' ), 'eu-central-1' => $this->format_named_region_label( \__( 'Europe (Frankfurt)', 'robotstxt-smtp-amazonses' ), 'eu-central-1' ), 'eu-west-1' => $this->format_named_region_label( \__( 'Europe (Ireland)', 'robotstxt-smtp-amazonses' ), 'eu-west-1' ), 'eu-west-2' => $this->format_named_region_label( \__( 'Europe (London)', 'robotstxt-smtp-amazonses' ), 'eu-west-2' ), 'eu-south-1' => $this->format_named_region_label( \__( 'Europe (Milan)', 'robotstxt-smtp-amazonses' ), 'eu-south-1' ), 'eu-west-3' => $this->format_named_region_label( \__( 'Europe (Paris)', 'robotstxt-smtp-amazonses' ), 'eu-west-3' ), 'eu-north-1' => $this->format_named_region_label( \__( 'Europe (Stockholm)', 'robotstxt-smtp-amazonses' ), 'eu-north-1' ), 'eu-central-2' => $this->format_named_region_label( \__( 'Europe (Zurich)', 'robotstxt-smtp-amazonses' ), 'eu-central-2' ), 'il-central-1' => $this->format_named_region_label( \__( 'Israel (Tel Aviv)', 'robotstxt-smtp-amazonses' ), 'il-central-1' ), 'me-south-1' => $this->format_named_region_label( \__( 'Middle East (Bahrain)', 'robotstxt-smtp-amazonses' ), 'me-south-1' ), 'me-central-1' => $this->format_named_region_label( \__( 'Middle East (UAE)', 'robotstxt-smtp-amazonses' ), 'me-central-1' ), 'sa-east-1' => $this->format_named_region_label( \__( 'South America (São Paulo)', 'robotstxt-smtp-amazonses' ), 'sa-east-1' ), 'us-gov-east-1' => $this->format_named_region_label( \__( 'AWS GovCloud (US-East)', 'robotstxt-smtp-amazonses' ), 'us-gov-east-1' ), 'us-gov-west-1' => $this->format_named_region_label( \__( 'AWS GovCloud (US-West)', 'robotstxt-smtp-amazonses' ), 'us-gov-west-1' ), ); } /** * Builds a formatted label for a named region. * * @param string $name Human-readable region name. * @param string $code Region identifier. * * @return string */ private function format_named_region_label( string $name, string $code ): string { return \sprintf( /* translators: 1: AWS region display name. 2: AWS region code. */ \__( '%1$s (%2$s)', 'robotstxt-smtp-amazonses' ), $name, $code ); } /** * Creates a fallback label for an AWS region code. * * @param string $code Region identifier. * * @return string */ private function format_region_label( string $code ): string { return \sprintf( /* translators: %s: AWS region code. */ \__( 'Region %s', 'robotstxt-smtp-amazonses' ), $code ); } /** * Validates the provided Amazon SES credentials by calling the GetAccount endpoint. * * @param string $access_key Access key ID. * @param string $secret_key Secret access key. * @param string $region Selected region. * * @return WP_Error|null */ private function validate_credentials( string $access_key, string $secret_key, string $region ): ?WP_Error { try { $client = new SesV2Client( array( 'version' => 'latest', 'region' => $region, 'credentials' => new Credentials( $access_key, $secret_key ), ) ); $client->getAccount(); } catch ( AwsException $exception ) { $aws_error_code = $exception->getAwsErrorCode(); $message = $exception->getAwsErrorMessage(); if ( ! is_string( $message ) || '' === $message ) { $message = $exception->getMessage(); } $message = \esc_html( (string) $message ); // Allow saving if the error is due to missing IAM permissions (not authorization to use SES itself). // The credentials may still work for sending emails which requires different permissions. if ( 'AccessDeniedException' === $aws_error_code || 'UnauthorizedException' === $aws_error_code ) { if ( \strpos( $message, 'not authorized to perform: ses:GetAccount' ) !== false ) { // Permission error for GetAccount API - credentials might still work for sending. // Return null (success) but we could add a notice here if needed. return null; } } // For authentication errors (invalid credentials), prevent saving. if ( in_array( $aws_error_code, array( 'InvalidClientTokenId', 'SignatureDoesNotMatch', 'InvalidAccessKeyId' ), true ) ) { return new WP_Error( 'robotstxt_smtp_amazon_ses_invalid_credentials', \sprintf( /* translators: %s: Error message from Amazon SES. */ \esc_html__( 'Invalid Amazon SES credentials: %s', 'robotstxt-smtp-amazonses' ), $message ) ); } // For other AWS errors, show warning but allow saving. return new WP_Error( 'robotstxt_smtp_amazon_ses_invalid_credentials', \sprintf( /* translators: %s: Error message from Amazon SES. */ \esc_html__( 'Could not verify Amazon SES credentials: %s', 'robotstxt-smtp-amazonses' ), $message ) ); } catch ( Throwable $exception ) { $message = \esc_html( $exception->getMessage() ); return new WP_Error( 'robotstxt_smtp_amazon_ses_unexpected_error', \sprintf( /* translators: %s: Error message describing the unexpected failure. */ \esc_html__( 'An unexpected error occurred while validating Amazon SES credentials: %s', 'robotstxt-smtp-amazonses' ), $message ) ); } return null; } /** * Schedules the SES quota update event if not already scheduled. * * @since 1.0.1 * * @return void */ public function maybe_schedule_quota_update(): void { if ( ! wp_next_scheduled( self::CRON_HOOK_UPDATE_QUOTA ) ) { wp_schedule_event( time(), 'daily', self::CRON_HOOK_UPDATE_QUOTA ); } } /** * Fetches current sending quotas from Amazon SES and updates rate limits. * * @since 1.0.1 * * @return void */ public function update_quota_from_ses(): void { $quota = $this->fetch_ses_send_quota(); if ( is_wp_error( $quota ) ) { if ( defined( 'WP_DEBUG' ) && WP_DEBUG && defined( 'WP_DEBUG_LOG' ) && WP_DEBUG_LOG ) { // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log error_log( sprintf( 'ROBOTSTXT SMTP Amazon SES: Failed to fetch send quota: %s', $quota->get_error_message() ) ); } return; } set_site_transient( self::get_quota_cache_key(), $quota, DAY_IN_SECONDS ); } /** * Fetches the current send quota from Amazon SES API. * * @since 1.0.1 * * @return array|WP_Error Quota data or error. */ private function fetch_ses_send_quota() { $settings = $this->get_stored_settings(); $access_key = (string) ( $settings[ self::OPTION_ACCESS_KEY ] ?? '' ); $secret_key = (string) ( $settings[ self::OPTION_SECRET_KEY ] ?? '' ); $region = (string) ( $settings[ self::OPTION_REGION ] ?? '' ); if ( '' === $access_key || '' === $secret_key || '' === $region ) { return new WP_Error( 'missing_credentials', __( 'Amazon SES credentials are not configured.', 'robotstxt-smtp-amazonses' ) ); } return $this->fetch_ses_quota_with_credentials( $access_key, $secret_key, $region ); } /** * Fetches the current send quota from Amazon SES API using provided credentials. * * @since 1.0.1 * * @param string $access_key AWS access key ID. * @param string $secret_key AWS secret access key. * @param string $region AWS region. * * @return array|WP_Error Quota data or error. */ private function fetch_ses_quota_with_credentials( string $access_key, string $secret_key, string $region ) { if ( '' === $access_key || '' === $secret_key || '' === $region ) { return new WP_Error( 'missing_credentials', __( 'Amazon SES credentials are not configured.', 'robotstxt-smtp-amazonses' ) ); } try { $client = $this->get_ses_client( $access_key, $secret_key, $region ); $result = $client->getAccount(); $send_quota = $result->get( 'SendQuota' ); if ( ! is_array( $send_quota ) ) { return new WP_Error( 'no_quota_data', __( 'Amazon SES did not return quota data.', 'robotstxt-smtp-amazonses' ) ); } $raw_max_24 = $send_quota['Max24HourSend'] ?? 0; $raw_rate = $send_quota['MaxSendRate'] ?? 0; $raw_sent_24 = $send_quota['SentLast24Hours'] ?? 0; return array( 'max_24_hour_send' => is_numeric( $raw_max_24 ) ? (int) $raw_max_24 : 0, 'max_send_rate' => is_numeric( $raw_rate ) ? (float) $raw_rate : 0.0, 'sent_last_24_hours' => is_numeric( $raw_sent_24 ) ? (int) $raw_sent_24 : 0, 'fetched_at' => time(), ); } catch ( AwsException $e ) { // Si el error es por falta de permisos para GetAccount, intentamos de todas formas pero no bloqueamos. $aws_error_code = $e->getAwsErrorCode(); if ( 'AccessDeniedException' === $aws_error_code || 'UnauthorizedException' === $aws_error_code ) { // No podemos obtener cuotas, pero no es un error crítico. return new WP_Error( 'quota_permission_denied', __( 'Cannot fetch SES quotas: IAM user lacks ses:GetAccount permission.', 'robotstxt-smtp-amazonses' ) ); } return new WP_Error( 'aws_api_error', sprintf( /* translators: %s: AWS error message */ __( 'Amazon SES API error: %s', 'robotstxt-smtp-amazonses' ), $e->getMessage() ) ); } catch ( Throwable $e ) { return new WP_Error( 'unknown_error', sprintf( /* translators: %s: Error message */ __( 'Failed to fetch SES quota: %s', 'robotstxt-smtp-amazonses' ), $e->getMessage() ) ); } } /** * Gets the transient cache key for Amazon SES quota data. * * Includes blog_id when not in network mode to prevent cache collisions * in MultiSite installations where different sites use different SES accounts. * * This is a public static method so the core SMTP plugin can also access * the correct transient key. * * @return string Transient key. */ public static function get_quota_cache_key(): string { // In network mode, all sites share the same quota cache. if ( Settings_Page::is_network_mode_enabled() ) { return 'robotstxt_smtp_amazonses_quota_network'; } // In site mode, each site has its own quota cache to prevent collisions. return 'robotstxt_smtp_amazonses_quota_' . \get_current_blog_id(); } /** * Gets cached SES quota data if available. * * @since 1.0.1 * * @return array|false Quota data or false if not cached. */ public function get_cached_ses_quota(): array|false { $result = \get_site_transient( self::get_quota_cache_key() ); return is_array( $result ) ? $result : false; } }