|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 ); } /** * 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; } if ( ! SMTP_Plugin::is_amazon_ses_integration_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 $mail_context Normalized mail data. * @param array $settings Stored plugin settings. * @param 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 { $phpmailer = $this->prepare_mailer_for_amazon_ses( $mail_context, $settings, $parsed_headers ); if ( $phpmailer instanceof WP_Error ) { 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' ), \wp_strip_all_tags( $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(), ) ); } $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' ), \wp_strip_all_tags( (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(), ) ); } 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' ), \wp_strip_all_tags( $exception->getMessage() ) ), array( 'to' => $mail_context['to'], 'subject' => $mail_context['subject'], 'message' => $mail_context['message'], 'headers' => $mail_context['headers'], 'attachments' => $mail_context['attachments'], ) ); } return null; } /** * Builds a PHPMailer instance configured with the provided mail data. * * @param array $mail_context Normalized mail data. * @param array $settings Stored plugin settings. * @param 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 = is_string( $parsed_headers['content_type'] ?? '' ) && '' !== $parsed_headers['content_type'] ? $parsed_headers['content_type'] : ( is_string( $default_content_type ) ? $default_content_type : 'text/plain' ); $charset = is_string( $parsed_headers['charset'] ?? '' ) && '' !== $parsed_headers['charset'] ? $parsed_headers['charset'] : ( is_string( $default_charset ) ? $default_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' ), \wp_strip_all_tags( $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' ), \wp_strip_all_tags( $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' ), \wp_strip_all_tags( $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' ), \wp_strip_all_tags( $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' ), \wp_strip_all_tags( $reply_to ) ), 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' ), \wp_strip_all_tags( $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 $settings Stored plugin settings. * * @return string */ private function determine_from_email( array $settings ): string { $default_email = \get_bloginfo( 'admin_email' ); if ( isset( $settings['from_email'] ) && \is_email( $settings['from_email'] ) ) { $default_email = $settings['from_email']; } $filtered = \apply_filters( 'wp_mail_from', $default_email ); if ( ! \is_email( $filtered ) ) { return (string) $default_email; } return (string) $filtered; } /** * Determines the From name based on stored settings and filters. * * @param array $settings Stored plugin settings. * * @return string */ private function determine_from_name( array $settings ): string { $default_name = \get_bloginfo( 'name', 'display' ); if ( isset( $settings['from_name'] ) && '' !== $settings['from_name'] ) { $default_name = $settings['from_name']; } $filtered = \apply_filters( 'wp_mail_from_name', $default_name ); return (string) $filtered; } /** * 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_array( $header ) ) { continue; } $line = trim( (string) $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 */ 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 ) ) ) { $parsed['charset'] = trim( $param_value, " \n\r\0\x0B\"'" ); } } } 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 = explode( ',', (string) $recipients ); } $normalized = array(); foreach ( $recipients as $recipient ) { if ( is_array( $recipient ) ) { continue; } $address = trim( (string) $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_array( $attachment ) ) { continue; } $path = trim( (string) $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; } /** * 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; } /** * 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', ), ); } /** * Outputs the Amazon SES access key field. * * @return void */ public function render_access_key_field(): void { $settings = $this->get_stored_settings(); ?>

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 ) ) { $submitted_access_key = \trim( (string) $options[ self::OPTION_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 ) ) { $submitted_secret_key = \trim( (string) $options[ self::OPTION_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 ) ) { $submitted_region = \sanitize_text_field( (string) $options[ self::OPTION_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' ); } return $clean; } /** * Restores the previously stored Amazon SES values in case validation fails. * * @param array $clean Current sanitized values. * @param array $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 */ 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(); } return \wp_parse_args( $settings, Settings_Page::get_default_settings() ); } /** * 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 { if ( \is_network_admin() && 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 ( is_string( $region ) && '' !== $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 ) { $message = $exception->getAwsErrorMessage(); if ( ! is_string( $message ) || '' === $message ) { $message = $exception->getMessage(); } $message = \wp_strip_all_tags( (string) $message ); 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 = \wp_strip_all_tags( $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; } }