v2.2.0
This commit is contained in:
parent
0a42e00299
commit
0ece5f3a6d
16 changed files with 2342 additions and 785 deletions
172
admin/class-emails-list-page.php
Normal file
172
admin/class-emails-list-page.php
Normal file
|
|
@ -0,0 +1,172 @@
|
||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Email Queue Admin Page
|
||||||
|
*
|
||||||
|
* Displays queued and sent emails with status information.
|
||||||
|
*
|
||||||
|
* @package ROBOTSTXT_SMTP_Newsletter
|
||||||
|
* @since 2.2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Robotstxt_SMTP_Newsletter\Admin;
|
||||||
|
|
||||||
|
if ( ! defined( 'ABSPATH' ) ) {
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Emails List Page class.
|
||||||
|
*
|
||||||
|
* Provides admin UI for viewing email queue.
|
||||||
|
*/
|
||||||
|
class Emails_List_Page {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register admin page hooks.
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public static function register() {
|
||||||
|
add_submenu_page(
|
||||||
|
'newsletter_main_main',
|
||||||
|
__( 'Email Queue', 'robotstxt-smtp-newsletter' ),
|
||||||
|
__( 'Email Queue', 'robotstxt-smtp-newsletter' ),
|
||||||
|
'manage_options',
|
||||||
|
'robotstxt-smtp-newsletter-emails',
|
||||||
|
array( __CLASS__, 'render_page' )
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render admin page.
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public static function render_page() {
|
||||||
|
if ( ! current_user_can( 'manage_options' ) ) {
|
||||||
|
wp_die( esc_html__( 'Insufficient permissions', 'robotstxt-smtp-newsletter' ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
$queue = \Robotstxt_SMTP_Newsletter\Queue::get_instance();
|
||||||
|
$page = isset( $_GET['paged'] ) ? max( 1, intval( $_GET['paged'] ) ) : 1;
|
||||||
|
$per_page = 50;
|
||||||
|
$offset = ( $page - 1 ) * $per_page;
|
||||||
|
|
||||||
|
$emails = $queue->get_recent_emails( $per_page, $offset );
|
||||||
|
$total_emails = $queue->count_total_emails();
|
||||||
|
$total_pages = ceil( $total_emails / $per_page );
|
||||||
|
|
||||||
|
?>
|
||||||
|
<div class="wrap">
|
||||||
|
<h1><?php esc_html_e( 'Email Queue', 'robotstxt-smtp-newsletter' ); ?></h1>
|
||||||
|
|
||||||
|
<p class="description">
|
||||||
|
<?php
|
||||||
|
printf(
|
||||||
|
/* translators: %d: total emails in queue */
|
||||||
|
esc_html__( 'Total emails in database: %d', 'robotstxt-smtp-newsletter' ),
|
||||||
|
number_format_i18n( $total_emails )
|
||||||
|
);
|
||||||
|
?>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<table class="wp-list-table widefat fixed striped">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width:50px;"><?php esc_html_e( 'ID', 'robotstxt-smtp-newsletter' ); ?></th>
|
||||||
|
<th><?php esc_html_e( 'To', 'robotstxt-smtp-newsletter' ); ?></th>
|
||||||
|
<th><?php esc_html_e( 'Subject', 'robotstxt-smtp-newsletter' ); ?></th>
|
||||||
|
<th><?php esc_html_e( 'Campaign', 'robotstxt-smtp-newsletter' ); ?></th>
|
||||||
|
<th><?php esc_html_e( 'Status', 'robotstxt-smtp-newsletter' ); ?></th>
|
||||||
|
<th><?php esc_html_e( 'Created', 'robotstxt-smtp-newsletter' ); ?></th>
|
||||||
|
<th><?php esc_html_e( 'Sent', 'robotstxt-smtp-newsletter' ); ?></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<?php if ( empty( $emails ) ) : ?>
|
||||||
|
<tr>
|
||||||
|
<td colspan="7" style="text-align:center; padding:40px;">
|
||||||
|
<p><?php esc_html_e( 'No emails in queue', 'robotstxt-smtp-newsletter' ); ?></p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<?php else : ?>
|
||||||
|
<?php foreach ( $emails as $email ) : ?>
|
||||||
|
<tr>
|
||||||
|
<td><?php echo absint( $email->id ); ?></td>
|
||||||
|
<td><?php echo esc_html( $email->to_email ); ?></td>
|
||||||
|
<td>
|
||||||
|
<strong><?php echo esc_html( $email->subject ); ?></strong>
|
||||||
|
<?php if ( ! empty( $email->last_error ) ) : ?>
|
||||||
|
<br><small style="color:#d9534f;">
|
||||||
|
<?php echo esc_html( $email->last_error ); ?>
|
||||||
|
</small>
|
||||||
|
<?php endif; ?>
|
||||||
|
</td>
|
||||||
|
<td><?php echo esc_html( $email->campaign_name ?? '-' ); ?></td>
|
||||||
|
<td>
|
||||||
|
<?php
|
||||||
|
if ( $email->sent_at ) {
|
||||||
|
echo '<span style="color:#5cb85c;">✓ ' . esc_html__( 'Sent', 'robotstxt-smtp-newsletter' ) . '</span>';
|
||||||
|
} elseif ( $email->locked_at ) {
|
||||||
|
echo '<span style="color:#f0ad4e;">⏳ ' . esc_html__( 'Processing', 'robotstxt-smtp-newsletter' ) . '</span>';
|
||||||
|
} elseif ( $email->attempts >= 3 ) {
|
||||||
|
echo '<span style="color:#d9534f;">✗ ' . esc_html__( 'Failed', 'robotstxt-smtp-newsletter' ) . '</span>';
|
||||||
|
} else {
|
||||||
|
echo '<span style="color:#999;">⏸ ' . esc_html__( 'Pending', 'robotstxt-smtp-newsletter' ) . '</span>';
|
||||||
|
if ( $email->attempts > 0 ) {
|
||||||
|
echo sprintf(
|
||||||
|
' <small>(%s)</small>',
|
||||||
|
sprintf(
|
||||||
|
/* translators: %d: number of retry attempts */
|
||||||
|
esc_html__( 'Retry %d', 'robotstxt-smtp-newsletter' ),
|
||||||
|
absint( $email->attempts )
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
?>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<?php
|
||||||
|
$created_time = mysql2date( 'Y-m-d H:i:s', $email->created_at );
|
||||||
|
echo esc_html( $created_time );
|
||||||
|
?>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<?php
|
||||||
|
if ( $email->sent_at ) {
|
||||||
|
$sent_time = mysql2date( 'Y-m-d H:i:s', $email->sent_at );
|
||||||
|
echo esc_html( $sent_time );
|
||||||
|
} else {
|
||||||
|
echo '-';
|
||||||
|
}
|
||||||
|
?>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
<?php endif; ?>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<?php if ( $total_pages > 1 ) : ?>
|
||||||
|
<div class="tablenav bottom">
|
||||||
|
<div class="tablenav-pages">
|
||||||
|
<?php
|
||||||
|
echo paginate_links(
|
||||||
|
array(
|
||||||
|
'base' => add_query_arg( 'paged', '%#%' ),
|
||||||
|
'format' => '',
|
||||||
|
'prev_text' => __( '«' ),
|
||||||
|
'next_text' => __( '»' ),
|
||||||
|
'total' => $total_pages,
|
||||||
|
'current' => $page,
|
||||||
|
)
|
||||||
|
);
|
||||||
|
?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
<?php
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -101,12 +101,28 @@ class Settings_Page {
|
||||||
'robotstxt_smtp_newsletter_main'
|
'robotstxt_smtp_newsletter_main'
|
||||||
);
|
);
|
||||||
|
|
||||||
add_settings_field(
|
// Queue Status section.
|
||||||
'max_per_connection',
|
add_settings_section(
|
||||||
__( 'Max Emails Per Connection', 'robotstxt-smtp-newsletter' ),
|
'robotstxt_smtp_newsletter_queue_status',
|
||||||
array( $this, 'render_max_per_connection_field' ),
|
__( 'Queue Status', 'robotstxt-smtp-newsletter' ),
|
||||||
'robotstxt_smtp_newsletter',
|
array( $this, 'render_queue_status_section' ),
|
||||||
'robotstxt_smtp_newsletter_main'
|
'robotstxt_smtp_newsletter'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Recent Campaigns section.
|
||||||
|
add_settings_section(
|
||||||
|
'robotstxt_smtp_newsletter_recent_campaigns',
|
||||||
|
__( 'Recent Campaigns', 'robotstxt-smtp-newsletter' ),
|
||||||
|
array( $this, 'render_recent_campaigns_section' ),
|
||||||
|
'robotstxt_smtp_newsletter'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Worker configuration section (at the end).
|
||||||
|
add_settings_section(
|
||||||
|
'robotstxt_smtp_newsletter_worker',
|
||||||
|
__( 'Queue Worker Configuration', 'robotstxt-smtp-newsletter' ),
|
||||||
|
array( $this, 'render_worker_section' ),
|
||||||
|
'robotstxt_smtp_newsletter'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -206,10 +222,9 @@ class Settings_Page {
|
||||||
* @return void
|
* @return void
|
||||||
*/
|
*/
|
||||||
private function render_recommendations() {
|
private function render_recommendations() {
|
||||||
$optimal = $this->calculate_optimal_settings();
|
$optimal = $this->calculate_optimal_settings();
|
||||||
$current_options = \Robotstxt_SMTP_Newsletter\Plugin::get_instance()->get_settings();
|
$current_options = \Robotstxt_SMTP_Newsletter\Plugin::get_instance()->get_settings();
|
||||||
$current_batch = absint( $current_options['batch_size'] ?? 10 );
|
$current_batch = absint( $current_options['batch_size'] ?? 10 );
|
||||||
$current_max_conn = absint( $current_options['max_per_connection'] ?? 50 );
|
|
||||||
|
|
||||||
// Determine batch size status.
|
// Determine batch size status.
|
||||||
$batch_status = 'optimal';
|
$batch_status = 'optimal';
|
||||||
|
|
@ -221,53 +236,26 @@ class Settings_Page {
|
||||||
$batch_status = 'suboptimal';
|
$batch_status = 'suboptimal';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine max_per_connection status (only for SMTP).
|
|
||||||
$max_conn_status = 'optimal';
|
|
||||||
if ( ! $optimal['is_ses'] && $optimal['max_per_connection'] > 0 ) {
|
|
||||||
if ( $current_max_conn < $optimal['max_per_connection'] * 0.5 ) {
|
|
||||||
$max_conn_status = 'too_low';
|
|
||||||
} elseif ( $current_max_conn > $optimal['max_per_connection'] * 1.5 ) {
|
|
||||||
$max_conn_status = 'too_high';
|
|
||||||
} elseif ( abs( $current_max_conn - $optimal['max_per_connection'] ) > 20 ) {
|
|
||||||
$max_conn_status = 'suboptimal';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Overall status.
|
|
||||||
$overall_status = 'optimal';
|
|
||||||
if ( 'too_low' === $batch_status || 'too_high' === $batch_status || 'too_low' === $max_conn_status || 'too_high' === $max_conn_status ) {
|
|
||||||
$overall_status = 'warning';
|
|
||||||
} elseif ( 'suboptimal' === $batch_status || 'suboptimal' === $max_conn_status ) {
|
|
||||||
$overall_status = 'info';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Status styling.
|
// Status styling.
|
||||||
$notice_class = 'notice-info';
|
$notice_class = 'notice-info';
|
||||||
$status_icon = 'ⓘ'; // Info icon.
|
$status_icon = 'ⓘ'; // Info icon.
|
||||||
|
|
||||||
if ( 'optimal' === $overall_status ) {
|
if ( 'optimal' === $batch_status ) {
|
||||||
$notice_class = 'notice-success';
|
$notice_class = 'notice-success';
|
||||||
$status_icon = '✔'; // Checkmark.
|
$status_icon = '✔'; // Checkmark.
|
||||||
} elseif ( 'warning' === $overall_status ) {
|
} elseif ( in_array( $batch_status, array( 'too_low', 'too_high' ), true ) ) {
|
||||||
$notice_class = 'notice-warning';
|
$notice_class = 'notice-warning';
|
||||||
$status_icon = '⚠'; // Warning icon.
|
$status_icon = '⚠'; // Warning icon.
|
||||||
}
|
}
|
||||||
|
|
||||||
echo '<div class="notice ' . esc_attr( $notice_class ) . ' inline" style="margin-top: 15px;"><p>';
|
echo '<div class="notice ' . esc_attr( $notice_class ) . ' inline" style="margin-top: 15px;"><p>';
|
||||||
echo '<strong>' . $status_icon . ' ' . esc_html__( 'Settings Recommendations', 'robotstxt-smtp-newsletter' ) . '</strong><br>';
|
echo '<strong>' . $status_icon . ' ' . esc_html__( 'Settings Recommendations', 'robotstxt-smtp-newsletter' ) . '</strong><br>';
|
||||||
echo '<ul style="margin: 10px 0 0 20px;">';
|
echo '<ul style="margin: 10px 0 0 20px;">';
|
||||||
|
|
||||||
// Show cron interval.
|
|
||||||
echo '<li>' . sprintf(
|
|
||||||
/* translators: %d: seconds */
|
|
||||||
esc_html__( 'Newsletter cron runs every: %d seconds', 'robotstxt-smtp-newsletter' ),
|
|
||||||
$optimal['cron_interval']
|
|
||||||
) . '</li>';
|
|
||||||
|
|
||||||
// Show delivery method.
|
// Show delivery method.
|
||||||
$delivery_method = $optimal['is_ses']
|
$delivery_method = $optimal['is_ses']
|
||||||
? __( 'Amazon SES API (REST)', 'robotstxt-smtp-newsletter' )
|
? __( 'Amazon SES API', 'robotstxt-smtp-newsletter' )
|
||||||
: __( 'SMTP with SMTPKeepAlive', 'robotstxt-smtp-newsletter' );
|
: __( 'SMTP', 'robotstxt-smtp-newsletter' );
|
||||||
echo '<li>' . sprintf(
|
echo '<li>' . sprintf(
|
||||||
/* translators: %s: delivery method */
|
/* translators: %s: delivery method */
|
||||||
esc_html__( 'Delivery method: %s', 'robotstxt-smtp-newsletter' ),
|
esc_html__( 'Delivery method: %s', 'robotstxt-smtp-newsletter' ),
|
||||||
|
|
@ -283,10 +271,9 @@ class Settings_Page {
|
||||||
|
|
||||||
echo '</ul>';
|
echo '</ul>';
|
||||||
|
|
||||||
// Show recommendations.
|
// Show recommendation.
|
||||||
echo '<p style="margin-top: 10px;"><strong>';
|
echo '<p style="margin-top: 10px;"><strong>';
|
||||||
|
|
||||||
// Batch size recommendation.
|
|
||||||
if ( 'optimal' === $batch_status ) {
|
if ( 'optimal' === $batch_status ) {
|
||||||
echo '✔ ' . sprintf(
|
echo '✔ ' . sprintf(
|
||||||
/* translators: %d: batch size */
|
/* translators: %d: batch size */
|
||||||
|
|
@ -303,7 +290,7 @@ class Settings_Page {
|
||||||
if ( 'too_low' === $batch_status ) {
|
if ( 'too_low' === $batch_status ) {
|
||||||
echo '<br> ' . sprintf(
|
echo '<br> ' . sprintf(
|
||||||
/* translators: %d: current batch size */
|
/* translators: %d: current batch size */
|
||||||
esc_html__( 'Current (%d) is significantly lower than optimal. Increase it to send faster.', 'robotstxt-smtp-newsletter' ),
|
esc_html__( 'Current (%d) is significantly lower than optimal. Increase it to enqueue faster.', 'robotstxt-smtp-newsletter' ),
|
||||||
$current_batch
|
$current_batch
|
||||||
);
|
);
|
||||||
} elseif ( 'too_high' === $batch_status ) {
|
} elseif ( 'too_high' === $batch_status ) {
|
||||||
|
|
@ -322,47 +309,7 @@ class Settings_Page {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Max per connection recommendation (only for SMTP).
|
|
||||||
if ( ! $optimal['is_ses'] && $optimal['max_per_connection'] > 0 ) {
|
|
||||||
echo '<br>';
|
|
||||||
if ( 'optimal' === $max_conn_status ) {
|
|
||||||
echo '✔ ' . sprintf(
|
|
||||||
/* translators: %d: max per connection */
|
|
||||||
esc_html__( 'Max per connection: Your current setting (%d) is optimal!', 'robotstxt-smtp-newsletter' ),
|
|
||||||
$current_max_conn
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
echo '► ' . sprintf(
|
|
||||||
/* translators: %d: recommended max per connection */
|
|
||||||
esc_html__( 'Recommended max per connection: %d emails', 'robotstxt-smtp-newsletter' ),
|
|
||||||
$optimal['max_per_connection']
|
|
||||||
);
|
|
||||||
|
|
||||||
if ( 'too_low' === $max_conn_status ) {
|
|
||||||
echo '<br> ' . sprintf(
|
|
||||||
/* translators: %d: current max per connection */
|
|
||||||
esc_html__( 'Current (%d) is too low. Increase to reduce connection overhead.', 'robotstxt-smtp-newsletter' ),
|
|
||||||
$current_max_conn
|
|
||||||
);
|
|
||||||
} elseif ( 'too_high' === $max_conn_status ) {
|
|
||||||
echo '<br> ' . sprintf(
|
|
||||||
/* translators: %d: current max per connection */
|
|
||||||
esc_html__( 'Current (%d) is too high and may cause SMTP timeouts. Reduce it.', 'robotstxt-smtp-newsletter' ),
|
|
||||||
$current_max_conn
|
|
||||||
);
|
|
||||||
} elseif ( 'suboptimal' === $max_conn_status ) {
|
|
||||||
echo '<br> ' . sprintf(
|
|
||||||
/* translators: 1: current max per connection, 2: recommended max per connection */
|
|
||||||
esc_html__( 'Current (%1$d) could be optimized. Consider changing to %2$d.', 'robotstxt-smtp-newsletter' ),
|
|
||||||
$current_max_conn,
|
|
||||||
$optimal['max_per_connection']
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
echo '</strong></p>';
|
echo '</strong></p>';
|
||||||
|
|
||||||
echo '</p></div>';
|
echo '</p></div>';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -403,45 +350,229 @@ class Settings_Page {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Render max per connection field.
|
* Render recent campaigns section.
|
||||||
*
|
*
|
||||||
* @return void
|
* @return void
|
||||||
*/
|
*/
|
||||||
public function render_max_per_connection_field() {
|
public function render_recent_campaigns_section() {
|
||||||
$options = get_option( self::OPTION_NAME, array() );
|
$stats = \Robotstxt_SMTP_Newsletter\Statistics::get_instance();
|
||||||
$max_per_connection = $options['max_per_connection'] ?? 50;
|
|
||||||
|
|
||||||
// Hide for Amazon SES.
|
// Sync statistics from queue.
|
||||||
if ( class_exists( '\Robotstxt_SMTP\Plugin' ) && \Robotstxt_SMTP\Plugin::is_amazon_ses_integration_active() ) {
|
$stats->sync_from_queue();
|
||||||
?>
|
|
||||||
<p class="description">
|
// Get recent campaigns (last 5).
|
||||||
<?php esc_html_e( 'Not applicable for Amazon SES (uses API instead of SMTP connections).', 'robotstxt-smtp-newsletter' ); ?>
|
$campaigns = $stats->get_recent_campaigns( 5 );
|
||||||
</p>
|
|
||||||
<input type="hidden" name="<?php echo esc_attr( self::OPTION_NAME ); ?>[max_per_connection]" value="<?php echo esc_attr( $max_per_connection ); ?>">
|
|
||||||
<?php
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
?>
|
?>
|
||||||
<input type="number" name="<?php echo esc_attr( self::OPTION_NAME ); ?>[max_per_connection]" value="<?php echo esc_attr( $max_per_connection ); ?>" min="1" max="1000" class="small-text">
|
<p class="description" style="margin-bottom:15px;">
|
||||||
<p class="description">
|
<?php esc_html_e( 'Statistics from the most recent email campaigns.', 'robotstxt-smtp-newsletter' ); ?>
|
||||||
<?php esc_html_e( 'Maximum emails to send before reconnecting to SMTP server. Prevents timeouts.', 'robotstxt-smtp-newsletter' ); ?><br>
|
<a href="<?php echo esc_url( admin_url( 'admin.php?page=robotstxt-smtp-newsletter-stats' ) ); ?>" style="margin-left:10px;">
|
||||||
<?php esc_html_e( 'See calculated recommendations below based on your rate limits.', 'robotstxt-smtp-newsletter' ); ?>
|
<?php esc_html_e( 'View detailed statistics →', 'robotstxt-smtp-newsletter' ); ?>
|
||||||
|
</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<?php if ( empty( $campaigns ) ) : ?>
|
||||||
|
<div class="notice notice-info inline" style="max-width:700px;">
|
||||||
|
<p>
|
||||||
|
<?php esc_html_e( 'No campaigns found yet. Send your first Newsletter campaign to see statistics here.', 'robotstxt-smtp-newsletter' ); ?>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<?php else : ?>
|
||||||
|
<table class="widefat" style="max-width:1000px; margin-top:10px;">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th><?php esc_html_e( 'Campaign', 'robotstxt-smtp-newsletter' ); ?></th>
|
||||||
|
<th style="text-align:center;"><?php esc_html_e( 'Started', 'robotstxt-smtp-newsletter' ); ?></th>
|
||||||
|
<th style="text-align:center;"><?php esc_html_e( 'Finished', 'robotstxt-smtp-newsletter' ); ?></th>
|
||||||
|
<th style="text-align:right;"><?php esc_html_e( 'Total', 'robotstxt-smtp-newsletter' ); ?></th>
|
||||||
|
<th style="text-align:right;"><?php esc_html_e( 'Sent', 'robotstxt-smtp-newsletter' ); ?></th>
|
||||||
|
<th style="text-align:right;"><?php esc_html_e( 'Failed', 'robotstxt-smtp-newsletter' ); ?></th>
|
||||||
|
<th style="text-align:right;"><?php esc_html_e( 'Duration', 'robotstxt-smtp-newsletter' ); ?></th>
|
||||||
|
<th style="text-align:right;"><?php esc_html_e( 'Speed', 'robotstxt-smtp-newsletter' ); ?></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<?php foreach ( $campaigns as $campaign ) : ?>
|
||||||
|
<?php
|
||||||
|
// Calculate success rate.
|
||||||
|
$success_rate = $campaign->total_emails > 0
|
||||||
|
? ( $campaign->sent_emails / $campaign->total_emails ) * 100
|
||||||
|
: 0;
|
||||||
|
$row_style = '';
|
||||||
|
if ( $success_rate < 95 ) {
|
||||||
|
$row_style = 'background-color: #fff3cd;'; // Warning yellow for low success rate.
|
||||||
|
}
|
||||||
|
?>
|
||||||
|
<tr style="<?php echo esc_attr( $row_style ); ?>">
|
||||||
|
<td>
|
||||||
|
<strong><?php echo esc_html( $campaign->campaign_name ); ?></strong>
|
||||||
|
<?php if ( $success_rate < 95 && $campaign->finished_at ) : ?>
|
||||||
|
<span style="color:#856404; margin-left:5px;" title="<?php esc_attr_e( 'Low success rate', 'robotstxt-smtp-newsletter' ); ?>">⚠</span>
|
||||||
|
<?php endif; ?>
|
||||||
|
</td>
|
||||||
|
<td style="text-align:center; font-size:12px;">
|
||||||
|
<?php
|
||||||
|
if ( $campaign->started_at ) {
|
||||||
|
echo esc_html( mysql2date( 'Y-m-d H:i', $campaign->started_at ) );
|
||||||
|
} else {
|
||||||
|
echo '-';
|
||||||
|
}
|
||||||
|
?>
|
||||||
|
</td>
|
||||||
|
<td style="text-align:center; font-size:12px;">
|
||||||
|
<?php
|
||||||
|
if ( $campaign->finished_at ) {
|
||||||
|
echo esc_html( mysql2date( 'Y-m-d H:i', $campaign->finished_at ) );
|
||||||
|
} else {
|
||||||
|
echo '<span style="color:#f0ad4e;">' . esc_html__( 'In progress', 'robotstxt-smtp-newsletter' ) . '</span>';
|
||||||
|
}
|
||||||
|
?>
|
||||||
|
</td>
|
||||||
|
<td style="text-align:right;">
|
||||||
|
<strong><?php echo number_format_i18n( $campaign->total_emails ); ?></strong>
|
||||||
|
</td>
|
||||||
|
<td style="text-align:right; color:#5cb85c;">
|
||||||
|
<strong><?php echo number_format_i18n( $campaign->sent_emails ); ?></strong>
|
||||||
|
<small style="color:#666;">(<?php echo number_format_i18n( $success_rate, 1 ); ?>%)</small>
|
||||||
|
</td>
|
||||||
|
<td style="text-align:right;">
|
||||||
|
<?php if ( $campaign->failed_emails > 0 ) : ?>
|
||||||
|
<strong style="color:#d9534f;"><?php echo number_format_i18n( $campaign->failed_emails ); ?></strong>
|
||||||
|
<?php else : ?>
|
||||||
|
<span style="color:#999;">0</span>
|
||||||
|
<?php endif; ?>
|
||||||
|
</td>
|
||||||
|
<td style="text-align:right; font-size:12px;">
|
||||||
|
<?php
|
||||||
|
if ( $campaign->duration_seconds ) {
|
||||||
|
$hours = floor( $campaign->duration_seconds / 3600 );
|
||||||
|
$minutes = floor( ( $campaign->duration_seconds % 3600 ) / 60 );
|
||||||
|
$seconds = $campaign->duration_seconds % 60;
|
||||||
|
|
||||||
|
if ( $hours > 0 ) {
|
||||||
|
echo sprintf( '%dh %dm', $hours, $minutes );
|
||||||
|
} elseif ( $minutes > 0 ) {
|
||||||
|
echo sprintf( '%dm %ds', $minutes, $seconds );
|
||||||
|
} else {
|
||||||
|
echo sprintf( '%ds', $seconds );
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
echo '-';
|
||||||
|
}
|
||||||
|
?>
|
||||||
|
</td>
|
||||||
|
<td style="text-align:right; font-size:12px;">
|
||||||
|
<?php
|
||||||
|
if ( $campaign->emails_per_hour ) {
|
||||||
|
echo '<strong>' . number_format_i18n( $campaign->emails_per_hour, 0 ) . '</strong> /h';
|
||||||
|
if ( $campaign->emails_per_minute ) {
|
||||||
|
echo '<br><small style="color:#666;">' . number_format_i18n( $campaign->emails_per_minute, 1 ) . ' /min</small>';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
echo '-';
|
||||||
|
}
|
||||||
|
?>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<?php endif; ?>
|
||||||
<?php
|
<?php
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Calculate optimal batch size and max per connection based on rate limits and cron interval.
|
* Render worker configuration section.
|
||||||
*
|
*
|
||||||
* @return array<string, mixed> Array with 'batch_size', 'max_per_connection', 'reasoning', and 'status'.
|
* @return void
|
||||||
|
*/
|
||||||
|
public function render_worker_section() {
|
||||||
|
$worker_path = ROBOTSTXT_SMTP_NEWSLETTER_PATH . 'worker.php';
|
||||||
|
?>
|
||||||
|
<div class="notice notice-info inline">
|
||||||
|
<p><strong><?php esc_html_e( 'Important: Queue Processing', 'robotstxt-smtp-newsletter' ); ?></strong></p>
|
||||||
|
<p><?php esc_html_e( 'Newsletter emails are now queued to the database and require a worker process to send them.', 'robotstxt-smtp-newsletter' ); ?></p>
|
||||||
|
|
||||||
|
<h4><?php esc_html_e( 'Single Worker Command', 'robotstxt-smtp-newsletter' ); ?></h4>
|
||||||
|
<code style="display:block; padding:10px; background:#f5f5f5; overflow-x:auto; margin:10px 0; font-family:monospace;">
|
||||||
|
php <?php echo esc_html( $worker_path ); ?> 10 "w1"
|
||||||
|
</code>
|
||||||
|
|
||||||
|
<h4><?php esc_html_e( 'Parallel Processing (10 workers)', 'robotstxt-smtp-newsletter' ); ?></h4>
|
||||||
|
<code style="display:block; padding:10px; background:#f5f5f5; overflow-x:auto; margin:10px 0; font-family:monospace;">
|
||||||
|
seq 1 10 | xargs -P10 -I{} php <?php echo esc_html( $worker_path ); ?> 10 "w{}"
|
||||||
|
</code>
|
||||||
|
|
||||||
|
<p style="margin-top:15px;">
|
||||||
|
<strong><?php esc_html_e( 'Recommended Setup:', 'robotstxt-smtp-newsletter' ); ?></strong>
|
||||||
|
<?php esc_html_e( 'Add to system cron (crontab) to run every minute, or use a process supervisor like systemd or supervisord.', 'robotstxt-smtp-newsletter' ); ?>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<?php
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render queue status section.
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function render_queue_status_section() {
|
||||||
|
$queue = \Robotstxt_SMTP_Newsletter\Queue::get_instance();
|
||||||
|
$stats = $queue->get_queue_stats();
|
||||||
|
?>
|
||||||
|
<p class="description" style="margin-bottom:15px;">
|
||||||
|
<?php esc_html_e( 'Real-time statistics from the email queue. Refresh the page to update.', 'robotstxt-smtp-newsletter' ); ?>
|
||||||
|
</p>
|
||||||
|
<table class="widefat" style="max-width:700px; margin-top:10px;">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th><?php esc_html_e( 'Status', 'robotstxt-smtp-newsletter' ); ?></th>
|
||||||
|
<th style="text-align:right;"><?php esc_html_e( 'Count', 'robotstxt-smtp-newsletter' ); ?></th>
|
||||||
|
<th style="text-align:left;"><?php esc_html_e( 'Description', 'robotstxt-smtp-newsletter' ); ?></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td><strong><?php esc_html_e( 'Pending', 'robotstxt-smtp-newsletter' ); ?></strong></td>
|
||||||
|
<td style="text-align:right;"><strong style="font-size:18px;"><?php echo number_format_i18n( $stats['pending'] ); ?></strong></td>
|
||||||
|
<td style="color:#666;">
|
||||||
|
<?php esc_html_e( 'Emails waiting to be processed by workers', 'robotstxt-smtp-newsletter' ); ?>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><strong><?php esc_html_e( 'Processing', 'robotstxt-smtp-newsletter' ); ?></strong></td>
|
||||||
|
<td style="text-align:right;"><strong style="font-size:18px; color:#f0ad4e;"><?php echo number_format_i18n( $stats['locked'] ); ?></strong></td>
|
||||||
|
<td style="color:#666;">
|
||||||
|
<?php esc_html_e( 'Emails currently being sent by active workers', 'robotstxt-smtp-newsletter' ); ?>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><strong><?php esc_html_e( 'Sent (24h)', 'robotstxt-smtp-newsletter' ); ?></strong></td>
|
||||||
|
<td style="text-align:right;"><strong style="font-size:18px; color:#5cb85c;"><?php echo number_format_i18n( $stats['sent_24h'] ); ?></strong></td>
|
||||||
|
<td style="color:#666;">
|
||||||
|
<?php esc_html_e( 'Emails successfully sent in the last 24 hours', 'robotstxt-smtp-newsletter' ); ?>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><strong><?php esc_html_e( 'Failed (24h)', 'robotstxt-smtp-newsletter' ); ?></strong></td>
|
||||||
|
<td style="text-align:right;"><strong style="font-size:18px; color:#d9534f;"><?php echo number_format_i18n( $stats['failed_24h'] ); ?></strong></td>
|
||||||
|
<td style="color:#666;">
|
||||||
|
<?php esc_html_e( 'Emails that failed after 3 retry attempts', 'robotstxt-smtp-newsletter' ); ?>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<?php
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate optimal batch size based on rate limits.
|
||||||
|
*
|
||||||
|
* @return array<string, mixed> Array with 'batch_size', 'reasoning', and 'is_ses'.
|
||||||
*/
|
*/
|
||||||
private function calculate_optimal_settings() {
|
private function calculate_optimal_settings() {
|
||||||
$plugin = \Robotstxt_SMTP_Newsletter\Plugin::get_instance();
|
$plugin = \Robotstxt_SMTP_Newsletter\Plugin::get_instance();
|
||||||
$settings = $plugin->get_smtp_settings();
|
$settings = $plugin->get_smtp_settings();
|
||||||
|
|
||||||
// Get Newsletter cron interval (default 300 seconds).
|
|
||||||
$cron_interval = defined( 'NEWSLETTER_CRON_INTERVAL' ) ? NEWSLETTER_CRON_INTERVAL : 300;
|
|
||||||
|
|
||||||
// Get rate limits.
|
// Get rate limits.
|
||||||
$rate_per_second = absint( $settings['rate_limit_per_second'] ?? 0 );
|
$rate_per_second = absint( $settings['rate_limit_per_second'] ?? 0 );
|
||||||
$rate_per_hour = absint( $settings['rate_limit_per_hour'] ?? 0 );
|
$rate_per_hour = absint( $settings['rate_limit_per_hour'] ?? 0 );
|
||||||
|
|
@ -450,72 +581,63 @@ class Settings_Page {
|
||||||
// Check if Amazon SES is active.
|
// Check if Amazon SES is active.
|
||||||
$is_ses = \Robotstxt_SMTP\Plugin::is_amazon_ses_integration_active();
|
$is_ses = \Robotstxt_SMTP\Plugin::is_amazon_ses_integration_active();
|
||||||
|
|
||||||
$optimal_batch = 10; // Default fallback.
|
$optimal_batch = 10; // Default fallback.
|
||||||
$optimal_max_conn = 50; // Default fallback.
|
$reasoning = array();
|
||||||
$reasoning = array();
|
|
||||||
$limiting_factor = '';
|
$limiting_factor = '';
|
||||||
|
|
||||||
// If no rate limits are set.
|
// If no rate limits are set.
|
||||||
if ( 0 === $rate_per_second && 0 === $rate_per_hour && 0 === $rate_per_day ) {
|
if ( 0 === $rate_per_second && 0 === $rate_per_hour && 0 === $rate_per_day ) {
|
||||||
$optimal_batch = $is_ses ? 50 : 20;
|
$optimal_batch = $is_ses ? 50 : 20;
|
||||||
$optimal_max_conn = $is_ses ? 0 : 100; // SES doesn't need max per connection.
|
$reasoning[] = __( 'No rate limits configured.', 'robotstxt-smtp-newsletter' );
|
||||||
$reasoning[] = __( 'No rate limits configured.', 'robotstxt-smtp-newsletter' );
|
$reasoning[] = $is_ses
|
||||||
$reasoning[] = $is_ses
|
|
||||||
? __( 'Using default for Amazon SES (50 emails/batch).', 'robotstxt-smtp-newsletter' )
|
? __( 'Using default for Amazon SES (50 emails/batch).', 'robotstxt-smtp-newsletter' )
|
||||||
: __( 'Using default for SMTP (20 emails/batch, 100 max per connection).', 'robotstxt-smtp-newsletter' );
|
: __( 'Using default for SMTP (20 emails/batch).', 'robotstxt-smtp-newsletter' );
|
||||||
$limiting_factor = 'default';
|
$limiting_factor = 'default';
|
||||||
} else {
|
} else {
|
||||||
// Calculate based on rate limits.
|
// Calculate based on rate limits - use conservative values for queuing.
|
||||||
$candidates = array();
|
$candidates = array();
|
||||||
|
|
||||||
// Per-second limit: Calculate how many emails we can send in one cron cycle.
|
|
||||||
if ( $rate_per_second > 0 ) {
|
if ( $rate_per_second > 0 ) {
|
||||||
// Reserve time for connection overhead (10s for SMTP, 2s for SES).
|
// For per-second: batch should be reasonable for burst processing.
|
||||||
$overhead = $is_ses ? 2 : 10;
|
$max_emails = min( 100, $rate_per_second * 30 ); // 30 seconds worth.
|
||||||
$available_time = max( 1, $cron_interval - $overhead );
|
|
||||||
$max_emails = floor( $rate_per_second * $available_time );
|
|
||||||
$candidates['per_second'] = $max_emails;
|
$candidates['per_second'] = $max_emails;
|
||||||
$reasoning[] = sprintf(
|
$reasoning[] = sprintf(
|
||||||
/* translators: 1: emails per second, 2: cron interval, 3: overhead, 4: calculated max */
|
/* translators: 1: emails per second, 2: calculated max */
|
||||||
__( 'Per-second limit: %1$d emails/sec × (%2$d sec - %3$d sec overhead) = %4$d emails/batch', 'robotstxt-smtp-newsletter' ),
|
__( 'Per-second limit: %1$d emails/sec → batch of %2$d', 'robotstxt-smtp-newsletter' ),
|
||||||
$rate_per_second,
|
$rate_per_second,
|
||||||
$cron_interval,
|
|
||||||
$overhead,
|
|
||||||
$max_emails
|
$max_emails
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Per-hour limit: Divide by number of cron executions per hour.
|
|
||||||
if ( $rate_per_hour > 0 ) {
|
if ( $rate_per_hour > 0 ) {
|
||||||
$batches_per_hour = floor( 3600 / $cron_interval );
|
// For per-hour: assume worker processes every 5 minutes.
|
||||||
$max_emails = floor( $rate_per_hour / $batches_per_hour );
|
$batches_per_hour = 12;
|
||||||
|
$max_emails = floor( $rate_per_hour / $batches_per_hour );
|
||||||
$candidates['per_hour'] = $max_emails;
|
$candidates['per_hour'] = $max_emails;
|
||||||
$reasoning[] = sprintf(
|
$reasoning[] = sprintf(
|
||||||
/* translators: 1: emails per hour, 2: batches per hour, 3: calculated max */
|
/* translators: 1: emails per hour, 2: calculated max */
|
||||||
__( 'Per-hour limit: %1$d emails/hour ÷ %2$d batches/hour = %3$d emails/batch', 'robotstxt-smtp-newsletter' ),
|
__( 'Per-hour limit: %1$d emails/hour → batch of %2$d', 'robotstxt-smtp-newsletter' ),
|
||||||
$rate_per_hour,
|
$rate_per_hour,
|
||||||
$batches_per_hour,
|
|
||||||
$max_emails
|
$max_emails
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Per-day limit: Divide by number of cron executions per day.
|
|
||||||
if ( $rate_per_day > 0 ) {
|
if ( $rate_per_day > 0 ) {
|
||||||
$batches_per_day = floor( 86400 / $cron_interval );
|
// For per-day: assume 288 worker runs per day (every 5 minutes).
|
||||||
$max_emails = floor( $rate_per_day / $batches_per_day );
|
$batches_per_day = 288;
|
||||||
|
$max_emails = floor( $rate_per_day / $batches_per_day );
|
||||||
$candidates['per_day'] = $max_emails;
|
$candidates['per_day'] = $max_emails;
|
||||||
$reasoning[] = sprintf(
|
$reasoning[] = sprintf(
|
||||||
/* translators: 1: emails per day, 2: batches per day, 3: calculated max */
|
/* translators: 1: emails per day, 2: calculated max */
|
||||||
__( 'Per-day limit: %1$d emails/day ÷ %2$d batches/day = %3$d emails/batch', 'robotstxt-smtp-newsletter' ),
|
__( 'Per-day limit: %1$d emails/day → batch of %2$d', 'robotstxt-smtp-newsletter' ),
|
||||||
$rate_per_day,
|
$rate_per_day,
|
||||||
$batches_per_day,
|
|
||||||
$max_emails
|
$max_emails
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// The optimal batch size is the minimum of all candidates.
|
// The optimal batch size is the minimum of all candidates.
|
||||||
if ( ! empty( $candidates ) ) {
|
if ( ! empty( $candidates ) ) {
|
||||||
$optimal_batch = min( $candidates );
|
$optimal_batch = min( $candidates );
|
||||||
$limiting_factor = array_search( $optimal_batch, $candidates, true );
|
$limiting_factor = array_search( $optimal_batch, $candidates, true );
|
||||||
|
|
||||||
// Apply safety margin (90%).
|
// Apply safety margin (90%).
|
||||||
|
|
@ -523,36 +645,18 @@ class Settings_Page {
|
||||||
|
|
||||||
$reasoning[] = sprintf(
|
$reasoning[] = sprintf(
|
||||||
/* translators: 1: limiting factor, 2: final batch size */
|
/* translators: 1: limiting factor, 2: final batch size */
|
||||||
__( 'Most restrictive limit: %1$s. Recommended batch size (with 10%% safety margin): %2$d', 'robotstxt-smtp-newsletter' ),
|
__( 'Most restrictive: %1$s. Recommended batch (with 10%% margin): %2$d', 'robotstxt-smtp-newsletter' ),
|
||||||
$limiting_factor,
|
$limiting_factor,
|
||||||
$optimal_batch
|
$optimal_batch
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate optimal max_per_connection for SMTP (not needed for SES).
|
|
||||||
if ( ! $is_ses ) {
|
|
||||||
// Max per connection should be 2-5x the batch size, or based on hourly limits.
|
|
||||||
if ( $rate_per_hour > 0 ) {
|
|
||||||
// Conservative: use 10 minutes of the hourly limit.
|
|
||||||
$optimal_max_conn = floor( ( $rate_per_hour / 6 ) * 0.9 );
|
|
||||||
} else {
|
|
||||||
$optimal_max_conn = min( 500, max( 50, $optimal_batch * 3 ) );
|
|
||||||
}
|
|
||||||
$reasoning[] = sprintf(
|
|
||||||
/* translators: %d: max per connection */
|
|
||||||
__( 'Max per connection (SMTP): %d emails before reconnecting to prevent timeouts.', 'robotstxt-smtp-newsletter' ),
|
|
||||||
$optimal_max_conn
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return array(
|
return array(
|
||||||
'batch_size' => $optimal_batch,
|
'batch_size' => $optimal_batch,
|
||||||
'max_per_connection' => $optimal_max_conn,
|
'reasoning' => $reasoning,
|
||||||
'reasoning' => $reasoning,
|
'limiting_factor' => $limiting_factor,
|
||||||
'limiting_factor' => $limiting_factor,
|
'is_ses' => $is_ses,
|
||||||
'cron_interval' => $cron_interval,
|
|
||||||
'is_ses' => $is_ses,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -571,9 +675,6 @@ class Settings_Page {
|
||||||
// Batch size: Allow up to 10,000 for high-throughput scenarios (SES, high rate limits).
|
// Batch size: Allow up to 10,000 for high-throughput scenarios (SES, high rate limits).
|
||||||
$clean['batch_size'] = max( 1, min( 10000, (int) ( $options['batch_size'] ?? 10 ) ) );
|
$clean['batch_size'] = max( 1, min( 10000, (int) ( $options['batch_size'] ?? 10 ) ) );
|
||||||
|
|
||||||
// Max per connection: Allow up to 1,000 for providers that support it.
|
|
||||||
$clean['max_per_connection'] = max( 1, min( 1000, (int) ( $options['max_per_connection'] ?? 50 ) ) );
|
|
||||||
|
|
||||||
return $clean;
|
return $clean;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
255
admin/class-statistics-page.php
Normal file
255
admin/class-statistics-page.php
Normal file
|
|
@ -0,0 +1,255 @@
|
||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Statistics Admin Page
|
||||||
|
*
|
||||||
|
* Displays campaign performance metrics and historical data.
|
||||||
|
*
|
||||||
|
* @package ROBOTSTXT_SMTP_Newsletter
|
||||||
|
* @since 2.2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Robotstxt_SMTP_Newsletter\Admin;
|
||||||
|
|
||||||
|
if ( ! defined( 'ABSPATH' ) ) {
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Statistics Page class.
|
||||||
|
*
|
||||||
|
* Provides admin UI for viewing campaign statistics and performance metrics.
|
||||||
|
*/
|
||||||
|
class Statistics_Page {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register admin page hooks.
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public static function register() {
|
||||||
|
add_submenu_page(
|
||||||
|
'newsletter_main_main',
|
||||||
|
__( 'Email Statistics', 'robotstxt-smtp-newsletter' ),
|
||||||
|
__( 'Statistics', 'robotstxt-smtp-newsletter' ),
|
||||||
|
'manage_options',
|
||||||
|
'robotstxt-smtp-newsletter-stats',
|
||||||
|
array( __CLASS__, 'render_page' )
|
||||||
|
);
|
||||||
|
|
||||||
|
add_action( 'admin_post_delete_campaign_history', array( __CLASS__, 'handle_delete_campaign' ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render admin page.
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public static function render_page() {
|
||||||
|
if ( ! current_user_can( 'manage_options' ) ) {
|
||||||
|
wp_die( esc_html__( 'Insufficient permissions', 'robotstxt-smtp-newsletter' ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
$stats_instance = \Robotstxt_SMTP_Newsletter\Statistics::get_instance();
|
||||||
|
|
||||||
|
// Sync from queue first to get latest data.
|
||||||
|
$stats_instance->sync_from_queue();
|
||||||
|
|
||||||
|
$campaigns = $stats_instance->get_recent_campaigns( 20 );
|
||||||
|
$global = $stats_instance->get_global_stats( 30 );
|
||||||
|
|
||||||
|
// Handle success message.
|
||||||
|
if ( isset( $_GET['deleted'] ) ) {
|
||||||
|
?>
|
||||||
|
<div class="notice notice-success is-dismissible">
|
||||||
|
<p>
|
||||||
|
<?php
|
||||||
|
printf(
|
||||||
|
/* translators: %d: number of records deleted */
|
||||||
|
esc_html__( '%d queue records deleted.', 'robotstxt-smtp-newsletter' ),
|
||||||
|
absint( $_GET['deleted'] )
|
||||||
|
);
|
||||||
|
?>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<?php
|
||||||
|
}
|
||||||
|
|
||||||
|
?>
|
||||||
|
<div class="wrap">
|
||||||
|
<h1><?php esc_html_e( 'Email Statistics', 'robotstxt-smtp-newsletter' ); ?></h1>
|
||||||
|
|
||||||
|
<!-- Global Stats -->
|
||||||
|
<h2><?php esc_html_e( 'Global Performance (Last 30 Days)', 'robotstxt-smtp-newsletter' ); ?></h2>
|
||||||
|
<table class="widefat" style="max-width:600px; margin-bottom:30px;">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td><strong><?php esc_html_e( 'Total Emails Sent', 'robotstxt-smtp-newsletter' ); ?></strong></td>
|
||||||
|
<td style="text-align:right;">
|
||||||
|
<?php echo number_format_i18n( $global['total_sent'] ); ?>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><strong><?php esc_html_e( 'Total Failed', 'robotstxt-smtp-newsletter' ); ?></strong></td>
|
||||||
|
<td style="text-align:right; color:#d9534f;">
|
||||||
|
<?php echo number_format_i18n( $global['total_failed'] ); ?>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><strong><?php esc_html_e( 'Average Speed', 'robotstxt-smtp-newsletter' ); ?></strong></td>
|
||||||
|
<td style="text-align:right;">
|
||||||
|
<?php
|
||||||
|
printf(
|
||||||
|
/* translators: %s: emails per hour */
|
||||||
|
esc_html__( '%s emails/hour', 'robotstxt-smtp-newsletter' ),
|
||||||
|
number_format_i18n( $global['avg_emails_per_hour'], 0 )
|
||||||
|
);
|
||||||
|
?>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><strong><?php esc_html_e( 'Success Rate', 'robotstxt-smtp-newsletter' ); ?></strong></td>
|
||||||
|
<td style="text-align:right;">
|
||||||
|
<?php
|
||||||
|
$total = $global['total_sent'] + $global['total_failed'];
|
||||||
|
$rate = $total > 0 ? ( $global['total_sent'] / $total ) * 100 : 0;
|
||||||
|
echo number_format_i18n( $rate, 1 ) . '%';
|
||||||
|
?>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<!-- Campaign Stats -->
|
||||||
|
<h2><?php esc_html_e( 'Recent Campaigns', 'robotstxt-smtp-newsletter' ); ?></h2>
|
||||||
|
<table class="wp-list-table widefat fixed striped">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th><?php esc_html_e( 'Campaign', 'robotstxt-smtp-newsletter' ); ?></th>
|
||||||
|
<th style="text-align:right;"><?php esc_html_e( 'Total', 'robotstxt-smtp-newsletter' ); ?></th>
|
||||||
|
<th style="text-align:right;"><?php esc_html_e( 'Sent', 'robotstxt-smtp-newsletter' ); ?></th>
|
||||||
|
<th style="text-align:right;"><?php esc_html_e( 'Failed', 'robotstxt-smtp-newsletter' ); ?></th>
|
||||||
|
<th><?php esc_html_e( 'Started', 'robotstxt-smtp-newsletter' ); ?></th>
|
||||||
|
<th><?php esc_html_e( 'Finished', 'robotstxt-smtp-newsletter' ); ?></th>
|
||||||
|
<th style="text-align:right;"><?php esc_html_e( 'Duration', 'robotstxt-smtp-newsletter' ); ?></th>
|
||||||
|
<th style="text-align:right;"><?php esc_html_e( 'Speed', 'robotstxt-smtp-newsletter' ); ?></th>
|
||||||
|
<th><?php esc_html_e( 'Actions', 'robotstxt-smtp-newsletter' ); ?></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<?php if ( empty( $campaigns ) ) : ?>
|
||||||
|
<tr>
|
||||||
|
<td colspan="9" style="text-align:center; padding:40px;">
|
||||||
|
<p><?php esc_html_e( 'No campaigns found', 'robotstxt-smtp-newsletter' ); ?></p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<?php else : ?>
|
||||||
|
<?php foreach ( $campaigns as $campaign ) : ?>
|
||||||
|
<tr>
|
||||||
|
<td><strong><?php echo esc_html( $campaign->campaign_name ); ?></strong></td>
|
||||||
|
<td style="text-align:right;">
|
||||||
|
<?php echo number_format_i18n( $campaign->total_emails ); ?>
|
||||||
|
</td>
|
||||||
|
<td style="text-align:right; color:#5cb85c;">
|
||||||
|
<?php echo number_format_i18n( $campaign->sent_emails ); ?>
|
||||||
|
</td>
|
||||||
|
<td style="text-align:right; color:#d9534f;">
|
||||||
|
<?php echo number_format_i18n( $campaign->failed_emails ); ?>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<?php
|
||||||
|
if ( $campaign->started_at ) {
|
||||||
|
$started_time = mysql2date( 'Y-m-d H:i:s', $campaign->started_at );
|
||||||
|
echo esc_html( $started_time );
|
||||||
|
} else {
|
||||||
|
echo '-';
|
||||||
|
}
|
||||||
|
?>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<?php
|
||||||
|
if ( $campaign->finished_at ) {
|
||||||
|
$finished_time = mysql2date( 'Y-m-d H:i:s', $campaign->finished_at );
|
||||||
|
echo esc_html( $finished_time );
|
||||||
|
} else {
|
||||||
|
echo '<em>' . esc_html__( 'In progress', 'robotstxt-smtp-newsletter' ) . '</em>';
|
||||||
|
}
|
||||||
|
?>
|
||||||
|
</td>
|
||||||
|
<td style="text-align:right;">
|
||||||
|
<?php
|
||||||
|
if ( $campaign->duration_seconds ) {
|
||||||
|
$minutes = floor( $campaign->duration_seconds / 60 );
|
||||||
|
$seconds = $campaign->duration_seconds % 60;
|
||||||
|
echo sprintf( '%dm %ds', absint( $minutes ), absint( $seconds ) );
|
||||||
|
} else {
|
||||||
|
echo '-';
|
||||||
|
}
|
||||||
|
?>
|
||||||
|
</td>
|
||||||
|
<td style="text-align:right;">
|
||||||
|
<?php
|
||||||
|
if ( $campaign->emails_per_hour ) {
|
||||||
|
echo number_format_i18n( $campaign->emails_per_hour, 0 ) . '/h<br>';
|
||||||
|
echo '<small>' . number_format_i18n( $campaign->emails_per_minute, 1 ) . '/min</small>';
|
||||||
|
} else {
|
||||||
|
echo '-';
|
||||||
|
}
|
||||||
|
?>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<?php if ( $campaign->newsletter_id ) : ?>
|
||||||
|
<form method="post" action="<?php echo esc_url( admin_url( 'admin-post.php' ) ); ?>" style="display:inline;">
|
||||||
|
<?php wp_nonce_field( 'delete_campaign_history', 'delete_campaign_nonce' ); ?>
|
||||||
|
<input type="hidden" name="action" value="delete_campaign_history">
|
||||||
|
<input type="hidden" name="newsletter_id" value="<?php echo absint( $campaign->newsletter_id ); ?>">
|
||||||
|
<button type="submit" class="button button-small" onclick="return confirm('<?php echo esc_attr__( 'Delete all queue records for this campaign? Statistics will be preserved.', 'robotstxt-smtp-newsletter' ); ?>');">
|
||||||
|
<?php esc_html_e( 'Delete Queue', 'robotstxt-smtp-newsletter' ); ?>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<?php else : ?>
|
||||||
|
-
|
||||||
|
<?php endif; ?>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
<?php endif; ?>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<?php
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle delete campaign queue action.
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public static function handle_delete_campaign() {
|
||||||
|
check_admin_referer( 'delete_campaign_history', 'delete_campaign_nonce' );
|
||||||
|
|
||||||
|
if ( ! current_user_can( 'manage_options' ) ) {
|
||||||
|
wp_die( esc_html__( 'Insufficient permissions', 'robotstxt-smtp-newsletter' ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
$newsletter_id = isset( $_POST['newsletter_id'] ) ? absint( $_POST['newsletter_id'] ) : 0;
|
||||||
|
|
||||||
|
if ( $newsletter_id > 0 ) {
|
||||||
|
$queue = \Robotstxt_SMTP_Newsletter\Queue::get_instance();
|
||||||
|
$deleted = $queue->delete_campaign_history( $newsletter_id );
|
||||||
|
|
||||||
|
wp_safe_redirect(
|
||||||
|
add_query_arg(
|
||||||
|
array(
|
||||||
|
'page' => 'robotstxt-smtp-newsletter-stats',
|
||||||
|
'deleted' => $deleted,
|
||||||
|
),
|
||||||
|
admin_url( 'admin.php' )
|
||||||
|
)
|
||||||
|
);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
wp_safe_redirect( admin_url( 'admin.php?page=robotstxt-smtp-newsletter-stats' ) );
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,3 +1,29 @@
|
||||||
|
= 2.2.0 =
|
||||||
|
|
||||||
|
**Major Update: Queue-Based Architecture**
|
||||||
|
|
||||||
|
* NEW: Database-backed email queue for persistent, reliable email delivery.
|
||||||
|
* NEW: External CLI worker script for asynchronous email processing.
|
||||||
|
* NEW: Queue Status widget showing real-time statistics (pending, processing, sent, failed).
|
||||||
|
* NEW: Recent Campaigns section with key metrics in settings page.
|
||||||
|
* NEW: Email Queue admin page to view all queued and sent emails.
|
||||||
|
* NEW: Statistics admin page with campaign performance metrics.
|
||||||
|
* NEW: Parallel worker support for high-volume sending (10+ workers simultaneously).
|
||||||
|
* NEW: Automatic retry with exponential backoff (up to 3 attempts: 5min → 15min → 30min).
|
||||||
|
* NEW: Campaign statistics tracking (start time, duration, speed, success rate).
|
||||||
|
* CHANGED: Emails are now queued to database instead of sent immediately.
|
||||||
|
* CHANGED: Settings page reorganized with Queue Status, Recent Campaigns, and Worker Configuration sections.
|
||||||
|
* CHANGED: Campaign statistics now grouped by unique Newsletter ID instead of campaign name.
|
||||||
|
* REMOVED: max_per_connection setting (no longer needed with worker architecture).
|
||||||
|
* REMOVED: Direct PHPMailer usage (replaced with wp_mail() in workers).
|
||||||
|
* FIXED: Duplicate campaign statistics for campaigns with same name but different IDs.
|
||||||
|
* FIXED: Worker command documentation (corrected parallel worker syntax).
|
||||||
|
* BREAKING: Manual worker setup required - emails no longer send automatically.
|
||||||
|
* BREAKING: System cron or process supervisor required for production use.
|
||||||
|
* Technical: New database tables for queue and statistics with automatic creation on activation.
|
||||||
|
* Technical: Microsecond precision timestamps for accurate performance metrics.
|
||||||
|
* Security: CLI-only worker script with SQL injection prevention and capability checks.
|
||||||
|
|
||||||
= 2.1.0 =
|
= 2.1.0 =
|
||||||
|
|
||||||
* Changed uninstall behavior: plugin no longer performs cleanup on uninstall, all data cleanup is now handled by the core SMTP plugin.
|
* Changed uninstall behavior: plugin no longer performs cleanup on uninstall, all data cleanup is now handled by the core SMTP plugin.
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
<?php
|
<?php
|
||||||
/**
|
/**
|
||||||
* Amazon SES Mailer.
|
* Queue-based Amazon SES Mailer.
|
||||||
*
|
*
|
||||||
* @package ROBOTSTXT_SMTP_Newsletter
|
* @package ROBOTSTXT_SMTP_Newsletter
|
||||||
*/
|
*/
|
||||||
|
|
@ -14,26 +14,14 @@ if ( ! defined( 'ABSPATH' ) ) {
|
||||||
/**
|
/**
|
||||||
* Amazon SES Mailer class.
|
* 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 {
|
class AmazonSES_Mailer extends \NewsletterMailer {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* SES client instance.
|
* Batch size for bulk enqueuing.
|
||||||
*
|
*
|
||||||
* @var \Aws\SesV2\SesV2Client|null
|
* SES can handle larger batches than SMTP.
|
||||||
*/
|
|
||||||
private $ses_client = null;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* SMTP settings from robotstxt-smtp plugin.
|
|
||||||
*
|
|
||||||
* @var array<string, mixed>
|
|
||||||
*/
|
|
||||||
private $settings = array();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Batch size for bulk sending.
|
|
||||||
*
|
*
|
||||||
* @var int
|
* @var int
|
||||||
*/
|
*/
|
||||||
|
|
@ -47,10 +35,7 @@ class AmazonSES_Mailer extends \NewsletterMailer {
|
||||||
public function __construct( $options = array() ) {
|
public function __construct( $options = array() ) {
|
||||||
parent::__construct( 'robotstxt-smtp-newsletter', $options );
|
parent::__construct( 'robotstxt-smtp-newsletter', $options );
|
||||||
|
|
||||||
$this->settings = Plugin::get_instance()->get_smtp_settings();
|
|
||||||
|
|
||||||
// Configure batch_size from turbo (standard Newsletter pattern).
|
// Configure batch_size from turbo (standard Newsletter pattern).
|
||||||
// SES can handle larger batches than SMTP.
|
|
||||||
if ( ! empty( $options['turbo'] ) ) {
|
if ( ! empty( $options['turbo'] ) ) {
|
||||||
$this->batch_size = max( 1, (int) $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.
|
* 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
|
* @return int
|
||||||
*/
|
*/
|
||||||
public function get_speed() {
|
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
|
* @param \TNP_Mailer_Message $message Message to enqueue.
|
||||||
* @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.
|
* @return bool|WP_Error True on success, WP_Error on failure.
|
||||||
*/
|
*/
|
||||||
public function send( $message ) {
|
public function send( $message ) {
|
||||||
try {
|
$email = Email_Message::from_newsletter_message( $message );
|
||||||
$client = $this->get_ses_client();
|
$queue = Queue::get_instance();
|
||||||
|
$queue_id = $queue->enqueue( $email );
|
||||||
|
|
||||||
// Check rate limits BEFORE sending.
|
if ( ! $queue_id ) {
|
||||||
if ( ! $this->check_rate_limits() ) {
|
return new \WP_Error( self::ERROR_GENERIC, __( 'Failed to enqueue email', 'robotstxt-smtp-newsletter' ) );
|
||||||
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() );
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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.
|
* @param array<\TNP_Mailer_Message> $messages Messages to enqueue.
|
||||||
* 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 failure.
|
||||||
*
|
|
||||||
* @return bool|WP_Error True on success, WP_Error on fatal error.
|
|
||||||
*/
|
*/
|
||||||
public function send_chunk( $messages ) {
|
public function send_chunk( $messages ) {
|
||||||
$undelivered = 0;
|
$emails = array_map(
|
||||||
$fatal_error = null;
|
array( Email_Message::class, 'from_newsletter_message' ),
|
||||||
|
$messages
|
||||||
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.
|
$queue = Queue::get_instance();
|
||||||
if ( ! empty( $message->body ) ) {
|
$queue_ids = $queue->enqueue_bulk( $emails );
|
||||||
$params['Content']['Simple']['Body']['Html'] = array(
|
|
||||||
'Data' => $message->body,
|
if ( count( $queue_ids ) !== count( $messages ) ) {
|
||||||
'Charset' => 'UTF-8',
|
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;
|
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>
|
* @var array<string, mixed>
|
||||||
*/
|
*/
|
||||||
private static $defaults = array(
|
private static $defaults = array(
|
||||||
'enabled' => false,
|
'enabled' => false,
|
||||||
'batch_size' => 10,
|
'batch_size' => 10,
|
||||||
'max_per_connection' => 50,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -115,13 +114,27 @@ class Plugin extends \NewsletterMailerAddon {
|
||||||
public function init() {
|
public function init() {
|
||||||
parent::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.
|
// Load text domain.
|
||||||
add_action( 'init', array( $this, 'load_textdomain' ) );
|
add_action( 'init', array( $this, 'load_textdomain' ) );
|
||||||
|
|
||||||
// Register settings (but not menu - parent handles that).
|
// Register admin pages.
|
||||||
if ( is_admin() ) {
|
if ( is_admin() ) {
|
||||||
require_once ROBOTSTXT_SMTP_NEWSLETTER_PATH . 'admin/class-settings-page.php';
|
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_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
|
* @return \NewsletterMailer
|
||||||
*/
|
*/
|
||||||
public function get_mailer() {
|
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.
|
// Get our settings and map to Newsletter's expected format.
|
||||||
$settings = $this->get_settings();
|
$settings = $this->get_settings();
|
||||||
$smtp_settings = $this->get_smtp_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.
|
// Newsletter expects 'turbo' for batch_size and 'speed' for emails/hour.
|
||||||
$mailer_options = array(
|
$mailer_options = array(
|
||||||
'turbo' => $settings['batch_size'] ?? 10,
|
'turbo' => $settings['batch_size'] ?? 10,
|
||||||
'speed' => $speed,
|
'speed' => $speed,
|
||||||
'max_per_connection' => $settings['max_per_connection'] ?? 50,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// Detect if Amazon SES is active.
|
// Detect if Amazon SES is active.
|
||||||
|
|
@ -243,4 +260,29 @@ class Plugin extends \NewsletterMailerAddon {
|
||||||
|
|
||||||
return $settings;
|
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
|
<?php
|
||||||
/**
|
/**
|
||||||
* SMTP Mailer with KeepAlive support.
|
* Queue-based SMTP Mailer.
|
||||||
*
|
*
|
||||||
* @package ROBOTSTXT_SMTP_Newsletter
|
* @package ROBOTSTXT_SMTP_Newsletter
|
||||||
*/
|
*/
|
||||||
|
|
@ -14,45 +14,17 @@ if ( ! defined( 'ABSPATH' ) ) {
|
||||||
/**
|
/**
|
||||||
* SMTP Mailer class.
|
* 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 {
|
class SMTP_Mailer extends \NewsletterMailer {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* PHPMailer instance with persistent connection.
|
* Batch size for bulk enqueuing.
|
||||||
*
|
|
||||||
* @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
|
* @var int
|
||||||
*/
|
*/
|
||||||
protected $batch_size = 10;
|
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.
|
* Constructor.
|
||||||
*
|
*
|
||||||
|
|
@ -61,14 +33,10 @@ class SMTP_Mailer extends \NewsletterMailer {
|
||||||
public function __construct( $options = array() ) {
|
public function __construct( $options = array() ) {
|
||||||
parent::__construct( 'robotstxt-smtp-newsletter', $options );
|
parent::__construct( 'robotstxt-smtp-newsletter', $options );
|
||||||
|
|
||||||
$this->settings = Plugin::get_instance()->get_smtp_settings();
|
|
||||||
|
|
||||||
// Configure batch_size from turbo (standard Newsletter pattern).
|
// Configure batch_size from turbo (standard Newsletter pattern).
|
||||||
if ( ! empty( $options['turbo'] ) ) {
|
if ( ! empty( $options['turbo'] ) ) {
|
||||||
$this->batch_size = max( 1, (int) $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
|
* @param \TNP_Mailer_Message $message Message to enqueue.
|
||||||
* @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.
|
* @return bool|WP_Error True on success, WP_Error on failure.
|
||||||
*/
|
*/
|
||||||
public function send( $message ) {
|
public function send( $message ) {
|
||||||
try {
|
$email = Email_Message::from_newsletter_message( $message );
|
||||||
$mailer = $this->get_phpmailer();
|
$queue = Queue::get_instance();
|
||||||
|
$queue_id = $queue->enqueue( $email );
|
||||||
|
|
||||||
// Clear previous message data but keep connection.
|
if ( ! $queue_id ) {
|
||||||
$mailer->clearAddresses();
|
return new \WP_Error( self::ERROR_GENERIC, __( 'Failed to enqueue email', 'robotstxt-smtp-newsletter' ) );
|
||||||
$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;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Prepare message for sending.
|
* Enqueue chunk of emails in bulk.
|
||||||
*
|
*
|
||||||
* @param \PHPMailer\PHPMailer\PHPMailer $mailer PHPMailer instance.
|
* @param array<\TNP_Mailer_Message> $messages Messages to enqueue.
|
||||||
* @param \TNP_Mailer_Message $message Message to prepare.
|
|
||||||
*
|
*
|
||||||
* @return void
|
* @return bool|WP_Error True on success, WP_Error on failure.
|
||||||
* @throws \PHPMailer\PHPMailer\Exception If preparation fails.
|
|
||||||
*/
|
*/
|
||||||
private function prepare_message( $mailer, $message ) {
|
public function send_chunk( $messages ) {
|
||||||
// Use FROM configured in ROBOTSTXT SMTP plugin (required for SMTP auth).
|
$emails = array_map(
|
||||||
// Falls back to Newsletter's FROM if not configured.
|
array( Email_Message::class, 'from_newsletter_message' ),
|
||||||
$from_email = ! empty( $this->settings['from_email'] ) ? $this->settings['from_email'] : $message->from;
|
$messages
|
||||||
$from_name = ! empty( $this->settings['from_name'] ) ? $this->settings['from_name'] : $message->from_name;
|
);
|
||||||
|
|
||||||
$mailer->setFrom( $from_email, $from_name );
|
$queue = Queue::get_instance();
|
||||||
$mailer->addAddress( $message->to );
|
$queue_ids = $queue->enqueue_bulk( $emails );
|
||||||
$mailer->Subject = $message->subject;
|
|
||||||
|
|
||||||
if ( ! empty( $message->body ) ) {
|
if ( count( $queue_ids ) !== count( $messages ) ) {
|
||||||
$mailer->isHTML( true );
|
return new \WP_Error(
|
||||||
$mailer->Body = $message->body;
|
self::ERROR_GENERIC,
|
||||||
|
sprintf(
|
||||||
if ( ! empty( $message->body_text ) ) {
|
/* translators: %d: number of emails that failed to enqueue */
|
||||||
$mailer->AltBody = $message->body_text;
|
__( '%d emails failed to enqueue', 'robotstxt-smtp-newsletter' ),
|
||||||
}
|
count( $messages ) - count( $queue_ids )
|
||||||
} 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;
|
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';
|
||||||
|
}
|
||||||
|
}
|
||||||
78
readme.txt
78
readme.txt
|
|
@ -1,52 +1,88 @@
|
||||||
=== SMTP (by ROBOTSTXT) Newsletter ===
|
=== SMTP (by ROBOTSTXT) Newsletter ===
|
||||||
Contributors: robotstxt
|
Contributors: robotstxt
|
||||||
Tags: newsletter, smtp, email, bulk, amazonses
|
Tags: newsletter, smtp, email, bulk, queue, worker, amazonses, statistics
|
||||||
Requires at least: 4.7
|
Requires at least: 4.7
|
||||||
Tested up to: 6.9
|
Tested up to: 6.9
|
||||||
Requires PHP: 7.2
|
Requires PHP: 7.2
|
||||||
Stable tag: 2.1.0
|
Stable tag: 2.2.0
|
||||||
License: GPLv2 or later
|
License: GPLv2 or later
|
||||||
License URI: https://www.gnu.org/licenses/gpl-2.0.html
|
License URI: https://www.gnu.org/licenses/gpl-2.0.html
|
||||||
|
|
||||||
Integrates ROBOTSTXT SMTP configuration with Newsletter plugin for efficient bulk email delivery with SMTPKeepAlive support.
|
Integrates ROBOTSTXT SMTP configuration with Newsletter plugin for reliable bulk email delivery with database-backed queue and external workers.
|
||||||
|
|
||||||
== Description ==
|
== Description ==
|
||||||
|
|
||||||
SMTP (by ROBOTSTXT) Newsletter connects your Newsletter plugin with ROBOTSTXT SMTP and Amazon SES plugins, enabling **20-30x faster bulk email sending** through SMTPKeepAlive technology.
|
SMTP (by ROBOTSTXT) Newsletter connects your Newsletter plugin with ROBOTSTXT SMTP and Amazon SES plugins, providing **reliable, scalable bulk email delivery** through a database-backed queue system with external worker processes.
|
||||||
|
|
||||||
= Key Features =
|
= Key Features =
|
||||||
|
|
||||||
* **SMTPKeepAlive Support**: Maintains persistent SMTP connections for batch sending
|
* **Database Queue**: Persistent email queue survives server restarts and crashes
|
||||||
* **Batch Processing**: Sends multiple emails per connection, dramatically improving performance
|
* **External Workers**: CLI worker script for asynchronous processing independent of WordPress cron
|
||||||
* **Automatic Configuration**: Uses existing ROBOTSTXT SMTP settings
|
* **Parallel Processing**: Run multiple workers simultaneously for high-volume campaigns
|
||||||
|
* **Automatic Retry**: Failed emails retry up to 3 times with exponential backoff
|
||||||
|
* **Real-Time Statistics**: Monitor queue status and campaign performance
|
||||||
|
* **Campaign Metrics**: Track speed, duration, success rate for each campaign
|
||||||
* **Amazon SES Support**: Native integration with Amazon SES API
|
* **Amazon SES Support**: Native integration with Amazon SES API
|
||||||
* **Rate Limiting**: Integrates with ROBOTSTXT SMTP rate limiting system
|
* **Rate Limiting**: Respects ROBOTSTXT SMTP rate limits
|
||||||
* **Zero Configuration**: Works out of the box with Newsletter plugin
|
* **Scalable**: Handle campaigns of any size with parallel workers
|
||||||
|
|
||||||
= Performance Improvement =
|
= Architecture =
|
||||||
|
|
||||||
**Without this plugin:**
|
**Version 2.2.0 introduces a queue-based architecture:**
|
||||||
* Each email requires: Connect → Authenticate → Send → Disconnect
|
|
||||||
* 10,000 emails = 8-10 hours
|
|
||||||
|
|
||||||
**With this plugin:**
|
* Newsletter campaigns are queued to database instead of sent immediately
|
||||||
* One connection for multiple emails using SMTPKeepAlive
|
* External worker processes pick up queued emails and send them via wp_mail()
|
||||||
* 10,000 emails = 35 minutes (SMTP) or 20 minutes (Amazon SES)
|
* Statistics are tracked and preserved even after queue cleanup
|
||||||
|
* Multiple workers can run in parallel for high-volume sending
|
||||||
|
|
||||||
|
= Performance & Reliability =
|
||||||
|
|
||||||
|
**Reliability:**
|
||||||
|
* Emails persisted to database - no data loss on crashes
|
||||||
|
* Automatic retry with exponential backoff (5min → 15min → 30min)
|
||||||
|
* Stale lock recovery prevents stuck emails
|
||||||
|
|
||||||
|
**Scalability:**
|
||||||
|
* Support for 10+ parallel workers
|
||||||
|
* Process 10,000+ emails per hour with proper worker setup
|
||||||
|
* Campaign statistics track actual sending speed
|
||||||
|
|
||||||
|
**Monitoring:**
|
||||||
|
* Real-time queue status (pending, processing, sent, failed)
|
||||||
|
* Campaign metrics (start time, duration, speed, success rate)
|
||||||
|
* Detailed email queue view in admin
|
||||||
|
|
||||||
= Requirements =
|
= Requirements =
|
||||||
|
|
||||||
* Newsletter plugin 9.0.0 or higher
|
* Newsletter plugin 9.0.0 or higher
|
||||||
* ROBOTSTXT SMTP plugin 1.2.0 or higher
|
* ROBOTSTXT SMTP plugin 2.0.0 or higher
|
||||||
* (Optional) ROBOTSTXT SMTP Amazon SES plugin 1.0.0 or higher
|
* (Optional) ROBOTSTXT SMTP Amazon SES plugin 1.0.0 or higher
|
||||||
|
* CLI access for worker setup (cron or process supervisor)
|
||||||
|
|
||||||
= How It Works =
|
= Setup =
|
||||||
|
|
||||||
|
**Installation:**
|
||||||
1. Install and activate the plugin
|
1. Install and activate the plugin
|
||||||
2. Go to Newsletter → ROBOTSTXT SMTP
|
2. Go to Newsletter → SMTP (by ROBOTSTXT)
|
||||||
3. Enable integration and configure batch size
|
3. Enable integration and configure batch size
|
||||||
4. Newsletter will automatically use ROBOTSTXT SMTP for all bulk sends
|
|
||||||
|
|
||||||
The plugin intelligently detects whether you're using standard SMTP or Amazon SES and optimizes accordingly.
|
**Worker Setup (Required):**
|
||||||
|
Set up a cron job or process supervisor to run the worker:
|
||||||
|
|
||||||
|
`* * * * * php /path/to/wp-content/plugins/robotstxt-smtp-newsletter/worker.php 10 "w1"`
|
||||||
|
|
||||||
|
For parallel processing (10 workers):
|
||||||
|
|
||||||
|
`* * * * * seq 1 10 | xargs -P10 -I{} php /path/to/worker.php 10 "w{}"`
|
||||||
|
|
||||||
|
See Settings → SMTP → Queue Worker Configuration for detailed commands.
|
||||||
|
|
||||||
|
= Monitoring =
|
||||||
|
|
||||||
|
Access statistics via:
|
||||||
|
* **Newsletter → SMTP (by ROBOTSTXT)** - Queue status and recent campaigns
|
||||||
|
* **Newsletter → Email Queue** - View all queued and sent emails
|
||||||
|
* **Newsletter → Statistics** - Detailed campaign performance metrics
|
||||||
|
|
||||||
= Recommended Settings =
|
= Recommended Settings =
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,8 @@
|
||||||
/**
|
/**
|
||||||
* Plugin Name: Newsletter - SMTP (by ROBOTSTXT)
|
* Plugin Name: Newsletter - SMTP (by ROBOTSTXT)
|
||||||
* Plugin URI: https://git.robotstxt.es/ROBOTSTXT/robotstxt-smtp-newsletter
|
* Plugin URI: https://git.robotstxt.es/ROBOTSTXT/robotstxt-smtp-newsletter
|
||||||
* Description: Integrates ROBOTSTXT SMTP configuration with Newsletter plugin for efficient bulk email delivery with SMTPKeepAlive support.
|
* Description: Integrates ROBOTSTXT SMTP configuration with Newsletter plugin for efficient bulk email delivery with database-backed queue and external workers.
|
||||||
* Version: 2.1.0
|
* Version: 2.2.0
|
||||||
* Requires at least: 4.7
|
* Requires at least: 4.7
|
||||||
* Requires PHP: 7.2
|
* Requires PHP: 7.2
|
||||||
* Security: robotstxt@robotstxt.es
|
* Security: robotstxt@robotstxt.es
|
||||||
|
|
@ -25,7 +25,7 @@ if ( ! defined( 'ABSPATH' ) ) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Plugin constants.
|
// Plugin constants.
|
||||||
define( 'ROBOTSTXT_SMTP_NEWSLETTER_VERSION', '2.1.0' );
|
define( 'ROBOTSTXT_SMTP_NEWSLETTER_VERSION', '2.2.0' );
|
||||||
define( 'ROBOTSTXT_SMTP_NEWSLETTER_FILE', __FILE__ );
|
define( 'ROBOTSTXT_SMTP_NEWSLETTER_FILE', __FILE__ );
|
||||||
define( 'ROBOTSTXT_SMTP_NEWSLETTER_PATH', plugin_dir_path( __FILE__ ) );
|
define( 'ROBOTSTXT_SMTP_NEWSLETTER_PATH', plugin_dir_path( __FILE__ ) );
|
||||||
define( 'ROBOTSTXT_SMTP_NEWSLETTER_URL', plugin_dir_url( __FILE__ ) );
|
define( 'ROBOTSTXT_SMTP_NEWSLETTER_URL', plugin_dir_url( __FILE__ ) );
|
||||||
|
|
@ -36,6 +36,9 @@ require_once ROBOTSTXT_SMTP_NEWSLETTER_PATH . 'includes/class-plugin.php';
|
||||||
// Initialize plugin.
|
// Initialize plugin.
|
||||||
add_action( 'newsletter_loaded', array( 'Robotstxt_SMTP_Newsletter\Plugin', 'instance_init' ) );
|
add_action( 'newsletter_loaded', array( 'Robotstxt_SMTP_Newsletter\Plugin', 'instance_init' ) );
|
||||||
|
|
||||||
|
// Activation hook.
|
||||||
|
register_activation_hook( __FILE__, array( 'Robotstxt_SMTP_Newsletter\Plugin', 'activation_hook' ) );
|
||||||
|
|
||||||
// Load updater.
|
// Load updater.
|
||||||
require_once __DIR__ . '/robotstxt-updater.php';
|
require_once __DIR__ . '/robotstxt-updater.php';
|
||||||
Robotstxt_Updater::init( __FILE__ );
|
Robotstxt_Updater::init( __FILE__ );
|
||||||
|
|
|
||||||
|
|
@ -64,6 +64,13 @@ if ( ! class_exists( 'Robotstxt_Updater' ) ) {
|
||||||
*/
|
*/
|
||||||
private array $plugin_data;
|
private array $plugin_data;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialized instances keyed by plugin basename.
|
||||||
|
*
|
||||||
|
* @var array<string, Robotstxt_Updater>
|
||||||
|
*/
|
||||||
|
private static array $instances = array();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize the updater.
|
* Initialize the updater.
|
||||||
*
|
*
|
||||||
|
|
@ -72,10 +79,20 @@ if ( ! class_exists( 'Robotstxt_Updater' ) ) {
|
||||||
* Robotstxt_Updater::init( __FILE__ );
|
* Robotstxt_Updater::init( __FILE__ );
|
||||||
*
|
*
|
||||||
* @param string $plugin_file_path Absolute path to the main plugin file.
|
* @param string $plugin_file_path Absolute path to the main plugin file.
|
||||||
|
* @param bool $add_action_links Whether to add "Check for Update" action link (only for main plugin).
|
||||||
*/
|
*/
|
||||||
public static function init( string $plugin_file_path ): void {
|
public static function init( string $plugin_file_path, bool $add_action_links = false ): void {
|
||||||
|
$basename = plugin_basename( $plugin_file_path );
|
||||||
|
|
||||||
|
// Avoid duplicate initialization.
|
||||||
|
if ( isset( self::$instances[ $basename ] ) ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
$instance = new self( $plugin_file_path );
|
$instance = new self( $plugin_file_path );
|
||||||
$instance->register();
|
$instance->register( $add_action_links );
|
||||||
|
|
||||||
|
self::$instances[ $basename ] = $instance;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -94,12 +111,20 @@ if ( ! class_exists( 'Robotstxt_Updater' ) ) {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Register WordPress hooks.
|
* Register WordPress hooks.
|
||||||
|
*
|
||||||
|
* @param bool $add_action_links Whether to add action links (only for main plugin).
|
||||||
*/
|
*/
|
||||||
private function register(): void {
|
private function register( bool $add_action_links = false ): void {
|
||||||
add_filter( 'pre_set_site_transient_update_plugins', array( $this, 'inject_update_info' ) );
|
add_filter( 'pre_set_site_transient_update_plugins', array( $this, 'inject_update_info' ) );
|
||||||
add_filter( 'plugins_api', array( $this, 'provide_plugin_details' ), 10, 3 );
|
add_filter( 'plugins_api', array( $this, 'provide_plugin_details' ), 10, 3 );
|
||||||
add_action( 'admin_init', array( $this, 'handle_cache_clear' ) );
|
add_action( 'admin_init', array( $this, 'handle_cache_clear' ) );
|
||||||
add_action( 'robotstxt_updater_clear_cache', array( $this, 'clear_cache' ) );
|
add_action( 'robotstxt_updater_clear_cache', array( $this, 'clear_cache' ) );
|
||||||
|
|
||||||
|
// Only add action links for the main SMTP plugin.
|
||||||
|
if ( $add_action_links ) {
|
||||||
|
add_filter( 'plugin_action_links_' . $this->plugin_basename, array( $this, 'add_action_links' ) );
|
||||||
|
add_filter( 'network_admin_plugin_action_links_' . $this->plugin_basename, array( $this, 'add_action_links' ) );
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -379,5 +404,35 @@ if ( ! class_exists( 'Robotstxt_Updater' ) ) {
|
||||||
delete_site_transient( $this->cache_key );
|
delete_site_transient( $this->cache_key );
|
||||||
delete_site_transient( 'update_plugins' );
|
delete_site_transient( 'update_plugins' );
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add custom action links to plugin row.
|
||||||
|
*
|
||||||
|
* @param array $links Existing action links.
|
||||||
|
*
|
||||||
|
* @return array Modified action links.
|
||||||
|
*/
|
||||||
|
public function add_action_links( array $links ): array {
|
||||||
|
// Only show for users who can update plugins.
|
||||||
|
if ( ! current_user_can( 'update_plugins' ) ) {
|
||||||
|
return $links;
|
||||||
|
}
|
||||||
|
|
||||||
|
$url = wp_nonce_url(
|
||||||
|
add_query_arg( 'robotstxt_clear_update_cache', '1' ),
|
||||||
|
'robotstxt_clear_update_cache'
|
||||||
|
);
|
||||||
|
|
||||||
|
$check_link = sprintf(
|
||||||
|
'<a href="%s" title="%s">%s</a>',
|
||||||
|
esc_url( $url ),
|
||||||
|
esc_attr__( 'Clear update cache and check for new version', 'robotstxt-smtp' ),
|
||||||
|
esc_html__( 'Check for Update', 'robotstxt-smtp' )
|
||||||
|
);
|
||||||
|
|
||||||
|
$links[] = $check_link;
|
||||||
|
|
||||||
|
return $links;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,7 @@
|
||||||
/**
|
/**
|
||||||
* Uninstall script for Newsletter - SMTP (by ROBOTSTXT).
|
* Uninstall script for Newsletter - SMTP (by ROBOTSTXT).
|
||||||
*
|
*
|
||||||
* This add-on does not perform any cleanup on uninstall. All data cleanup
|
* Removes plugin data and database tables on uninstall.
|
||||||
* (including Newsletter plugin options) is handled by the core SMTP plugin
|
|
||||||
* when it is uninstalled.
|
|
||||||
*
|
|
||||||
* This ensures that:
|
|
||||||
* - If only the Newsletter add-on is removed, the configuration remains intact
|
|
||||||
* - If the core SMTP plugin is removed, all data (including add-on data) is cleaned up
|
|
||||||
*
|
*
|
||||||
* @package ROBOTSTXT_SMTP_Newsletter
|
* @package ROBOTSTXT_SMTP_Newsletter
|
||||||
*/
|
*/
|
||||||
|
|
@ -18,4 +12,59 @@ if ( ! defined( 'WP_UNINSTALL_PLUGIN' ) ) {
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
// No cleanup needed - handled by core SMTP plugin uninstall.
|
// Check if should delete data.
|
||||||
|
$should_delete = false;
|
||||||
|
|
||||||
|
if ( function_exists( 'is_multisite' ) && is_multisite() ) {
|
||||||
|
// Check network-level setting if available.
|
||||||
|
$network_options = get_site_option( 'robotstxt_smtp_network_options', array() );
|
||||||
|
$should_delete = ! empty( $network_options['delete_data_on_uninstall'] );
|
||||||
|
} else {
|
||||||
|
// Check site-level setting.
|
||||||
|
$options = get_option( 'robotstxt_smtp_options', array() );
|
||||||
|
$should_delete = ! empty( $options['delete_data_on_uninstall'] );
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( ! $should_delete ) {
|
||||||
|
// User wants to preserve data.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drop tables for all sites if multisite.
|
||||||
|
if ( function_exists( 'is_multisite' ) && is_multisite() ) {
|
||||||
|
$sites = get_sites( array( 'fields' => 'ids' ) );
|
||||||
|
|
||||||
|
foreach ( $sites as $site_id ) {
|
||||||
|
switch_to_blog( $site_id );
|
||||||
|
robotstxt_smtp_newsletter_drop_tables();
|
||||||
|
restore_current_blog();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
robotstxt_smtp_newsletter_drop_tables();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete plugin options.
|
||||||
|
delete_option( 'robotstxt_smtp_newsletter_options' );
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drop queue and statistics tables.
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
function robotstxt_smtp_newsletter_drop_tables() {
|
||||||
|
global $wpdb;
|
||||||
|
|
||||||
|
$queue_table = $wpdb->prefix . 'robotstxt_smtp_newsletter_queue';
|
||||||
|
$stats_table = $wpdb->prefix . 'robotstxt_smtp_newsletter_stats';
|
||||||
|
|
||||||
|
// Drop both tables.
|
||||||
|
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.DirectDatabaseQuery.SchemaChange,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||||
|
$wpdb->query( "DROP TABLE IF EXISTS {$queue_table}" );
|
||||||
|
|
||||||
|
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.DirectDatabaseQuery.SchemaChange,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||||
|
$wpdb->query( "DROP TABLE IF EXISTS {$stats_table}" );
|
||||||
|
|
||||||
|
// Delete version options.
|
||||||
|
delete_option( 'robotstxt_smtp_newsletter_queue_db_version' );
|
||||||
|
delete_option( 'robotstxt_smtp_newsletter_stats_db_version' );
|
||||||
|
}
|
||||||
|
|
|
||||||
14
update.json
14
update.json
|
|
@ -1,19 +1,19 @@
|
||||||
{
|
{
|
||||||
"name": "Newsletter - SMTP (by ROBOTSTXT)",
|
"name": "Newsletter - SMTP (by ROBOTSTXT)",
|
||||||
"slug": "robotstxt-smtp-newsletter",
|
"slug": "robotstxt-smtp-newsletter",
|
||||||
"version": "2.1.0",
|
"version": "2.2.0",
|
||||||
"author": "ROBOTSTXT",
|
"author": "ROBOTSTXT",
|
||||||
"homepage": "https://git.robotstxt.es/ROBOTSTXT/robotstxt-smtp-newsletter",
|
"homepage": "https://git.robotstxt.es/ROBOTSTXT/robotstxt-smtp-newsletter",
|
||||||
"download_url": "https://git.robotstxt.es/ROBOTSTXT/robotstxt-smtp-newsletter/releases/download/2.1.0/robotstxt-smtp-newsletter-2.1.0.zip",
|
"download_url": "https://git.robotstxt.es/ROBOTSTXT/robotstxt-smtp-newsletter/releases/download/2.2.0/robotstxt-smtp-newsletter-2.2.0.zip",
|
||||||
"requires": "4.7",
|
"requires": "4.7",
|
||||||
"tested": "6.9",
|
"tested": "6.9",
|
||||||
"requires_php": "7.2",
|
"requires_php": "7.2",
|
||||||
"last_updated": "2026-02-09",
|
"last_updated": "2026-02-16",
|
||||||
"description": "Integrates ROBOTSTXT SMTP configuration with Newsletter plugin for efficient bulk email delivery with SMTPKeepAlive support. Achieves 20-30x faster bulk sending compared to standard wp_mail().",
|
"description": "Integrates ROBOTSTXT SMTP configuration with Newsletter plugin for reliable bulk email delivery with database-backed queue and external workers. Provides persistent email queue, parallel processing, automatic retry, and comprehensive campaign statistics.",
|
||||||
"changelog": "<h4>2.1.0 - 2026-02-09</h4><ul><li><strong>Changed:</strong> Uninstall behavior: plugin no longer performs cleanup on uninstall, all data cleanup is now handled by the core SMTP plugin</li><li><strong>Changed:</strong> This ensures Newsletter configuration is preserved when only the add-on is removed, and properly cleaned when the core plugin is uninstalled</li><li><strong>Technical:</strong> Simplified uninstall.php to delegate all cleanup responsibility to core SMTP plugin</li><li><strong>Technical:</strong> Improved data preservation and cleanup consistency across the plugin ecosystem</li></ul><h4>2.0.1 - 2026-01-29</h4><ul><li><strong>Changed:</strong> Removed Network requirement from plugin headers to allow single-site activation</li></ul><h4>2.0.0 - 2026-01-29</h4><ul><li><strong>Changed:</strong> Updated compatibility with SMTP (by ROBOTSTXT) plugin 2.0.0</li><li><strong>Changed:</strong> Enhanced integration with core SMTP plugin security improvements</li><li><strong>Improved:</strong> Better error handling and validation</li><li><strong>Improved:</strong> Code quality improvements</li></ul><h4>1.0.0 - 2026-01-29</h4><ul><li><strong>Added:</strong> Initial release</li><li><strong>Added:</strong> SMTPKeepAlive support for efficient SMTP batch sending</li><li><strong>Added:</strong> Amazon SES API integration for optimized delivery</li><li><strong>Added:</strong> Dynamic speed calculation from SMTP rate limits (up to 32,400 emails/hour)</li><li><strong>Added:</strong> Batch processing with configurable batch size (up to 2,610 emails per chunk)</li><li><strong>Added:</strong> Automatic FROM address configuration from ROBOTSTXT SMTP</li><li><strong>Added:</strong> HELO hostname configuration for multisite compatibility</li><li><strong>Added:</strong> Resilient error handling - continues sending even if individual recipients fail</li><li><strong>Added:</strong> WP-CLI compatibility for cron execution</li><li><strong>Performance:</strong> 20-30x faster bulk email sending compared to standard wp_mail()</li><li><strong>Performance:</strong> Sends 22 emails in ~7 seconds using SMTPKeepAlive</li></ul>",
|
"changelog": "<h4>2.2.0 - 2026-02-16</h4><ul><li><strong>MAJOR:</strong> Database-backed email queue for persistent, reliable email delivery</li><li><strong>MAJOR:</strong> External CLI worker script for asynchronous email processing</li><li><strong>Added:</strong> Queue Status widget with real-time statistics (pending, processing, sent, failed)</li><li><strong>Added:</strong> Recent Campaigns section with key metrics in settings page</li><li><strong>Added:</strong> Email Queue admin page to view all queued and sent emails</li><li><strong>Added:</strong> Statistics admin page with campaign performance metrics</li><li><strong>Added:</strong> Parallel worker support for high-volume sending (10+ workers simultaneously)</li><li><strong>Added:</strong> Automatic retry with exponential backoff (up to 3 attempts: 5min → 15min → 30min)</li><li><strong>Added:</strong> Campaign statistics tracking (start time, duration, speed, success rate)</li><li><strong>Changed:</strong> Emails are now queued to database instead of sent immediately</li><li><strong>Changed:</strong> Settings page reorganized with Queue Status, Recent Campaigns, and Worker Configuration sections</li><li><strong>Changed:</strong> Campaign statistics now grouped by unique Newsletter ID instead of campaign name</li><li><strong>Removed:</strong> max_per_connection setting (no longer needed with worker architecture)</li><li><strong>Removed:</strong> Direct PHPMailer usage (replaced with wp_mail() in workers)</li><li><strong>Fixed:</strong> Duplicate campaign statistics for campaigns with same name but different IDs</li><li><strong>Fixed:</strong> Worker command documentation (corrected parallel worker syntax)</li><li><strong>BREAKING:</strong> Manual worker setup required - emails no longer send automatically</li><li><strong>BREAKING:</strong> System cron or process supervisor required for production use</li><li><strong>Technical:</strong> New database tables for queue and statistics with automatic creation on activation</li><li><strong>Technical:</strong> Microsecond precision timestamps for accurate performance metrics</li><li><strong>Security:</strong> CLI-only worker script with SQL injection prevention and capability checks</li></ul><h4>2.1.0 - 2026-02-09</h4><ul><li><strong>Changed:</strong> Uninstall behavior: plugin no longer performs cleanup on uninstall, all data cleanup is now handled by the core SMTP plugin</li><li><strong>Changed:</strong> This ensures Newsletter configuration is preserved when only the add-on is removed, and properly cleaned when the core plugin is uninstalled</li><li><strong>Technical:</strong> Simplified uninstall.php to delegate all cleanup responsibility to core SMTP plugin</li></ul><h4>2.0.1 - 2026-01-29</h4><ul><li><strong>Changed:</strong> Removed Network requirement from plugin headers to allow single-site activation</li></ul><h4>2.0.0 - 2026-01-29</h4><ul><li><strong>Changed:</strong> Updated compatibility with SMTP (by ROBOTSTXT) plugin 2.0.0</li><li><strong>Changed:</strong> Enhanced integration with core SMTP plugin security improvements</li><li><strong>Improved:</strong> Better error handling and validation</li></ul><h4>1.0.0 - 2026-01-29</h4><ul><li><strong>Added:</strong> Initial release with SMTPKeepAlive support</li><li><strong>Added:</strong> Amazon SES API integration for optimized delivery</li><li><strong>Performance:</strong> 20-30x faster bulk email sending compared to standard wp_mail()</li></ul>",
|
||||||
"sections": {
|
"sections": {
|
||||||
"description": "Integrates ROBOTSTXT SMTP configuration with Newsletter plugin for efficient bulk email delivery with SMTPKeepAlive support. Achieves 20-30x faster bulk sending compared to standard wp_mail().",
|
"description": "Integrates ROBOTSTXT SMTP configuration with Newsletter plugin for reliable bulk email delivery with database-backed queue and external workers. Version 2.2.0 introduces a queue-based architecture with persistent email storage, parallel worker processing, automatic retry logic, and comprehensive campaign statistics. Emails are queued to database and processed by external CLI workers, providing reliability, scalability, and detailed monitoring.",
|
||||||
"changelog": "<h4>2.1.0 - 2026-02-09</h4><ul><li><strong>Changed:</strong> Uninstall behavior: plugin no longer performs cleanup on uninstall, all data cleanup is now handled by the core SMTP plugin</li><li><strong>Changed:</strong> This ensures Newsletter configuration is preserved when only the add-on is removed, and properly cleaned when the core plugin is uninstalled</li><li><strong>Technical:</strong> Simplified uninstall.php to delegate all cleanup responsibility to core SMTP plugin</li><li><strong>Technical:</strong> Improved data preservation and cleanup consistency across the plugin ecosystem</li></ul><h4>2.0.1 - 2026-01-29</h4><ul><li><strong>Changed:</strong> Removed Network requirement from plugin headers to allow single-site activation</li></ul><h4>2.0.0 - 2026-01-29</h4><ul><li><strong>Changed:</strong> Updated compatibility with SMTP (by ROBOTSTXT) plugin 2.0.0</li><li><strong>Changed:</strong> Enhanced integration with core SMTP plugin security improvements</li><li><strong>Improved:</strong> Better error handling and validation</li><li><strong>Improved:</strong> Code quality improvements</li></ul><h4>1.0.0 - 2026-01-29</h4><ul><li><strong>Added:</strong> Initial release</li><li><strong>Added:</strong> SMTPKeepAlive support for efficient SMTP batch sending</li><li><strong>Added:</strong> Amazon SES API integration for optimized delivery</li><li><strong>Added:</strong> Dynamic speed calculation from SMTP rate limits (up to 32,400 emails/hour)</li><li><strong>Added:</strong> Batch processing with configurable batch size (up to 2,610 emails per chunk)</li><li><strong>Added:</strong> Automatic FROM address configuration from ROBOTSTXT SMTP</li><li><strong>Added:</strong> HELO hostname configuration for multisite compatibility</li><li><strong>Added:</strong> Resilient error handling - continues sending even if individual recipients fail</li><li><strong>Added:</strong> WP-CLI compatibility for cron execution</li><li><strong>Performance:</strong> 20-30x faster bulk email sending compared to standard wp_mail()</li><li><strong>Performance:</strong> Sends 22 emails in ~7 seconds using SMTPKeepAlive</li></ul>"
|
"changelog": "<h4>2.2.0 - 2026-02-16</h4><ul><li><strong>MAJOR:</strong> Database-backed email queue for persistent, reliable email delivery</li><li><strong>MAJOR:</strong> External CLI worker script for asynchronous email processing</li><li><strong>Added:</strong> Queue Status widget with real-time statistics</li><li><strong>Added:</strong> Recent Campaigns section with key metrics</li><li><strong>Added:</strong> Email Queue admin page</li><li><strong>Added:</strong> Statistics admin page with campaign metrics</li><li><strong>Added:</strong> Parallel worker support (10+ workers)</li><li><strong>Added:</strong> Automatic retry with exponential backoff</li><li><strong>Changed:</strong> Emails queued to database instead of sent immediately</li><li><strong>Changed:</strong> Campaign statistics grouped by Newsletter ID</li><li><strong>Removed:</strong> max_per_connection setting</li><li><strong>Fixed:</strong> Duplicate campaign statistics</li><li><strong>BREAKING:</strong> Manual worker setup required</li></ul>"
|
||||||
},
|
},
|
||||||
"banners": {
|
"banners": {
|
||||||
"low": "",
|
"low": "",
|
||||||
|
|
|
||||||
146
worker.php
Normal file
146
worker.php
Normal file
|
|
@ -0,0 +1,146 @@
|
||||||
|
#!/usr/bin/env php
|
||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Newsletter Queue Worker
|
||||||
|
*
|
||||||
|
* Processes queued emails via wp_mail() using configured SMTP settings.
|
||||||
|
*
|
||||||
|
* This script runs as a standalone CLI process, independent of WordPress cron.
|
||||||
|
* It loads WordPress with DOING_CRON defined to prevent triggering actual cron.
|
||||||
|
*
|
||||||
|
* Usage: php worker.php <batch_size> <worker_id>
|
||||||
|
* Example: php worker.php 10 "w1"
|
||||||
|
* Parallel: seq 1 10 | xargs -P10 -I{} php worker.php 10 "w{}"
|
||||||
|
*
|
||||||
|
* @package ROBOTSTXT_SMTP_Newsletter
|
||||||
|
* @since 2.2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Security: CLI-only execution.
|
||||||
|
if ( php_sapi_name() !== 'cli' ) {
|
||||||
|
http_response_code( 403 );
|
||||||
|
die( 'Forbidden: This script must be run from command line' );
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse command-line arguments.
|
||||||
|
if ( $argc < 3 ) {
|
||||||
|
fwrite( STDERR, "Usage: php worker.php <batch_size> <worker_id>\n" );
|
||||||
|
fwrite( STDERR, "Example: php worker.php 10 \"w1\"\n" );
|
||||||
|
exit( 1 );
|
||||||
|
}
|
||||||
|
|
||||||
|
$batch_size = max( 1, (int) $argv[1] );
|
||||||
|
$worker_id = $argv[2];
|
||||||
|
|
||||||
|
// Load WordPress.
|
||||||
|
define( 'DOING_CRON', true );
|
||||||
|
|
||||||
|
// Find wp-load.php (3 levels up from plugin directory).
|
||||||
|
$wp_load_path = __DIR__ . '/../../../wp-load.php';
|
||||||
|
|
||||||
|
if ( ! file_exists( $wp_load_path ) ) {
|
||||||
|
fwrite( STDERR, "Error: Could not find wp-load.php at {$wp_load_path}\n" );
|
||||||
|
exit( 1 );
|
||||||
|
}
|
||||||
|
|
||||||
|
require_once $wp_load_path;
|
||||||
|
|
||||||
|
// Verify plugin is active.
|
||||||
|
if ( ! defined( 'ROBOTSTXT_SMTP_NEWSLETTER_VERSION' ) ) {
|
||||||
|
fwrite( STDERR, "Error: Newsletter SMTP plugin is not active\n" );
|
||||||
|
exit( 1 );
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get queue and statistics instances.
|
||||||
|
$queue = \Robotstxt_SMTP_Newsletter\Queue::get_instance();
|
||||||
|
$stats = \Robotstxt_SMTP_Newsletter\Statistics::get_instance();
|
||||||
|
|
||||||
|
// Process batch.
|
||||||
|
$items = $queue->get_and_lock_batch( $batch_size, $worker_id );
|
||||||
|
|
||||||
|
if ( empty( $items ) ) {
|
||||||
|
echo "No items to process\n";
|
||||||
|
exit( 0 );
|
||||||
|
}
|
||||||
|
|
||||||
|
echo sprintf( "Worker %s: Processing %d emails...\n", $worker_id, count( $items ) );
|
||||||
|
|
||||||
|
$success_count = 0;
|
||||||
|
$failure_count = 0;
|
||||||
|
|
||||||
|
foreach ( $items as $item ) {
|
||||||
|
try {
|
||||||
|
// Build headers array.
|
||||||
|
$headers = array();
|
||||||
|
|
||||||
|
// Add From header if specified.
|
||||||
|
if ( ! empty( $item->from_email ) ) {
|
||||||
|
$from = ! empty( $item->from_name )
|
||||||
|
? sprintf( '%s <%s>', $item->from_name, $item->from_email )
|
||||||
|
: $item->from_email;
|
||||||
|
$headers[] = 'From: ' . $from;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add Reply-To header if specified.
|
||||||
|
if ( ! empty( $item->reply_to ) ) {
|
||||||
|
$headers[] = 'Reply-To: ' . $item->reply_to;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add Content-Type header (prefer HTML if available).
|
||||||
|
if ( ! empty( $item->body_html ) ) {
|
||||||
|
$headers[] = 'Content-Type: text/html; charset=UTF-8';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decode and add custom headers from JSON.
|
||||||
|
if ( ! empty( $item->headers_json ) ) {
|
||||||
|
$custom = json_decode( $item->headers_json, true );
|
||||||
|
if ( is_array( $custom ) ) {
|
||||||
|
foreach ( $custom as $key => $value ) {
|
||||||
|
$headers[] = sprintf( '%s: %s', $key, $value );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Select body (prefer HTML over text).
|
||||||
|
$body = ! empty( $item->body_html ) ? $item->body_html : $item->body_text;
|
||||||
|
|
||||||
|
if ( empty( $body ) ) {
|
||||||
|
throw new \Exception( 'Email body is empty' );
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send via wp_mail (uses core SMTP configuration).
|
||||||
|
$result = wp_mail(
|
||||||
|
$item->to_email,
|
||||||
|
$item->subject,
|
||||||
|
$body,
|
||||||
|
$headers
|
||||||
|
);
|
||||||
|
|
||||||
|
if ( $result ) {
|
||||||
|
$queue->mark_sent( $item->id );
|
||||||
|
$success_count++;
|
||||||
|
echo sprintf( " ✓ Sent: %s\n", $item->to_email );
|
||||||
|
} else {
|
||||||
|
$queue->mark_failed( $item->id, 'wp_mail returned false' );
|
||||||
|
$failure_count++;
|
||||||
|
echo sprintf( " ✗ Failed: %s (wp_mail returned false)\n", $item->to_email );
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch ( \Exception $e ) {
|
||||||
|
$queue->mark_failed( $item->id, $e->getMessage() );
|
||||||
|
$failure_count++;
|
||||||
|
echo sprintf( " ✗ Error: %s - %s\n", $item->to_email, $e->getMessage() );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sync statistics after batch processing.
|
||||||
|
$stats->sync_from_queue();
|
||||||
|
|
||||||
|
echo sprintf(
|
||||||
|
"Worker %s: Done. Success: %d, Failed: %d\n",
|
||||||
|
$worker_id,
|
||||||
|
$success_count,
|
||||||
|
$failure_count
|
||||||
|
);
|
||||||
|
|
||||||
|
exit( 0 );
|
||||||
Loading…
Reference in a new issue