| 1 |
<?php |
| 2 |
/* allow-hardcoded-color: file-level exemption. Every literal in this file is required: HTML emitted here is delivered via wp_mail() and rendered by remote mail clients (Gmail, Outlook, Apple Mail) which do not load the admin stylesheet and strip external CSS, so theme vars (--abj404-*) cannot be used. */ |
| 3 |
if (!defined('ABSPATH')) { |
| 4 |
exit; |
| 5 |
} |
| 6 |
|
| 7 |
/** |
| 8 |
* Generates and sends rich digest email notifications for captured 404s. |
| 9 |
* |
| 10 |
* This class is responsible for: |
| 11 |
* - Building an HTML email digest with a summary of captured 404 URLs. |
| 12 |
* - Sending the digest via wp_mail(). |
| 13 |
* - Managing the WP-Cron schedule for daily/weekly digests. |
| 14 |
*/ |
| 15 |
class ABJ_404_Solution_EmailDigest { |
| 16 |
|
| 17 |
/** @var ABJ_404_Solution_LogsRepository */ |
| 18 |
private $logsRepo; |
| 19 |
|
| 20 |
/** @var ABJ_404_Solution_StatsRepositoryInterface */ |
| 21 |
private $statsRepo; |
| 22 |
|
| 23 |
/** @var ABJ_404_Solution_Logging */ |
| 24 |
private $logger; |
| 25 |
|
| 26 |
/** |
| 27 |
* @param ABJ_404_Solution_LogsRepository|object $logsRepoOrLegacyDao Real LogsRepository, or a |
| 28 |
* DataAccess facade that exposes getLogsRepo() (legacy + test path). When a DataAccess is |
| 29 |
* supplied, this class resolves the real LogsRepository off the facade so it does not |
| 30 |
* depend on pass-through LogsRepo methods existing on DataAccess. |
| 31 |
* @param ABJ_404_Solution_Logging|ABJ_404_Solution_StatsRepositoryInterface|null $loggerOrStatsRepo |
| 32 |
* StatsRepository when first arg is LogsRepository (modern signature); otherwise the |
| 33 |
* Logging service (legacy signature where the DAO is also the stats repo via pass-through). |
| 34 |
* @param ABJ_404_Solution_Logging|null $logger Logging service for the modern signature. |
| 35 |
*/ |
| 36 |
public function __construct($logsRepoOrLegacyDao, $loggerOrStatsRepo = null, $logger = null) { |
| 37 |
if ($logsRepoOrLegacyDao instanceof ABJ_404_Solution_LogsRepository) { |
| 38 |
$this->logsRepo = $logsRepoOrLegacyDao; |
| 39 |
$this->statsRepo = $loggerOrStatsRepo instanceof ABJ_404_Solution_StatsRepositoryInterface |
| 40 |
? $loggerOrStatsRepo |
| 41 |
: $this->resolveStatsRepository(); |
| 42 |
$this->logger = $logger !== null ? $logger : abj_service('logging'); |
| 43 |
} else { |
| 44 |
// Legacy / test path: caller handed in a DataAccess facade. Resolve the real |
| 45 |
// LogsRepository off the facade; StatsRepository must be injected or registered. |
| 46 |
$this->logsRepo = method_exists($logsRepoOrLegacyDao, 'getLogsRepo') |
| 47 |
? $logsRepoOrLegacyDao->getLogsRepo() |
| 48 |
: $logsRepoOrLegacyDao; |
| 49 |
$this->statsRepo = $this->resolveStatsRepository(); |
| 50 |
$this->logger = $loggerOrStatsRepo instanceof ABJ_404_Solution_Logging |
| 51 |
? $loggerOrStatsRepo |
| 52 |
: abj_service('logging'); |
| 53 |
} |
| 54 |
} |
| 55 |
|
| 56 |
/** |
| 57 |
* @return ABJ_404_Solution_StatsRepositoryInterface |
| 58 |
*/ |
| 59 |
private function resolveStatsRepository(): ABJ_404_Solution_StatsRepositoryInterface { |
| 60 |
$service = class_exists('ABJ_404_Solution_ServiceContainer') |
| 61 |
? ABJ_404_Solution_ServiceContainer::safeGet('stats_repository') |
| 62 |
: null; |
| 63 |
if ($service instanceof ABJ_404_Solution_StatsRepositoryInterface) { |
| 64 |
return $service; |
| 65 |
} |
| 66 |
|
| 67 |
return ABJ_404_Solution_StatsRepositoryResolver::resolve(__CLASS__); |
| 68 |
} |
| 69 |
|
| 70 |
/** |
| 71 |
* Generate HTML email body for the digest. |
| 72 |
* |
| 73 |
* @param array<int, array<string, mixed>> $topCaptured Array of captured 404 rows from getTopCapturedForDigest(). |
| 74 |
* @param array{total_captured: int, total_manual: int, total_auto: int} $stats From getDigestSummaryStats(). |
| 75 |
* @param string $dateRange Human-readable date range label for the digest header. |
| 76 |
* @param bool $rollupAvailable Whether the logs_hits rollup is currently |
| 77 |
* available. When false and $topCaptured is empty, the empty-state |
| 78 |
* cell renders an "unavailable, rebuild scheduled" message instead of |
| 79 |
* "No captured 404s in this period" so the admin can distinguish the |
| 80 |
* two cases. |
| 81 |
* @return string HTML email body with inline CSS. |
| 82 |
*/ |
| 83 |
public function generateDigestHTML(array $topCaptured, array $stats, string $dateRange = '', bool $rollupAvailable = true): string { |
| 84 |
if ($dateRange === '') { |
| 85 |
$dateRange = date('Y-m-d', abj_clock()->now()); |
| 86 |
} |
| 87 |
|
| 88 |
$adminUrl = function_exists('admin_url') |
| 89 |
? admin_url('options-general.php?page=' . ABJ404_PP . '&subpage=abj404_captured') |
| 90 |
: '#'; |
| 91 |
$settingsUrl = function_exists('admin_url') |
| 92 |
? admin_url('options-general.php?page=' . ABJ404_PP . '&subpage=abj404_options') |
| 93 |
: '#'; |
| 94 |
|
| 95 |
$s = $this->computeDigestStats($stats); |
| 96 |
$tableRows = $this->buildDigestTableRows($topCaptured, $rollupAvailable); |
| 97 |
$t = $this->getDigestTranslations((int) $s['resolved'], (int) $s['totalAll']); |
| 98 |
|
| 99 |
// Load the digest email body template from disk and substitute |
| 100 |
// computed values. The HTML/CSS lives in includes/html/emailDigestBody.html |
| 101 |
// so that presentation can be edited independently of PHP. Email-client |
| 102 |
// compatibility requires inline / embedded CSS, so styles intentionally |
| 103 |
// live inside the template rather than an external stylesheet. |
| 104 |
$template = ABJ_404_Solution_FileSystemService::readFileContents(dirname(__DIR__) . '/html/emailDigestBody.html', false); |
| 105 |
|
| 106 |
$replacements = array( |
| 107 |
'{t_digest}' => $t['digest'], |
| 108 |
'{t_report}' => $t['report'], |
| 109 |
'{t_summary}' => $t['summary'], |
| 110 |
'{t_captured}' => $t['captured'], |
| 111 |
'{t_urls404}' => $t['urls404'], |
| 112 |
'{t_auto}' => $t['auto'], |
| 113 |
'{t_redirected}' => $t['redirected'], |
| 114 |
'{t_manual}' => $t['manual'], |
| 115 |
'{t_configured}' => $t['configured'], |
| 116 |
'{t_resolution}' => $t['resolution'], |
| 117 |
'{t_handled}' => $t['handled'], |
| 118 |
'{t_top_urls}' => $t['top_urls'], |
| 119 |
'{t_url}' => $t['url'], |
| 120 |
'{t_hits}' => $t['hits'], |
| 121 |
'{t_first_seen}' => $t['first_seen'], |
| 122 |
'{t_view_cta}' => $t['view_cta'], |
| 123 |
'{t_settings}' => $t['settings'], |
| 124 |
'{t_unsubscribe}' => $t['unsubscribe'], |
| 125 |
'{t_manage}' => $t['manage'], |
| 126 |
'{dateRange}' => esc_html($dateRange), |
| 127 |
'{totalCaptured}' => (string) $s['totalCaptured'], |
| 128 |
'{totalAuto}' => (string) $s['totalAuto'], |
| 129 |
'{totalManual}' => (string) $s['totalManual'], |
| 130 |
'{resolutionPct}' => (string) $s['resolutionPct'], |
| 131 |
'{progressBarFill}' => (string) $s['progressBarFill'], |
| 132 |
'{progressBarEmpty}'=> (string) $s['progressBarEmpty'], |
| 133 |
'{tableRows}' => $tableRows, |
| 134 |
'{adminUrl}' => esc_url($adminUrl), |
| 135 |
'{settingsUrl}' => esc_url($settingsUrl), |
| 136 |
'{pluginVersion}' => esc_html((string) $s['pluginVersion']), |
| 137 |
'{phpVersion}' => esc_html((string) $s['phpVersion']), |
| 138 |
'{sentAt}' => esc_html((string) $s['sentAt']), |
| 139 |
); |
| 140 |
|
| 141 |
return str_replace(array_keys($replacements), array_values($replacements), $template); |
| 142 |
} |
| 143 |
|
| 144 |
/** |
| 145 |
* @param array{total_captured: int, total_manual: int, total_auto: int} $stats |
| 146 |
* @return array<string, int|string> |
| 147 |
*/ |
| 148 |
private function computeDigestStats(array $stats): array { |
| 149 |
$totalCaptured = intval($stats['total_captured']); |
| 150 |
$totalManual = intval($stats['total_manual']); |
| 151 |
$totalAuto = intval($stats['total_auto']); |
| 152 |
$totalAll = $totalCaptured + $totalAuto + $totalManual; |
| 153 |
$resolved = $totalAuto + $totalManual; |
| 154 |
$resolutionPct = $totalAll > 0 ? min(100, (int) round($resolved / $totalAll * 100)) : 0; |
| 155 |
$remainderPct = 100 - $resolutionPct; |
| 156 |
|
| 157 |
$progressBarFill = $resolutionPct > 0 |
| 158 |
? '<td width="' . $resolutionPct . '%" bgcolor="#2563eb" style="background:#2563eb;border-radius:3px;font-size:0;line-height:0;" height="6"> </td>' |
| 159 |
: ''; |
| 160 |
$progressBarEmpty = $remainderPct > 0 |
| 161 |
? '<td width="' . $remainderPct . '%" style="font-size:0;line-height:0;" height="6"> </td>' |
| 162 |
: ''; |
| 163 |
|
| 164 |
return [ |
| 165 |
'totalCaptured' => $totalCaptured, 'totalManual' => $totalManual, |
| 166 |
'totalAuto' => $totalAuto, 'totalAll' => $totalAll, 'resolved' => $resolved, |
| 167 |
'resolutionPct' => $resolutionPct, 'progressBarFill' => $progressBarFill, |
| 168 |
'progressBarEmpty' => $progressBarEmpty, |
| 169 |
'pluginVersion' => defined('ABJ404_VERSION') ? ABJ404_VERSION : '', |
| 170 |
'phpVersion' => PHP_VERSION, 'sentAt' => date('Y-m-d H:i T', abj_clock()->now()), |
| 171 |
]; |
| 172 |
} |
| 173 |
|
| 174 |
/** |
| 175 |
* @param array<int, array<string, mixed>> $topCaptured |
| 176 |
* @param bool $rollupAvailable |
| 177 |
* @return string |
| 178 |
*/ |
| 179 |
private function buildDigestTableRows(array $topCaptured, bool $rollupAvailable): string { |
| 180 |
if (empty($topCaptured)) { |
| 181 |
$emptyMessage = $rollupAvailable |
| 182 |
? esc_html__('No captured 404s in this period.', '404-solution') |
| 183 |
: esc_html__('Top URLs unavailable: log rollup is being rebuilt. Will be available in the next digest.', '404-solution'); |
| 184 |
$emptyTemplate = ABJ_404_Solution_FileSystemService::readFileContents(dirname(__DIR__) . '/html/emailDigestEmptyRow.html', false); |
| 185 |
return str_replace('{emptyMessage}', $emptyMessage, $emptyTemplate); |
| 186 |
} |
| 187 |
|
| 188 |
$rowTemplate = ABJ_404_Solution_FileSystemService::readFileContents(dirname(__DIR__) . '/html/emailDigestTableRow.html', false); |
| 189 |
$tableRows = ''; |
| 190 |
$rowIndex = 0; |
| 191 |
foreach ($topCaptured as $row) { |
| 192 |
$rowIndex++; |
| 193 |
$rawUrl = isset($row['url']) && is_string($row['url']) ? $row['url'] : ''; |
| 194 |
$urlText = esc_html($rawUrl); |
| 195 |
$hits = isset($row['logshits']) ? intval(is_scalar($row['logshits']) ? $row['logshits'] : 0) : 0; |
| 196 |
$created = isset($row['created']) ? date('Y-m-d', intval(is_scalar($row['created']) ? $row['created'] : 0)) : ''; |
| 197 |
|
| 198 |
$rowBg = ($rowIndex % 2 === 0) ? '#f8fafc' : '#ffffff'; |
| 199 |
|
| 200 |
if ($hits >= 100) { |
| 201 |
$badgeBg = '#fee2e2'; $badgeFg = '#dc2626'; |
| 202 |
} elseif ($hits >= 20) { |
| 203 |
$badgeBg = '#fef3c7'; $badgeFg = '#d97706'; |
| 204 |
} else { |
| 205 |
$badgeBg = '#f1f5f9'; $badgeFg = '#475569'; |
| 206 |
} |
| 207 |
|
| 208 |
$tableRows .= str_replace( |
| 209 |
array('{rowBg}', '{urlText}', '{badgeBg}', '{badgeFg}', '{hits}', '{created}'), |
| 210 |
array($rowBg, $urlText, $badgeBg, $badgeFg, (string) $hits, esc_html($created)), |
| 211 |
$rowTemplate |
| 212 |
); |
| 213 |
} |
| 214 |
return $tableRows; |
| 215 |
} |
| 216 |
|
| 217 |
/** |
| 218 |
* @param int $resolved |
| 219 |
* @param int $totalAll |
| 220 |
* @return array<string, string> |
| 221 |
*/ |
| 222 |
private function getDigestTranslations(int $resolved, int $totalAll): array { |
| 223 |
return [ |
| 224 |
'digest' => esc_html__('404 Solution Digest', '404-solution'), |
| 225 |
'report' => esc_html__('Digest Report', '404-solution'), |
| 226 |
'summary' => esc_html__('Summary', '404-solution'), |
| 227 |
'captured' => esc_html__('Captured', '404-solution'), |
| 228 |
'urls404' => esc_html__('404 URLs', '404-solution'), |
| 229 |
'auto' => esc_html__('Auto', '404-solution'), |
| 230 |
'redirected' => esc_html__('Redirected', '404-solution'), |
| 231 |
'manual' => esc_html__('Manual', '404-solution'), |
| 232 |
'configured' => esc_html__('Configured', '404-solution'), |
| 233 |
'resolution' => esc_html__('Resolution Rate', '404-solution'), |
| 234 |
'handled' => sprintf( |
| 235 |
/* translators: 1: resolved count, 2: total count */ |
| 236 |
esc_html__('%1$d of %2$d URLs handled', '404-solution'), |
| 237 |
$resolved, |
| 238 |
$totalAll |
| 239 |
), |
| 240 |
'top_urls' => esc_html__('Top Captured 404 URLs', '404-solution'), |
| 241 |
'url' => esc_html__('URL', '404-solution'), |
| 242 |
'hits' => esc_html__('Hits', '404-solution'), |
| 243 |
'first_seen' => esc_html__('First Seen', '404-solution'), |
| 244 |
'view_cta' => esc_html__('View Captured 404s', '404-solution'), |
| 245 |
'settings' => esc_html__('Manage Settings', '404-solution'), |
| 246 |
'unsubscribe' => esc_html__('To stop these emails, update your notification settings.', '404-solution'), |
| 247 |
'manage' => esc_html__('Manage settings', '404-solution'), |
| 248 |
]; |
| 249 |
} |
| 250 |
|
| 251 |
/** |
| 252 |
* Send the digest email. Returns a description of what happened. |
| 253 |
* |
| 254 |
* @return string |
| 255 |
*/ |
| 256 |
public function sendDigest(): string { |
| 257 |
$options = $this->getOptions(); |
| 258 |
|
| 259 |
$frequency = isset($options['admin_notification_frequency']) && is_string($options['admin_notification_frequency']) |
| 260 |
? $options['admin_notification_frequency'] |
| 261 |
: 'instant'; |
| 262 |
|
| 263 |
if ($frequency === 'instant') { |
| 264 |
return 'Digest skipped: frequency is instant.'; |
| 265 |
} |
| 266 |
|
| 267 |
$to = isset($options['admin_notification_email']) && is_string($options['admin_notification_email']) |
| 268 |
? trim($options['admin_notification_email']) |
| 269 |
: ''; |
| 270 |
|
| 271 |
if ($to === '') { |
| 272 |
$adminEmail = function_exists('get_option') ? get_option('admin_email') : ''; |
| 273 |
$to = is_string($adminEmail) ? $adminEmail : ''; |
| 274 |
} |
| 275 |
|
| 276 |
if ($to === '') { |
| 277 |
return 'Digest skipped: no recipient email address configured.'; |
| 278 |
} |
| 279 |
|
| 280 |
$limit = isset($options['admin_notification_digest_limit']) && is_numeric($options['admin_notification_digest_limit']) |
| 281 |
? max(1, intval($options['admin_notification_digest_limit'])) |
| 282 |
: 10; |
| 283 |
|
| 284 |
// Pre-check rollup availability so the email distinguishes "rollup is |
| 285 |
// being rebuilt" from "no captured 404s." Without this, a missing |
| 286 |
// rollup silently produces an "No captured 404s in this period" cell |
| 287 |
// even when captured rows exist — misleading to the admin. |
| 288 |
$rollupAvailable = $this->logsRepo->logsHitsTableExists(); |
| 289 |
if (!$rollupAvailable) { |
| 290 |
// Schedule a rebuild now so the next digest run has data. |
| 291 |
$this->logsRepo->scheduleHitsTableRebuild(); |
| 292 |
$topCaptured = array(); |
| 293 |
} else { |
| 294 |
$topCaptured = $this->statsRepo->getTopCapturedForDigest($limit); |
| 295 |
} |
| 296 |
$stats = $this->statsRepo->getDigestSummaryStats(); |
| 297 |
|
| 298 |
// Skip the email entirely only when there is genuinely nothing to report |
| 299 |
// AND the rollup is healthy. If the rollup is unavailable but stats show |
| 300 |
// captured rows exist, ship the email with a "top URLs unavailable" note |
| 301 |
// so the admin learns about the rebuild rather than hearing silence. |
| 302 |
if ($rollupAvailable && intval($stats['total_captured']) === 0 && empty($topCaptured)) { |
| 303 |
return 'Digest skipped: no captured 404s to report.'; |
| 304 |
} |
| 305 |
|
| 306 |
$dateRange = date('Y-m-d', abj_clock()->now()); |
| 307 |
$body = $this->generateDigestHTML($topCaptured, $stats, $dateRange, $rollupAvailable); |
| 308 |
|
| 309 |
$subject = sprintf( |
| 310 |
/* translators: %s: current date */ |
| 311 |
__('404 Solution Digest — %s', '404-solution'), |
| 312 |
$dateRange |
| 313 |
); |
| 314 |
|
| 315 |
$adminEmail = function_exists('get_option') ? get_option('admin_email') : ''; |
| 316 |
$adminEmailStr = is_string($adminEmail) ? $adminEmail : ''; |
| 317 |
$headers = array( |
| 318 |
'Content-Type: text/html; charset=UTF-8', |
| 319 |
'From: ' . $adminEmailStr . ' <' . $adminEmailStr . '>', |
| 320 |
); |
| 321 |
|
| 322 |
$this->logger->debugMessage('Sending 404 digest email to: ' . $to); |
| 323 |
wp_mail($to, $subject, $body, $headers); |
| 324 |
$this->logger->debugMessage('404 digest email sent.'); |
| 325 |
|
| 326 |
if (function_exists('update_option')) { |
| 327 |
update_option('admin_notification_last_sent', abj_clock()->now()); |
| 328 |
} |
| 329 |
|
| 330 |
return 'Digest email sent to: ' . $to; |
| 331 |
} |
| 332 |
|
| 333 |
/** |
| 334 |
* Schedule the next digest send based on the frequency option. |
| 335 |
* Reschedules or clears WP-Cron as needed. |
| 336 |
* |
| 337 |
* @return void |
| 338 |
*/ |
| 339 |
public function scheduleNextDigest(): void { |
| 340 |
$options = $this->getOptions(); |
| 341 |
$frequency = isset($options['admin_notification_frequency']) && is_string($options['admin_notification_frequency']) |
| 342 |
? $options['admin_notification_frequency'] |
| 343 |
: 'instant'; |
| 344 |
|
| 345 |
$scheduler = abj_cron_scheduler(); |
| 346 |
$hook = ABJ_404_Solution_CronScheduler::HOOK_SEND_DIGEST; |
| 347 |
|
| 348 |
if ($frequency === 'instant') { |
| 349 |
$scheduler->clearHook($hook); |
| 350 |
return; |
| 351 |
} |
| 352 |
|
| 353 |
$recurrence = ($frequency === 'weekly') ? 'weekly' : 'daily'; |
| 354 |
$scheduler->scheduleRecurringIfMissing($hook, $recurrence); |
| 355 |
} |
| 356 |
|
| 357 |
/** @return array<string, mixed> */ |
| 358 |
private function getOptions(): array { |
| 359 |
return abj_service('options_repository')->getOptions(true); |
| 360 |
} |
| 361 |
|
| 362 |
/** |
| 363 |
* Hook callback for the WP-Cron event 'abj404_send_digest'. |
| 364 |
* |
| 365 |
* @return void |
| 366 |
*/ |
| 367 |
public function onCronSendDigest(): void { |
| 368 |
$result = $this->sendDigest(); |
| 369 |
$this->logger->debugMessage('onCronSendDigest: ' . $result); |
| 370 |
} |
| 371 |
|
| 372 |
} |
| 373 |
|