robotstxt-smtp-amazonses/includes/class-plugin.php
2025-11-26 13:39:30 +00:00

1192 lines
40 KiB
PHP

<?php
/**
* Amazon SES integration bootstrap.
*
* @package Robotstxt_SMTP_AmazonSES
*/
namespace Robotstxt_SMTP_AmazonSES;
use Aws\Credentials\Credentials;
use Aws\Endpoint\PartitionEndpointProvider;
use Aws\Exception\AwsException;
use Aws\SesV2\SesV2Client;
use PHPMailer\PHPMailer\Exception as PHPMailerException;
use PHPMailer\PHPMailer\PHPMailer;
use Robotstxt_SMTP\Admin\Settings_Page;
use Robotstxt_SMTP\Plugin as SMTP_Plugin;
use Throwable;
use WP_Error;
/**
* Amazon SES integration plugin class.
*/
class Plugin {
/**
* Option key used to store the Amazon SES access key ID.
*/
private const OPTION_ACCESS_KEY = 'amazon_ses_access_key';
/**
* Option key used to store the Amazon SES secret access key.
*/
private const OPTION_SECRET_KEY = 'amazon_ses_secret_key';
/**
* Option key used to store the selected Amazon SES region.
*/
private const OPTION_REGION = 'amazon_ses_region';
/**
* Identifier used for Amazon SES related settings notices.
*/
private const SETTINGS_ERROR_CREDENTIALS = 'robotstxt_smtp_amazon_ses_credentials';
/**
* Holds the singleton instance.
*
* @var Plugin|null
*/
private static ?Plugin $instance = null;
/**
* Cached list of available Amazon SES regions keyed by region code.
*
* @var array<string, string>|null
*/
private ?array $region_choices = null;
/**
* Cached SES clients indexed by a hash of the credentials and region.
*
* @var array<string, SesV2Client>
*/
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<string, mixed> $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<string, mixed> $mail_context Normalized mail data.
* @param array<string, mixed> $settings Stored plugin settings.
* @param array<string, mixed> $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<string, mixed> $mail_context Normalized mail data.
* @param array<string, mixed> $settings Stored plugin settings.
* @param array<string, mixed> $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<string, mixed> $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<string, mixed> $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<int, string>
*/
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<int, string> $headers Normalized header lines.
*
* @return array<string, mixed>
*/
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<int, string>
*/
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<int, string>
*/
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<int, array<string, mixed>> $fields Registered field definitions.
* @param Settings_Page $settings_page Settings page instance.
*
* @return array<int, array<string, mixed>>
*/
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();
?>
<input
name="<?php echo \esc_attr( $this->get_field_name( self::OPTION_ACCESS_KEY ) ); ?>"
type="text"
id="robotstxt_smtp_amazon_ses_access_key"
class="regular-text"
value="<?php echo \esc_attr( $settings[ self::OPTION_ACCESS_KEY ] ); ?>"
autocomplete="off"
/>
<p class="description"><?php \esc_html_e( 'Provide the IAM access key ID with permissions to send email through Amazon SES.', 'robotstxt-smtp-amazonses' ); ?></p>
<?php
}
/**
* Outputs the Amazon SES secret key field.
*
* @return void
*/
public function render_secret_key_field(): void {
$settings = $this->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' ) : '';
?>
<input
name="<?php echo \esc_attr( $this->get_field_name( self::OPTION_SECRET_KEY ) ); ?>"
type="password"
id="robotstxt_smtp_amazon_ses_secret_key"
class="regular-text"
value=""
autocomplete="new-password"
placeholder="<?php echo \esc_attr( $placeholder_label ); ?>"
/>
<p class="description"><?php \esc_html_e( 'Enter the secret access key associated with the IAM user. Leave the field empty to retain the existing value.', 'robotstxt-smtp-amazonses' ); ?></p>
<?php
}
/**
* Outputs the Amazon SES region selector.
*
* @return void
*/
public function render_region_field(): void {
$settings = $this->get_stored_settings();
$current_region = $settings[ self::OPTION_REGION ];
$regions = $this->get_region_choices();
?>
<select name="<?php echo \esc_attr( $this->get_field_name( self::OPTION_REGION ) ); ?>" id="robotstxt_smtp_amazon_ses_region">
<option value="" <?php \selected( '', $current_region ); ?>><?php \esc_html_e( 'Select a region', 'robotstxt-smtp-amazonses' ); ?></option>
<?php foreach ( $regions as $region => $label ) : ?>
<option value="<?php echo \esc_attr( $region ); ?>" <?php \selected( $current_region, $region ); ?>><?php echo \esc_html( $label ); ?></option>
<?php endforeach; ?>
</select>
<p class="description"><?php \esc_html_e( 'Choose the AWS region where your Amazon SES account is hosted.', 'robotstxt-smtp-amazonses' ); ?></p>
<?php
}
/**
* Sanitizes and validates the Amazon SES specific settings.
*
* @param array<string, mixed> $clean Sanitized option values prepared by the core plugin.
* @param array<string, mixed> $options Raw submitted option values after `wp_unslash()`.
* @param Settings_Page $settings Settings page instance.
*
* @return array<string, mixed>
*/
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<string, mixed> $clean Current sanitized values.
* @param array<string, mixed> $stored_settings Stored settings merged with defaults.
*
* @return array<string, mixed>
*/
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<string, mixed>
*/
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<string, string>
*/
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<string, string>
*/
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;
}
}