logsRepo = $logsRepoOrLegacyDao; $this->statsRepo = $loggerOrStatsRepo instanceof ABJ_404_Solution_StatsRepositoryInterface ? $loggerOrStatsRepo : $this->resolveStatsRepository(); $this->logger = $logger !== null ? $logger : abj_service('logging'); } else { // Legacy / test path: caller handed in a DataAccess facade. Resolve the real // LogsRepository off the facade; StatsRepository must be injected or registered. $this->logsRepo = method_exists($logsRepoOrLegacyDao, 'getLogsRepo') ? $logsRepoOrLegacyDao->getLogsRepo() : $logsRepoOrLegacyDao; $this->statsRepo = $this->resolveStatsRepository(); $this->logger = $loggerOrStatsRepo instanceof ABJ_404_Solution_Logging ? $loggerOrStatsRepo : abj_service('logging'); } } /** * @return ABJ_404_Solution_StatsRepositoryInterface */ private function resolveStatsRepository(): ABJ_404_Solution_StatsRepositoryInterface { $service = class_exists('ABJ_404_Solution_ServiceContainer') ? ABJ_404_Solution_ServiceContainer::safeGet('stats_repository') : null; if ($service instanceof ABJ_404_Solution_StatsRepositoryInterface) { return $service; } return ABJ_404_Solution_StatsRepositoryResolver::resolve(__CLASS__); } /** * 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', abj_clock()->now()); } $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']); // Load the digest email body template from disk and substitute // computed values. The HTML/CSS lives in includes/html/emailDigestBody.html // so that presentation can be edited independently of PHP. Email-client // compatibility requires inline / embedded CSS, so styles intentionally // live inside the template rather than an external stylesheet. $template = ABJ_404_Solution_FileSystemService::readFileContents(dirname(__DIR__) . '/html/emailDigestBody.html', false); $replacements = array( '{t_digest}' => $t['digest'], '{t_report}' => $t['report'], '{t_summary}' => $t['summary'], '{t_captured}' => $t['captured'], '{t_urls404}' => $t['urls404'], '{t_auto}' => $t['auto'], '{t_redirected}' => $t['redirected'], '{t_manual}' => $t['manual'], '{t_configured}' => $t['configured'], '{t_resolution}' => $t['resolution'], '{t_handled}' => $t['handled'], '{t_top_urls}' => $t['top_urls'], '{t_url}' => $t['url'], '{t_hits}' => $t['hits'], '{t_first_seen}' => $t['first_seen'], '{t_view_cta}' => $t['view_cta'], '{t_settings}' => $t['settings'], '{t_unsubscribe}' => $t['unsubscribe'], '{t_manage}' => $t['manage'], '{dateRange}' => esc_html($dateRange), '{totalCaptured}' => (string) $s['totalCaptured'], '{totalAuto}' => (string) $s['totalAuto'], '{totalManual}' => (string) $s['totalManual'], '{resolutionPct}' => (string) $s['resolutionPct'], '{progressBarFill}' => (string) $s['progressBarFill'], '{progressBarEmpty}'=> (string) $s['progressBarEmpty'], '{tableRows}' => $tableRows, '{adminUrl}' => esc_url($adminUrl), '{settingsUrl}' => esc_url($settingsUrl), '{pluginVersion}' => esc_html((string) $s['pluginVersion']), '{phpVersion}' => esc_html((string) $s['phpVersion']), '{sentAt}' => esc_html((string) $s['sentAt']), ); return str_replace(array_keys($replacements), array_values($replacements), $template); } /** * @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', abj_clock()->now()), ]; } /** * @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'); $emptyTemplate = ABJ_404_Solution_FileSystemService::readFileContents(dirname(__DIR__) . '/html/emailDigestEmptyRow.html', false); return str_replace('{emptyMessage}', $emptyMessage, $emptyTemplate); } $rowTemplate = ABJ_404_Solution_FileSystemService::readFileContents(dirname(__DIR__) . '/html/emailDigestTableRow.html', false); $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 .= str_replace( array('{rowBg}', '{urlText}', '{badgeBg}', '{badgeFg}', '{hits}', '{created}'), array($rowBg, $urlText, $badgeBg, $badgeFg, (string) $hits, esc_html($created)), $rowTemplate ); } 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 = $this->getOptions(); $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', abj_clock()->now()); $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', abj_clock()->now()); } 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 = $this->getOptions(); $frequency = isset($options['admin_notification_frequency']) && is_string($options['admin_notification_frequency']) ? $options['admin_notification_frequency'] : 'instant'; $scheduler = abj_cron_scheduler(); $hook = ABJ_404_Solution_CronScheduler::HOOK_SEND_DIGEST; if ($frequency === 'instant') { $scheduler->clearHook($hook); return; } $recurrence = ($frequency === 'weekly') ? 'weekly' : 'daily'; $scheduler->scheduleRecurringIfMissing($hook, $recurrence); } /** @return array */ private function getOptions(): array { return abj_service('options_repository')->getOptions(true); } /** * Hook callback for the WP-Cron event 'abj404_send_digest'. * * @return void */ public function onCronSendDigest(): void { $result = $this->sendDigest(); $this->logger->debugMessage('onCronSendDigest: ' . $result); } }