prefix . 'storesuit_mail_log'; } public static function install(): void { if ( get_option( self::SCHEMA ) === '1' ) { return; } global $wpdb; require_once ABSPATH . 'wp-admin/includes/upgrade.php'; $table = self::table(); $charset = $wpdb->get_charset_collate(); dbDelta( "CREATE TABLE $table ( id bigint(20) unsigned NOT NULL AUTO_INCREMENT, created datetime NOT NULL, recipient varchar(255) NOT NULL DEFAULT '', subject varchar(255) NOT NULL DEFAULT '', status varchar(8) NOT NULL, error varchar(255) NOT NULL DEFAULT '', PRIMARY KEY (id), KEY created (created), KEY status_created (status,created) ) $charset;" ); if ( $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $wpdb->esc_like( $table ) ) ) === $table ) { update_option( self::SCHEMA, '1', false ); add_option( self::STARTED, time(), '', false ); } } /** * When the history started (a live store, 28 Oct 2026: "0 emails" read as broken while none had been sent since the * table was made). Stores from before this option take the table's creation time once. */ private static function started(): int { $at = (int) get_option( self::STARTED, 0 ); if ( $at > 0 ) { return $at; } global $wpdb; $at = (int) $wpdb->get_var( $wpdb->prepare( 'SELECT UNIX_TIMESTAMP(CREATE_TIME) FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=%s', self::table() ) ); if ( $at > 0 ) { update_option( self::STARTED, $at, false ); } return $at; } public static function hooks(): void { add_action( 'wp_mail_succeeded', array( __CLASS__, 'sent' ) ); add_action( 'wp_mail_failed', array( __CLASS__, 'failed' ) ); add_action( self::CLEANUP, array( __CLASS__, 'cleanup' ) ); add_action( 'init', static function () { if ( ! wp_next_scheduled( self::CLEANUP ) ) { wp_schedule_event( time() + HOUR_IN_SECONDS, 'daily', self::CLEANUP ); } }, 99 ); add_filter( 'wp_privacy_personal_data_exporters', static function ( $exporters ) { $exporters['storesuit-mail-log'] = array( 'exporter_friendly_name'=>I18n::t( 'Email history' ), 'callback'=>array( __CLASS__, 'export' ) ); return $exporters; } ); add_filter( 'wp_privacy_personal_data_erasers', static function ( $erasers ) { $erasers['storesuit-mail-log'] = array( 'eraser_friendly_name'=>I18n::t( 'Email history' ), 'callback'=>array( __CLASS__, 'erase' ) ); return $erasers; } ); } public static function deactivate(): void { wp_clear_scheduled_hook( self::CLEANUP ); } public static function sent( $mail ): void { $mail = (array) $mail; self::record( $mail['to'] ?? '', (string) ( $mail['subject'] ?? '' ), 'sent', '' ); } public static function failed( $error ): void { if ( ! $error instanceof \WP_Error ) { return; } $data = (array) $error->get_error_data(); self::record( $data['to'] ?? '', (string) ( $data['subject'] ?? '' ), 'failed', $error->get_error_message() ); } private static function record( $to, string $subject, string $status, string $error ): void { if ( get_option( self::SCHEMA ) !== '1' ) { return; } global $wpdb; $recipient = implode( ', ', array_map( 'trim', is_array( $to ) ? $to : explode( ',', (string) $to ) ) ); $wpdb->insert( self::table(), array( 'created'=>gmdate( 'Y-m-d H:i:s' ), 'recipient'=>mb_substr( strtolower( $recipient ), 0, 255 ), 'subject'=>mb_substr( wp_strip_all_tags( $subject ), 0, 255 ), 'status'=>$status, 'error'=>mb_substr( wp_strip_all_tags( $error ), 0, 255 ) ) ); } public static function read( array $data = array() ): array { Store::require_cap( 'manage_woocommerce' ); global $wpdb; $table = self::table(); if ( get_option( self::SCHEMA ) !== '1' ) { return array( 'rows'=>array(), 'page'=>1, 'pages'=>1, 'sent'=>0, 'failed'=>0 ); } // Both statements are literal: the status and the search are constant switches such as (%d=0 OR ...), every value a placeholder. $failed = ( $data['status'] ?? '' ) === 'failed' ? 1 : 0; $search = trim( Store::text( $data, 'search' ) ); $has = $search !== '' ? 1 : 0; $like = '%' . $wpdb->esc_like( $search ) . '%'; $count = (int) $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM %i WHERE (%d=0 OR status=%s) AND (%d=0 OR (recipient LIKE %s OR subject LIKE %s))', $table, $failed, 'failed', $has, $like, $like ) ); $pages = max( 1, (int) ceil( $count / 50 ) ); $page = min( max( 1, absint( $data['page'] ?? 1 ) ), $pages ); $rows = $wpdb->get_results( $wpdb->prepare( 'SELECT id,created,recipient,subject,status,error FROM %i WHERE (%d=0 OR status=%s) AND (%d=0 OR (recipient LIKE %s OR subject LIKE %s)) ORDER BY id DESC LIMIT 50 OFFSET %d', $table, $failed, 'failed', $has, $like, $like, ( $page - 1 ) * 50 ), ARRAY_A ) ?: array(); foreach ( $rows as &$row ) { $row['created'] = gmdate( 'c', (int) strtotime( $row['created'] . ' UTC' ) ); } unset( $row ); $since = gmdate( 'Y-m-d H:i:s', time() - self::KEEP_DAYS * DAY_IN_SECONDS ); $totals = $wpdb->get_row( $wpdb->prepare( 'SELECT SUM(status=%s) sent, SUM(status=%s) failed FROM %i WHERE created>=%s', 'sent', 'failed', $table, $since ), ARRAY_A ); $since = self::started(); return array( 'rows'=>$rows, 'page'=>$page, 'pages'=>$pages, 'sent'=>(int) ( $totals['sent'] ?? 0 ), 'failed'=>(int) ( $totals['failed'] ?? 0 ), 'since'=>$since ? gmdate( 'c', $since ) : '' ); } public static function cleanup(): void { if ( get_option( self::SCHEMA ) !== '1' ) { return; } global $wpdb; $table = self::table(); $wpdb->query( $wpdb->prepare( 'DELETE FROM %i WHERE created<%s', $table, gmdate( 'Y-m-d H:i:s', time() - self::KEEP_DAYS * DAY_IN_SECONDS ) ) ); $keep = (int) $wpdb->get_var( $wpdb->prepare( 'SELECT id FROM %i ORDER BY id DESC LIMIT 1 OFFSET %d', $table, self::MAX_ROWS - 1 ) ); if ( $keep ) { $wpdb->query( $wpdb->prepare( 'DELETE FROM %i WHERE id<%d', $table, $keep ) ); } } public static function export( string $email, int $page = 1 ): array { global $wpdb; $data = array(); if ( get_option( self::SCHEMA ) === '1' && is_email( $email ) ) { foreach ( (array) $wpdb->get_results( $wpdb->prepare( 'SELECT id,created,subject,status FROM %i WHERE CONCAT(%s, recipient, %s) LIKE %s LIMIT 500', self::table(), ', ', ',', self::token( $email ) ), ARRAY_A ) as $row ) { $data[] = array( 'group_id'=>'storesuit-mail-log', 'group_label'=>I18n::t( 'Email history' ), 'item_id'=>'mail-' . $row['id'], 'data'=>array( array( 'name'=>I18n::t( 'Subject' ), 'value'=>$row['subject'] ), array( 'name'=>I18n::t( 'Status' ), 'value'=>$row['status'] ), array( 'name'=>I18n::t( 'Date' ), 'value'=>$row['created'] ), ) ); } } return array( 'data'=>$data, 'done'=>true ); } /** Matches one whole address inside the stored "a, b" recipient list, never a longer address containing it. */ private static function token( string $email ): string { global $wpdb; return '%, ' . $wpdb->esc_like( strtolower( trim( $email ) ) ) . ',%'; } public static function erase( string $email, int $page = 1 ): array { global $wpdb; $removed = 0; if ( get_option( self::SCHEMA ) === '1' && is_email( $email ) ) { $removed = (int) $wpdb->query( $wpdb->prepare( 'DELETE FROM %i WHERE CONCAT(%s, recipient, %s) LIKE %s', self::table(), ', ', ',', self::token( $email ) ) ); } return array( 'items_removed'=>$removed > 0, 'items_retained'=>false, 'messages'=>array(), 'done'=>true ); } }