v2.2.0
This commit is contained in:
parent
0a42e00299
commit
0ece5f3a6d
16 changed files with 2342 additions and 785 deletions
|
|
@ -1,6 +1,6 @@
|
|||
<?php
|
||||
/**
|
||||
* Amazon SES Mailer.
|
||||
* Queue-based Amazon SES Mailer.
|
||||
*
|
||||
* @package ROBOTSTXT_SMTP_Newsletter
|
||||
*/
|
||||
|
|
@ -14,26 +14,14 @@ if ( ! defined( 'ABSPATH' ) ) {
|
|||
/**
|
||||
* Amazon SES Mailer class.
|
||||
*
|
||||
* Implements batch email sending using Amazon SES API.
|
||||
* Enqueues emails to database for asynchronous processing by external workers.
|
||||
*/
|
||||
class AmazonSES_Mailer extends \NewsletterMailer {
|
||||
|
||||
/**
|
||||
* SES client instance.
|
||||
* Batch size for bulk enqueuing.
|
||||
*
|
||||
* @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.
|
||||
* SES can handle larger batches than SMTP.
|
||||
*
|
||||
* @var int
|
||||
*/
|
||||
|
|
@ -47,10 +35,7 @@ class AmazonSES_Mailer extends \NewsletterMailer {
|
|||
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'] );
|
||||
}
|
||||
|
|
@ -59,9 +44,6 @@ class AmazonSES_Mailer extends \NewsletterMailer {
|
|||
/**
|
||||
* 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() {
|
||||
|
|
@ -69,282 +51,51 @@ class AmazonSES_Mailer extends \NewsletterMailer {
|
|||
}
|
||||
|
||||
/**
|
||||
* Get SES client instance.
|
||||
* Enqueue single email to database.
|
||||
*
|
||||
* @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.
|
||||
* @param \TNP_Mailer_Message $message Message to enqueue.
|
||||
*
|
||||
* @return bool|WP_Error True on success, WP_Error on failure.
|
||||
*/
|
||||
public function send( $message ) {
|
||||
try {
|
||||
$client = $this->get_ses_client();
|
||||
$email = Email_Message::from_newsletter_message( $message );
|
||||
$queue = Queue::get_instance();
|
||||
$queue_id = $queue->enqueue( $email );
|
||||
|
||||
// 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() );
|
||||
if ( ! $queue_id ) {
|
||||
return new \WP_Error( self::ERROR_GENERIC, __( 'Failed to enqueue email', 'robotstxt-smtp-newsletter' ) );
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send chunk of emails via SES API.
|
||||
* Enqueue chunk of emails in bulk.
|
||||
*
|
||||
* 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 enqueue.
|
||||
*
|
||||
* @param array<\TNP_Mailer_Message> $messages Messages to send.
|
||||
*
|
||||
* @return bool|WP_Error True on success, WP_Error on fatal error.
|
||||
* @return bool|WP_Error True on success, WP_Error on failure.
|
||||
*/
|
||||
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(),
|
||||
),
|
||||
),
|
||||
$emails = array_map(
|
||||
array( Email_Message::class, 'from_newsletter_message' ),
|
||||
$messages
|
||||
);
|
||||
|
||||
// Add HTML body.
|
||||
if ( ! empty( $message->body ) ) {
|
||||
$params['Content']['Simple']['Body']['Html'] = array(
|
||||
'Data' => $message->body,
|
||||
'Charset' => 'UTF-8',
|
||||
$queue = Queue::get_instance();
|
||||
$queue_ids = $queue->enqueue_bulk( $emails );
|
||||
|
||||
if ( count( $queue_ids ) !== count( $messages ) ) {
|
||||
return new \WP_Error(
|
||||
self::ERROR_GENERIC,
|
||||
sprintf(
|
||||
/* translators: %d: number of emails that failed to enqueue */
|
||||
__( '%d emails failed to enqueue', 'robotstxt-smtp-newsletter' ),
|
||||
count( $messages ) - count( $queue_ids )
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// 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
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
161
includes/class-email-message.php
Normal file
161
includes/class-email-message.php
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
<?php
|
||||
/**
|
||||
* Email Message Data Transfer Object
|
||||
*
|
||||
* Type-safe container for email data between Newsletter and queue system.
|
||||
*
|
||||
* @package ROBOTSTXT_SMTP_Newsletter
|
||||
* @since 2.2.0
|
||||
*/
|
||||
|
||||
namespace Robotstxt_SMTP_Newsletter;
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Email Message DTO class.
|
||||
*
|
||||
* Encapsulates email message data for type safety and standardization.
|
||||
*/
|
||||
class Email_Message {
|
||||
|
||||
/**
|
||||
* Sender email address.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
public $from;
|
||||
|
||||
/**
|
||||
* Sender display name.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
public $from_name;
|
||||
|
||||
/**
|
||||
* Recipient email address.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
public $to;
|
||||
|
||||
/**
|
||||
* Recipient display name.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
public $to_name;
|
||||
|
||||
/**
|
||||
* Email subject.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
public $subject;
|
||||
|
||||
/**
|
||||
* Email body (HTML).
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
public $body;
|
||||
|
||||
/**
|
||||
* Email body (plain text).
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
public $body_text;
|
||||
|
||||
/**
|
||||
* Additional headers.
|
||||
*
|
||||
* @var array<string, string>
|
||||
*/
|
||||
public $headers;
|
||||
|
||||
/**
|
||||
* Newsletter campaign ID.
|
||||
*
|
||||
* @var int|null
|
||||
*/
|
||||
public $newsletter_id;
|
||||
|
||||
/**
|
||||
* Campaign display name.
|
||||
*
|
||||
* @var string|null
|
||||
*/
|
||||
public $campaign_name;
|
||||
|
||||
/**
|
||||
* Reply-To email address.
|
||||
*
|
||||
* @var string|null
|
||||
*/
|
||||
public $reply_to;
|
||||
|
||||
/**
|
||||
* Email provider identifier.
|
||||
*
|
||||
* @var string|null
|
||||
*/
|
||||
public $provider;
|
||||
|
||||
/**
|
||||
* Create from Newsletter message object.
|
||||
*
|
||||
* Converts TNP_Mailer_Message to our standardized format.
|
||||
*
|
||||
* @param \TNP_Mailer_Message $msg Newsletter message object.
|
||||
*
|
||||
* @return self
|
||||
*/
|
||||
public static function from_newsletter_message( $msg ) {
|
||||
$instance = new self();
|
||||
|
||||
// Basic sender/recipient.
|
||||
$instance->from = $msg->from ?? '';
|
||||
$instance->from_name = $msg->from_name ?? '';
|
||||
$instance->to = $msg->to ?? '';
|
||||
$instance->to_name = $msg->to_name ?? '';
|
||||
|
||||
// Subject and body.
|
||||
$instance->subject = $msg->subject ?? '';
|
||||
$instance->body = $msg->body ?? '';
|
||||
$instance->body_text = $msg->body_text ?? '';
|
||||
|
||||
// Headers and reply-to.
|
||||
$instance->headers = is_array( $msg->headers ?? null ) ? $msg->headers : array();
|
||||
$instance->reply_to = $msg->reply_to ?? null;
|
||||
|
||||
// Campaign identification.
|
||||
// Newsletter uses 'id' property for the email ID (not campaign).
|
||||
// We'll try to extract campaign info from the message if available.
|
||||
$instance->newsletter_id = $msg->id ?? null;
|
||||
$instance->campaign_name = $msg->subject ?? __( 'Unnamed Campaign', 'robotstxt-smtp-newsletter' );
|
||||
|
||||
// Detect provider.
|
||||
$instance->provider = self::detect_provider();
|
||||
|
||||
return $instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect current email provider.
|
||||
*
|
||||
* @return string Provider identifier.
|
||||
*/
|
||||
private static function detect_provider() {
|
||||
if ( ! class_exists( '\Robotstxt_SMTP\Plugin' ) ) {
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
$is_ses = \Robotstxt_SMTP\Plugin::is_amazon_ses_integration_active();
|
||||
|
||||
return $is_ses ? 'amazonses' : 'postfix';
|
||||
}
|
||||
}
|
||||
|
|
@ -31,9 +31,8 @@ class Plugin extends \NewsletterMailerAddon {
|
|||
* @var array<string, mixed>
|
||||
*/
|
||||
private static $defaults = array(
|
||||
'enabled' => false,
|
||||
'batch_size' => 10,
|
||||
'max_per_connection' => 50,
|
||||
'enabled' => false,
|
||||
'batch_size' => 10,
|
||||
);
|
||||
|
||||
/**
|
||||
|
|
@ -115,13 +114,27 @@ class Plugin extends \NewsletterMailerAddon {
|
|||
public function init() {
|
||||
parent::init();
|
||||
|
||||
// Load required classes.
|
||||
require_once ROBOTSTXT_SMTP_NEWSLETTER_PATH . 'includes/class-queue.php';
|
||||
require_once ROBOTSTXT_SMTP_NEWSLETTER_PATH . 'includes/class-statistics.php';
|
||||
require_once ROBOTSTXT_SMTP_NEWSLETTER_PATH . 'includes/class-email-message.php';
|
||||
|
||||
// Initialize database tables.
|
||||
Queue::get_instance();
|
||||
Statistics::get_instance();
|
||||
|
||||
// Load text domain.
|
||||
add_action( 'init', array( $this, 'load_textdomain' ) );
|
||||
|
||||
// Register settings (but not menu - parent handles that).
|
||||
// Register admin pages.
|
||||
if ( is_admin() ) {
|
||||
require_once ROBOTSTXT_SMTP_NEWSLETTER_PATH . 'admin/class-settings-page.php';
|
||||
require_once ROBOTSTXT_SMTP_NEWSLETTER_PATH . 'admin/class-emails-list-page.php';
|
||||
require_once ROBOTSTXT_SMTP_NEWSLETTER_PATH . 'admin/class-statistics-page.php';
|
||||
|
||||
add_action( 'admin_init', array( 'Robotstxt_SMTP_Newsletter\Admin\Settings_Page', 'register_settings_static' ) );
|
||||
add_action( 'admin_menu', array( 'Robotstxt_SMTP_Newsletter\Admin\Emails_List_Page', 'register' ) );
|
||||
add_action( 'admin_menu', array( 'Robotstxt_SMTP_Newsletter\Admin\Statistics_Page', 'register' ) );
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -147,6 +160,11 @@ class Plugin extends \NewsletterMailerAddon {
|
|||
* @return \NewsletterMailer
|
||||
*/
|
||||
public function get_mailer() {
|
||||
// Load required classes.
|
||||
require_once ROBOTSTXT_SMTP_NEWSLETTER_PATH . 'includes/class-queue.php';
|
||||
require_once ROBOTSTXT_SMTP_NEWSLETTER_PATH . 'includes/class-statistics.php';
|
||||
require_once ROBOTSTXT_SMTP_NEWSLETTER_PATH . 'includes/class-email-message.php';
|
||||
|
||||
// Get our settings and map to Newsletter's expected format.
|
||||
$settings = $this->get_settings();
|
||||
$smtp_settings = $this->get_smtp_settings();
|
||||
|
|
@ -156,9 +174,8 @@ class Plugin extends \NewsletterMailerAddon {
|
|||
|
||||
// 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,
|
||||
'turbo' => $settings['batch_size'] ?? 10,
|
||||
'speed' => $speed,
|
||||
);
|
||||
|
||||
// Detect if Amazon SES is active.
|
||||
|
|
@ -243,4 +260,29 @@ class Plugin extends \NewsletterMailerAddon {
|
|||
|
||||
return $settings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Plugin activation hook.
|
||||
*
|
||||
* Creates database tables and migrates settings.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function activation_hook() {
|
||||
// Load required classes.
|
||||
require_once ROBOTSTXT_SMTP_NEWSLETTER_PATH . 'includes/class-queue.php';
|
||||
require_once ROBOTSTXT_SMTP_NEWSLETTER_PATH . 'includes/class-statistics.php';
|
||||
require_once ROBOTSTXT_SMTP_NEWSLETTER_PATH . 'includes/class-email-message.php';
|
||||
|
||||
// Tables are created automatically via __construct().
|
||||
Queue::get_instance();
|
||||
Statistics::get_instance();
|
||||
|
||||
// Migrate settings: remove deprecated max_per_connection.
|
||||
$options = get_option( 'robotstxt_smtp_newsletter_options', array() );
|
||||
if ( isset( $options['max_per_connection'] ) ) {
|
||||
unset( $options['max_per_connection'] );
|
||||
update_option( 'robotstxt_smtp_newsletter_options', $options );
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
594
includes/class-queue.php
Normal file
594
includes/class-queue.php
Normal file
|
|
@ -0,0 +1,594 @@
|
|||
<?php
|
||||
/**
|
||||
* Email Queue Management Class
|
||||
*
|
||||
* Manages the database-backed email queue with locking, statistics, and maintenance operations.
|
||||
*
|
||||
* @package ROBOTSTXT_SMTP_Newsletter
|
||||
* @since 2.2.0
|
||||
*/
|
||||
|
||||
namespace Robotstxt_SMTP_Newsletter;
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue management class.
|
||||
*
|
||||
* Handles CRUD operations, batch locking, and queue statistics.
|
||||
*/
|
||||
class Queue {
|
||||
|
||||
/**
|
||||
* Table name (without prefix).
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
private const TABLE_NAME = 'robotstxt_smtp_newsletter_queue';
|
||||
|
||||
/**
|
||||
* Database version for migrations.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
private const DB_VERSION = '1.0';
|
||||
|
||||
/**
|
||||
* Option name for database version tracking.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
private const DB_VERSION_OPTION = 'robotstxt_smtp_newsletter_queue_db_version';
|
||||
|
||||
/**
|
||||
* Maximum retry attempts before marking as failed.
|
||||
*
|
||||
* @var int
|
||||
*/
|
||||
private const MAX_ATTEMPTS = 3;
|
||||
|
||||
/**
|
||||
* Default lock timeout in seconds.
|
||||
*
|
||||
* @var int
|
||||
*/
|
||||
private const LOCK_TIMEOUT = 300;
|
||||
|
||||
/**
|
||||
* Singleton instance.
|
||||
*
|
||||
* @var Queue|null
|
||||
*/
|
||||
private static $instance = null;
|
||||
|
||||
/**
|
||||
* Get singleton instance.
|
||||
*
|
||||
* @return Queue
|
||||
*/
|
||||
public static function get_instance() {
|
||||
if ( null === self::$instance ) {
|
||||
self::$instance = new self();
|
||||
}
|
||||
return self::$instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructor.
|
||||
*
|
||||
* Ensures table is created on instantiation.
|
||||
*/
|
||||
private function __construct() {
|
||||
$this->maybe_create_table();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get full table name with WordPress prefix.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public static function get_table_name() {
|
||||
global $wpdb;
|
||||
return $wpdb->prefix . self::TABLE_NAME;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create or upgrade table if needed.
|
||||
*
|
||||
* Checks stored version against current version and runs CREATE TABLE IF NOT EXISTS.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private function maybe_create_table() {
|
||||
$stored_version = get_option( self::DB_VERSION_OPTION, '0' );
|
||||
|
||||
if ( version_compare( $stored_version, self::DB_VERSION, '>=' ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
global $wpdb;
|
||||
$table_name = self::get_table_name();
|
||||
$charset_collate = $wpdb->get_charset_collate();
|
||||
|
||||
$sql = "CREATE TABLE {$table_name} (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
provider VARCHAR(32) NOT NULL DEFAULT 'postfix',
|
||||
newsletter_id BIGINT UNSIGNED NULL,
|
||||
campaign_name VARCHAR(255) NULL,
|
||||
from_email VARCHAR(255) NOT NULL,
|
||||
from_name VARCHAR(255) NULL,
|
||||
to_email VARCHAR(255) NOT NULL,
|
||||
to_name VARCHAR(255) NULL,
|
||||
reply_to VARCHAR(255) NULL,
|
||||
subject VARCHAR(998) NOT NULL,
|
||||
body_html MEDIUMTEXT NULL,
|
||||
body_text MEDIUMTEXT NULL,
|
||||
headers_json JSON NULL,
|
||||
created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
|
||||
available_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
|
||||
locked_at DATETIME(6) NULL,
|
||||
sent_at DATETIME(6) NULL,
|
||||
locked_by VARCHAR(64) NULL,
|
||||
attempts INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
last_error TEXT NULL,
|
||||
message_id VARCHAR(255) NULL,
|
||||
priority TINYINT NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (id),
|
||||
KEY idx_pick (sent_at, locked_at, available_at, priority, id),
|
||||
KEY idx_locked (locked_at),
|
||||
KEY idx_sent (sent_at),
|
||||
KEY idx_newsletter (newsletter_id, sent_at),
|
||||
KEY idx_created (created_at),
|
||||
KEY idx_to (to_email)
|
||||
) {$charset_collate} ENGINE=InnoDB;";
|
||||
|
||||
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
|
||||
dbDelta( $sql );
|
||||
|
||||
update_option( self::DB_VERSION_OPTION, self::DB_VERSION );
|
||||
}
|
||||
|
||||
/**
|
||||
* Enqueue a single email message.
|
||||
*
|
||||
* @param Email_Message $message Email message object.
|
||||
* @param int $priority Priority (higher = processed first).
|
||||
*
|
||||
* @return int|false Inserted ID on success, false on failure.
|
||||
*/
|
||||
public function enqueue( Email_Message $message, $priority = 0 ) {
|
||||
global $wpdb;
|
||||
$table = self::get_table_name();
|
||||
|
||||
$data = array(
|
||||
'provider' => $message->provider ?? 'postfix',
|
||||
'newsletter_id' => $message->newsletter_id,
|
||||
'campaign_name' => $message->campaign_name,
|
||||
'from_email' => $message->from,
|
||||
'from_name' => $message->from_name,
|
||||
'to_email' => $message->to,
|
||||
'to_name' => $message->to_name,
|
||||
'reply_to' => $message->reply_to ?? null,
|
||||
'subject' => $message->subject,
|
||||
'body_html' => $message->body,
|
||||
'body_text' => $message->body_text,
|
||||
'headers_json' => ! empty( $message->headers ) ? wp_json_encode( $message->headers ) : null,
|
||||
'priority' => absint( $priority ),
|
||||
);
|
||||
|
||||
$format = array(
|
||||
'%s', // provider.
|
||||
'%d', // newsletter_id.
|
||||
'%s', // campaign_name.
|
||||
'%s', // from_email.
|
||||
'%s', // from_name.
|
||||
'%s', // to_email.
|
||||
'%s', // to_name.
|
||||
'%s', // reply_to.
|
||||
'%s', // subject.
|
||||
'%s', // body_html.
|
||||
'%s', // body_text.
|
||||
'%s', // headers_json.
|
||||
'%d', // priority.
|
||||
);
|
||||
|
||||
$result = $wpdb->insert( $table, $data, $format );
|
||||
|
||||
return $result ? $wpdb->insert_id : false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enqueue multiple email messages in bulk.
|
||||
*
|
||||
* @param Email_Message[] $messages Array of email message objects.
|
||||
* @param int $priority Priority for all messages.
|
||||
*
|
||||
* @return int[] Array of inserted IDs.
|
||||
*/
|
||||
public function enqueue_bulk( array $messages, $priority = 0 ) {
|
||||
if ( empty( $messages ) ) {
|
||||
return array();
|
||||
}
|
||||
|
||||
global $wpdb;
|
||||
$table = self::get_table_name();
|
||||
$ids = array();
|
||||
|
||||
// Build multi-row INSERT.
|
||||
$values = array();
|
||||
$placeholders = array();
|
||||
|
||||
foreach ( $messages as $message ) {
|
||||
$values[] = $message->provider ?? 'postfix';
|
||||
$values[] = $message->newsletter_id;
|
||||
$values[] = $message->campaign_name;
|
||||
$values[] = $message->from;
|
||||
$values[] = $message->from_name;
|
||||
$values[] = $message->to;
|
||||
$values[] = $message->to_name;
|
||||
$values[] = $message->reply_to ?? null;
|
||||
$values[] = $message->subject;
|
||||
$values[] = $message->body;
|
||||
$values[] = $message->body_text;
|
||||
$values[] = ! empty( $message->headers ) ? wp_json_encode( $message->headers ) : null;
|
||||
$values[] = absint( $priority );
|
||||
|
||||
$placeholders[] = '(%s, %d, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %d)';
|
||||
}
|
||||
|
||||
$query = "INSERT INTO {$table} (
|
||||
provider, newsletter_id, campaign_name,
|
||||
from_email, from_name, to_email, to_name, reply_to,
|
||||
subject, body_html, body_text, headers_json, priority
|
||||
) VALUES " . implode( ', ', $placeholders );
|
||||
|
||||
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- False positive: query is properly prepared with placeholders.
|
||||
$result = $wpdb->query( $wpdb->prepare( $query, $values ) );
|
||||
|
||||
if ( $result ) {
|
||||
// Get IDs of inserted rows (assumes sequential IDs).
|
||||
$first_id = $wpdb->insert_id;
|
||||
$ids = range( $first_id, $first_id + count( $messages ) - 1 );
|
||||
}
|
||||
|
||||
return $ids;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get and lock a batch of emails for processing.
|
||||
*
|
||||
* Uses transaction-based locking to prevent race conditions.
|
||||
*
|
||||
* @param int $limit Maximum emails to lock.
|
||||
* @param string $worker_id Unique worker identifier.
|
||||
*
|
||||
* @return object[] Array of email objects.
|
||||
*/
|
||||
public function get_and_lock_batch( $limit, $worker_id ) {
|
||||
global $wpdb;
|
||||
$table = self::get_table_name();
|
||||
|
||||
// Release stale locks first.
|
||||
$this->release_stale_locks();
|
||||
|
||||
// Start transaction for atomic lock acquisition.
|
||||
$wpdb->query( 'START TRANSACTION' );
|
||||
|
||||
// Select emails that are available and lock them.
|
||||
$items = $wpdb->get_results(
|
||||
$wpdb->prepare(
|
||||
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name is safe.
|
||||
"SELECT * FROM {$table}
|
||||
WHERE sent_at IS NULL
|
||||
AND locked_at IS NULL
|
||||
AND available_at <= NOW(6)
|
||||
AND attempts < %d
|
||||
ORDER BY priority DESC, id ASC
|
||||
LIMIT %d
|
||||
FOR UPDATE",
|
||||
self::MAX_ATTEMPTS,
|
||||
$limit
|
||||
)
|
||||
);
|
||||
|
||||
if ( ! empty( $items ) ) {
|
||||
$ids = wp_list_pluck( $items, 'id' );
|
||||
$placeholders = implode( ',', array_fill( 0, count( $ids ), '%d' ) );
|
||||
|
||||
$wpdb->query(
|
||||
$wpdb->prepare(
|
||||
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name and placeholders are safe.
|
||||
"UPDATE {$table}
|
||||
SET locked_at = NOW(6), locked_by = %s
|
||||
WHERE id IN ({$placeholders})",
|
||||
array_merge( array( $worker_id ), $ids )
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
$wpdb->query( 'COMMIT' );
|
||||
|
||||
return $items;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark an email as successfully sent.
|
||||
*
|
||||
* @param int $id Email ID.
|
||||
* @param string|null $message_id Optional Message-ID header value.
|
||||
*
|
||||
* @return bool Success status.
|
||||
*/
|
||||
public function mark_sent( $id, $message_id = null ) {
|
||||
global $wpdb;
|
||||
$table = self::get_table_name();
|
||||
|
||||
$data = array(
|
||||
'sent_at' => current_time( 'mysql', true ),
|
||||
'message_id' => $message_id,
|
||||
);
|
||||
|
||||
$format = array( '%s', '%s' );
|
||||
|
||||
return false !== $wpdb->update( $table, $data, array( 'id' => $id ), $format, array( '%d' ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark an email as failed and increment retry counter.
|
||||
*
|
||||
* @param int $id Email ID.
|
||||
* @param string $error Error message.
|
||||
*
|
||||
* @return bool Success status.
|
||||
*/
|
||||
public function mark_failed( $id, $error ) {
|
||||
global $wpdb;
|
||||
$table = self::get_table_name();
|
||||
|
||||
// Get current attempts.
|
||||
$current = $wpdb->get_row(
|
||||
$wpdb->prepare(
|
||||
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name is safe.
|
||||
"SELECT attempts FROM {$table} WHERE id = %d",
|
||||
$id
|
||||
)
|
||||
);
|
||||
|
||||
if ( ! $current ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$new_attempts = absint( $current->attempts ) + 1;
|
||||
|
||||
// Calculate exponential backoff: 5 minutes, 15 minutes, 30 minutes.
|
||||
$delay_minutes = min( 5 * pow( 2, $new_attempts - 1 ), 30 );
|
||||
|
||||
$data = array(
|
||||
'attempts' => $new_attempts,
|
||||
'last_error' => $error,
|
||||
'locked_at' => null,
|
||||
'locked_by' => null,
|
||||
'available_at' => gmdate( 'Y-m-d H:i:s.u', strtotime( "+{$delay_minutes} minutes" ) ),
|
||||
);
|
||||
|
||||
$format = array( '%d', '%s', '%s', '%s', '%s' );
|
||||
|
||||
return false !== $wpdb->update( $table, $data, array( 'id' => $id ), $format, array( '%d' ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Release lock on a specific email.
|
||||
*
|
||||
* @param int $id Email ID.
|
||||
*
|
||||
* @return bool Success status.
|
||||
*/
|
||||
public function release_lock( $id ) {
|
||||
global $wpdb;
|
||||
$table = self::get_table_name();
|
||||
|
||||
$data = array(
|
||||
'locked_at' => null,
|
||||
'locked_by' => null,
|
||||
);
|
||||
|
||||
return false !== $wpdb->update( $table, $data, array( 'id' => $id ), array( '%s', '%s' ), array( '%d' ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Release all locks older than timeout.
|
||||
*
|
||||
* @param int $timeout_seconds Timeout in seconds.
|
||||
*
|
||||
* @return int Number of locks released.
|
||||
*/
|
||||
public function release_stale_locks( $timeout_seconds = self::LOCK_TIMEOUT ) {
|
||||
global $wpdb;
|
||||
$table = self::get_table_name();
|
||||
|
||||
$result = $wpdb->query(
|
||||
$wpdb->prepare(
|
||||
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name is safe.
|
||||
"UPDATE {$table}
|
||||
SET locked_at = NULL, locked_by = NULL
|
||||
WHERE locked_at IS NOT NULL
|
||||
AND locked_at < DATE_SUB(NOW(6), INTERVAL %d SECOND)
|
||||
AND sent_at IS NULL",
|
||||
$timeout_seconds
|
||||
)
|
||||
);
|
||||
|
||||
return absint( $result );
|
||||
}
|
||||
|
||||
/**
|
||||
* Get real-time queue statistics.
|
||||
*
|
||||
* @return array{pending: int, locked: int, sent_24h: int, failed_24h: int}
|
||||
*/
|
||||
public function get_queue_stats() {
|
||||
global $wpdb;
|
||||
$table = self::get_table_name();
|
||||
|
||||
$stats = $wpdb->get_row(
|
||||
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name is safe.
|
||||
"SELECT
|
||||
SUM(CASE WHEN sent_at IS NULL AND locked_at IS NULL AND attempts < " . self::MAX_ATTEMPTS . " THEN 1 ELSE 0 END) as pending,
|
||||
SUM(CASE WHEN locked_at IS NOT NULL THEN 1 ELSE 0 END) as locked,
|
||||
SUM(CASE WHEN sent_at IS NOT NULL AND sent_at >= DATE_SUB(NOW(), INTERVAL 24 HOUR) THEN 1 ELSE 0 END) as sent_24h,
|
||||
SUM(CASE WHEN attempts >= " . self::MAX_ATTEMPTS . " AND sent_at IS NULL THEN 1 ELSE 0 END) as failed_24h
|
||||
FROM {$table}",
|
||||
ARRAY_A
|
||||
);
|
||||
|
||||
return array(
|
||||
'pending' => absint( $stats['pending'] ?? 0 ),
|
||||
'locked' => absint( $stats['locked'] ?? 0 ),
|
||||
'sent_24h' => absint( $stats['sent_24h'] ?? 0 ),
|
||||
'failed_24h' => absint( $stats['failed_24h'] ?? 0 ),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get queue statistics for a specific campaign.
|
||||
*
|
||||
* @param int|null $newsletter_id Newsletter ID (null = all campaigns).
|
||||
*
|
||||
* @return array{total: int, sent: int, failed: int, pending: int}
|
||||
*/
|
||||
public function get_campaign_queue_stats( $newsletter_id = null ) {
|
||||
global $wpdb;
|
||||
$table = self::get_table_name();
|
||||
|
||||
$where = $newsletter_id ? $wpdb->prepare( 'WHERE newsletter_id = %d', $newsletter_id ) : '';
|
||||
|
||||
$stats = $wpdb->get_row(
|
||||
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name and WHERE clause are safe.
|
||||
"SELECT
|
||||
COUNT(*) as total,
|
||||
SUM(CASE WHEN sent_at IS NOT NULL THEN 1 ELSE 0 END) as sent,
|
||||
SUM(CASE WHEN attempts >= " . self::MAX_ATTEMPTS . " AND sent_at IS NULL THEN 1 ELSE 0 END) as failed,
|
||||
SUM(CASE WHEN sent_at IS NULL AND attempts < " . self::MAX_ATTEMPTS . " THEN 1 ELSE 0 END) as pending
|
||||
FROM {$table}
|
||||
{$where}",
|
||||
ARRAY_A
|
||||
);
|
||||
|
||||
return array(
|
||||
'total' => absint( $stats['total'] ?? 0 ),
|
||||
'sent' => absint( $stats['sent'] ?? 0 ),
|
||||
'failed' => absint( $stats['failed'] ?? 0 ),
|
||||
'pending' => absint( $stats['pending'] ?? 0 ),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get recent emails for admin UI.
|
||||
*
|
||||
* @param int $limit Maximum results.
|
||||
* @param int $offset Offset for pagination.
|
||||
*
|
||||
* @return object[] Array of email objects.
|
||||
*/
|
||||
public function get_recent_emails( $limit = 50, $offset = 0 ) {
|
||||
global $wpdb;
|
||||
$table = self::get_table_name();
|
||||
|
||||
return $wpdb->get_results(
|
||||
$wpdb->prepare(
|
||||
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name is safe.
|
||||
"SELECT * FROM {$table}
|
||||
ORDER BY created_at DESC
|
||||
LIMIT %d OFFSET %d",
|
||||
$limit,
|
||||
$offset
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get emails for a specific campaign.
|
||||
*
|
||||
* @param int $newsletter_id Newsletter ID.
|
||||
* @param int $limit Maximum results.
|
||||
*
|
||||
* @return object[] Array of email objects.
|
||||
*/
|
||||
public function get_campaign_emails( $newsletter_id, $limit = 50 ) {
|
||||
global $wpdb;
|
||||
$table = self::get_table_name();
|
||||
|
||||
return $wpdb->get_results(
|
||||
$wpdb->prepare(
|
||||
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name is safe.
|
||||
"SELECT * FROM {$table}
|
||||
WHERE newsletter_id = %d
|
||||
ORDER BY created_at DESC
|
||||
LIMIT %d",
|
||||
$newsletter_id,
|
||||
$limit
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Count total emails in queue (for pagination).
|
||||
*
|
||||
* @return int Total count.
|
||||
*/
|
||||
public function count_total_emails() {
|
||||
global $wpdb;
|
||||
$table = self::get_table_name();
|
||||
|
||||
return absint(
|
||||
$wpdb->get_var(
|
||||
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name is safe.
|
||||
"SELECT COUNT(*) FROM {$table}"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete old sent/failed emails.
|
||||
*
|
||||
* @param int $days Number of days to keep.
|
||||
*
|
||||
* @return int Number of items deleted.
|
||||
*/
|
||||
public function cleanup_old_items( $days = 30 ) {
|
||||
global $wpdb;
|
||||
$table = self::get_table_name();
|
||||
|
||||
$result = $wpdb->query(
|
||||
$wpdb->prepare(
|
||||
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name is safe.
|
||||
"DELETE FROM {$table}
|
||||
WHERE sent_at IS NOT NULL
|
||||
AND sent_at < DATE_SUB(NOW(), INTERVAL %d DAY)",
|
||||
$days
|
||||
)
|
||||
);
|
||||
|
||||
return absint( $result );
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete all queue records for a campaign.
|
||||
*
|
||||
* Used when user wants to clear campaign history but preserve statistics.
|
||||
*
|
||||
* @param int $newsletter_id Newsletter ID.
|
||||
*
|
||||
* @return int Number of items deleted.
|
||||
*/
|
||||
public function delete_campaign_history( $newsletter_id ) {
|
||||
global $wpdb;
|
||||
$table = self::get_table_name();
|
||||
|
||||
$result = $wpdb->delete( $table, array( 'newsletter_id' => $newsletter_id ), array( '%d' ) );
|
||||
|
||||
return absint( $result );
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
<?php
|
||||
/**
|
||||
* SMTP Mailer with KeepAlive support.
|
||||
* Queue-based SMTP Mailer.
|
||||
*
|
||||
* @package ROBOTSTXT_SMTP_Newsletter
|
||||
*/
|
||||
|
|
@ -14,45 +14,17 @@ if ( ! defined( 'ABSPATH' ) ) {
|
|||
/**
|
||||
* SMTP Mailer class.
|
||||
*
|
||||
* Implements efficient batch email sending using SMTPKeepAlive.
|
||||
* Enqueues emails to database for asynchronous processing by external workers.
|
||||
*/
|
||||
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.
|
||||
* Batch size for bulk enqueuing.
|
||||
*
|
||||
* @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.
|
||||
*
|
||||
|
|
@ -61,14 +33,10 @@ class SMTP_Mailer extends \NewsletterMailer {
|
|||
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 ) );
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -84,281 +52,52 @@ class SMTP_Mailer extends \NewsletterMailer {
|
|||
}
|
||||
|
||||
/**
|
||||
* Initialize PHPMailer with SMTPKeepAlive.
|
||||
* Enqueue single email to database.
|
||||
*
|
||||
* @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.
|
||||
* @param \TNP_Mailer_Message $message Message to enqueue.
|
||||
*
|
||||
* @return bool|WP_Error True on success, WP_Error on failure.
|
||||
*/
|
||||
public function send( $message ) {
|
||||
try {
|
||||
$mailer = $this->get_phpmailer();
|
||||
$email = Email_Message::from_newsletter_message( $message );
|
||||
$queue = Queue::get_instance();
|
||||
$queue_id = $queue->enqueue( $email );
|
||||
|
||||
// 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 ) );
|
||||
if ( ! $queue_id ) {
|
||||
return new \WP_Error( self::ERROR_GENERIC, __( 'Failed to enqueue email', 'robotstxt-smtp-newsletter' ) );
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare message for sending.
|
||||
* Enqueue chunk of emails in bulk.
|
||||
*
|
||||
* @param \PHPMailer\PHPMailer\PHPMailer $mailer PHPMailer instance.
|
||||
* @param \TNP_Mailer_Message $message Message to prepare.
|
||||
* @param array<\TNP_Mailer_Message> $messages Messages to enqueue.
|
||||
*
|
||||
* @return void
|
||||
* @throws \PHPMailer\PHPMailer\Exception If preparation fails.
|
||||
* @return bool|WP_Error True on success, WP_Error on failure.
|
||||
*/
|
||||
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;
|
||||
public function send_chunk( $messages ) {
|
||||
$emails = array_map(
|
||||
array( Email_Message::class, 'from_newsletter_message' ),
|
||||
$messages
|
||||
);
|
||||
|
||||
$mailer->setFrom( $from_email, $from_name );
|
||||
$mailer->addAddress( $message->to );
|
||||
$mailer->Subject = $message->subject;
|
||||
$queue = Queue::get_instance();
|
||||
$queue_ids = $queue->enqueue_bulk( $emails );
|
||||
|
||||
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'] ?? ''
|
||||
if ( count( $queue_ids ) !== count( $messages ) ) {
|
||||
return new \WP_Error(
|
||||
self::ERROR_GENERIC,
|
||||
sprintf(
|
||||
/* translators: %d: number of emails that failed to enqueue */
|
||||
__( '%d emails failed to enqueue', 'robotstxt-smtp-newsletter' ),
|
||||
count( $messages ) - count( $queue_ids )
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// 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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
427
includes/class-statistics.php
Normal file
427
includes/class-statistics.php
Normal file
|
|
@ -0,0 +1,427 @@
|
|||
<?php
|
||||
/**
|
||||
* Email Statistics Management Class
|
||||
*
|
||||
* Manages campaign performance metrics and historical data.
|
||||
*
|
||||
* @package ROBOTSTXT_SMTP_Newsletter
|
||||
* @since 2.2.0
|
||||
*/
|
||||
|
||||
namespace Robotstxt_SMTP_Newsletter;
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Statistics management class.
|
||||
*
|
||||
* Handles aggregation, persistence, and querying of email campaign metrics.
|
||||
*/
|
||||
class Statistics {
|
||||
|
||||
/**
|
||||
* Table name (without prefix).
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
private const TABLE_NAME = 'robotstxt_smtp_newsletter_stats';
|
||||
|
||||
/**
|
||||
* Database version for migrations.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
private const DB_VERSION = '1.1';
|
||||
|
||||
/**
|
||||
* Option name for database version tracking.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
private const DB_VERSION_OPTION = 'robotstxt_smtp_newsletter_stats_db_version';
|
||||
|
||||
/**
|
||||
* Singleton instance.
|
||||
*
|
||||
* @var Statistics|null
|
||||
*/
|
||||
private static $instance = null;
|
||||
|
||||
/**
|
||||
* Get singleton instance.
|
||||
*
|
||||
* @return Statistics
|
||||
*/
|
||||
public static function get_instance() {
|
||||
if ( null === self::$instance ) {
|
||||
self::$instance = new self();
|
||||
}
|
||||
return self::$instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructor.
|
||||
*
|
||||
* Ensures table is created on instantiation.
|
||||
*/
|
||||
private function __construct() {
|
||||
$this->maybe_create_table();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get full table name with WordPress prefix.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public static function get_table_name() {
|
||||
global $wpdb;
|
||||
return $wpdb->prefix . self::TABLE_NAME;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create or upgrade table if needed.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private function maybe_create_table() {
|
||||
$stored_version = get_option( self::DB_VERSION_OPTION, '0' );
|
||||
|
||||
if ( version_compare( $stored_version, self::DB_VERSION, '>=' ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
global $wpdb;
|
||||
$table_name = self::get_table_name();
|
||||
$charset_collate = $wpdb->get_charset_collate();
|
||||
|
||||
$sql = "CREATE TABLE {$table_name} (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
newsletter_id BIGINT UNSIGNED NULL,
|
||||
campaign_name VARCHAR(255) NULL,
|
||||
provider VARCHAR(32) NOT NULL,
|
||||
total_emails INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
sent_emails INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
failed_emails INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
started_at DATETIME(6) NULL,
|
||||
finished_at DATETIME(6) NULL,
|
||||
duration_seconds FLOAT NULL,
|
||||
emails_per_hour FLOAT NULL,
|
||||
emails_per_minute FLOAT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY idx_newsletter_unique (newsletter_id),
|
||||
KEY idx_created (created_at),
|
||||
KEY idx_finished (finished_at)
|
||||
) {$charset_collate} ENGINE=InnoDB;";
|
||||
|
||||
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
|
||||
dbDelta( $sql );
|
||||
|
||||
update_option( self::DB_VERSION_OPTION, self::DB_VERSION );
|
||||
}
|
||||
|
||||
/**
|
||||
* Start tracking a new campaign.
|
||||
*
|
||||
* @param int $newsletter_id Newsletter ID.
|
||||
* @param string $campaign_name Campaign display name.
|
||||
* @param int $total_emails Total emails to send.
|
||||
*
|
||||
* @return int|false Inserted stat ID on success, false on failure.
|
||||
*/
|
||||
public function start_campaign( $newsletter_id, $campaign_name, $total_emails ) {
|
||||
global $wpdb;
|
||||
$table = self::get_table_name();
|
||||
|
||||
$data = array(
|
||||
'newsletter_id' => $newsletter_id,
|
||||
'campaign_name' => $campaign_name,
|
||||
'provider' => $this->get_provider_name(),
|
||||
'total_emails' => absint( $total_emails ),
|
||||
'started_at' => current_time( 'mysql', true ),
|
||||
);
|
||||
|
||||
$format = array( '%d', '%s', '%s', '%d', '%s' );
|
||||
|
||||
$result = $wpdb->insert( $table, $data, $format );
|
||||
|
||||
return $result ? $wpdb->insert_id : false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update campaign progress.
|
||||
*
|
||||
* @param int $stat_id Stat ID.
|
||||
* @param int $sent Number of emails sent.
|
||||
* @param int $failed Number of emails failed.
|
||||
*
|
||||
* @return bool Success status.
|
||||
*/
|
||||
public function update_campaign_progress( $stat_id, $sent, $failed ) {
|
||||
global $wpdb;
|
||||
$table = self::get_table_name();
|
||||
|
||||
$data = array(
|
||||
'sent_emails' => absint( $sent ),
|
||||
'failed_emails' => absint( $failed ),
|
||||
);
|
||||
|
||||
$format = array( '%d', '%d' );
|
||||
|
||||
return false !== $wpdb->update( $table, $data, array( 'id' => $stat_id ), $format, array( '%d' ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark campaign as finished and calculate final metrics.
|
||||
*
|
||||
* @param int $stat_id Stat ID.
|
||||
*
|
||||
* @return bool Success status.
|
||||
*/
|
||||
public function finish_campaign( $stat_id ) {
|
||||
global $wpdb;
|
||||
$table = self::get_table_name();
|
||||
|
||||
// Get campaign data.
|
||||
$campaign = $wpdb->get_row(
|
||||
$wpdb->prepare(
|
||||
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name is safe.
|
||||
"SELECT started_at, sent_emails FROM {$table} WHERE id = %d",
|
||||
$stat_id
|
||||
)
|
||||
);
|
||||
|
||||
if ( ! $campaign || ! $campaign->started_at ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$finished_at = current_time( 'mysql', true );
|
||||
$start_time = strtotime( $campaign->started_at );
|
||||
$finish_time = strtotime( $finished_at );
|
||||
$duration = max( 1, $finish_time - $start_time );
|
||||
|
||||
$emails_per_hour = ( $campaign->sent_emails / $duration ) * 3600;
|
||||
$emails_per_minute = ( $campaign->sent_emails / $duration ) * 60;
|
||||
|
||||
$data = array(
|
||||
'finished_at' => $finished_at,
|
||||
'duration_seconds' => $duration,
|
||||
'emails_per_hour' => $emails_per_hour,
|
||||
'emails_per_minute' => $emails_per_minute,
|
||||
);
|
||||
|
||||
$format = array( '%s', '%f', '%f', '%f' );
|
||||
|
||||
return false !== $wpdb->update( $table, $data, array( 'id' => $stat_id ), $format, array( '%d' ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronize statistics from queue table.
|
||||
*
|
||||
* Aggregates queue data and updates/creates statistics records.
|
||||
* This preserves metrics even after queue cleanup.
|
||||
*
|
||||
* @param int|null $newsletter_id Optional newsletter ID to sync (null = all).
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function sync_from_queue( $newsletter_id = null ) {
|
||||
global $wpdb;
|
||||
$queue_table = Queue::get_table_name();
|
||||
$stats_table = self::get_table_name();
|
||||
|
||||
$where = $newsletter_id ? $wpdb->prepare( 'WHERE newsletter_id = %d', $newsletter_id ) : 'WHERE newsletter_id IS NOT NULL';
|
||||
|
||||
$campaigns = $wpdb->get_results(
|
||||
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- WHERE clause is safe.
|
||||
"SELECT
|
||||
newsletter_id,
|
||||
MAX(campaign_name) as campaign_name,
|
||||
MAX(provider) as provider,
|
||||
MIN(created_at) as started_at,
|
||||
MAX(sent_at) as finished_at,
|
||||
COUNT(*) as total_emails,
|
||||
SUM(CASE WHEN sent_at IS NOT NULL THEN 1 ELSE 0 END) as sent_emails,
|
||||
SUM(CASE WHEN attempts >= 3 AND sent_at IS NULL THEN 1 ELSE 0 END) as failed_emails
|
||||
FROM {$queue_table}
|
||||
{$where}
|
||||
GROUP BY newsletter_id"
|
||||
);
|
||||
|
||||
foreach ( $campaigns as $campaign ) {
|
||||
$duration = null;
|
||||
$per_hour = null;
|
||||
$per_minute = null;
|
||||
|
||||
if ( $campaign->finished_at && $campaign->started_at ) {
|
||||
$start = strtotime( $campaign->started_at );
|
||||
$finish = strtotime( $campaign->finished_at );
|
||||
$duration = max( 1, $finish - $start );
|
||||
|
||||
if ( $duration > 0 && $campaign->sent_emails > 0 ) {
|
||||
$per_hour = ( $campaign->sent_emails / $duration ) * 3600;
|
||||
$per_minute = ( $campaign->sent_emails / $duration ) * 60;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if stat record exists (by newsletter_id only).
|
||||
$existing = $wpdb->get_var(
|
||||
$wpdb->prepare(
|
||||
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name is safe.
|
||||
"SELECT id FROM {$stats_table} WHERE newsletter_id = %d",
|
||||
$campaign->newsletter_id
|
||||
)
|
||||
);
|
||||
|
||||
if ( $existing ) {
|
||||
// Update existing.
|
||||
$wpdb->update(
|
||||
$stats_table,
|
||||
array(
|
||||
'total_emails' => $campaign->total_emails,
|
||||
'sent_emails' => $campaign->sent_emails,
|
||||
'failed_emails' => $campaign->failed_emails,
|
||||
'started_at' => $campaign->started_at,
|
||||
'finished_at' => $campaign->finished_at,
|
||||
'duration_seconds' => $duration,
|
||||
'emails_per_hour' => $per_hour,
|
||||
'emails_per_minute' => $per_minute,
|
||||
),
|
||||
array( 'id' => $existing ),
|
||||
array( '%d', '%d', '%d', '%s', '%s', '%f', '%f', '%f' ),
|
||||
array( '%d' )
|
||||
);
|
||||
} else {
|
||||
// Insert new.
|
||||
$wpdb->insert(
|
||||
$stats_table,
|
||||
array(
|
||||
'newsletter_id' => $campaign->newsletter_id,
|
||||
'campaign_name' => $campaign->campaign_name,
|
||||
'provider' => $campaign->provider,
|
||||
'total_emails' => $campaign->total_emails,
|
||||
'sent_emails' => $campaign->sent_emails,
|
||||
'failed_emails' => $campaign->failed_emails,
|
||||
'started_at' => $campaign->started_at,
|
||||
'finished_at' => $campaign->finished_at,
|
||||
'duration_seconds' => $duration,
|
||||
'emails_per_hour' => $per_hour,
|
||||
'emails_per_minute' => $per_minute,
|
||||
),
|
||||
array( '%d', '%s', '%s', '%d', '%d', '%d', '%s', '%s', '%f', '%f', '%f' )
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get statistics for a specific campaign.
|
||||
*
|
||||
* @param int $newsletter_id Newsletter ID.
|
||||
*
|
||||
* @return object|null Campaign statistics or null if not found.
|
||||
*/
|
||||
public function get_campaign_stats( $newsletter_id ) {
|
||||
global $wpdb;
|
||||
$table = self::get_table_name();
|
||||
|
||||
return $wpdb->get_row(
|
||||
$wpdb->prepare(
|
||||
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name is safe.
|
||||
"SELECT * FROM {$table}
|
||||
WHERE newsletter_id = %d
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1",
|
||||
$newsletter_id
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get recent campaigns.
|
||||
*
|
||||
* @param int $limit Maximum results.
|
||||
*
|
||||
* @return object[] Array of campaign statistics.
|
||||
*/
|
||||
public function get_recent_campaigns( $limit = 10 ) {
|
||||
global $wpdb;
|
||||
$table = self::get_table_name();
|
||||
|
||||
return $wpdb->get_results(
|
||||
$wpdb->prepare(
|
||||
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name is safe.
|
||||
"SELECT * FROM {$table}
|
||||
ORDER BY created_at DESC
|
||||
LIMIT %d",
|
||||
$limit
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get global statistics.
|
||||
*
|
||||
* @param int $days Number of days to aggregate.
|
||||
*
|
||||
* @return array{total_sent: int, total_failed: int, avg_emails_per_hour: float}
|
||||
*/
|
||||
public function get_global_stats( $days = 30 ) {
|
||||
global $wpdb;
|
||||
$table = self::get_table_name();
|
||||
|
||||
$stats = $wpdb->get_row(
|
||||
$wpdb->prepare(
|
||||
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name is safe.
|
||||
"SELECT
|
||||
SUM(sent_emails) as total_sent,
|
||||
SUM(failed_emails) as total_failed,
|
||||
AVG(emails_per_hour) as avg_emails_per_hour
|
||||
FROM {$table}
|
||||
WHERE created_at >= DATE_SUB(NOW(), INTERVAL %d DAY)",
|
||||
$days
|
||||
),
|
||||
ARRAY_A
|
||||
);
|
||||
|
||||
return array(
|
||||
'total_sent' => absint( $stats['total_sent'] ?? 0 ),
|
||||
'total_failed' => absint( $stats['total_failed'] ?? 0 ),
|
||||
'avg_emails_per_hour' => floatval( $stats['avg_emails_per_hour'] ?? 0 ),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete statistics for a campaign.
|
||||
*
|
||||
* @param int $newsletter_id Newsletter ID.
|
||||
*
|
||||
* @return int Number of records deleted.
|
||||
*/
|
||||
public function delete_campaign_stats( $newsletter_id ) {
|
||||
global $wpdb;
|
||||
$table = self::get_table_name();
|
||||
|
||||
$result = $wpdb->delete( $table, array( 'newsletter_id' => $newsletter_id ), array( '%d' ) );
|
||||
|
||||
return absint( $result );
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current provider name.
|
||||
*
|
||||
* @return string Provider identifier.
|
||||
*/
|
||||
private function get_provider_name() {
|
||||
if ( ! class_exists( '\Robotstxt_SMTP\Plugin' ) ) {
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
$is_ses = \Robotstxt_SMTP\Plugin::is_amazon_ses_integration_active();
|
||||
|
||||
return $is_ses ? 'amazonses' : 'postfix';
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue