PluginProbe
404 Solution / 4.3.0
404 Solution v4.3.0
4.3.5 4.3.4 4.3.3 4.3.2 4.3.1 4.3.0 4.2.0 4.1.19 4.1.18 4.1.17 4.1.16 4.1.15 4.1.13 4.1.12 4.1.11 4.1.10 4.1.9 4.1.8 4.1.7 4.1.6 4.1.5 4.1.4 4.1.3 trunk 2.30.0 All 109 releases
404-solution / includes / logs / EmailDigest.php

EmailDigest.php in 404 Solution 4.3.0, at includes/logs/EmailDigest.php

373 lines 17.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
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">&nbsp;</td>'
159 : '';
160 $progressBarEmpty = $remainderPct > 0
161 ? '<td width="' . $remainderPct . '%" style="font-size:0;line-height:0;" height="6">&nbsp;</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