427 lines
11 KiB
PHP
427 lines
11 KiB
PHP
<?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';
|
|
}
|
|
}
|