350 lines
8.7 KiB
PHP
350 lines
8.7 KiB
PHP
<?php
|
|
/**
|
|
* Amazon SES Mailer.
|
|
*
|
|
* @package ROBOTSTXT_SMTP_Newsletter
|
|
*/
|
|
|
|
namespace Robotstxt_SMTP_Newsletter;
|
|
|
|
if ( ! defined( 'ABSPATH' ) ) {
|
|
exit;
|
|
}
|
|
|
|
/**
|
|
* Amazon SES Mailer class.
|
|
*
|
|
* Implements batch email sending using Amazon SES API.
|
|
*/
|
|
class AmazonSES_Mailer extends \NewsletterMailer {
|
|
|
|
/**
|
|
* SES client instance.
|
|
*
|
|
* @var \Aws\SesV2\SesV2Client|null
|
|
*/
|
|
private $ses_client = null;
|
|
|
|
/**
|
|
* SMTP settings from robotstxt-smtp plugin.
|
|
*
|
|
* @var array<string, mixed>
|
|
*/
|
|
private $settings = array();
|
|
|
|
/**
|
|
* Batch size for bulk sending.
|
|
*
|
|
* @var int
|
|
*/
|
|
protected $batch_size = 50;
|
|
|
|
/**
|
|
* Constructor.
|
|
*
|
|
* @param array<string, mixed> $options Plugin options.
|
|
*/
|
|
public function __construct( $options = array() ) {
|
|
parent::__construct( 'robotstxt-smtp-newsletter', $options );
|
|
|
|
$this->settings = Plugin::get_instance()->get_smtp_settings();
|
|
|
|
// Configure batch_size from turbo (standard Newsletter pattern).
|
|
// SES can handle larger batches than SMTP.
|
|
if ( ! empty( $options['turbo'] ) ) {
|
|
$this->batch_size = max( 1, (int) $options['turbo'] );
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get speed (emails per hour) for Newsletter engine.
|
|
*
|
|
* Standard Newsletter addons don't implement this, but we do
|
|
* to allow dynamic speed calculation based on rate limits.
|
|
*
|
|
* @return int
|
|
*/
|
|
public function get_speed() {
|
|
return $this->speed;
|
|
}
|
|
|
|
/**
|
|
* Get SES client instance.
|
|
*
|
|
* @return \Aws\SesV2\SesV2Client
|
|
* @throws \Exception If SES client cannot be initialized.
|
|
*/
|
|
private function get_ses_client() {
|
|
if ( $this->ses_client !== null ) {
|
|
return $this->ses_client;
|
|
}
|
|
|
|
if ( ! class_exists( '\Robotstxt_SMTP_AmazonSES\Plugin' ) ) {
|
|
throw new \Exception( __( 'Amazon SES plugin not available.', 'robotstxt-smtp-newsletter' ) );
|
|
}
|
|
|
|
$ses_plugin = \Robotstxt_SMTP_AmazonSES\Plugin::get_instance();
|
|
|
|
$this->ses_client = $ses_plugin->get_ses_client(
|
|
$this->settings['amazon_ses_access_key'],
|
|
$this->settings['amazon_ses_secret_key'],
|
|
$this->settings['amazon_ses_region']
|
|
);
|
|
|
|
return $this->ses_client;
|
|
}
|
|
|
|
/**
|
|
* Send single email via SES API.
|
|
*
|
|
* @param \TNP_Mailer_Message $message Message to send.
|
|
*
|
|
* @return bool|WP_Error True on success, WP_Error on failure.
|
|
*/
|
|
public function send( $message ) {
|
|
try {
|
|
$client = $this->get_ses_client();
|
|
|
|
// Check rate limits BEFORE sending.
|
|
if ( ! $this->check_rate_limits() ) {
|
|
return new \WP_Error(
|
|
self::ERROR_FATAL,
|
|
__( 'Rate limit exceeded. Please wait before sending more emails.', 'robotstxt-smtp-newsletter' )
|
|
);
|
|
}
|
|
|
|
$params = $this->prepare_ses_params( $message );
|
|
$result = $client->sendEmail( $params );
|
|
|
|
// Track rate limit.
|
|
$this->track_email_sent();
|
|
|
|
return true;
|
|
|
|
} catch ( \Aws\Exception\AwsException $e ) {
|
|
$this->log_error( $message, $e->getAwsErrorMessage() );
|
|
return new \WP_Error( self::ERROR_GENERIC, $e->getAwsErrorMessage() );
|
|
} catch ( \Exception $e ) {
|
|
$this->log_error( $message, $e->getMessage() );
|
|
return new \WP_Error( self::ERROR_GENERIC, $e->getMessage() );
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Send chunk of emails via SES API.
|
|
*
|
|
* Note: SES API doesn't have batch endpoint, so we send sequentially.
|
|
* However, API calls are much faster than SMTP connections.
|
|
*
|
|
* @param array<\TNP_Mailer_Message> $messages Messages to send.
|
|
*
|
|
* @return bool|WP_Error True on success, WP_Error on fatal error.
|
|
*/
|
|
public function send_chunk( $messages ) {
|
|
$undelivered = 0;
|
|
$fatal_error = null;
|
|
|
|
foreach ( $messages as $message ) {
|
|
try {
|
|
$client = $this->get_ses_client();
|
|
|
|
// Check rate limits.
|
|
if ( ! $this->check_rate_limits() ) {
|
|
$message->error = __( 'Rate limit exceeded', 'robotstxt-smtp-newsletter' );
|
|
$fatal_error = new \WP_Error(
|
|
self::ERROR_FATAL,
|
|
__( 'Rate limit exceeded. Batch stopped.', 'robotstxt-smtp-newsletter' )
|
|
);
|
|
break; // Stop on rate limit.
|
|
}
|
|
|
|
$params = $this->prepare_ses_params( $message );
|
|
$result = $client->sendEmail( $params );
|
|
|
|
// Track rate limit.
|
|
$this->track_email_sent();
|
|
|
|
} catch ( \Aws\Exception\AwsException $e ) {
|
|
$this->log_error( $message, $e->getAwsErrorMessage() );
|
|
$message->error = $e->getAwsErrorMessage();
|
|
++$undelivered;
|
|
|
|
// Only stop on critical AWS errors (credentials, rate limits).
|
|
// Continue sending if only individual recipient failed (bounce, suppress list).
|
|
$error_code = $e->getAwsErrorCode();
|
|
$is_fatal = in_array(
|
|
$error_code,
|
|
array( 'InvalidClientTokenId', 'SignatureDoesNotMatch', 'Throttling', 'RequestExpired' ),
|
|
true
|
|
);
|
|
|
|
if ( $is_fatal ) {
|
|
$fatal_error = new \WP_Error( self::ERROR_FATAL, $e->getAwsErrorMessage() );
|
|
break; // Stop batch on critical errors.
|
|
}
|
|
// Otherwise continue with next recipient.
|
|
} catch ( \Exception $e ) {
|
|
$this->log_error( $message, $e->getMessage() );
|
|
$message->error = $e->getMessage();
|
|
++$undelivered;
|
|
|
|
// On unexpected errors, stop the batch.
|
|
$fatal_error = new \WP_Error( self::ERROR_FATAL, $e->getMessage() );
|
|
break;
|
|
}
|
|
}
|
|
|
|
if ( $fatal_error ) {
|
|
return $fatal_error;
|
|
}
|
|
|
|
if ( $undelivered > 0 ) {
|
|
return new \WP_Error( self::ERROR_GENERIC, sprintf( '%d emails undelivered', $undelivered ) );
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Prepare SES API parameters from message.
|
|
*
|
|
* @param \TNP_Mailer_Message $message Message to prepare.
|
|
*
|
|
* @return array<string, mixed> SES API parameters.
|
|
*/
|
|
private function prepare_ses_params( $message ) {
|
|
$params = array(
|
|
'FromEmailAddress' => ! empty( $message->from_name )
|
|
? sprintf( '%s <%s>', $message->from_name, $message->from )
|
|
: $message->from,
|
|
'Destination' => array(
|
|
'ToAddresses' => array( $message->to ),
|
|
),
|
|
'Content' => array(
|
|
'Simple' => array(
|
|
'Subject' => array(
|
|
'Data' => $message->subject,
|
|
'Charset' => 'UTF-8',
|
|
),
|
|
'Body' => array(),
|
|
),
|
|
),
|
|
);
|
|
|
|
// Add HTML body.
|
|
if ( ! empty( $message->body ) ) {
|
|
$params['Content']['Simple']['Body']['Html'] = array(
|
|
'Data' => $message->body,
|
|
'Charset' => 'UTF-8',
|
|
);
|
|
}
|
|
|
|
// Add text body.
|
|
if ( ! empty( $message->body_text ) ) {
|
|
$params['Content']['Simple']['Body']['Text'] = array(
|
|
'Data' => $message->body_text,
|
|
'Charset' => 'UTF-8',
|
|
);
|
|
}
|
|
|
|
// Reply-To.
|
|
if ( ! empty( $this->settings['reply_to_email'] ) ) {
|
|
$params['ReplyToAddresses'] = array( $this->settings['reply_to_email'] );
|
|
}
|
|
|
|
// Custom headers.
|
|
if ( ! empty( $message->headers ) && is_array( $message->headers ) ) {
|
|
$email_headers = array();
|
|
foreach ( $message->headers as $key => $value ) {
|
|
$email_headers[] = array(
|
|
'Name' => $key,
|
|
'Value' => $value,
|
|
);
|
|
}
|
|
if ( ! empty( $email_headers ) ) {
|
|
$params['Content']['Raw'] = array(
|
|
'Data' => $this->build_raw_message( $message, $email_headers ),
|
|
);
|
|
unset( $params['Content']['Simple'] );
|
|
}
|
|
}
|
|
|
|
return $params;
|
|
}
|
|
|
|
/**
|
|
* Build raw MIME message for custom headers.
|
|
*
|
|
* @param \TNP_Mailer_Message $message Message.
|
|
* @param array<array> $headers Custom headers.
|
|
*
|
|
* @return string Base64-encoded raw message.
|
|
*/
|
|
private function build_raw_message( $message, $headers ) {
|
|
// This is a simplified implementation.
|
|
// For production, consider using a proper MIME builder.
|
|
$raw = "From: {$message->from}\r\n";
|
|
$raw .= "To: {$message->to}\r\n";
|
|
$raw .= "Subject: {$message->subject}\r\n";
|
|
|
|
foreach ( $headers as $header ) {
|
|
$raw .= "{$header['Name']}: {$header['Value']}\r\n";
|
|
}
|
|
|
|
$raw .= "MIME-Version: 1.0\r\n";
|
|
$raw .= "Content-Type: text/html; charset=UTF-8\r\n\r\n";
|
|
$raw .= $message->body;
|
|
|
|
return base64_encode( $raw );
|
|
}
|
|
|
|
/**
|
|
* Check rate limits using SES quotas.
|
|
*
|
|
* @return bool True if sending is allowed, false otherwise.
|
|
*/
|
|
private function check_rate_limits() {
|
|
// Rate limiting will be implemented using SES quota integration.
|
|
// For now, return true to allow sending.
|
|
// TODO: Integrate with robotstxt-smtp-amazonses quota checking.
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Track sent email for rate limiting.
|
|
*
|
|
* @return void
|
|
*/
|
|
private function track_email_sent() {
|
|
// Track email for rate limiting.
|
|
// TODO: Integrate with robotstxt-smtp rate limiting tracking.
|
|
}
|
|
|
|
/**
|
|
* Log error to Newsletter system.
|
|
*
|
|
* @param \TNP_Mailer_Message $message Message that failed.
|
|
* @param string $error_message Error message.
|
|
*
|
|
* @return void
|
|
*/
|
|
private function log_error( $message, $error_message ) {
|
|
error_log(
|
|
sprintf(
|
|
'ROBOTSTXT SMTP Newsletter (SES): Failed to send to %s. Error: %s',
|
|
$message->to,
|
|
$error_message
|
|
)
|
|
);
|
|
if ( $logger ) {
|
|
$logger->error(
|
|
sprintf(
|
|
'Amazon SES: Failed to send to %s. Error: %s',
|
|
$message->to,
|
|
$error_message
|
|
)
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|