1629 lines
53 KiB
PHP
1629 lines
53 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();
|
|
|
|
/**
|
|
* Retrieves the instance.
|
|
*
|
|
* @return Plugin
|
|
*/
|
|
public static function get_instance(): Plugin {
|
|
if ( null === self::$instance ) {
|
|
self::$instance = new self();
|
|
}
|
|
|
|
return self::$instance;
|
|
}
|
|
|
|
/**
|
|
* Registers WordPress hooks used by the integration.
|
|
*
|
|
* @return void
|
|
*/
|
|
public function run(): void {
|
|
\add_filter( 'robotstxt_smtp_amazon_ses_active', array( $this, 'declare_active' ) );
|
|
\add_filter( 'robotstxt_smtp_connection_field_definitions', array( $this, 'provide_field_definitions' ), 10, 2 );
|
|
\add_filter( 'robotstxt_smtp_sanitized_options', array( $this, 'sanitize_settings' ), 10, 3 );
|
|
\add_filter( 'pre_wp_mail', array( $this, 'maybe_route_mail_via_amazon_ses' ), 10, 2 );
|
|
\add_action( 'init', array( $this, 'maybe_schedule_quota_update' ) );
|
|
\add_action( self::CRON_HOOK_UPDATE_QUOTA, array( $this, 'update_quota_from_ses' ) );
|
|
\add_action( 'admin_init', array( $this, 'handle_refresh_quota_request' ) );
|
|
\add_action( 'admin_notices', array( $this, 'display_admin_notices' ) );
|
|
\add_action( 'network_admin_notices', array( $this, 'display_admin_notices' ) );
|
|
}
|
|
|
|
/**
|
|
* Routes outgoing mail through Amazon SES when credentials are available.
|
|
*
|
|
* @param mixed $pre_wp_mail Short-circuit value provided by earlier filters.
|
|
* @param array<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<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' ),
|
|
\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(),
|
|
)
|
|
);
|
|
}
|
|
|
|
$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(),
|
|
)
|
|
);
|
|
} 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'],
|
|
)
|
|
);
|
|
}
|
|
|
|
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' ),
|
|
\esc_html( $exception->getMessage() )
|
|
),
|
|
array(
|
|
'to' => $mail_context['to'],
|
|
'subject' => $mail_context['subject'],
|
|
'message' => $mail_context['message'],
|
|
'headers' => $mail_context['headers'],
|
|
'attachments' => $mail_context['attachments'],
|
|
)
|
|
);
|
|
}
|
|
|
|
foreach ( $mail_context['to'] as $recipient ) {
|
|
try {
|
|
$phpmailer->addAddress( $recipient );
|
|
} catch ( PHPMailerException $exception ) {
|
|
return new WP_Error(
|
|
'robotstxt_smtp_amazon_ses_invalid_recipient',
|
|
\sprintf(
|
|
/* translators: %s: Invalid recipient email address. */
|
|
\esc_html__( 'Amazon SES rejected the recipient address: %s', 'robotstxt-smtp-amazonses' ),
|
|
\esc_html( $recipient )
|
|
),
|
|
array(
|
|
'to' => $mail_context['to'],
|
|
'subject' => $mail_context['subject'],
|
|
'message' => $mail_context['message'],
|
|
'headers' => $mail_context['headers'],
|
|
'attachments' => $mail_context['attachments'],
|
|
)
|
|
);
|
|
}
|
|
}
|
|
|
|
foreach ( $parsed_headers['cc'] ?? array() as $cc ) {
|
|
try {
|
|
$phpmailer->addCC( $cc );
|
|
} catch ( PHPMailerException $exception ) {
|
|
return new WP_Error(
|
|
'robotstxt_smtp_amazon_ses_invalid_cc',
|
|
\sprintf(
|
|
/* translators: %s: Invalid CC email address. */
|
|
\esc_html__( 'Amazon SES rejected the CC address: %s', 'robotstxt-smtp-amazonses' ),
|
|
\esc_html( $cc )
|
|
),
|
|
array(
|
|
'to' => $mail_context['to'],
|
|
'subject' => $mail_context['subject'],
|
|
'message' => $mail_context['message'],
|
|
'headers' => $mail_context['headers'],
|
|
'attachments' => $mail_context['attachments'],
|
|
)
|
|
);
|
|
}
|
|
}
|
|
|
|
foreach ( $parsed_headers['bcc'] ?? array() as $bcc ) {
|
|
try {
|
|
$phpmailer->addBCC( $bcc );
|
|
} catch ( PHPMailerException $exception ) {
|
|
return new WP_Error(
|
|
'robotstxt_smtp_amazon_ses_invalid_bcc',
|
|
\sprintf(
|
|
/* translators: %s: Invalid BCC email address. */
|
|
\esc_html__( 'Amazon SES rejected the BCC address: %s', 'robotstxt-smtp-amazonses' ),
|
|
\esc_html( $bcc )
|
|
),
|
|
array(
|
|
'to' => $mail_context['to'],
|
|
'subject' => $mail_context['subject'],
|
|
'message' => $mail_context['message'],
|
|
'headers' => $mail_context['headers'],
|
|
'attachments' => $mail_context['attachments'],
|
|
)
|
|
);
|
|
}
|
|
}
|
|
|
|
foreach ( $parsed_headers['reply_to'] ?? array() as $reply_to ) {
|
|
try {
|
|
$phpmailer->addReplyTo( $reply_to );
|
|
} catch ( PHPMailerException $exception ) {
|
|
return new WP_Error(
|
|
'robotstxt_smtp_amazon_ses_invalid_reply_to',
|
|
\sprintf(
|
|
/* translators: %s: Invalid reply-to email address. */
|
|
\esc_html__( 'Amazon SES rejected the reply-to address: %s', 'robotstxt-smtp-amazonses' ),
|
|
\esc_html( $reply_to )
|
|
),
|
|
array(
|
|
'to' => $mail_context['to'],
|
|
'subject' => $mail_context['subject'],
|
|
'message' => $mail_context['message'],
|
|
'headers' => $mail_context['headers'],
|
|
'attachments' => $mail_context['attachments'],
|
|
)
|
|
);
|
|
}
|
|
}
|
|
|
|
// Add Reply-To from settings if not already set by headers.
|
|
// phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
|
|
if ( empty( $phpmailer->getReplyToAddresses() ) ) {
|
|
$reply_to_email = isset( $settings['reply_to_email'] ) ? \sanitize_email( $settings['reply_to_email'] ) : '';
|
|
|
|
if ( ! empty( $reply_to_email ) && \is_email( $reply_to_email ) ) {
|
|
$reply_to_name = isset( $settings['reply_to_name'] ) ? \sanitize_text_field( $settings['reply_to_name'] ) : '';
|
|
|
|
try {
|
|
$phpmailer->addReplyTo( $reply_to_email, $reply_to_name );
|
|
} catch ( PHPMailerException $exception ) {
|
|
return new WP_Error(
|
|
'robotstxt_smtp_amazon_ses_invalid_reply_to_settings',
|
|
\sprintf(
|
|
/* translators: %s: Invalid reply-to email address from settings. */
|
|
\esc_html__( 'Amazon SES rejected the configured reply-to address: %s', 'robotstxt-smtp-amazonses' ),
|
|
\esc_html( $reply_to_email )
|
|
),
|
|
array(
|
|
'to' => $mail_context['to'],
|
|
'subject' => $mail_context['subject'],
|
|
'message' => $mail_context['message'],
|
|
'headers' => $mail_context['headers'],
|
|
'attachments' => $mail_context['attachments'],
|
|
)
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
$phpmailer->ContentType = $content_type;
|
|
|
|
if ( 'text/html' === strtolower( $content_type ) ) {
|
|
$phpmailer->isHTML( true );
|
|
} else {
|
|
$phpmailer->isHTML( false );
|
|
}
|
|
|
|
foreach ( $parsed_headers['custom'] ?? array() as $custom_header ) {
|
|
if ( isset( $custom_header['name'], $custom_header['value'] ) ) {
|
|
$phpmailer->addCustomHeader( (string) $custom_header['name'], (string) $custom_header['value'] );
|
|
}
|
|
}
|
|
|
|
$phpmailer->Subject = \wp_specialchars_decode( $mail_context['subject'], ENT_QUOTES );
|
|
|
|
$message = (string) $mail_context['message'];
|
|
|
|
if ( $phpmailer->isHTML() ) {
|
|
$phpmailer->msgHTML( $message );
|
|
|
|
if ( '' === $phpmailer->AltBody ) {
|
|
$phpmailer->AltBody = \wp_strip_all_tags( $message );
|
|
}
|
|
} else {
|
|
$phpmailer->Body = $message;
|
|
}
|
|
|
|
foreach ( $mail_context['attachments'] as $attachment ) {
|
|
try {
|
|
$phpmailer->addAttachment( $attachment );
|
|
} catch ( PHPMailerException $exception ) {
|
|
return new WP_Error(
|
|
'robotstxt_smtp_amazon_ses_invalid_attachment',
|
|
\sprintf(
|
|
/* translators: %s: Attachment path that could not be added. */
|
|
\esc_html__( 'Amazon SES could not include the attachment: %s', 'robotstxt-smtp-amazonses' ),
|
|
\esc_html( $attachment )
|
|
),
|
|
array(
|
|
'to' => $mail_context['to'],
|
|
'subject' => $mail_context['subject'],
|
|
'message' => $mail_context['message'],
|
|
'headers' => $mail_context['headers'],
|
|
'attachments' => $mail_context['attachments'],
|
|
'phpmailer_exception_code' => $exception->getCode(),
|
|
)
|
|
);
|
|
}
|
|
}
|
|
|
|
// phpcs:enable WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
|
|
|
|
return $phpmailer;
|
|
}
|
|
/**
|
|
* Determines the From email address based on the stored settings and filters.
|
|
*
|
|
* @param array<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;
|
|
}
|
|
|
|
/**
|
|
* 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 ) {
|
|
$type = $notice['type'] ?? 'info';
|
|
$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 = $nonce_post ?? $nonce_get ?? '';
|
|
|
|
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_label = $has_access_key ? \__( 'Leave empty to keep the stored access key.', 'robotstxt-smtp-amazonses' ) : '';
|
|
?>
|
|
<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_label ); ?>"
|
|
/>
|
|
<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_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 );
|
|
|
|
// Debug: Log incoming $clean array.
|
|
|
|
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;
|
|
|
|
// Temporary debug logging
|
|
|
|
// Debug: Check credentials_changed and values
|
|
|
|
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 );
|
|
}
|
|
}
|
|
|
|
|
|
// Debug: Log final $clean array before return.
|
|
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();
|
|
}
|
|
|
|
$settings = \wp_parse_args( $settings, Settings_Page::get_default_settings() );
|
|
|
|
// 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 ( 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 ] );
|
|
}
|
|
|
|
return $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 {
|
|
// 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 ( 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 ) {
|
|
$aws_error_code = $exception->getAwsErrorCode();
|
|
$message = $exception->getAwsErrorMessage();
|
|
|
|
if ( ! is_string( $message ) || '' === $message ) {
|
|
$message = $exception->getMessage();
|
|
}
|
|
|
|
$message = \esc_html( (string) $message );
|
|
|
|
// Allow saving if the error is due to missing IAM permissions (not authorization to use SES itself).
|
|
// The credentials may still work for sending emails which requires different permissions.
|
|
if ( 'AccessDeniedException' === $aws_error_code || 'UnauthorizedException' === $aws_error_code ) {
|
|
if ( \strpos( $message, 'not authorized to perform: ses:GetAccount' ) !== false ) {
|
|
// Permission error for GetAccount API - credentials might still work for sending.
|
|
// Return null (success) but we could add a notice here if needed.
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// For authentication errors (invalid credentials), prevent saving.
|
|
if ( in_array( $aws_error_code, array( 'InvalidClientTokenId', 'SignatureDoesNotMatch', 'InvalidAccessKeyId' ), true ) ) {
|
|
return new WP_Error(
|
|
'robotstxt_smtp_amazon_ses_invalid_credentials',
|
|
\sprintf(
|
|
/* translators: %s: Error message from Amazon SES. */
|
|
\esc_html__( 'Invalid Amazon SES credentials: %s', 'robotstxt-smtp-amazonses' ),
|
|
$message
|
|
)
|
|
);
|
|
}
|
|
|
|
// For other AWS errors, show warning but allow saving.
|
|
return new WP_Error(
|
|
'robotstxt_smtp_amazon_ses_invalid_credentials',
|
|
\sprintf(
|
|
/* translators: %s: Error message from Amazon SES. */
|
|
\esc_html__( 'Could not verify Amazon SES credentials: %s', 'robotstxt-smtp-amazonses' ),
|
|
$message
|
|
)
|
|
);
|
|
} catch ( Throwable $exception ) {
|
|
$message = \esc_html( $exception->getMessage() );
|
|
|
|
return new WP_Error(
|
|
'robotstxt_smtp_amazon_ses_unexpected_error',
|
|
\sprintf(
|
|
/* translators: %s: Error message describing the unexpected failure. */
|
|
\esc_html__( 'An unexpected error occurred while validating Amazon SES credentials: %s', 'robotstxt-smtp-amazonses' ),
|
|
$message
|
|
)
|
|
);
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Schedules the SES quota update event if not already scheduled.
|
|
*
|
|
* @since 1.0.1
|
|
*
|
|
* @return void
|
|
*/
|
|
public function maybe_schedule_quota_update(): void {
|
|
if ( ! wp_next_scheduled( self::CRON_HOOK_UPDATE_QUOTA ) ) {
|
|
wp_schedule_event( time(), 'daily', self::CRON_HOOK_UPDATE_QUOTA );
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Fetches current sending quotas from Amazon SES and updates rate limits.
|
|
*
|
|
* @since 1.0.1
|
|
*
|
|
* @return void
|
|
*/
|
|
public function update_quota_from_ses(): void {
|
|
$quota = $this->fetch_ses_send_quota();
|
|
|
|
if ( is_wp_error( $quota ) ) {
|
|
if ( defined( 'WP_DEBUG' ) && WP_DEBUG && defined( 'WP_DEBUG_LOG' ) && WP_DEBUG_LOG ) {
|
|
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
|
|
error_log( sprintf( 'ROBOTSTXT SMTP Amazon SES: Failed to fetch send quota: %s', $quota->get_error_message() ) );
|
|
}
|
|
return;
|
|
}
|
|
|
|
set_site_transient( self::get_quota_cache_key(), $quota, DAY_IN_SECONDS );
|
|
}
|
|
|
|
/**
|
|
* Fetches the current send quota from Amazon SES API.
|
|
*
|
|
* @since 1.0.1
|
|
*
|
|
* @return array<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 ( ! $send_quota ) {
|
|
return new WP_Error(
|
|
'no_quota_data',
|
|
__( 'Amazon SES did not return quota data.', 'robotstxt-smtp-amazonses' )
|
|
);
|
|
}
|
|
|
|
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 ),
|
|
'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()
|
|
)
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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.
|
|
*
|
|
* 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, mixed>|false Quota data or false if not cached.
|
|
*/
|
|
public function get_cached_ses_quota() {
|
|
return get_site_transient( self::get_quota_cache_key() );
|
|
}
|
|
}
|