1192 lines
40 KiB
PHP
1192 lines
40 KiB
PHP
<?php
|
|
/**
|
|
* Amazon SES integration bootstrap.
|
|
*
|
|
* @package Robotstxt_SMTP_AmazonSES
|
|
*/
|
|
|
|
namespace Robotstxt_SMTP_AmazonSES;
|
|
|
|
use Aws\Credentials\Credentials;
|
|
use Aws\Endpoint\PartitionEndpointProvider;
|
|
use Aws\Exception\AwsException;
|
|
use Aws\SesV2\SesV2Client;
|
|
use PHPMailer\PHPMailer\Exception as PHPMailerException;
|
|
use PHPMailer\PHPMailer\PHPMailer;
|
|
use Robotstxt_SMTP\Admin\Settings_Page;
|
|
use Robotstxt_SMTP\Plugin as SMTP_Plugin;
|
|
use Throwable;
|
|
use WP_Error;
|
|
|
|
/**
|
|
* Amazon SES integration plugin class.
|
|
*/
|
|
class Plugin {
|
|
/**
|
|
* Option key used to store the Amazon SES access key ID.
|
|
*/
|
|
private const OPTION_ACCESS_KEY = 'amazon_ses_access_key';
|
|
|
|
/**
|
|
* Option key used to store the Amazon SES secret access key.
|
|
*/
|
|
private const OPTION_SECRET_KEY = 'amazon_ses_secret_key';
|
|
|
|
/**
|
|
* Option key used to store the selected Amazon SES region.
|
|
*/
|
|
private const OPTION_REGION = 'amazon_ses_region';
|
|
|
|
/**
|
|
* Identifier used for Amazon SES related settings notices.
|
|
*/
|
|
private const SETTINGS_ERROR_CREDENTIALS = 'robotstxt_smtp_amazon_ses_credentials';
|
|
|
|
/**
|
|
* Holds the singleton instance.
|
|
*
|
|
* @var Plugin|null
|
|
*/
|
|
private static ?Plugin $instance = null;
|
|
|
|
/**
|
|
* Cached list of available Amazon SES regions keyed by region code.
|
|
*
|
|
* @var array<string, string>|null
|
|
*/
|
|
private ?array $region_choices = null;
|
|
|
|
/**
|
|
* Cached SES clients indexed by a hash of the credentials and region.
|
|
*
|
|
* @var array<string, SesV2Client>
|
|
*/
|
|
private array $client_cache = array();
|
|
|
|
/**
|
|
* Retrieves the instance.
|
|
*
|
|
* @return Plugin
|
|
*/
|
|
public static function get_instance(): Plugin {
|
|
if ( null === self::$instance ) {
|
|
self::$instance = new self();
|
|
}
|
|
|
|
return self::$instance;
|
|
}
|
|
|
|
/**
|
|
* Registers WordPress hooks used by the integration.
|
|
*
|
|
* @return void
|
|
*/
|
|
public function run(): void {
|
|
\add_filter( 'robotstxt_smtp_amazon_ses_active', array( $this, 'declare_active' ) );
|
|
\add_filter( 'robotstxt_smtp_connection_field_definitions', array( $this, 'provide_field_definitions' ), 10, 2 );
|
|
\add_filter( 'robotstxt_smtp_sanitized_options', array( $this, 'sanitize_settings' ), 10, 3 );
|
|
\add_filter( 'pre_wp_mail', array( $this, 'maybe_route_mail_via_amazon_ses' ), 10, 2 );
|
|
}
|
|
|
|
/**
|
|
* Routes outgoing mail through Amazon SES when credentials are available.
|
|
*
|
|
* @param mixed $pre_wp_mail Short-circuit value provided by earlier filters.
|
|
* @param array<string, mixed> $mail_data Normalized email arguments from wp_mail().
|
|
*
|
|
* @return mixed
|
|
*/
|
|
public function maybe_route_mail_via_amazon_ses( $pre_wp_mail, array $mail_data ) {
|
|
if ( null !== $pre_wp_mail ) {
|
|
return $pre_wp_mail;
|
|
}
|
|
|
|
if ( ! SMTP_Plugin::is_amazon_ses_integration_active() ) {
|
|
return $pre_wp_mail;
|
|
}
|
|
|
|
$settings = $this->get_stored_settings();
|
|
$access_key = (string) ( $settings[ self::OPTION_ACCESS_KEY ] ?? '' );
|
|
$secret_key = (string) ( $settings[ self::OPTION_SECRET_KEY ] ?? '' );
|
|
$region = (string) ( $settings[ self::OPTION_REGION ] ?? '' );
|
|
|
|
if ( '' === $access_key || '' === $secret_key || '' === $region ) {
|
|
return $pre_wp_mail;
|
|
}
|
|
|
|
$mail_data = \wp_parse_args(
|
|
$mail_data,
|
|
array(
|
|
'to' => array(),
|
|
'subject' => '',
|
|
'message' => '',
|
|
'headers' => array(),
|
|
'attachments' => array(),
|
|
)
|
|
);
|
|
|
|
$recipients = $this->normalize_recipients( $mail_data['to'] );
|
|
|
|
if ( empty( $recipients ) ) {
|
|
$error = new WP_Error(
|
|
'robotstxt_smtp_amazon_ses_missing_recipient',
|
|
\esc_html__( 'Amazon SES could not send the email because no recipient was provided.', 'robotstxt-smtp-amazonses' ),
|
|
array(
|
|
'to' => array(),
|
|
'subject' => (string) $mail_data['subject'],
|
|
'message' => (string) $mail_data['message'],
|
|
'headers' => $mail_data['headers'],
|
|
'attachments' => $this->normalize_attachments( $mail_data['attachments'] ),
|
|
)
|
|
);
|
|
|
|
\do_action( 'wp_mail_failed', $error );
|
|
|
|
return $error;
|
|
}
|
|
|
|
$attachments = $this->normalize_attachments( $mail_data['attachments'] );
|
|
$headers = $this->normalize_headers( $mail_data['headers'] );
|
|
$parsed = $this->parse_headers( $headers );
|
|
|
|
$mail_context = array(
|
|
'to' => $recipients,
|
|
'subject' => (string) $mail_data['subject'],
|
|
'message' => (string) $mail_data['message'],
|
|
'headers' => $mail_data['headers'],
|
|
'attachments' => $attachments,
|
|
);
|
|
|
|
$send_error = $this->deliver_via_amazon_ses(
|
|
$mail_context,
|
|
$settings,
|
|
$parsed,
|
|
$access_key,
|
|
$secret_key,
|
|
$region
|
|
);
|
|
|
|
if ( $send_error instanceof WP_Error ) {
|
|
\do_action( 'wp_mail_failed', $send_error );
|
|
|
|
return $send_error;
|
|
}
|
|
|
|
\do_action( 'wp_mail_succeeded', $mail_context );
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Sends the email data to Amazon SES using the SDK client.
|
|
*
|
|
* @param array<string, mixed> $mail_context Normalized mail data.
|
|
* @param array<string, mixed> $settings Stored plugin settings.
|
|
* @param array<string, mixed> $parsed_headers Structured header data.
|
|
* @param string $access_key AWS access key ID.
|
|
* @param string $secret_key AWS secret access key.
|
|
* @param string $region AWS region identifier.
|
|
*
|
|
* @return WP_Error|null
|
|
*/
|
|
private function deliver_via_amazon_ses( array $mail_context, array $settings, array $parsed_headers, string $access_key, string $secret_key, string $region ): ?WP_Error {
|
|
$phpmailer = $this->prepare_mailer_for_amazon_ses( $mail_context, $settings, $parsed_headers );
|
|
|
|
if ( $phpmailer instanceof WP_Error ) {
|
|
return $phpmailer;
|
|
}
|
|
|
|
try {
|
|
$phpmailer->preSend();
|
|
} catch ( PHPMailerException $exception ) {
|
|
return new WP_Error(
|
|
'robotstxt_smtp_amazon_ses_presend_failed',
|
|
\sprintf(
|
|
/* translators: %s: Error details describing the email preparation failure. */
|
|
\esc_html__( 'Amazon SES could not prepare the email: %s', 'robotstxt-smtp-amazonses' ),
|
|
\wp_strip_all_tags( $exception->getMessage() )
|
|
),
|
|
array(
|
|
'to' => $mail_context['to'],
|
|
'subject' => $mail_context['subject'],
|
|
'message' => $mail_context['message'],
|
|
'headers' => $mail_context['headers'],
|
|
'attachments' => $mail_context['attachments'],
|
|
'phpmailer_exception_code' => $exception->getCode(),
|
|
)
|
|
);
|
|
}
|
|
|
|
$raw_message = $phpmailer->getSentMIMEMessage();
|
|
|
|
try {
|
|
$client = $this->get_ses_client( $access_key, $secret_key, $region );
|
|
|
|
$client->sendEmail(
|
|
array(
|
|
'Content' => array(
|
|
'Raw' => array(
|
|
'Data' => $raw_message,
|
|
),
|
|
),
|
|
// phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
|
|
'FromEmailAddress' => $phpmailer->From,
|
|
)
|
|
);
|
|
} catch ( AwsException $exception ) {
|
|
$message = $exception->getAwsErrorMessage();
|
|
|
|
if ( ! is_string( $message ) || '' === $message ) {
|
|
$message = $exception->getMessage();
|
|
}
|
|
|
|
return new WP_Error(
|
|
'robotstxt_smtp_amazon_ses_send_failed',
|
|
\sprintf(
|
|
/* translators: %s: Error message returned by Amazon SES. */
|
|
\esc_html__( 'Amazon SES could not send the email: %s', 'robotstxt-smtp-amazonses' ),
|
|
\wp_strip_all_tags( (string) $message )
|
|
),
|
|
array(
|
|
'to' => $mail_context['to'],
|
|
'subject' => $mail_context['subject'],
|
|
'message' => $mail_context['message'],
|
|
'headers' => $mail_context['headers'],
|
|
'attachments' => $mail_context['attachments'],
|
|
'ses_error_code' => $exception->getAwsErrorCode(),
|
|
)
|
|
);
|
|
} catch ( Throwable $exception ) {
|
|
return new WP_Error(
|
|
'robotstxt_smtp_amazon_ses_unexpected_send_error',
|
|
\sprintf(
|
|
/* translators: %s: Error message describing the unexpected failure. */
|
|
\esc_html__( 'An unexpected error occurred while sending through Amazon SES: %s', 'robotstxt-smtp-amazonses' ),
|
|
\wp_strip_all_tags( $exception->getMessage() )
|
|
),
|
|
array(
|
|
'to' => $mail_context['to'],
|
|
'subject' => $mail_context['subject'],
|
|
'message' => $mail_context['message'],
|
|
'headers' => $mail_context['headers'],
|
|
'attachments' => $mail_context['attachments'],
|
|
)
|
|
);
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Builds a PHPMailer instance configured with the provided mail data.
|
|
*
|
|
* @param array<string, mixed> $mail_context Normalized mail data.
|
|
* @param array<string, mixed> $settings Stored plugin settings.
|
|
* @param array<string, mixed> $parsed_headers Structured header data.
|
|
*
|
|
* @return PHPMailer|WP_Error
|
|
*/
|
|
private function prepare_mailer_for_amazon_ses( array $mail_context, array $settings, array $parsed_headers ) {
|
|
$phpmailer = new PHPMailer( true );
|
|
|
|
$default_content_type = \apply_filters( 'wp_mail_content_type', 'text/plain' );
|
|
$default_charset = \apply_filters( 'wp_mail_charset', \get_bloginfo( 'charset' ) );
|
|
|
|
$content_type = is_string( $parsed_headers['content_type'] ?? '' ) && '' !== $parsed_headers['content_type'] ? $parsed_headers['content_type'] : ( is_string( $default_content_type ) ? $default_content_type : 'text/plain' );
|
|
$charset = is_string( $parsed_headers['charset'] ?? '' ) && '' !== $parsed_headers['charset'] ? $parsed_headers['charset'] : ( is_string( $default_charset ) ? $default_charset : 'utf-8' );
|
|
|
|
// phpcs:disable WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
|
|
$phpmailer->CharSet = $charset;
|
|
$phpmailer->Encoding = '8bit';
|
|
|
|
$from_email = $this->determine_from_email( $settings );
|
|
$from_name = $this->determine_from_name( $settings );
|
|
|
|
try {
|
|
$phpmailer->setFrom( $from_email, $from_name, false );
|
|
} catch ( PHPMailerException $exception ) {
|
|
return new WP_Error(
|
|
'robotstxt_smtp_amazon_ses_invalid_from',
|
|
\sprintf(
|
|
/* translators: %s: Error details describing why the From header is invalid. */
|
|
\esc_html__( 'Amazon SES rejected the "From" address: %s', 'robotstxt-smtp-amazonses' ),
|
|
\wp_strip_all_tags( $exception->getMessage() )
|
|
),
|
|
array(
|
|
'to' => $mail_context['to'],
|
|
'subject' => $mail_context['subject'],
|
|
'message' => $mail_context['message'],
|
|
'headers' => $mail_context['headers'],
|
|
'attachments' => $mail_context['attachments'],
|
|
)
|
|
);
|
|
}
|
|
|
|
foreach ( $mail_context['to'] as $recipient ) {
|
|
try {
|
|
$phpmailer->addAddress( $recipient );
|
|
} catch ( PHPMailerException $exception ) {
|
|
return new WP_Error(
|
|
'robotstxt_smtp_amazon_ses_invalid_recipient',
|
|
\sprintf(
|
|
/* translators: %s: Invalid recipient email address. */
|
|
\esc_html__( 'Amazon SES rejected the recipient address: %s', 'robotstxt-smtp-amazonses' ),
|
|
\wp_strip_all_tags( $recipient )
|
|
),
|
|
array(
|
|
'to' => $mail_context['to'],
|
|
'subject' => $mail_context['subject'],
|
|
'message' => $mail_context['message'],
|
|
'headers' => $mail_context['headers'],
|
|
'attachments' => $mail_context['attachments'],
|
|
)
|
|
);
|
|
}
|
|
}
|
|
|
|
foreach ( $parsed_headers['cc'] ?? array() as $cc ) {
|
|
try {
|
|
$phpmailer->addCC( $cc );
|
|
} catch ( PHPMailerException $exception ) {
|
|
return new WP_Error(
|
|
'robotstxt_smtp_amazon_ses_invalid_cc',
|
|
\sprintf(
|
|
/* translators: %s: Invalid CC email address. */
|
|
\esc_html__( 'Amazon SES rejected the CC address: %s', 'robotstxt-smtp-amazonses' ),
|
|
\wp_strip_all_tags( $cc )
|
|
),
|
|
array(
|
|
'to' => $mail_context['to'],
|
|
'subject' => $mail_context['subject'],
|
|
'message' => $mail_context['message'],
|
|
'headers' => $mail_context['headers'],
|
|
'attachments' => $mail_context['attachments'],
|
|
)
|
|
);
|
|
}
|
|
}
|
|
|
|
foreach ( $parsed_headers['bcc'] ?? array() as $bcc ) {
|
|
try {
|
|
$phpmailer->addBCC( $bcc );
|
|
} catch ( PHPMailerException $exception ) {
|
|
return new WP_Error(
|
|
'robotstxt_smtp_amazon_ses_invalid_bcc',
|
|
\sprintf(
|
|
/* translators: %s: Invalid BCC email address. */
|
|
\esc_html__( 'Amazon SES rejected the BCC address: %s', 'robotstxt-smtp-amazonses' ),
|
|
\wp_strip_all_tags( $bcc )
|
|
),
|
|
array(
|
|
'to' => $mail_context['to'],
|
|
'subject' => $mail_context['subject'],
|
|
'message' => $mail_context['message'],
|
|
'headers' => $mail_context['headers'],
|
|
'attachments' => $mail_context['attachments'],
|
|
)
|
|
);
|
|
}
|
|
}
|
|
|
|
foreach ( $parsed_headers['reply_to'] ?? array() as $reply_to ) {
|
|
try {
|
|
$phpmailer->addReplyTo( $reply_to );
|
|
} catch ( PHPMailerException $exception ) {
|
|
return new WP_Error(
|
|
'robotstxt_smtp_amazon_ses_invalid_reply_to',
|
|
\sprintf(
|
|
/* translators: %s: Invalid reply-to email address. */
|
|
\esc_html__( 'Amazon SES rejected the reply-to address: %s', 'robotstxt-smtp-amazonses' ),
|
|
\wp_strip_all_tags( $reply_to )
|
|
),
|
|
array(
|
|
'to' => $mail_context['to'],
|
|
'subject' => $mail_context['subject'],
|
|
'message' => $mail_context['message'],
|
|
'headers' => $mail_context['headers'],
|
|
'attachments' => $mail_context['attachments'],
|
|
)
|
|
);
|
|
}
|
|
}
|
|
|
|
$phpmailer->ContentType = $content_type;
|
|
|
|
if ( 'text/html' === strtolower( $content_type ) ) {
|
|
$phpmailer->isHTML( true );
|
|
} else {
|
|
$phpmailer->isHTML( false );
|
|
}
|
|
|
|
foreach ( $parsed_headers['custom'] ?? array() as $custom_header ) {
|
|
if ( isset( $custom_header['name'], $custom_header['value'] ) ) {
|
|
$phpmailer->addCustomHeader( (string) $custom_header['name'], (string) $custom_header['value'] );
|
|
}
|
|
}
|
|
|
|
$phpmailer->Subject = \wp_specialchars_decode( $mail_context['subject'], ENT_QUOTES );
|
|
|
|
$message = (string) $mail_context['message'];
|
|
|
|
if ( $phpmailer->isHTML() ) {
|
|
$phpmailer->msgHTML( $message );
|
|
|
|
if ( '' === $phpmailer->AltBody ) {
|
|
$phpmailer->AltBody = \wp_strip_all_tags( $message );
|
|
}
|
|
} else {
|
|
$phpmailer->Body = $message;
|
|
}
|
|
|
|
foreach ( $mail_context['attachments'] as $attachment ) {
|
|
try {
|
|
$phpmailer->addAttachment( $attachment );
|
|
} catch ( PHPMailerException $exception ) {
|
|
return new WP_Error(
|
|
'robotstxt_smtp_amazon_ses_invalid_attachment',
|
|
\sprintf(
|
|
/* translators: %s: Attachment path that could not be added. */
|
|
\esc_html__( 'Amazon SES could not include the attachment: %s', 'robotstxt-smtp-amazonses' ),
|
|
\wp_strip_all_tags( $attachment )
|
|
),
|
|
array(
|
|
'to' => $mail_context['to'],
|
|
'subject' => $mail_context['subject'],
|
|
'message' => $mail_context['message'],
|
|
'headers' => $mail_context['headers'],
|
|
'attachments' => $mail_context['attachments'],
|
|
'phpmailer_exception_code' => $exception->getCode(),
|
|
)
|
|
);
|
|
}
|
|
}
|
|
|
|
// phpcs:enable WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
|
|
|
|
return $phpmailer;
|
|
}
|
|
/**
|
|
* Determines the From email address based on the stored settings and filters.
|
|
*
|
|
* @param array<string, mixed> $settings Stored plugin settings.
|
|
*
|
|
* @return string
|
|
*/
|
|
private function determine_from_email( array $settings ): string {
|
|
$default_email = \get_bloginfo( 'admin_email' );
|
|
|
|
if ( isset( $settings['from_email'] ) && \is_email( $settings['from_email'] ) ) {
|
|
$default_email = $settings['from_email'];
|
|
}
|
|
|
|
$filtered = \apply_filters( 'wp_mail_from', $default_email );
|
|
|
|
if ( ! \is_email( $filtered ) ) {
|
|
return (string) $default_email;
|
|
}
|
|
|
|
return (string) $filtered;
|
|
}
|
|
|
|
/**
|
|
* Determines the From name based on stored settings and filters.
|
|
*
|
|
* @param array<string, mixed> $settings Stored plugin settings.
|
|
*
|
|
* @return string
|
|
*/
|
|
private function determine_from_name( array $settings ): string {
|
|
$default_name = \get_bloginfo( 'name', 'display' );
|
|
|
|
if ( isset( $settings['from_name'] ) && '' !== $settings['from_name'] ) {
|
|
$default_name = $settings['from_name'];
|
|
}
|
|
|
|
$filtered = \apply_filters( 'wp_mail_from_name', $default_name );
|
|
|
|
return (string) $filtered;
|
|
}
|
|
|
|
/**
|
|
* Converts different header representations into a clean array of header lines.
|
|
*
|
|
* @param mixed $headers Headers supplied to wp_mail().
|
|
*
|
|
* @return array<int, string>
|
|
*/
|
|
private function normalize_headers( $headers ): array {
|
|
if ( empty( $headers ) ) {
|
|
return array();
|
|
}
|
|
|
|
if ( is_string( $headers ) ) {
|
|
$headers = preg_split( "/\r\n|\r|\n/", $headers );
|
|
}
|
|
|
|
if ( ! is_array( $headers ) ) {
|
|
return array();
|
|
}
|
|
|
|
$normalized = array();
|
|
|
|
foreach ( $headers as $header ) {
|
|
if ( is_array( $header ) ) {
|
|
continue;
|
|
}
|
|
|
|
$line = trim( (string) $header );
|
|
|
|
if ( '' !== $line ) {
|
|
$normalized[] = $line;
|
|
}
|
|
}
|
|
|
|
return $normalized;
|
|
}
|
|
|
|
/**
|
|
* Breaks down the headers into structured data used when building the PHPMailer instance.
|
|
*
|
|
* @param array<int, string> $headers Normalized header lines.
|
|
*
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function parse_headers( array $headers ): array {
|
|
$parsed = array(
|
|
'content_type' => null,
|
|
'charset' => null,
|
|
'cc' => array(),
|
|
'bcc' => array(),
|
|
'reply_to' => array(),
|
|
'custom' => array(),
|
|
);
|
|
|
|
foreach ( $headers as $header ) {
|
|
if ( false === strpos( $header, ':' ) ) {
|
|
continue;
|
|
}
|
|
|
|
list( $name, $value ) = explode( ':', $header, 2 );
|
|
$normalized_name = strtolower( trim( $name ) );
|
|
$original_name = trim( $name );
|
|
$value = trim( $value );
|
|
|
|
switch ( $normalized_name ) {
|
|
case 'content-type':
|
|
if ( false !== strpos( $value, ';' ) ) {
|
|
$parts = explode( ';', $value );
|
|
$parsed['content_type'] = trim( array_shift( $parts ) );
|
|
|
|
foreach ( $parts as $part ) {
|
|
if ( false === strpos( $part, '=' ) ) {
|
|
continue;
|
|
}
|
|
|
|
list( $param, $param_value ) = explode( '=', $part, 2 );
|
|
|
|
if ( 'charset' === strtolower( trim( $param ) ) ) {
|
|
$parsed['charset'] = trim( $param_value, " \n\r\0\x0B\"'" );
|
|
}
|
|
}
|
|
} else {
|
|
$parsed['content_type'] = $value;
|
|
}
|
|
break;
|
|
case 'cc':
|
|
$parsed['cc'] = array_merge( $parsed['cc'], $this->normalize_recipients( $value ) );
|
|
break;
|
|
case 'bcc':
|
|
$parsed['bcc'] = array_merge( $parsed['bcc'], $this->normalize_recipients( $value ) );
|
|
break;
|
|
case 'reply-to':
|
|
$parsed['reply_to'] = array_merge( $parsed['reply_to'], $this->normalize_recipients( $value ) );
|
|
break;
|
|
default:
|
|
if ( '' !== $original_name && '' !== $value ) {
|
|
$parsed['custom'][] = array(
|
|
'name' => $original_name,
|
|
'value' => $value,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
return $parsed;
|
|
}
|
|
|
|
/**
|
|
* Normalizes the list of recipients into an array of individual addresses.
|
|
*
|
|
* @param mixed $recipients Recipient data supplied to wp_mail().
|
|
*
|
|
* @return array<int, string>
|
|
*/
|
|
private function normalize_recipients( $recipients ): array {
|
|
if ( empty( $recipients ) ) {
|
|
return array();
|
|
}
|
|
|
|
if ( ! is_array( $recipients ) ) {
|
|
$recipients = explode( ',', (string) $recipients );
|
|
}
|
|
|
|
$normalized = array();
|
|
|
|
foreach ( $recipients as $recipient ) {
|
|
if ( is_array( $recipient ) ) {
|
|
continue;
|
|
}
|
|
|
|
$address = trim( (string) $recipient );
|
|
|
|
if ( '' !== $address ) {
|
|
$normalized[] = $address;
|
|
}
|
|
}
|
|
|
|
return $normalized;
|
|
}
|
|
|
|
/**
|
|
* Ensures the attachments argument is converted into an array of paths.
|
|
*
|
|
* @param mixed $attachments Attachment data supplied to wp_mail().
|
|
*
|
|
* @return array<int, string>
|
|
*/
|
|
private function normalize_attachments( $attachments ): array {
|
|
if ( empty( $attachments ) ) {
|
|
return array();
|
|
}
|
|
|
|
if ( ! is_array( $attachments ) ) {
|
|
$attachments = array( $attachments );
|
|
}
|
|
|
|
$normalized = array();
|
|
|
|
foreach ( $attachments as $attachment ) {
|
|
if ( is_array( $attachment ) ) {
|
|
continue;
|
|
}
|
|
|
|
$path = trim( (string) $attachment );
|
|
|
|
if ( '' !== $path ) {
|
|
$normalized[] = $path;
|
|
}
|
|
}
|
|
|
|
return $normalized;
|
|
}
|
|
|
|
/**
|
|
* Retrieves a cached Amazon SES client or creates a new instance.
|
|
*
|
|
* @param string $access_key AWS access key ID.
|
|
* @param string $secret_key AWS secret access key.
|
|
* @param string $region AWS region identifier.
|
|
*
|
|
* @return SesV2Client
|
|
*/
|
|
private function get_ses_client( string $access_key, string $secret_key, string $region ): SesV2Client {
|
|
$cache_key = \md5( $access_key . '|' . $secret_key . '|' . $region );
|
|
|
|
if ( isset( $this->client_cache[ $cache_key ] ) ) {
|
|
return $this->client_cache[ $cache_key ];
|
|
}
|
|
|
|
$client = new SesV2Client(
|
|
array(
|
|
'version' => 'latest',
|
|
'region' => $region,
|
|
'credentials' => new Credentials( $access_key, $secret_key ),
|
|
)
|
|
);
|
|
|
|
$this->client_cache[ $cache_key ] = $client;
|
|
|
|
return $client;
|
|
}
|
|
|
|
/**
|
|
* Marks the Amazon SES integration as active.
|
|
*
|
|
* @param bool $is_active Current filter value.
|
|
*
|
|
* @return bool
|
|
*/
|
|
public function declare_active( bool $is_active ): bool {
|
|
unset( $is_active );
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Replaces the SMTP connection fields with Amazon SES specific fields.
|
|
*
|
|
* @param array<int, array<string, mixed>> $fields Registered field definitions.
|
|
* @param Settings_Page $settings_page Settings page instance.
|
|
*
|
|
* @return array<int, array<string, mixed>>
|
|
*/
|
|
public function provide_field_definitions( array $fields, Settings_Page $settings_page ): array {
|
|
if ( ! SMTP_Plugin::is_amazon_ses_integration_active() ) {
|
|
return $fields;
|
|
}
|
|
|
|
return array(
|
|
array(
|
|
'id' => 'robotstxt_smtp_amazon_ses_access_key',
|
|
'label' => \esc_html__( 'Access Key ID', 'robotstxt-smtp-amazonses' ),
|
|
'callback' => array( $this, 'render_access_key_field' ),
|
|
'label_for' => 'robotstxt_smtp_amazon_ses_access_key',
|
|
),
|
|
array(
|
|
'id' => 'robotstxt_smtp_amazon_ses_secret_key',
|
|
'label' => \esc_html__( 'Secret Access Key', 'robotstxt-smtp-amazonses' ),
|
|
'callback' => array( $this, 'render_secret_key_field' ),
|
|
'label_for' => 'robotstxt_smtp_amazon_ses_secret_key',
|
|
),
|
|
array(
|
|
'id' => 'robotstxt_smtp_amazon_ses_region',
|
|
'label' => \esc_html__( 'Region', 'robotstxt-smtp-amazonses' ),
|
|
'callback' => array( $this, 'render_region_field' ),
|
|
'label_for' => 'robotstxt_smtp_amazon_ses_region',
|
|
),
|
|
array(
|
|
'id' => 'robotstxt_smtp_from_email',
|
|
'label' => \esc_html__( 'From Email', 'robotstxt-smtp' ),
|
|
'callback' => array( $settings_page, 'render_from_email_field' ),
|
|
'label_for' => 'robotstxt_smtp_from_email',
|
|
),
|
|
array(
|
|
'id' => 'robotstxt_smtp_from_name',
|
|
'label' => \esc_html__( 'From Name', 'robotstxt-smtp' ),
|
|
'callback' => array( $settings_page, 'render_from_name_field' ),
|
|
'label_for' => 'robotstxt_smtp_from_name',
|
|
),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Outputs the Amazon SES access key field.
|
|
*
|
|
* @return void
|
|
*/
|
|
public function render_access_key_field(): void {
|
|
$settings = $this->get_stored_settings();
|
|
?>
|
|
<input
|
|
name="<?php echo \esc_attr( $this->get_field_name( self::OPTION_ACCESS_KEY ) ); ?>"
|
|
type="text"
|
|
id="robotstxt_smtp_amazon_ses_access_key"
|
|
class="regular-text"
|
|
value="<?php echo \esc_attr( $settings[ self::OPTION_ACCESS_KEY ] ); ?>"
|
|
autocomplete="off"
|
|
/>
|
|
<p class="description"><?php \esc_html_e( 'Provide the IAM access key ID with permissions to send email through Amazon SES.', 'robotstxt-smtp-amazonses' ); ?></p>
|
|
<?php
|
|
}
|
|
|
|
/**
|
|
* Outputs the Amazon SES secret key field.
|
|
*
|
|
* @return void
|
|
*/
|
|
public function render_secret_key_field(): void {
|
|
$settings = $this->get_stored_settings();
|
|
$has_secret_key = '' !== $settings[ self::OPTION_SECRET_KEY ];
|
|
$placeholder_label = $has_secret_key ? \__( 'Leave empty to keep the stored secret access key.', 'robotstxt-smtp-amazonses' ) : '';
|
|
?>
|
|
<input
|
|
name="<?php echo \esc_attr( $this->get_field_name( self::OPTION_SECRET_KEY ) ); ?>"
|
|
type="password"
|
|
id="robotstxt_smtp_amazon_ses_secret_key"
|
|
class="regular-text"
|
|
value=""
|
|
autocomplete="new-password"
|
|
placeholder="<?php echo \esc_attr( $placeholder_label ); ?>"
|
|
/>
|
|
<p class="description"><?php \esc_html_e( 'Enter the secret access key associated with the IAM user. Leave the field empty to retain the existing value.', 'robotstxt-smtp-amazonses' ); ?></p>
|
|
<?php
|
|
}
|
|
|
|
/**
|
|
* Outputs the Amazon SES region selector.
|
|
*
|
|
* @return void
|
|
*/
|
|
public function render_region_field(): void {
|
|
$settings = $this->get_stored_settings();
|
|
$current_region = $settings[ self::OPTION_REGION ];
|
|
$regions = $this->get_region_choices();
|
|
?>
|
|
<select name="<?php echo \esc_attr( $this->get_field_name( self::OPTION_REGION ) ); ?>" id="robotstxt_smtp_amazon_ses_region">
|
|
<option value="" <?php \selected( '', $current_region ); ?>><?php \esc_html_e( 'Select a region', 'robotstxt-smtp-amazonses' ); ?></option>
|
|
<?php foreach ( $regions as $region => $label ) : ?>
|
|
<option value="<?php echo \esc_attr( $region ); ?>" <?php \selected( $current_region, $region ); ?>><?php echo \esc_html( $label ); ?></option>
|
|
<?php endforeach; ?>
|
|
</select>
|
|
<p class="description"><?php \esc_html_e( 'Choose the AWS region where your Amazon SES account is hosted.', 'robotstxt-smtp-amazonses' ); ?></p>
|
|
<?php
|
|
}
|
|
|
|
/**
|
|
* Sanitizes and validates the Amazon SES specific settings.
|
|
*
|
|
* @param array<string, mixed> $clean Sanitized option values prepared by the core plugin.
|
|
* @param array<string, mixed> $options Raw submitted option values after `wp_unslash()`.
|
|
* @param Settings_Page $settings Settings page instance.
|
|
*
|
|
* @return array<string, mixed>
|
|
*/
|
|
public function sanitize_settings( array $clean, array $options, Settings_Page $settings ): array {
|
|
unset( $settings );
|
|
|
|
if ( ! SMTP_Plugin::is_amazon_ses_integration_active() ) {
|
|
return $clean;
|
|
}
|
|
|
|
$stored_settings = $this->get_stored_settings();
|
|
$settings_error_slug = $this->get_settings_option_name();
|
|
$access_key = $stored_settings[ self::OPTION_ACCESS_KEY ] ?? '';
|
|
$secret_key = $stored_settings[ self::OPTION_SECRET_KEY ] ?? '';
|
|
$region = $stored_settings[ self::OPTION_REGION ] ?? '';
|
|
$regions = $this->get_region_choices();
|
|
$credentials_changed = false;
|
|
|
|
if ( array_key_exists( self::OPTION_ACCESS_KEY, $options ) ) {
|
|
$submitted_access_key = \trim( (string) $options[ self::OPTION_ACCESS_KEY ] );
|
|
|
|
if ( '' !== $submitted_access_key ) {
|
|
$sanitized_access_key = \sanitize_text_field( $submitted_access_key );
|
|
|
|
if ( $sanitized_access_key !== $access_key ) {
|
|
$credentials_changed = true;
|
|
}
|
|
|
|
$access_key = $sanitized_access_key;
|
|
}
|
|
}
|
|
|
|
if ( array_key_exists( self::OPTION_SECRET_KEY, $options ) ) {
|
|
$submitted_secret_key = \trim( (string) $options[ self::OPTION_SECRET_KEY ] );
|
|
|
|
if ( '' !== $submitted_secret_key ) {
|
|
$sanitized_secret_key = \sanitize_text_field( $submitted_secret_key );
|
|
|
|
if ( $sanitized_secret_key !== $secret_key ) {
|
|
$credentials_changed = true;
|
|
}
|
|
|
|
$secret_key = $sanitized_secret_key;
|
|
}
|
|
}
|
|
|
|
if ( array_key_exists( self::OPTION_REGION, $options ) ) {
|
|
$submitted_region = \sanitize_text_field( (string) $options[ self::OPTION_REGION ] );
|
|
|
|
if ( '' === $submitted_region ) {
|
|
if ( '' !== $region ) {
|
|
$credentials_changed = true;
|
|
}
|
|
|
|
$region = '';
|
|
} elseif ( isset( $regions[ $submitted_region ] ) ) {
|
|
if ( $submitted_region !== $region ) {
|
|
$credentials_changed = true;
|
|
}
|
|
|
|
$region = $submitted_region;
|
|
} else {
|
|
\add_settings_error(
|
|
$settings_error_slug,
|
|
self::SETTINGS_ERROR_CREDENTIALS,
|
|
\esc_html__( 'The selected Amazon SES region is not available.', 'robotstxt-smtp-amazonses' ),
|
|
'error'
|
|
);
|
|
|
|
return $this->restore_amazon_settings( $clean, $stored_settings );
|
|
}
|
|
}
|
|
|
|
$clean[ self::OPTION_ACCESS_KEY ] = $access_key;
|
|
$clean[ self::OPTION_SECRET_KEY ] = $secret_key;
|
|
$clean[ self::OPTION_REGION ] = $region;
|
|
|
|
if ( $credentials_changed && ( '' === $access_key || '' === $secret_key || '' === $region ) ) {
|
|
\add_settings_error(
|
|
$settings_error_slug,
|
|
self::SETTINGS_ERROR_CREDENTIALS,
|
|
\esc_html__( 'Amazon SES credentials were not saved. Provide an access key, secret key, and region.', 'robotstxt-smtp-amazonses' ),
|
|
'error'
|
|
);
|
|
|
|
return $this->restore_amazon_settings( $clean, $stored_settings );
|
|
}
|
|
|
|
if ( $credentials_changed && '' !== $access_key && '' !== $secret_key && '' !== $region ) {
|
|
$validation_error = $this->validate_credentials( $access_key, $secret_key, $region );
|
|
|
|
if ( $validation_error instanceof WP_Error ) {
|
|
\add_settings_error(
|
|
$settings_error_slug,
|
|
self::SETTINGS_ERROR_CREDENTIALS,
|
|
\esc_html( $validation_error->get_error_message() ),
|
|
'error'
|
|
);
|
|
|
|
return $this->restore_amazon_settings( $clean, $stored_settings );
|
|
}
|
|
|
|
\add_settings_error(
|
|
$settings_error_slug,
|
|
self::SETTINGS_ERROR_CREDENTIALS,
|
|
\esc_html__( 'Amazon SES credentials verified successfully.', 'robotstxt-smtp-amazonses' ),
|
|
'updated'
|
|
);
|
|
}
|
|
|
|
return $clean;
|
|
}
|
|
|
|
/**
|
|
* Restores the previously stored Amazon SES values in case validation fails.
|
|
*
|
|
* @param array<string, mixed> $clean Current sanitized values.
|
|
* @param array<string, mixed> $stored_settings Stored settings merged with defaults.
|
|
*
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function restore_amazon_settings( array $clean, array $stored_settings ): array {
|
|
$clean[ self::OPTION_ACCESS_KEY ] = $stored_settings[ self::OPTION_ACCESS_KEY ] ?? '';
|
|
$clean[ self::OPTION_SECRET_KEY ] = $stored_settings[ self::OPTION_SECRET_KEY ] ?? '';
|
|
$clean[ self::OPTION_REGION ] = $stored_settings[ self::OPTION_REGION ] ?? '';
|
|
|
|
return $clean;
|
|
}
|
|
|
|
/**
|
|
* Retrieves the stored settings merged with defaults for the active scope.
|
|
*
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function get_stored_settings(): array {
|
|
$option_name = $this->get_settings_option_name();
|
|
|
|
if ( Settings_Page::NETWORK_OPTION_NAME === $option_name ) {
|
|
$settings = \get_site_option( $option_name, array() );
|
|
} else {
|
|
$settings = \get_option( $option_name, array() );
|
|
}
|
|
|
|
if ( ! is_array( $settings ) ) {
|
|
$settings = array();
|
|
}
|
|
|
|
return \wp_parse_args( $settings, Settings_Page::get_default_settings() );
|
|
}
|
|
|
|
/**
|
|
* Builds the settings field name for the current configuration scope.
|
|
*
|
|
* @param string $field Field identifier.
|
|
*
|
|
* @return string
|
|
*/
|
|
private function get_field_name( string $field ): string {
|
|
return $this->get_settings_option_name() . '[' . $field . ']';
|
|
}
|
|
|
|
/**
|
|
* Determines the correct option name for the current configuration scope.
|
|
*
|
|
* @return string
|
|
*/
|
|
private function get_settings_option_name(): string {
|
|
if ( \is_network_admin() && Settings_Page::is_network_mode_enabled() ) {
|
|
return Settings_Page::NETWORK_OPTION_NAME;
|
|
}
|
|
|
|
return Settings_Page::OPTION_NAME;
|
|
}
|
|
|
|
/**
|
|
* Retrieves the list of available Amazon SES regions.
|
|
*
|
|
* @return array<string, string>
|
|
*/
|
|
private function get_region_choices(): array {
|
|
if ( null !== $this->region_choices ) {
|
|
return $this->region_choices;
|
|
}
|
|
|
|
$labels = $this->get_known_region_labels();
|
|
$choices = $labels;
|
|
$discovered_list = array();
|
|
|
|
try {
|
|
$provider = PartitionEndpointProvider::defaultProvider();
|
|
|
|
foreach ( array( 'aws', 'aws-us-gov' ) as $partition_name ) {
|
|
$partition = $provider->getPartitionByName( $partition_name );
|
|
|
|
if ( ! $partition ) {
|
|
continue;
|
|
}
|
|
|
|
$regions = $partition->getAvailableEndpoints( 'ses' );
|
|
|
|
foreach ( $regions as $region ) {
|
|
if ( is_string( $region ) && '' !== $region ) {
|
|
$discovered_list[ $region ] = $labels[ $region ] ?? $this->format_region_label( $region );
|
|
}
|
|
}
|
|
}
|
|
} catch ( Throwable $exception ) {
|
|
unset( $exception );
|
|
}
|
|
|
|
foreach ( $discovered_list as $region => $label ) {
|
|
if ( isset( $choices[ $region ] ) ) {
|
|
continue;
|
|
}
|
|
|
|
$choices[ $region ] = $label;
|
|
}
|
|
|
|
if ( empty( $choices ) ) {
|
|
$choices = array(
|
|
'us-east-1' => $this->format_region_label( 'us-east-1' ),
|
|
'us-west-2' => $this->format_region_label( 'us-west-2' ),
|
|
);
|
|
}
|
|
|
|
$this->region_choices = $choices;
|
|
|
|
return $this->region_choices;
|
|
}
|
|
|
|
/**
|
|
* Provides translated labels for known Amazon SES regions.
|
|
*
|
|
* @return array<string, string>
|
|
*/
|
|
private function get_known_region_labels(): array {
|
|
return array(
|
|
'us-east-2' => $this->format_named_region_label( \__( 'US East (Ohio)', 'robotstxt-smtp-amazonses' ), 'us-east-2' ),
|
|
'us-east-1' => $this->format_named_region_label( \__( 'US East (N. Virginia)', 'robotstxt-smtp-amazonses' ), 'us-east-1' ),
|
|
'us-west-1' => $this->format_named_region_label( \__( 'US West (N. California)', 'robotstxt-smtp-amazonses' ), 'us-west-1' ),
|
|
'us-west-2' => $this->format_named_region_label( \__( 'US West (Oregon)', 'robotstxt-smtp-amazonses' ), 'us-west-2' ),
|
|
'af-south-1' => $this->format_named_region_label( \__( 'Africa (Cape Town)', 'robotstxt-smtp-amazonses' ), 'af-south-1' ),
|
|
'ap-south-2' => $this->format_named_region_label( \__( 'Asia Pacific (Hyderabad)', 'robotstxt-smtp-amazonses' ), 'ap-south-2' ),
|
|
'ap-southeast-3' => $this->format_named_region_label( \__( 'Asia Pacific (Jakarta)', 'robotstxt-smtp-amazonses' ), 'ap-southeast-3' ),
|
|
'ap-south-1' => $this->format_named_region_label( \__( 'Asia Pacific (Mumbai)', 'robotstxt-smtp-amazonses' ), 'ap-south-1' ),
|
|
'ap-northeast-3' => $this->format_named_region_label( \__( 'Asia Pacific (Osaka)', 'robotstxt-smtp-amazonses' ), 'ap-northeast-3' ),
|
|
'ap-northeast-2' => $this->format_named_region_label( \__( 'Asia Pacific (Seoul)', 'robotstxt-smtp-amazonses' ), 'ap-northeast-2' ),
|
|
'ap-southeast-1' => $this->format_named_region_label( \__( 'Asia Pacific (Singapore)', 'robotstxt-smtp-amazonses' ), 'ap-southeast-1' ),
|
|
'ap-southeast-2' => $this->format_named_region_label( \__( 'Asia Pacific (Sydney)', 'robotstxt-smtp-amazonses' ), 'ap-southeast-2' ),
|
|
'ap-northeast-1' => $this->format_named_region_label( \__( 'Asia Pacific (Tokyo)', 'robotstxt-smtp-amazonses' ), 'ap-northeast-1' ),
|
|
'ca-central-1' => $this->format_named_region_label( \__( 'Canada (Central)', 'robotstxt-smtp-amazonses' ), 'ca-central-1' ),
|
|
'eu-central-1' => $this->format_named_region_label( \__( 'Europe (Frankfurt)', 'robotstxt-smtp-amazonses' ), 'eu-central-1' ),
|
|
'eu-west-1' => $this->format_named_region_label( \__( 'Europe (Ireland)', 'robotstxt-smtp-amazonses' ), 'eu-west-1' ),
|
|
'eu-west-2' => $this->format_named_region_label( \__( 'Europe (London)', 'robotstxt-smtp-amazonses' ), 'eu-west-2' ),
|
|
'eu-south-1' => $this->format_named_region_label( \__( 'Europe (Milan)', 'robotstxt-smtp-amazonses' ), 'eu-south-1' ),
|
|
'eu-west-3' => $this->format_named_region_label( \__( 'Europe (Paris)', 'robotstxt-smtp-amazonses' ), 'eu-west-3' ),
|
|
'eu-north-1' => $this->format_named_region_label( \__( 'Europe (Stockholm)', 'robotstxt-smtp-amazonses' ), 'eu-north-1' ),
|
|
'eu-central-2' => $this->format_named_region_label( \__( 'Europe (Zurich)', 'robotstxt-smtp-amazonses' ), 'eu-central-2' ),
|
|
'il-central-1' => $this->format_named_region_label( \__( 'Israel (Tel Aviv)', 'robotstxt-smtp-amazonses' ), 'il-central-1' ),
|
|
'me-south-1' => $this->format_named_region_label( \__( 'Middle East (Bahrain)', 'robotstxt-smtp-amazonses' ), 'me-south-1' ),
|
|
'me-central-1' => $this->format_named_region_label( \__( 'Middle East (UAE)', 'robotstxt-smtp-amazonses' ), 'me-central-1' ),
|
|
'sa-east-1' => $this->format_named_region_label( \__( 'South America (São Paulo)', 'robotstxt-smtp-amazonses' ), 'sa-east-1' ),
|
|
'us-gov-east-1' => $this->format_named_region_label( \__( 'AWS GovCloud (US-East)', 'robotstxt-smtp-amazonses' ), 'us-gov-east-1' ),
|
|
'us-gov-west-1' => $this->format_named_region_label( \__( 'AWS GovCloud (US-West)', 'robotstxt-smtp-amazonses' ), 'us-gov-west-1' ),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Builds a formatted label for a named region.
|
|
*
|
|
* @param string $name Human-readable region name.
|
|
* @param string $code Region identifier.
|
|
*
|
|
* @return string
|
|
*/
|
|
private function format_named_region_label( string $name, string $code ): string {
|
|
return \sprintf(
|
|
/* translators: 1: AWS region display name. 2: AWS region code. */
|
|
\__( '%1$s (%2$s)', 'robotstxt-smtp-amazonses' ),
|
|
$name,
|
|
$code
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Creates a fallback label for an AWS region code.
|
|
*
|
|
* @param string $code Region identifier.
|
|
*
|
|
* @return string
|
|
*/
|
|
private function format_region_label( string $code ): string {
|
|
return \sprintf(
|
|
/* translators: %s: AWS region code. */
|
|
\__( 'Region %s', 'robotstxt-smtp-amazonses' ),
|
|
$code
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Validates the provided Amazon SES credentials by calling the GetAccount endpoint.
|
|
*
|
|
* @param string $access_key Access key ID.
|
|
* @param string $secret_key Secret access key.
|
|
* @param string $region Selected region.
|
|
*
|
|
* @return WP_Error|null
|
|
*/
|
|
private function validate_credentials( string $access_key, string $secret_key, string $region ): ?WP_Error {
|
|
try {
|
|
$client = new SesV2Client(
|
|
array(
|
|
'version' => 'latest',
|
|
'region' => $region,
|
|
'credentials' => new Credentials( $access_key, $secret_key ),
|
|
)
|
|
);
|
|
|
|
$client->getAccount();
|
|
} catch ( AwsException $exception ) {
|
|
$message = $exception->getAwsErrorMessage();
|
|
|
|
if ( ! is_string( $message ) || '' === $message ) {
|
|
$message = $exception->getMessage();
|
|
}
|
|
|
|
$message = \wp_strip_all_tags( (string) $message );
|
|
|
|
return new WP_Error(
|
|
'robotstxt_smtp_amazon_ses_invalid_credentials',
|
|
\sprintf(
|
|
/* translators: %s: Error message from Amazon SES. */
|
|
\esc_html__( 'Could not verify Amazon SES credentials: %s', 'robotstxt-smtp-amazonses' ),
|
|
$message
|
|
)
|
|
);
|
|
} catch ( Throwable $exception ) {
|
|
$message = \wp_strip_all_tags( $exception->getMessage() );
|
|
|
|
return new WP_Error(
|
|
'robotstxt_smtp_amazon_ses_unexpected_error',
|
|
\sprintf(
|
|
/* translators: %s: Error message describing the unexpected failure. */
|
|
\esc_html__( 'An unexpected error occurred while validating Amazon SES credentials: %s', 'robotstxt-smtp-amazonses' ),
|
|
$message
|
|
)
|
|
);
|
|
}
|
|
|
|
return null;
|
|
}
|
|
}
|