dao = $dao; $this->logger = $logger; } /** * 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') : '#'; $totalCaptured = intval($stats['total_captured']); $totalManual = intval($stats['total_manual']); $totalAuto = intval($stats['total_auto']); // Resolution rate: fraction of all tracked URLs that have been handled. $totalAll = $totalCaptured + $totalAuto + $totalManual; $resolved = $totalAuto + $totalManual; $resolutionPct = $totalAll > 0 ? min(100, (int) round($resolved / $totalAll * 100)) : 0; $remainderPct = 100 - $resolutionPct; // Progress bar: avoid a zero-width cell in edge cases. $progressBarFill = $resolutionPct > 0 ? ' ' : ''; $progressBarEmpty = $remainderPct > 0 ? ' ' : ''; // ---- HTML rows for the top-captured table ---- $tableRows = ''; 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'); $tableRows = '' . $emptyMessage . ''; } else { $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'; // Color-coded hit badge. 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"; } } $pluginVersion = defined('ABJ404_VERSION') ? ABJ404_VERSION : ''; $phpVersion = PHP_VERSION; $sentAt = date('Y-m-d H:i T'); // Translatable strings resolved once for readability. $t_digest = esc_html__('404 Solution Digest', '404-solution'); $t_report = esc_html__('Digest Report', '404-solution'); $t_summary = esc_html__('Summary', '404-solution'); $t_captured = esc_html__('Captured', '404-solution'); $t_404urls = esc_html__('404 URLs', '404-solution'); $t_auto = esc_html__('Auto', '404-solution'); $t_redirected = esc_html__('Redirected', '404-solution'); $t_manual = esc_html__('Manual', '404-solution'); $t_configured = esc_html__('Configured', '404-solution'); $t_resolution = esc_html__('Resolution Rate', '404-solution'); $t_handled = sprintf( /* translators: 1: resolved count, 2: total count */ esc_html__('%1$d of %2$d URLs handled', '404-solution'), $resolved, $totalAll ); $t_top_urls = esc_html__('Top Captured 404 URLs', '404-solution'); $t_url = esc_html__('URL', '404-solution'); $t_hits = esc_html__('Hits', '404-solution'); $t_first_seen = esc_html__('First Seen', '404-solution'); $t_view_cta = esc_html__('View Captured 404s', '404-solution'); $t_settings = esc_html__('Manage Settings', '404-solution'); $t_unsubscribe = esc_html__('To stop these emails, update your notification settings.', '404-solution'); $t_manage = esc_html__('Manage settings', '404-solution'); $html = ' ' . $t_digest . '
🛡️
404 Solution
' . $t_report . '
' . esc_html($dateRange) . '
' . $t_summary . '
📊 ' . $t_captured . '
' . $totalCaptured . '
' . $t_404urls . '
 
✅ ' . $t_auto . '
' . $totalAuto . '
' . $t_redirected . '
 
✍ ' . $t_manual . '
' . $totalManual . '
' . $t_configured . '
' . $t_resolution . ' ' . $resolutionPct . '%
' . $progressBarFill . $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($pluginVersion) . '  ·  PHP ' . esc_html($phpVersion) . '  ·  ' . esc_html($sentAt) . '

'; return $html; } /** * 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->dao->logsHitsTableExists(); if (!$rollupAvailable) { // Schedule a rebuild now so the next digest run has data. $this->dao->scheduleHitsTableRebuild(); $topCaptured = array(); } else { $topCaptured = $this->dao->getTopCapturedForDigest($limit); } $stats = $this->dao->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); } }