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 = $this->readFrequencyOption($options); if ($frequency === 'instant' || $frequency === 'never') { return 'Digest skipped: frequency is ' . $frequency . '.'; } // Centralized cadence gate: sendDigest() is reachable from more than // one trigger (the dedicated abj404_send_digest WP-Cron event, and // the plugin's daily maintenance cron via // emailCaptured404Notification()). Without this check here, ANY // trigger firing more often than the configured frequency (e.g. the // daily maintenance cron running while frequency=weekly) sends a // digest every time it runs, regardless of what the admin selected. $cooldownSkip = $this->cooldownSkipMessage($frequency, $options); if ($cooldownSkip !== '') { return $cooldownSkip; } $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); $sent = wp_mail($to, $subject, $body, $headers); if (!$sent) { // Do not stamp admin_notification_last_sent on a failed send: // that would suppress the cooldown gate's retry for up to a // week even though nothing actually went out. A hosting-level // mail failure is a warning (the plugin can still function), // not an error -- the next daily-maintenance-cron trigger // retries naturally since last_sent is unchanged. $this->logger->warn('404 digest email failed to send to: ' . $to . ' (wp_mail() reported failure).'); return 'Digest email failed to send to: ' . $to; } $this->logger->debugMessage('404 digest email sent.'); // Write through the options repository (into the bundled // abj404_settings option), NOT a bare update_option() call: this // must land in the SAME storage location cooldownSkipMessage() // reads via getOptions(), or the cooldown gate silently never // engages (the storage-mismatch half of WP.org support topic // weekly-digest-3 -- see EmailDigestCadenceRealStorageRoundTripTest). $lastSent = abj_clock()->now(); abj_service('options_repository')->setRawSettingValue('admin_notification_last_sent', $lastSent); // Compatibility-window dual-write: 4.3.1 (already shipped to // production) persisted this same value as a STANDALONE WP option // via a bare update_option('admin_notification_last_sent', ...) // call. External integrations (other plugins, monitoring scripts, // site-owner custom code) may read that released contract via // get_option('admin_notification_last_sent'). The options_repository // write above does not touch that standalone option -- it lands // inside abj404_settings instead -- so without this second write, // any such external reader would silently stop receiving updates // after upgrading to 4.3.2+ (design-audit category 110 Contract // Compatibility finding). Keep both writes: the repository write is // load-bearing for this class's own cooldown gate, and this one // preserves the public contract for everyone else. if (function_exists('update_option')) { update_option('admin_notification_last_sent', $lastSent); } return 'Digest email sent to: ' . $to; } /** * Schedule the next digest send based on the frequency option. * Reschedules or clears WP-Cron as needed. * * The dedicated `abj404_send_digest` WP-Cron event always runs at a * fixed `daily` recurrence, regardless of the configured * admin_notification_frequency (daily/weekly). WP-Cron only fires * opportunistically on page loads with no guaranteed exact timing, so * tying this event's OWN recurrence to the desired send interval means * a single missed `weekly`-recurrence firing silently doubles the wait * to two weeks. cooldownSkipMessage() (called from sendDigest(), the * actual send boundary) is the single source of truth for cadence, * keyed off `admin_notification_last_sent`. A daily trigger just needs * to check in often enough that a missed firing costs at most a day, * never a week -- the same pattern LoggingFeedbackDispatcher uses for * its weekly heartbeat (frequent trigger, elapsed-time gate). * * @param string|null $frequencyOverride When provided, used instead of * re-reading the option. Callers that just validated and are about * to persist a new frequency value (e.g. SettingsNotificationPolicy) * must pass it explicitly: the options repository write happens * later in the same request, so a re-fetch here would read the * stale pre-save value and could incorrectly clear/schedule against * the wrong instant-vs-not state. * @return void */ public function scheduleNextDigest(?string $frequencyOverride = null): void { $frequency = $frequencyOverride !== null ? $frequencyOverride : $this->readFrequencyOption($this->getOptions()); $scheduler = abj_cron_scheduler(); $hook = ABJ_404_Solution_CronScheduler::HOOK_SEND_DIGEST; if ($frequency === 'instant' || $frequency === 'never') { $scheduler->clearHook($hook); return; } abj_cron_recurrence_migration()->ensureDailyRecurrence($hook); } /** @param array $options */ private function readFrequencyOption(array $options): string { return isset($options['admin_notification_frequency']) && is_string($options['admin_notification_frequency']) ? $options['admin_notification_frequency'] : 'instant'; } /** * Returns a non-empty skip message when the configured cadence has not * yet elapsed since the last successful send, or '' when sending is * allowed. `admin_notification_last_sent` (written at the bottom of * sendDigest()) is the single source of truth for cadence enforcement, * independent of which caller/cron triggered this method. * * @param array $options */ private function cooldownSkipMessage(string $frequency, array $options): string { $intervalSeconds = $frequency === 'weekly' ? (defined('WEEK_IN_SECONDS') ? WEEK_IN_SECONDS : 604800) : (defined('DAY_IN_SECONDS') ? DAY_IN_SECONDS : 86400); $lastSentRaw = isset($options['admin_notification_last_sent']) && is_scalar($options['admin_notification_last_sent']) ? $options['admin_notification_last_sent'] : 0; $lastSent = intval($lastSentRaw); if ($lastSent <= 0) { return ''; } $elapsed = abj_clock()->now() - $lastSent; if ($elapsed < $intervalSeconds) { return sprintf( 'Digest skipped: last sent %d seconds ago; next %s digest eligible in %d seconds.', $elapsed, $frequency, $intervalSeconds - $elapsed ); } return ''; } /** @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); } }