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'; } }