robotstxt-smtp-amazonses/includes/class-plugin.php
2026-09-01 06:17:08 +00:00

1934 lines
71 KiB
PHP

<?php
/**
* Amazon SES integration bootstrap.
*
* @package Robotstxt_SMTP_AmazonSES
*/
namespace Robotstxt_SMTP_AmazonSES;
defined( 'ABSPATH' ) || exit;
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';
/**
* Cron hook name for updating SES quotas.
*
* @var string
*/
private const CRON_HOOK_UPDATE_QUOTA = 'robotstxt_smtp_amazonses_update_quota';
/**
* 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();
/**
* Credential field being force-cleared during a direct option update.
*
* Set only for the duration of handle_clear_credential() to bypass the
* keep-existing logic in sanitize_settings() via a priority-11 filter.
*
* @var string|null
*/
private ?string $clearing_field = null;
/**
* 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' ) );
\add_action( 'admin_post_robotstxt_smtp_amazonses_clear_access_key', array( $this, 'handle_clear_access_key' ) );
\add_action( 'admin_post_robotstxt_smtp_amazonses_clear_secret_key', array( $this, 'handle_clear_secret_key' ) );
\add_action( 'admin_notices', array( $this, 'display_clear_key_notice' ) );
\add_action( 'network_admin_notices', array( $this, 'display_clear_key_notice' ) );
}
/**
* 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;
}
$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<int, string>, subject: string, message: string, headers: mixed, attachments: array<int, string>} $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<int, string>, bcc: array<int, string>, reply_to: array<int, string>, custom: array<int, array{name: string, value: string}>} $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<int, string>, subject: string, message: string, headers: mixed, attachments: array<int, string>} $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<int, string>, bcc: array<int, string>, reply_to: array<int, string>, custom: array<int, array{name: string, value: string}>} $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 ) {
list( $recipient_address, $recipient_name ) = self::split_name_and_address( $recipient );
try {
$phpmailer->addAddress( $recipient_address, $recipient_name );
} 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 ) {
list( $cc_address, $cc_name ) = self::split_name_and_address( $cc );
try {
$phpmailer->addCC( $cc_address, $cc_name );
} 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 ) {
list( $bcc_address, $bcc_name ) = self::split_name_and_address( $bcc );
try {
$phpmailer->addBCC( $bcc_address, $bcc_name );
} 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 ) {
list( $reply_to_address, $reply_to_name ) = self::split_name_and_address( $reply_to );
try {
$phpmailer->addReplyTo( $reply_to_address, $reply_to_name );
} 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<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_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<int, string> $headers Normalized header lines.
*
* @return array{
* content_type: string|null,
* charset: string|null,
* cc: array<int, string>,
* bcc: array<int, string>,
* reply_to: array<int, string>,
* custom: array<int, array{name: string, value: string}>
* }
*/
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<int, string>
*/
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;
}
/**
* Splits a recipient string into an email address and an optional display name.
*
* Callers such as wp_mail() may supply addresses in the combined
* "Name <email@example.com>" form. PHPMailer expects the address and the
* display name as separate arguments, so they must be split first.
*
* @since 2.1.9
*
* @param string $recipient Recipient as supplied to wp_mail().
* @return array{0: string, 1: string} Email address and display name (empty string when absent).
*/
public static function split_name_and_address( string $recipient ): array {
$recipient = trim( $recipient );
if ( preg_match( '/^(.*)<([^>]+)>$/s', $recipient, $matches ) ) {
$name = trim( (string) preg_replace( '/\s+/', ' ', $matches[1] ) );
$address = trim( $matches[2] );
if ( strlen( $name ) > 1 && '"' === $name[0] && '"' === substr( $name, -1 ) ) {
$name = substr( $name, 1, -1 );
}
return array( $address, $name );
}
return array( $recipient, '' );
}
/**
* 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_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<string, mixed> 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(
'<div class="notice notice-%s is-dismissible"><p>%s</p></div>',
\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<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',
),
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 = $has_access_key ? \__( 'Leave empty to keep the stored access key.', 'robotstxt-smtp-amazonses' ) : '';
$scope = \is_network_admin() ? 'network' : 'site';
?>
<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=""
autocomplete="off"
placeholder="<?php echo \esc_attr( $placeholder ); ?>"
/>
<?php if ( $has_access_key ) : ?>
<a href="
<?php
echo \esc_url(
\wp_nonce_url(
\add_query_arg(
array(
'action' => 'robotstxt_smtp_amazonses_clear_access_key',
'robotstxt_smtp_scope' => $scope,
),
\admin_url( 'admin-post.php' )
),
'robotstxt_smtp_amazonses_clear_access_key'
)
);
?>
" class="button button-secondary" style="margin-left: 6px;">
<?php \esc_html_e( 'Clear access key', 'robotstxt-smtp-amazonses' ); ?>
</a>
<?php endif; ?>
<p class="description"><?php \esc_html_e( 'Provide the IAM access key ID with permissions to send email through Amazon SES. Leave the field empty to retain the existing value.', '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 = $has_secret_key ? \__( 'Leave empty to keep the stored secret access key.', 'robotstxt-smtp-amazonses' ) : '';
$scope = \is_network_admin() ? 'network' : 'site';
?>
<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 ); ?>"
/>
<?php if ( $has_secret_key ) : ?>
<a href="
<?php
echo \esc_url(
\wp_nonce_url(
\add_query_arg(
array(
'action' => 'robotstxt_smtp_amazonses_clear_secret_key',
'robotstxt_smtp_scope' => $scope,
),
\admin_url( 'admin-post.php' )
),
'robotstxt_smtp_amazonses_clear_secret_key'
)
);
?>
" class="button button-secondary" style="margin-left: 6px;">
<?php \esc_html_e( 'Clear secret key', 'robotstxt-smtp-amazonses' ); ?>
</a>
<?php endif; ?>
<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 ) ) {
$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 );
}
}
// The parent plugin only updates reply_to_email when a valid address is submitted — it never
// clears the field. Explicitly clear it here when the user submits an empty value.
if ( array_key_exists( 'reply_to_email', $options ) ) {
$submitted_reply_to = is_string( $options['reply_to_email'] ) ? \sanitize_email( \trim( $options['reply_to_email'] ) ) : '';
if ( '' === $submitted_reply_to ) {
$clean['reply_to_email'] = '';
}
}
return $clean;
}
/**
* Restores the previously stored Amazon SES values in case validation fails.
*
* @param array<string, mixed> $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<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{
* 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<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 ( '' !== $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 ) {
$aws_error_code = $exception->getAwsErrorCode();
$message = $exception->getAwsErrorMessage();
if ( ! is_string( $message ) || '' === $message ) {
$message = $exception->getMessage();
}
$message = \esc_html( (string) $message );
// Block only when credentials are definitively wrong (wrong access key or signature).
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
)
);
}
// Any other AWS error (missing permission, wrong region, SES not enabled in region, etc.) —
// the credentials may still be valid for sending. Allow saving.
} catch ( Throwable $exception ) {
unset( $exception ); // Network error or timeout — cannot validate, allow saving.
}
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<string, mixed>|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<string, mixed>|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<string|int, mixed>|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;
}
/**
* Handles the request to clear the stored Access Key ID.
*
* @since 2.1.6
*
* @return void
*/
public function handle_clear_access_key(): void {
$this->handle_clear_credential(
self::OPTION_ACCESS_KEY,
'robotstxt_smtp_amazonses_clear_access_key',
'access_key'
);
}
/**
* Handles the request to clear the stored Secret Access Key.
*
* @since 2.1.6
*
* @return void
*/
public function handle_clear_secret_key(): void {
$this->handle_clear_credential(
self::OPTION_SECRET_KEY,
'robotstxt_smtp_amazonses_clear_secret_key',
'secret_key'
);
}
/**
* Clears a stored AWS credential from the settings option.
*
* Uses a priority-11 filter on `robotstxt_smtp_sanitized_options` to override
* the keep-existing logic in sanitize_settings() (priority 10) during the direct
* option update, matching the same pattern used by the parent plugin for the SMTP
* password.
*
* @since 2.1.6
*
* @param string $field Settings key to clear (OPTION_ACCESS_KEY or OPTION_SECRET_KEY).
* @param string $nonce_action Nonce action string for this specific clear request.
* @param string $cleared_key Value used in the success redirect query parameter.
*
* @return void
*/
private function handle_clear_credential( string $field, string $nonce_action, string $cleared_key ): void {
$nonce_raw = \filter_input( INPUT_GET, '_wpnonce', FILTER_UNSAFE_RAW );
$nonce = is_string( $nonce_raw ) ? (string) \wp_unslash( $nonce_raw ) : '';
if ( ! \wp_verify_nonce( $nonce, $nonce_action ) ) {
\wp_die( \esc_html__( 'The link you followed has expired.', 'robotstxt-smtp-amazonses' ) );
}
$scope_raw = \filter_input( INPUT_GET, 'robotstxt_smtp_scope', FILTER_UNSAFE_RAW );
$is_network = ROBOTSTXT_SMTP_IS_MULTISITE
&& is_string( $scope_raw )
&& 'network' === \sanitize_key( \wp_unslash( $scope_raw ) )
&& Settings_Page::is_network_mode_enabled();
$required_cap = $is_network ? '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' ) );
}
if ( $is_network ) {
$settings = \get_site_option( Settings_Page::NETWORK_OPTION_NAME, array() );
$settings = is_array( $settings ) ? $settings : array();
$settings[ $field ] = '';
\update_site_option( Settings_Page::NETWORK_OPTION_NAME, $settings );
$redirect_base = \network_admin_url( 'admin.php' );
$page_slug = 'robotstxt-smtp-network';
} else {
$settings = \get_option( Settings_Page::OPTION_NAME, array() );
$settings = is_array( $settings ) ? $settings : array();
$settings[ $field ] = '';
// sanitize_settings() at priority 10 preserves the existing value when the
// submitted field is empty. Hook at priority 11 to force the empty value after it.
$this->clearing_field = $field;
\add_filter( 'robotstxt_smtp_sanitized_options', array( $this, 'force_clear_credential' ), 11, 1 );
\update_option( Settings_Page::OPTION_NAME, $settings );
\remove_filter( 'robotstxt_smtp_sanitized_options', array( $this, 'force_clear_credential' ), 11 );
$this->clearing_field = null;
$redirect_base = \admin_url( 'admin.php' );
$page_slug = 'robotstxt-smtp';
}
\delete_site_transient( self::get_quota_cache_key() );
\wp_safe_redirect(
\add_query_arg(
array(
'page' => $page_slug,
'robotstxt_smtp_amazonses_cleared' => $cleared_key,
),
$redirect_base
)
);
exit;
}
/**
* Forces a credential field to empty string during a direct option update.
*
* Runs at priority 11, after sanitize_settings() at priority 10, and overrides
* its keep-existing logic. Only active during handle_clear_credential().
*
* @since 2.1.6
*
* @param array<string, mixed> $clean Sanitized option values.
*
* @return array<string, mixed>
*/
public function force_clear_credential( array $clean ): array {
if ( null !== $this->clearing_field ) {
$clean[ $this->clearing_field ] = '';
}
return $clean;
}
/**
* Displays a success notice after an AWS credential has been cleared.
*
* @since 2.1.6
*
* @return void
*/
public function display_clear_key_notice(): void {
$cleared_raw = \filter_input( INPUT_GET, 'robotstxt_smtp_amazonses_cleared', FILTER_SANITIZE_SPECIAL_CHARS );
$cleared_raw = is_string( $cleared_raw ) ? \sanitize_key( $cleared_raw ) : '';
if ( '' === $cleared_raw ) {
return;
}
$screen = \get_current_screen();
if ( null === $screen || false === strpos( $screen->id, 'robotstxt-smtp' ) ) {
return;
}
if ( 'access_key' === $cleared_raw ) {
$message = \__( 'The Amazon SES Access Key ID has been cleared.', 'robotstxt-smtp-amazonses' );
} elseif ( 'secret_key' === $cleared_raw ) {
$message = \__( 'The Amazon SES Secret Access Key has been cleared.', 'robotstxt-smtp-amazonses' );
} else {
return;
}
printf(
'<div class="notice notice-success is-dismissible"><p>%s</p></div>',
\esc_html( $message )
);
}
}