v2.0.0
This commit is contained in:
parent
5638b8e13b
commit
a29b2d82ad
19 changed files with 3792 additions and 231 deletions
350
includes/class-amazonses-mailer.php
Normal file
350
includes/class-amazonses-mailer.php
Normal file
|
|
@ -0,0 +1,350 @@
|
|||
<?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
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
246
includes/class-plugin.php
Normal file
246
includes/class-plugin.php
Normal file
|
|
@ -0,0 +1,246 @@
|
|||
<?php
|
||||
/**
|
||||
* Main plugin class.
|
||||
*
|
||||
* @package ROBOTSTXT_SMTP_Newsletter
|
||||
*/
|
||||
|
||||
namespace Robotstxt_SMTP_Newsletter;
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Plugin main class.
|
||||
*
|
||||
* Extends NewsletterMailerAddon to integrate with Newsletter plugin.
|
||||
*/
|
||||
class Plugin extends \NewsletterMailerAddon {
|
||||
|
||||
/**
|
||||
* Singleton instance.
|
||||
*
|
||||
* @var Plugin|null
|
||||
*/
|
||||
private static $instance = null;
|
||||
|
||||
/**
|
||||
* Default options.
|
||||
*
|
||||
* @var array<string, mixed>
|
||||
*/
|
||||
private static $defaults = array(
|
||||
'enabled' => false,
|
||||
'batch_size' => 10,
|
||||
'max_per_connection' => 50,
|
||||
);
|
||||
|
||||
/**
|
||||
* Plugin display name.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
public $name;
|
||||
|
||||
/**
|
||||
* Plugin slug.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
public $slug;
|
||||
|
||||
/**
|
||||
* Menu title.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
public $menu_title;
|
||||
|
||||
/**
|
||||
* Get singleton instance.
|
||||
*
|
||||
* @return Plugin
|
||||
*/
|
||||
public static function get_instance() {
|
||||
if ( null === self::$instance ) {
|
||||
self::$instance = new self();
|
||||
}
|
||||
return self::$instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize plugin (called on newsletter_loaded hook).
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function instance_init() {
|
||||
$instance = self::get_instance();
|
||||
$instance->init();
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructor.
|
||||
*/
|
||||
private function __construct() {
|
||||
parent::__construct( 'robotstxt-smtp-newsletter', ROBOTSTXT_SMTP_NEWSLETTER_VERSION, ROBOTSTXT_SMTP_NEWSLETTER_PATH );
|
||||
$this->name = 'SMTP (by ROBOTSTXT)';
|
||||
$this->slug = 'robotstxt-smtp-newsletter';
|
||||
$this->menu_title = 'SMTP (by ROBOTSTXT)';
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup options.
|
||||
*
|
||||
* Overrides parent to use our custom option name.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function setup_options() {
|
||||
if ( $this->options ) {
|
||||
return;
|
||||
}
|
||||
// Load our options using our custom option name (not newsletter_ prefix).
|
||||
$this->options = get_option( 'robotstxt_smtp_newsletter_options', array() );
|
||||
$this->options = wp_parse_args( $this->options, self::$defaults );
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize plugin hooks.
|
||||
*
|
||||
* Overrides parent init() method.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function init() {
|
||||
parent::init();
|
||||
|
||||
// Load text domain.
|
||||
add_action( 'init', array( $this, 'load_textdomain' ) );
|
||||
|
||||
// Register settings (but not menu - parent handles that).
|
||||
if ( is_admin() ) {
|
||||
require_once ROBOTSTXT_SMTP_NEWSLETTER_PATH . 'admin/class-settings-page.php';
|
||||
add_action( 'admin_init', array( 'Robotstxt_SMTP_Newsletter\Admin\Settings_Page', 'register_settings_static' ) );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load plugin text domain for translations.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function load_textdomain() {
|
||||
load_plugin_textdomain(
|
||||
'robotstxt-smtp-newsletter',
|
||||
false,
|
||||
dirname( plugin_basename( ROBOTSTXT_SMTP_NEWSLETTER_FILE ) ) . '/languages'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the mailer instance.
|
||||
*
|
||||
* This method is called by the parent class to get the mailer
|
||||
* that should be registered with Newsletter.
|
||||
*
|
||||
* @return \NewsletterMailer
|
||||
*/
|
||||
public function get_mailer() {
|
||||
// Get our settings and map to Newsletter's expected format.
|
||||
$settings = $this->get_settings();
|
||||
$smtp_settings = $this->get_smtp_settings();
|
||||
|
||||
// Calculate optimal speed (emails per hour) based on rate limits.
|
||||
$speed = $this->calculate_optimal_speed( $smtp_settings );
|
||||
|
||||
// Newsletter expects 'turbo' for batch_size and 'speed' for emails/hour.
|
||||
$mailer_options = array(
|
||||
'turbo' => $settings['batch_size'] ?? 10,
|
||||
'speed' => $speed,
|
||||
'max_per_connection' => $settings['max_per_connection'] ?? 50,
|
||||
);
|
||||
|
||||
// Detect if Amazon SES is active.
|
||||
if ( $this->is_amazon_ses_active() ) {
|
||||
require_once ROBOTSTXT_SMTP_NEWSLETTER_PATH . 'includes/class-amazonses-mailer.php';
|
||||
return new AmazonSES_Mailer( $mailer_options );
|
||||
} else {
|
||||
require_once ROBOTSTXT_SMTP_NEWSLETTER_PATH . 'includes/class-smtp-mailer.php';
|
||||
return new SMTP_Mailer( $mailer_options );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate optimal speed (emails per hour) based on rate limits.
|
||||
*
|
||||
* @param array<string, mixed> $smtp_settings SMTP settings with rate limits.
|
||||
*
|
||||
* @return int Emails per hour.
|
||||
*/
|
||||
private function calculate_optimal_speed( $smtp_settings ) {
|
||||
// Get rate limits from ROBOTSTXT SMTP settings.
|
||||
$rate_per_hour = absint( $smtp_settings['rate_limit_per_hour'] ?? 0 );
|
||||
|
||||
// If hourly limit is set, use it (with 90% safety margin).
|
||||
if ( $rate_per_hour > 0 ) {
|
||||
return (int) floor( $rate_per_hour * 0.9 );
|
||||
}
|
||||
|
||||
// If no hourly limit, calculate from per-second limit.
|
||||
$rate_per_second = absint( $smtp_settings['rate_limit_per_second'] ?? 0 );
|
||||
if ( $rate_per_second > 0 ) {
|
||||
return (int) floor( $rate_per_second * 3600 * 0.9 );
|
||||
}
|
||||
|
||||
// If no limits set, use a conservative default based on delivery method.
|
||||
$is_ses = $this->is_amazon_ses_active();
|
||||
return $is_ses ? 1000 : 500;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if Amazon SES integration is active.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
private function is_amazon_ses_active() {
|
||||
if ( ! class_exists( '\Robotstxt_SMTP\Plugin' ) ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return \Robotstxt_SMTP\Plugin::is_amazon_ses_integration_active();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get plugin settings with defaults.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function get_settings() {
|
||||
$options = get_option( 'robotstxt_smtp_newsletter_options', array() );
|
||||
|
||||
return wp_parse_args( $options, self::$defaults );
|
||||
}
|
||||
|
||||
/**
|
||||
* Get SMTP settings from robotstxt-smtp plugin.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function get_smtp_settings() {
|
||||
if ( ! class_exists( '\Robotstxt_SMTP\Plugin' ) || ! class_exists( '\Robotstxt_SMTP\Admin\Settings_Page' ) ) {
|
||||
return array();
|
||||
}
|
||||
|
||||
// Check if network mode is enabled.
|
||||
$scope = get_site_option( 'robotstxt_smtp_configuration_scope', 'site' );
|
||||
|
||||
if ( 'network' === $scope ) {
|
||||
$settings = get_site_option( 'robotstxt_smtp_network_options', array() );
|
||||
} else {
|
||||
$settings = get_option( 'robotstxt_smtp_options', array() );
|
||||
}
|
||||
|
||||
return $settings;
|
||||
}
|
||||
}
|
||||
364
includes/class-smtp-mailer.php
Normal file
364
includes/class-smtp-mailer.php
Normal file
|
|
@ -0,0 +1,364 @@
|
|||
<?php
|
||||
/**
|
||||
* SMTP Mailer with KeepAlive support.
|
||||
*
|
||||
* @package ROBOTSTXT_SMTP_Newsletter
|
||||
*/
|
||||
|
||||
namespace Robotstxt_SMTP_Newsletter;
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* SMTP Mailer class.
|
||||
*
|
||||
* Implements efficient batch email sending using SMTPKeepAlive.
|
||||
*/
|
||||
class SMTP_Mailer extends \NewsletterMailer {
|
||||
|
||||
/**
|
||||
* PHPMailer instance with persistent connection.
|
||||
*
|
||||
* @var \PHPMailer\PHPMailer\PHPMailer|null
|
||||
*/
|
||||
private $phpmailer = null;
|
||||
|
||||
/**
|
||||
* SMTP settings from robotstxt-smtp plugin.
|
||||
*
|
||||
* @var array<string, mixed>
|
||||
*/
|
||||
private $settings = array();
|
||||
|
||||
/**
|
||||
* Batch size for bulk sending.
|
||||
*
|
||||
* @var int
|
||||
*/
|
||||
protected $batch_size = 10;
|
||||
|
||||
/**
|
||||
* Maximum emails per connection before reconnecting.
|
||||
*
|
||||
* @var int
|
||||
*/
|
||||
private $max_emails_per_connection = 50;
|
||||
|
||||
/**
|
||||
* Count of emails sent in current connection.
|
||||
*
|
||||
* @var int
|
||||
*/
|
||||
private $emails_sent_in_connection = 0;
|
||||
|
||||
/**
|
||||
* 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).
|
||||
if ( ! empty( $options['turbo'] ) ) {
|
||||
$this->batch_size = max( 1, (int) $options['turbo'] );
|
||||
}
|
||||
|
||||
$this->max_emails_per_connection = max( 1, (int) ( $options['max_per_connection'] ?? 50 ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize PHPMailer with SMTPKeepAlive.
|
||||
*
|
||||
* @return \PHPMailer\PHPMailer\PHPMailer
|
||||
* @throws \Exception If PHPMailer cannot be initialized.
|
||||
*/
|
||||
private function get_phpmailer() {
|
||||
if ( $this->phpmailer !== null ) {
|
||||
return $this->phpmailer;
|
||||
}
|
||||
|
||||
$this->phpmailer = new \PHPMailer\PHPMailer\PHPMailer( true );
|
||||
|
||||
// Configure SMTP.
|
||||
$this->phpmailer->isSMTP();
|
||||
$this->phpmailer->Host = $this->settings['host'];
|
||||
$this->phpmailer->Port = $this->settings['port'];
|
||||
$this->phpmailer->SMTPAuth = ! empty( $this->settings['user'] );
|
||||
$this->phpmailer->Username = $this->settings['user'];
|
||||
$this->phpmailer->Password = $this->settings['password'];
|
||||
|
||||
// Security.
|
||||
$encryption = $this->settings['encryption'] ?? '';
|
||||
if ( 'tls' === $encryption ) {
|
||||
$this->phpmailer->SMTPSecure = \PHPMailer\PHPMailer\PHPMailer::ENCRYPTION_STARTTLS;
|
||||
} elseif ( 'ssl' === $encryption ) {
|
||||
$this->phpmailer->SMTPSecure = \PHPMailer\PHPMailer\PHPMailer::ENCRYPTION_SMTPS;
|
||||
}
|
||||
|
||||
// CRITICAL: Enable KeepAlive for batch sending.
|
||||
$this->phpmailer->SMTPKeepAlive = true;
|
||||
|
||||
// Set HELO hostname using WordPress site URL (works in multisite and WP-CLI).
|
||||
$site_hostname = wp_parse_url( get_site_url(), PHP_URL_HOST );
|
||||
if ( $site_hostname ) {
|
||||
$this->phpmailer->Hostname = $site_hostname;
|
||||
}
|
||||
|
||||
// Additional settings.
|
||||
$this->phpmailer->Timeout = 30;
|
||||
$this->phpmailer->SMTPDebug = 0;
|
||||
$this->phpmailer->CharSet = 'UTF-8';
|
||||
|
||||
return $this->phpmailer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send single email.
|
||||
*
|
||||
* Uses persistent SMTP connection (SMTPKeepAlive) for efficiency.
|
||||
*
|
||||
* @param \TNP_Mailer_Message $message Message to send.
|
||||
*
|
||||
* @return bool|WP_Error True on success, WP_Error on failure.
|
||||
*/
|
||||
public function send( $message ) {
|
||||
try {
|
||||
$mailer = $this->get_phpmailer();
|
||||
|
||||
// Clear previous message data but keep connection.
|
||||
$mailer->clearAddresses();
|
||||
$mailer->clearReplyTos();
|
||||
$mailer->clearAttachments();
|
||||
$mailer->clearCustomHeaders();
|
||||
|
||||
$this->prepare_message( $mailer, $message );
|
||||
|
||||
// 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' )
|
||||
);
|
||||
}
|
||||
|
||||
$mailer->send();
|
||||
++$this->emails_sent_in_connection;
|
||||
|
||||
// Track rate limit.
|
||||
$this->track_email_sent();
|
||||
|
||||
// Reconnect if threshold reached.
|
||||
if ( $this->emails_sent_in_connection >= $this->max_emails_per_connection ) {
|
||||
$this->close_connection();
|
||||
}
|
||||
|
||||
return true;
|
||||
|
||||
} catch ( \Exception $e ) {
|
||||
$this->log_error( $message, $e->getMessage() );
|
||||
return new \WP_Error( self::ERROR_GENERIC, $e->getMessage() );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send chunk of emails using persistent SMTP connection.
|
||||
*
|
||||
* Unlike API-based addons that use curl_multi for parallel sending,
|
||||
* SMTP is sequential but benefits from keeping the connection alive.
|
||||
*
|
||||
* @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 ) {
|
||||
$mailer = $this->get_phpmailer();
|
||||
$undelivered = 0;
|
||||
$fatal_error = null;
|
||||
|
||||
foreach ( $messages as $index => $message ) {
|
||||
try {
|
||||
// Clear previous message data but keep connection.
|
||||
$mailer->clearAddresses();
|
||||
$mailer->clearReplyTos();
|
||||
$mailer->clearAttachments();
|
||||
$mailer->clearCustomHeaders();
|
||||
|
||||
$this->prepare_message( $mailer, $message );
|
||||
|
||||
// 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.
|
||||
}
|
||||
|
||||
$mailer->send();
|
||||
++$this->emails_sent_in_connection;
|
||||
$this->track_email_sent();
|
||||
|
||||
// Reconnect if threshold reached.
|
||||
if ( $this->emails_sent_in_connection >= $this->max_emails_per_connection ) {
|
||||
$this->close_connection();
|
||||
$mailer = $this->get_phpmailer(); // Get fresh connection.
|
||||
}
|
||||
} catch ( \Exception $e ) {
|
||||
$this->log_error( $message, $e->getMessage() );
|
||||
$message->error = $e->getMessage();
|
||||
++$undelivered;
|
||||
|
||||
// Only stop on critical SMTP errors (connection, auth).
|
||||
// Continue sending if only individual recipient failed.
|
||||
$error_msg = $e->getMessage();
|
||||
$is_fatal = (
|
||||
stripos( $error_msg, 'Could not connect' ) !== false ||
|
||||
stripos( $error_msg, 'MAIL FROM command failed' ) !== false ||
|
||||
stripos( $error_msg, 'Authentication failed' ) !== false ||
|
||||
stripos( $error_msg, 'Invalid HELO' ) !== false
|
||||
);
|
||||
|
||||
if ( $is_fatal ) {
|
||||
$fatal_error = new \WP_Error( self::ERROR_FATAL, $e->getMessage() );
|
||||
break; // Stop batch on critical errors.
|
||||
}
|
||||
// Otherwise continue with next recipient.
|
||||
}
|
||||
}
|
||||
|
||||
if ( $fatal_error ) {
|
||||
return $fatal_error;
|
||||
}
|
||||
|
||||
if ( $undelivered > 0 ) {
|
||||
return new \WP_Error( self::ERROR_GENERIC, sprintf( '%d emails undelivered', $undelivered ) );
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare message for sending.
|
||||
*
|
||||
* @param \PHPMailer\PHPMailer\PHPMailer $mailer PHPMailer instance.
|
||||
* @param \TNP_Mailer_Message $message Message to prepare.
|
||||
*
|
||||
* @return void
|
||||
* @throws \PHPMailer\PHPMailer\Exception If preparation fails.
|
||||
*/
|
||||
private function prepare_message( $mailer, $message ) {
|
||||
// Use FROM configured in ROBOTSTXT SMTP plugin (required for SMTP auth).
|
||||
// Falls back to Newsletter's FROM if not configured.
|
||||
$from_email = ! empty( $this->settings['from_email'] ) ? $this->settings['from_email'] : $message->from;
|
||||
$from_name = ! empty( $this->settings['from_name'] ) ? $this->settings['from_name'] : $message->from_name;
|
||||
|
||||
$mailer->setFrom( $from_email, $from_name );
|
||||
$mailer->addAddress( $message->to );
|
||||
$mailer->Subject = $message->subject;
|
||||
|
||||
if ( ! empty( $message->body ) ) {
|
||||
$mailer->isHTML( true );
|
||||
$mailer->Body = $message->body;
|
||||
|
||||
if ( ! empty( $message->body_text ) ) {
|
||||
$mailer->AltBody = $message->body_text;
|
||||
}
|
||||
} else {
|
||||
$mailer->isHTML( false );
|
||||
$mailer->Body = $message->body_text;
|
||||
}
|
||||
|
||||
// Reply-To.
|
||||
if ( ! empty( $this->settings['reply_to_email'] ) ) {
|
||||
$mailer->addReplyTo(
|
||||
$this->settings['reply_to_email'],
|
||||
$this->settings['reply_to_name'] ?? ''
|
||||
);
|
||||
}
|
||||
|
||||
// Custom headers.
|
||||
if ( ! empty( $message->headers ) && is_array( $message->headers ) ) {
|
||||
foreach ( $message->headers as $key => $value ) {
|
||||
$mailer->addCustomHeader( $key, $value );
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check rate limits using robotstxt-smtp logic.
|
||||
*
|
||||
* @return bool True if sending is allowed, false otherwise.
|
||||
*/
|
||||
private function check_rate_limits() {
|
||||
// Rate limiting will be implemented using robotstxt-smtp methods.
|
||||
// For now, return true to allow sending.
|
||||
// TODO: Integrate with robotstxt-smtp rate limiting system.
|
||||
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.
|
||||
}
|
||||
|
||||
/**
|
||||
* Close SMTP connection.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private function close_connection() {
|
||||
if ( $this->phpmailer !== null ) {
|
||||
$this->phpmailer->smtpClose();
|
||||
$this->phpmailer = null;
|
||||
$this->emails_sent_in_connection = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log error to PHP error log.
|
||||
*
|
||||
* @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: Failed to send to %s. Error: %s',
|
||||
$message->to,
|
||||
$error_message
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup on destruction.
|
||||
*/
|
||||
public function __destruct() {
|
||||
$this->close_connection();
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue