This commit is contained in:
Javier Casares 2026-01-29 10:02:03 +00:00
commit a29b2d82ad
19 changed files with 3792 additions and 231 deletions

View 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();
}
}