'off', 'recipients' => '', 'attach_pdf' => 0, ) ); } public static function state() { $stored = get_option( self::STATE_OPTION, array() ); return wp_parse_args( is_array( $stored ) ? $stored : array(), array( 'last_period' => '', 'last_attempt' => 0, 'last_ok' => false, 'last_test_at' => 0, 'last_test_ok' => false, ) ); } /** * Strictly validate 1-5 recipients. Never silently discard an invalid * address, because that can send to the wrong subset of a client team. * * @return string[]|WP_Error */ public static function recipients( $raw ) { $parts = preg_split( '/[,;\r\n]+/', (string) $raw ); $valid = array(); foreach ( $parts as $part ) { $part = trim( (string) $part ); if ( '' === $part ) { continue; } if ( ! is_email( $part ) ) { return new WP_Error( 'ai_o_optimiser_report_invalid_email', __( 'Enter valid email addresses separated by commas.', 'ai-o-optimiser' ) ); } $email = sanitize_email( $part ); if ( ! in_array( strtolower( $email ), array_map( 'strtolower', $valid ), true ) ) { $valid[] = $email; } } if ( ! $valid || count( $valid ) > 5 ) { return new WP_Error( 'ai_o_optimiser_report_email_count', __( 'Specify between one and five email addresses.', 'ai-o-optimiser' ) ); } return $valid; } /** * Next local Monday 9am or first-of-month 9am, converted to UTC epoch. * Single events rather than a fixed 30-day interval preserve calendar * months and the site's DST transitions. */ public static function next_due( $frequency, $timestamp = null ) { $zone = wp_timezone(); $now = ( new DateTimeImmutable( '@' . ( null === $timestamp ? time() : (int) $timestamp ) ) )->setTimezone( $zone ); if ( 'weekly' === $frequency ) { $next = $now->modify( 'monday this week' )->setTime( 9, 0 ); if ( $next <= $now ) { $next = $next->modify( '+7 days' ); } } elseif ( 'monthly' === $frequency ) { $next = $now->modify( 'first day of this month' )->setTime( 9, 0 ); if ( $next <= $now ) { $next = $next->modify( 'first day of next month' ); } } else { return 0; } return $next->getTimestamp(); } /** Previous completed Monday-Sunday week, or previous calendar month. */ public static function period( $frequency, $timestamp = null ) { $now = ( new DateTimeImmutable( '@' . ( null === $timestamp ? time() : (int) $timestamp ) ) )->setTimezone( wp_timezone() ); if ( 'weekly' === $frequency ) { $monday = $now->modify( 'monday this week' ); $from = $monday->modify( '-7 days' ); $to = $monday->modify( '-1 day' ); } else { $from = $now->modify( 'first day of last month' ); $to = $from->modify( 'last day of this month' ); } return array( 'from' => $from->format( 'Y-m-d' ), 'to' => $to->format( 'Y-m-d' ), 'key' => $frequency . ':' . $from->format( 'Y-m-d' ) . ':' . $to->format( 'Y-m-d' ), ); } /** Reconcile one event per site. Changes or disabling take effect on save. */ public static function sync_schedule() { $settings = self::settings(); $frequency = $settings['frequency']; $addresses = 'off' === $frequency ? array() : self::recipients( $settings['recipients'] ); $enabled = in_array( $frequency, array( 'weekly', 'monthly' ), true ) && ! is_wp_error( $addresses ); $existing = wp_next_scheduled( self::CRON_HOOK ); if ( ! $enabled ) { if ( $existing ) { wp_clear_scheduled_hook( self::CRON_HOOK ); } return; } if ( ! $existing ) { wp_schedule_single_event( self::next_due( $frequency ), self::CRON_HOOK ); } } /** Next event is scheduled before sending, so a mail failure cannot stall the schedule. */ public static function run_scheduled() { wp_clear_scheduled_hook( self::CRON_HOOK ); self::sync_schedule(); self::send( false ); } /** @return bool|WP_Error */ public static function send( $test = false ) { $settings = self::settings(); $frequency = (string) $settings['frequency']; if ( ! in_array( $frequency, array( 'weekly', 'monthly' ), true ) ) { return new WP_Error( 'ai_o_optimiser_report_disabled', __( 'Scheduled client reports are off.', 'ai-o-optimiser' ) ); } $recipients = self::recipients( $settings['recipients'] ); if ( is_wp_error( $recipients ) ) { return $recipients; } $period = self::period( $frequency ); $state = self::state(); if ( ! $test && $state['last_period'] === $period['key'] ) { return new WP_Error( 'ai_o_optimiser_report_already_sent', __( 'This reporting period was already attempted.', 'ai-o-optimiser' ) ); } // At-most-once scheduled delivery. This marker precedes any database // reads or mail attempts, preventing duplicate email on cron replays. if ( ! $test ) { $state['last_period'] = $period['key']; $state['last_attempt'] = time(); update_option( self::STATE_OPTION, $state, false ); } require_once AI_O_Optimiser_PLUGIN_DIR . 'includes/class-ai-o-optimiser-monthly-report.php'; $report = AI_O_Optimiser_Monthly_Report::build_email_traffic( $period['from'], $period['to'] ); $host = wp_parse_url( home_url( '/' ), PHP_URL_HOST ); $subject = sprintf( /* translators: 1: site host, 2: frequency, 3: report start, 4: report end. */ __( '[%1$s] AI-O %2$s AI traffic report (%3$s to %4$s)', 'ai-o-optimiser' ), $host ? $host : __( 'Your website', 'ai-o-optimiser' ), ucfirst( $frequency ), $period['from'], $period['to'] ); if ( $test ) { $subject = '[Test] ' . $subject; } // Shared safe report data; keep a plain-text alternative for recipients. $plain = self::summary( $report, $frequency ); require_once __DIR__ . '/class-ai-o-optimiser-email-report-template.php'; $message = AI_O_Optimiser_Email_Report_Template::html( $report, $frequency, $test ); $attachments = array(); $pdf_file = ''; if ( ! empty( $settings['attach_pdf'] ) ) { require_once __DIR__ . '/class-ai-o-optimiser-email-report-pdf.php'; $pdf = AI_O_Optimiser_Email_Report_PDF::render( $plain, $frequency, $host ); $pdf_file = self::write_private_pdf( $pdf ); if ( is_wp_error( $pdf_file ) ) { $state = self::state(); if ( $test ) { $state['last_test_at'] = time(); $state['last_test_ok'] = false; } else { $state['last_ok'] = false; } update_option( self::STATE_OPTION, $state, false ); return $pdf_file; } $filename = sprintf( 'AI-O-Activity-Report-%s-to-%s.pdf', $period['from'], $period['to'] ); $attachments[ $filename ] = $pdf_file; } // The authenticated SMTP provider owns the From address. Never forge // the envelope sender; scope the display name and plain-text fallback. $name_filter = static function ( $name ) { return 'AI-O Optimiser'; }; $alternative = static function ( $mailer ) use ( $plain ) { $mailer->AltBody = $plain; // CID image travels inside the authenticated email itself: no // external image host, recipient IP leakage or live asset URL. $logo_path = AI_O_Optimiser_PLUGIN_DIR . 'assets/ai-o-report-email-logo.png'; if ( is_file( $logo_path ) && filesize( $logo_path ) < 10000 && method_exists( $mailer, 'addEmbeddedImage' ) ) { $mailer->addEmbeddedImage( $logo_path, 'aio-optimiser-brand', 'ai-o-logo.png', 'base64', 'image/png' ); } }; add_filter( 'wp_mail_from_name', $name_filter, 20 ); add_action( 'phpmailer_init', $alternative, 20 ); $sent = false; try { $sent = (bool) wp_mail( $recipients, $subject, $message, array( 'Content-Type: text/html; charset=UTF-8' ), $attachments ); } finally { remove_filter( 'wp_mail_from_name', $name_filter, 20 ); remove_action( 'phpmailer_init', $alternative, 20 ); if ( $pdf_file ) { // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink -- Private OS temp files must be removed synchronously after the mail attempt, even without WP_Filesystem credentials. @unlink( $pdf_file ); } } $state = self::state(); if ( $test ) { $state['last_test_at'] = time(); $state['last_test_ok'] = (bool) $sent; } else { $state['last_ok'] = (bool) $sent; } update_option( self::STATE_OPTION, $state, false ); return (bool) $sent; } /** * Read-only client summary: counts are observed activity, not rankings * or guaranteed AI citations. Links require WordPress authentication. */ public static function summary( array $report, $frequency ) { $traffic = (array) ( $report['traffic'] ?? array() ); $lines = array( /* translators: %s: Weekly or Monthly reporting frequency. */ sprintf( __( 'AI-O Optimiser %s AI traffic report', 'ai-o-optimiser' ), ucfirst( (string) $frequency ) ), (string) ( $report['site'] ?? home_url( '/' ) ), sprintf( '%s to %s', $report['local_start'] ?? '', $report['local_end'] ?? '' ), '', __( 'AI bot / fetch requests', 'ai-o-optimiser' ), /* translators: %d: Observed AI crawler requests in the reporting period. */ sprintf( __( 'AI bot/fetch requests: %d', 'ai-o-optimiser' ), (int) ( $traffic['crawler_hits'] ?? 0 ) ), /* translators: %d: Observed AI-referred visits in the reporting period. */ sprintf( __( 'AI-referred visits: %d', 'ai-o-optimiser' ), (int) ( $traffic['referral_hits'] ?? 0 ) ), /* translators: %d: Requests for guidance files in the reporting period. */ sprintf( __( 'Guidance-file requests: %d', 'ai-o-optimiser' ), (int) ( $traffic['guidance_hits'] ?? 0 ) ), ); /* translators: %d: Bot/fetch requests with 2xx responses, including legacy rows without recorded status. */ $lines[] = sprintf( __( 'Delivered bot/fetch responses: %d', 'ai-o-optimiser' ), (int) ( $traffic['crawler_successful'] ?? 0 ) ); /* translators: %d: Bot/fetch requests with positively verified provider identity. */ $lines[] = sprintf( __( 'Verified bot/fetch requests: %d', 'ai-o-optimiser' ), (int) ( $traffic['crawler_verified'] ?? 0 ) ); $details = self::traffic_details( $traffic ); foreach ( array( 'bots' => __( 'Top AI bots / fetchers (requests, last seen UTC)', 'ai-o-optimiser' ), 'pages' => __( 'Most-fetched pages (bot requests)', 'ai-o-optimiser' ), 'referrers' => __( 'AI referral sources (visits)', 'ai-o-optimiser' ), 'guidance' => __( 'Guidance files (requests)', 'ai-o-optimiser' ), ) as $group => $heading ) { $lines[] = ''; $lines[] = $heading; if ( ! $details[ $group ] ) { $lines[] = __( 'No matching activity recorded in this period.', 'ai-o-optimiser' ); continue; } foreach ( $details[ $group ] as $row ) { $lines[] = $row['label'] . ': ' . $row['count'] . ( '' !== $row['last_seen'] ? ' (last ' . $row['last_seen'] . ' UTC)' : '' ); } } $lines[] = ''; $lines[] = __( 'View detailed AI traffic analytics in WordPress after signing in:', 'ai-o-optimiser' ); $lines[] = admin_url( 'admin.php?page=ai-o-optimiser-dashboard' ); $lines[] = ''; $lines[] = __( 'Counts reflect observed AI bot/fetch requests and referrals, not unique AI users, guaranteed citations, rankings or search results.', 'ai-o-optimiser' ); $lines[] = __( 'Manage or stop these emails in WordPress: AI-O Optimiser → Settings → Health.', 'ai-o-optimiser' ); return implode( "\n", $lines ); } /** * Generate attachments in the OS private temporary directory, never * uploads/public_html. Fail closed if it is unavailable. * * @return string|WP_Error */ private static function write_private_pdf( $pdf ) { if ( ! is_string( $pdf ) || strlen( $pdf ) < 100 || strlen( $pdf ) > 500000 || 0 !== strpos( $pdf, '%PDF-1.4' ) || false === strpos( $pdf, '%%EOF' ) ) { return new WP_Error( 'ai_o_report_invalid_pdf', __( 'Could not generate a valid, size-bounded PDF report.', 'ai-o-optimiser' ) ); } $dir = realpath( sys_get_temp_dir() ); $root = realpath( ABSPATH ); if ( ! $dir || ! wp_is_writable( $dir ) || ( $root && ( $dir === $root || 0 === strpos( $dir . '/', $root . '/' ) ) ) ) { return new WP_Error( 'ai_o_report_pdf_temp', __( 'No private temporary directory is available for PDF attachments.', 'ai-o-optimiser' ) ); } $path = @tempnam( $dir, 'aiorpt-' ); if ( ! is_string( $path ) || realpath( dirname( $path ) ) !== $dir ) { if ( is_string( $path ) ) { // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink -- Discard an OS temp file created outside the verified directory. @unlink( $path ); } return new WP_Error( 'ai_o_report_pdf_temp', __( 'Could not create a private PDF attachment.', 'ai-o-optimiser' ) ); } // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_chmod -- This is a private OS temp file, not a WordPress-owned persistent file. @chmod( $path, 0600 ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- Bounded PDF bytes written only to the private temp file, without requiring FTP credentials. if ( strlen( $pdf ) !== @file_put_contents( $path, $pdf ) ) { // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink -- Remove a failed temporary PDF immediately, even if WordPress mail cannot run. @unlink( $path ); return new WP_Error( 'ai_o_report_pdf_write', __( 'Could not write the private PDF attachment.', 'ai-o-optimiser' ) ); } return $path; } /** * Bounded, query-free email presentation of already-aggregated traffic. * Never forward raw user agents, referrer URLs, client IPs or credentials. */ public static function traffic_details( array $traffic ) { $output = array( 'bots' => array(), 'pages' => array(), 'referrers' => array(), 'guidance' => array() ); $groups = array( 'bots' => array( 'field' => 'by_bot', 'label' => 'bot_label', 'limit' => 8 ), 'referrers' => array( 'field' => 'by_referrer', 'label' => 'source_label', 'limit' => 5 ), 'guidance' => array( 'field' => 'by_guidance', 'label' => 'source_label', 'limit' => 5 ), 'pages' => array( 'field' => 'top_pages', 'label' => 'request_uri', 'limit' => 5 ), ); foreach ( $groups as $group => $config ) { foreach ( array_slice( (array) ( $traffic[ $config['field'] ] ?? array() ), 0, 25 ) as $source ) { $value = (string) ( $source->{$config['label']} ?? '' ); if ( 'pages' === $group ) { $value = explode( '?', $value, 2 )[0]; $value = wp_parse_url( $value, PHP_URL_PATH ); if ( ! is_string( $value ) || ! str_starts_with( $value, '/' ) || preg_match( '#/(?:wp-admin|wp-json|wp-login|api)(?:/|$)|(?:token|secret|nonce|password|auth[_-]?key)#i', $value ) ) { continue; } } $label = sanitize_text_field( $value ); $label = trim( (string) preg_replace( '/\s+/', ' ', $label ) ); if ( '' === $label ) { continue; } $label = substr( $label, 0, 78 ); $last = 'bots' === $group ? (string) ( $source->last_seen ?? '' ) : ''; $last = preg_match( '/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}/', $last ) ? substr( $last, 0, 16 ) : ''; $output[ $group ][] = array( 'label' => $label, 'count' => max( 0, (int) ( $source->total ?? 0 ) ), 'last_seen' => $last, ); if ( count( $output[ $group ] ) >= $config['limit'] ) { break; } } } return $output; } private static function permission() { if ( ! current_user_can( AI_O_Optimiser_SENSITIVE_CAPABILITY ) ) { wp_die( esc_html__( 'You are not allowed to configure client reports.', 'ai-o-optimiser' ), '', array( 'response' => 403 ) ); } } private static function redirect_result( $result ) { wp_safe_redirect( add_query_arg( array( 'section' => 'health', 'report_email' => sanitize_key( $result ), ), admin_url( 'admin.php?page=ai-o-optimiser-settings' ) ) ); exit; } public static function handle_save() { self::permission(); check_admin_referer( self::SAVE_ACTION ); $frequency = isset( $_POST['frequency'] ) ? sanitize_key( wp_unslash( $_POST['frequency'] ) ) : 'off'; if ( ! in_array( $frequency, array( 'off', 'weekly', 'monthly' ), true ) ) { $frequency = 'off'; } $raw = isset( $_POST['recipients'] ) ? sanitize_text_field( wp_unslash( $_POST['recipients'] ) ) : ''; $recipients = self::recipients( $raw ); if ( 'off' !== $frequency && is_wp_error( $recipients ) ) { self::redirect_result( 'invalid' ); } // Preserve the recipient field when turning sending off, so re-enabling // does not require the client to retype their addresses. if ( is_wp_error( $recipients ) ) { $previous = self::settings(); $addresses = $previous['recipients']; } else { $addresses = implode( ', ', $recipients ); } update_option( self::SETTINGS_OPTION, array( 'frequency' => $frequency, 'recipients' => $addresses, 'attach_pdf' => ! empty( $_POST['attach_pdf'] ) ? 1 : 0, ), false ); wp_clear_scheduled_hook( self::CRON_HOOK ); self::sync_schedule(); self::redirect_result( 'saved' ); } public static function handle_test() { self::permission(); check_admin_referer( self::TEST_ACTION ); $result = self::send( true ); self::redirect_result( true === $result ? 'test_sent' : 'test_failed' ); } /** Settings → Health, after the main Settings form (never nested forms). */ public static function render_settings_panel() { $settings = self::settings(); $state = self::state(); $next = wp_next_scheduled( self::CRON_HOOK ); $feedback = isset( $_GET['report_email'] ) ? sanitize_key( wp_unslash( $_GET['report_email'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Display-only status; no mutation. $notices = array( 'saved' => __( 'Client report email settings saved.', 'ai-o-optimiser' ), 'invalid' => __( 'Enter 1–5 valid email addresses before enabling scheduled reports. No settings were changed.', 'ai-o-optimiser' ), 'test_sent' => __( 'Test email accepted by WordPress. Check delivery with your mail provider.', 'ai-o-optimiser' ), 'test_failed' => __( 'WordPress could not hand off the test email. Check the site mail/SMTP configuration.', 'ai-o-optimiser' ), ); ?>