logsRepo = $logsRepoOrLegacyDao; $this->statsRepo = $loggerOrStatsRepo; $this->logger = $logger !== null ? $logger : abj_service('logging'); } else { $this->logsRepo = $logsRepoOrLegacyDao; $this->statsRepo = $logsRepoOrLegacyDao; $this->logger = $loggerOrStatsRepo !== null ? $loggerOrStatsRepo : abj_service('logging'); } } /** * Generate HTML email body for the digest. * * @param array> $topCaptured Array of captured 404 rows from getTopCapturedForDigest(). * @param array{total_captured: int, total_manual: int, total_auto: int} $stats From getDigestSummaryStats(). * @param string $dateRange Human-readable date range label for the digest header. * @param bool $rollupAvailable Whether the logs_hits rollup is currently * available. When false and $topCaptured is empty, the empty-state * cell renders an "unavailable, rebuild scheduled" message instead of * "No captured 404s in this period" so the admin can distinguish the * two cases. * @return string HTML email body with inline CSS. */ public function generateDigestHTML(array $topCaptured, array $stats, string $dateRange = '', bool $rollupAvailable = true): string { if ($dateRange === '') { $dateRange = date('Y-m-d'); } $adminUrl = function_exists('admin_url') ? admin_url('options-general.php?page=' . ABJ404_PP . '&subpage=abj404_captured') : '#'; $settingsUrl = function_exists('admin_url') ? admin_url('options-general.php?page=' . ABJ404_PP . '&subpage=abj404_options') : '#'; $s = $this->computeDigestStats($stats); $tableRows = $this->buildDigestTableRows($topCaptured, $rollupAvailable); $t = $this->getDigestTranslations((int) $s['resolved'], (int) $s['totalAll']); $html = ' ' . $t['digest'] . '
🛡️
404 Solution
' . $t['report'] . '
' . esc_html($dateRange) . '
' . $t['summary'] . '
📊 ' . $t['captured'] . '
' . $s['totalCaptured'] . '
' . $t['urls404'] . '
 
✅ ' . $t['auto'] . '
' . $s['totalAuto'] . '
' . $t['redirected'] . '
 
✍ ' . $t['manual'] . '
' . $s['totalManual'] . '
' . $t['configured'] . '
' . $t['resolution'] . ' ' . $s['resolutionPct'] . '%
' . $s['progressBarFill'] . $s['progressBarEmpty'] . '
' . $t['handled'] . '
' . $t['top_urls'] . '
' . $tableRows . '
' . $t['url'] . ' ' . $t['hits'] . ' ' . $t['first_seen'] . '
' . $t['view_cta'] . ' → ' . $t['settings'] . '

' . $t['unsubscribe'] . ' ' . $t['manage'] . '

404 Solution v' . esc_html((string)$s['pluginVersion']) . '  ·  PHP ' . esc_html((string)$s['phpVersion']) . '  ·  ' . esc_html((string)$s['sentAt']) . '

'; return $html; } /** * @param array{total_captured: int, total_manual: int, total_auto: int} $stats * @return array */ private function computeDigestStats(array $stats): array { $totalCaptured = intval($stats['total_captured']); $totalManual = intval($stats['total_manual']); $totalAuto = intval($stats['total_auto']); $totalAll = $totalCaptured + $totalAuto + $totalManual; $resolved = $totalAuto + $totalManual; $resolutionPct = $totalAll > 0 ? min(100, (int) round($resolved / $totalAll * 100)) : 0; $remainderPct = 100 - $resolutionPct; $progressBarFill = $resolutionPct > 0 ? ' ' : ''; $progressBarEmpty = $remainderPct > 0 ? ' ' : ''; return [ 'totalCaptured' => $totalCaptured, 'totalManual' => $totalManual, 'totalAuto' => $totalAuto, 'totalAll' => $totalAll, 'resolved' => $resolved, 'resolutionPct' => $resolutionPct, 'progressBarFill' => $progressBarFill, 'progressBarEmpty' => $progressBarEmpty, 'pluginVersion' => defined('ABJ404_VERSION') ? ABJ404_VERSION : '', 'phpVersion' => PHP_VERSION, 'sentAt' => date('Y-m-d H:i T'), ]; } /** * @param array> $topCaptured * @param bool $rollupAvailable * @return string */ private function buildDigestTableRows(array $topCaptured, bool $rollupAvailable): string { if (empty($topCaptured)) { $emptyMessage = $rollupAvailable ? esc_html__('No captured 404s in this period.', '404-solution') : esc_html__('Top URLs unavailable: log rollup is being rebuilt. Will be available in the next digest.', '404-solution'); return '' . $emptyMessage . ''; } $tableRows = ''; $rowIndex = 0; foreach ($topCaptured as $row) { $rowIndex++; $rawUrl = isset($row['url']) && is_string($row['url']) ? $row['url'] : ''; $urlText = esc_html($rawUrl); $hits = isset($row['logshits']) ? intval(is_scalar($row['logshits']) ? $row['logshits'] : 0) : 0; $created = isset($row['created']) ? date('Y-m-d', intval(is_scalar($row['created']) ? $row['created'] : 0)) : ''; $rowBg = ($rowIndex % 2 === 0) ? '#f8fafc' : '#ffffff'; if ($hits >= 100) { $badgeBg = '#fee2e2'; $badgeFg = '#dc2626'; } elseif ($hits >= 20) { $badgeBg = '#fef3c7'; $badgeFg = '#d97706'; } else { $badgeBg = '#f1f5f9'; $badgeFg = '#475569'; } $tableRows .= '' . '' . $urlText . '' . '' . '' . $hits . '' . '' . '' . esc_html($created) . '' . '' . "\n"; } return $tableRows; } /** * @param int $resolved * @param int $totalAll * @return array */ private function getDigestTranslations(int $resolved, int $totalAll): array { return [ 'digest' => esc_html__('404 Solution Digest', '404-solution'), 'report' => esc_html__('Digest Report', '404-solution'), 'summary' => esc_html__('Summary', '404-solution'), 'captured' => esc_html__('Captured', '404-solution'), 'urls404' => esc_html__('404 URLs', '404-solution'), 'auto' => esc_html__('Auto', '404-solution'), 'redirected' => esc_html__('Redirected', '404-solution'), 'manual' => esc_html__('Manual', '404-solution'), 'configured' => esc_html__('Configured', '404-solution'), 'resolution' => esc_html__('Resolution Rate', '404-solution'), 'handled' => sprintf( /* translators: 1: resolved count, 2: total count */ esc_html__('%1$d of %2$d URLs handled', '404-solution'), $resolved, $totalAll ), 'top_urls' => esc_html__('Top Captured 404 URLs', '404-solution'), 'url' => esc_html__('URL', '404-solution'), 'hits' => esc_html__('Hits', '404-solution'), 'first_seen' => esc_html__('First Seen', '404-solution'), 'view_cta' => esc_html__('View Captured 404s', '404-solution'), 'settings' => esc_html__('Manage Settings', '404-solution'), 'unsubscribe' => esc_html__('To stop these emails, update your notification settings.', '404-solution'), 'manage' => esc_html__('Manage settings', '404-solution'), ]; } /** * Send the digest email. Returns a description of what happened. * * @return string */ public function sendDigest(): string { $options = abj_service('plugin_logic')->getOptions(true); $frequency = isset($options['admin_notification_frequency']) && is_string($options['admin_notification_frequency']) ? $options['admin_notification_frequency'] : 'instant'; if ($frequency === 'instant') { return 'Digest skipped: frequency is instant.'; } $to = isset($options['admin_notification_email']) && is_string($options['admin_notification_email']) ? trim($options['admin_notification_email']) : ''; if ($to === '') { $adminEmail = function_exists('get_option') ? get_option('admin_email') : ''; $to = is_string($adminEmail) ? $adminEmail : ''; } if ($to === '') { return 'Digest skipped: no recipient email address configured.'; } $limit = isset($options['admin_notification_digest_limit']) && is_numeric($options['admin_notification_digest_limit']) ? max(1, intval($options['admin_notification_digest_limit'])) : 10; // Pre-check rollup availability so the email distinguishes "rollup is // being rebuilt" from "no captured 404s." Without this, a missing // rollup silently produces an "No captured 404s in this period" cell // even when captured rows exist — misleading to the admin. $rollupAvailable = $this->logsRepo->logsHitsTableExists(); if (!$rollupAvailable) { // Schedule a rebuild now so the next digest run has data. $this->logsRepo->scheduleHitsTableRebuild(); $topCaptured = array(); } else { $topCaptured = $this->statsRepo->getTopCapturedForDigest($limit); } $stats = $this->statsRepo->getDigestSummaryStats(); // Skip the email entirely only when there is genuinely nothing to report // AND the rollup is healthy. If the rollup is unavailable but stats show // captured rows exist, ship the email with a "top URLs unavailable" note // so the admin learns about the rebuild rather than hearing silence. if ($rollupAvailable && intval($stats['total_captured']) === 0 && empty($topCaptured)) { return 'Digest skipped: no captured 404s to report.'; } $dateRange = date('Y-m-d'); $body = $this->generateDigestHTML($topCaptured, $stats, $dateRange, $rollupAvailable); $subject = sprintf( /* translators: %s: current date */ __('404 Solution Digest — %s', '404-solution'), $dateRange ); $adminEmail = function_exists('get_option') ? get_option('admin_email') : ''; $adminEmailStr = is_string($adminEmail) ? $adminEmail : ''; $headers = array( 'Content-Type: text/html; charset=UTF-8', 'From: ' . $adminEmailStr . ' <' . $adminEmailStr . '>', ); $this->logger->debugMessage('Sending 404 digest email to: ' . $to); wp_mail($to, $subject, $body, $headers); $this->logger->debugMessage('404 digest email sent.'); if (function_exists('update_option')) { update_option('admin_notification_last_sent', time()); } return 'Digest email sent to: ' . $to; } /** * Schedule the next digest send based on the frequency option. * Reschedules or clears WP-Cron as needed. * * @return void */ public function scheduleNextDigest(): void { $options = abj_service('plugin_logic')->getOptions(true); $frequency = isset($options['admin_notification_frequency']) && is_string($options['admin_notification_frequency']) ? $options['admin_notification_frequency'] : 'instant'; $hook = 'abj404_send_digest'; if ($frequency === 'instant') { if (function_exists('wp_clear_scheduled_hook')) { wp_clear_scheduled_hook($hook); } return; } $recurrence = ($frequency === 'weekly') ? 'weekly' : 'daily'; if (function_exists('wp_next_scheduled') && !wp_next_scheduled($hook)) { if (function_exists('wp_schedule_event')) { wp_schedule_event(time(), $recurrence, $hook); } } } /** * Hook callback for the WP-Cron event 'abj404_send_digest'. * * @return void */ public function onCronSendDigest(): void { $result = $this->sendDigest(); $this->logger->debugMessage('onCronSendDigest: ' . $result); } }