This commit is contained in:
Javier Casares 2026-06-09 12:52:52 +00:00
commit 19ef0566df
610 changed files with 21958 additions and 3112 deletions

View file

@ -126,12 +126,10 @@ class Plugin {
$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(
@ -198,12 +196,12 @@ class Plugin {
/**
* 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.
* @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
*/
@ -278,15 +276,15 @@ class Plugin {
\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,
'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 ) {
@ -314,9 +312,9 @@ class Plugin {
/**
* 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.
* @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
*/
@ -326,14 +324,21 @@ class Plugin {
$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' );
$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( (string) $charset );
if ( '' === $charset || 'null' === \strtolower( $charset ) ) {
$charset = 'utf-8';
}
$charset = \trim( $charset );
if ( '' === $charset || 'null' === \strtolower( $charset ) ) {
$charset = 'utf-8';
}
// phpcs:disable WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
$phpmailer->CharSet = $charset;
@ -538,43 +543,43 @@ class Plugin {
/**
* Determines the From email address based on the stored settings and filters.
*
* @param array<string, mixed> $settings Stored plugin settings.
* @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 ( isset( $settings['from_email'] ) && \is_email( $settings['from_email'] ) ) {
if ( \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;
if ( ! is_string( $filtered ) || ! \is_email( $filtered ) ) {
return $default_email;
}
return (string) $filtered;
return $filtered;
}
/**
* Determines the From name based on stored settings and filters.
*
* @param array<string, mixed> $settings Stored plugin settings.
* @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 ( isset( $settings['from_name'] ) && '' !== $settings['from_name'] ) {
if ( '' !== $settings['from_name'] ) {
$default_name = $settings['from_name'];
}
$filtered = \apply_filters( 'wp_mail_from_name', $default_name );
return (string) $filtered;
return is_string( $filtered ) ? $filtered : $default_name;
}
/**
@ -600,11 +605,11 @@ class Plugin {
$normalized = array();
foreach ( $headers as $header ) {
if ( is_array( $header ) ) {
if ( ! is_string( $header ) ) {
continue;
}
$line = trim( (string) $header );
$line = trim( $header );
if ( '' !== $line ) {
$normalized[] = $line;
@ -619,7 +624,14 @@ class Plugin {
*
* @param array<int, string> $headers Normalized header lines.
*
* @return array<string, mixed>
* @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(
@ -701,17 +713,17 @@ class Plugin {
}
if ( ! is_array( $recipients ) ) {
$recipients = explode( ',', (string) $recipients );
$recipients = is_string( $recipients ) ? explode( ',', $recipients ) : array();
}
$normalized = array();
foreach ( $recipients as $recipient ) {
if ( is_array( $recipient ) ) {
if ( ! is_string( $recipient ) ) {
continue;
}
$address = trim( (string) $recipient );
$address = trim( $recipient );
if ( '' !== $address ) {
$normalized[] = $address;
@ -740,11 +752,11 @@ class Plugin {
$normalized = array();
foreach ( $attachments as $attachment ) {
if ( is_array( $attachment ) ) {
if ( ! is_string( $attachment ) ) {
continue;
}
$path = trim( (string) $attachment );
$path = trim( $attachment );
if ( '' !== $path ) {
$normalized[] = $path;
@ -810,11 +822,11 @@ class Plugin {
$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',
'region' => $region,
'access_key' => $masked_key,
'sdk_version' => $sdk_version,
'endpoint' => $endpoint,
'timestamp' => \gmdate( 'Y-m-d H:i:s' ) . ' UTC',
);
}
@ -828,7 +840,6 @@ class Plugin {
public function declare_active( bool $is_active ): bool {
unset( $is_active );
return true;
}
@ -854,8 +865,12 @@ class Plugin {
\delete_transient( 'robotstxt_smtp_admin_notices' );
foreach ( $notices as $notice ) {
$type = $notice['type'] ?? 'info';
$message = $notice['message'] ?? '';
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(
@ -888,7 +903,8 @@ class Plugin {
// 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 = $nonce_post ?? $nonce_get ?? '';
$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' ) );
@ -1007,7 +1023,7 @@ class Plugin {
* @return void
*/
public function render_access_key_field(): void {
$settings = $this->get_stored_settings();
$settings = $this->get_stored_settings();
$has_access_key = '' !== $settings[ self::OPTION_ACCESS_KEY ];
$placeholder_label = $has_access_key ? \__( 'Leave empty to keep the stored access key.', 'robotstxt-smtp-amazonses' ) : '';
?>
@ -1079,8 +1095,6 @@ class Plugin {
public function sanitize_settings( array $clean, array $options, Settings_Page $settings ): array {
unset( $settings );
// Debug: Log incoming $clean array.
if ( ! SMTP_Plugin::is_amazon_ses_integration_active() ) {
return $clean;
}
@ -1094,7 +1108,8 @@ class Plugin {
$credentials_changed = false;
if ( array_key_exists( self::OPTION_ACCESS_KEY, $options ) ) {
$submitted_access_key = \trim( (string) $options[ self::OPTION_ACCESS_KEY ] );
$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 );
@ -1108,7 +1123,8 @@ class Plugin {
}
if ( array_key_exists( self::OPTION_SECRET_KEY, $options ) ) {
$submitted_secret_key = \trim( (string) $options[ self::OPTION_SECRET_KEY ] );
$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 );
@ -1122,7 +1138,8 @@ class Plugin {
}
if ( array_key_exists( self::OPTION_REGION, $options ) ) {
$submitted_region = \sanitize_text_field( (string) $options[ self::OPTION_REGION ] );
$raw_region = $options[ self::OPTION_REGION ] ?? null;
$submitted_region = \sanitize_text_field( is_string( $raw_region ) ? $raw_region : '' );
if ( '' === $submitted_region ) {
if ( '' !== $region ) {
@ -1152,10 +1169,6 @@ class Plugin {
$clean[ self::OPTION_SECRET_KEY ] = $secret_key;
$clean[ self::OPTION_REGION ] = $region;
// Temporary debug logging
// Debug: Check credentials_changed and values
if ( $credentials_changed && ( '' === $access_key || '' === $secret_key || '' === $region ) ) {
\add_settings_error(
$settings_error_slug,
@ -1197,16 +1210,14 @@ class Plugin {
}
}
// Debug: Log final $clean array before return.
return $clean;
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.
* @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>
*/
@ -1221,7 +1232,29 @@ class Plugin {
/**
* Retrieves the stored settings merged with defaults for the active scope.
*
* @return array<string, mixed>
* @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();
@ -1238,16 +1271,43 @@ class Plugin {
$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 ( isset( $settings[ self::OPTION_ACCESS_KEY ] ) && '' !== $settings[ self::OPTION_ACCESS_KEY ] ) {
$settings[ self::OPTION_ACCESS_KEY ] = \Robotstxt_SMTP_Encryption::decrypt( $settings[ self::OPTION_ACCESS_KEY ] );
if ( '' !== $access_key ) {
$access_key = \Robotstxt_SMTP_Encryption::decrypt( $access_key );
}
if ( isset( $settings[ self::OPTION_SECRET_KEY ] ) && '' !== $settings[ self::OPTION_SECRET_KEY ] ) {
$settings[ self::OPTION_SECRET_KEY ] = \Robotstxt_SMTP_Encryption::decrypt( $settings[ self::OPTION_SECRET_KEY ] );
if ( '' !== $secret_key ) {
$secret_key = \Robotstxt_SMTP_Encryption::decrypt( $secret_key );
}
return $settings;
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,
);
}
/**
@ -1303,7 +1363,7 @@ class Plugin {
$regions = $partition->getAvailableEndpoints( 'ses' );
foreach ( $regions as $region ) {
if ( is_string( $region ) && '' !== $region ) {
if ( '' !== $region ) {
$discovered_list[ $region ] = $labels[ $region ] ?? $this->format_region_label( $region );
}
}
@ -1561,17 +1621,21 @@ class Plugin {
$send_quota = $result->get( 'SendQuota' );
if ( ! $send_quota ) {
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' => (int) ( $send_quota['Max24HourSend'] ?? 0 ),
'max_send_rate' => (float) ( $send_quota['MaxSendRate'] ?? 0 ),
'sent_last_24_hours' => (int) ( $send_quota['SentLast24Hours'] ?? 0 ),
'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(),
);
@ -1606,56 +1670,6 @@ class Plugin {
}
}
/**
* Applies SES quota limits to the SMTP plugin rate limiting settings.
*
* @since 1.0.1
*
* @param array<string, mixed> $quota Quota data from SES.
* @return void
*/
private function apply_ses_quota_to_rate_limits( array $quota ): void {
$max_send_rate = (float) ( $quota['max_send_rate'] ?? 0 );
$max_24_hour_send = (int) ( $quota['max_24_hour_send'] ?? 0 );
$settings_page = \Robotstxt_SMTP\Admin\Settings_Page::class;
if ( ! class_exists( $settings_page ) ) {
return;
}
$is_network = ROBOTSTXT_SMTP_IS_MULTISITE && $settings_page::is_network_mode_enabled();
$option_name = $is_network ? $settings_page::NETWORK_OPTION_NAME : $settings_page::OPTION_NAME;
$settings = $is_network ? get_site_option( $option_name, array() ) : get_option( $option_name, array() );
if ( ! is_array( $settings ) ) {
$settings = array();
}
$settings['rate_limit_per_second'] = (int) floor( $max_send_rate );
$settings['rate_limit_per_hour'] = (int) floor( $max_send_rate * 3600 * 0.9 );
$settings['rate_limit_per_day'] = $max_24_hour_send;
if ( $is_network ) {
update_site_option( $option_name, $settings );
} else {
update_option( $option_name, $settings );
}
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: Updated rate limits from Amazon SES quota - Per second: %d, Per hour: %d, Per day: %d',
$settings['rate_limit_per_second'],
$settings['rate_limit_per_hour'],
$settings['rate_limit_per_day']
)
);
}
}
/**
* Gets the transient cache key for Amazon SES quota data.
*
@ -1682,9 +1696,10 @@ class Plugin {
*
* @since 1.0.1
*
* @return array<string, mixed>|false Quota data or false if not cached.
* @return array<string|int, mixed>|false Quota data or false if not cached.
*/
public function get_cached_ses_quota() {
return get_site_transient( self::get_quota_cache_key() );
public function get_cached_ses_quota(): array|false {
$result = \get_site_transient( self::get_quota_cache_key() );
return is_array( $result ) ? $result : false;
}
}