PluginProbe
404 Solution / trunk
404 Solution vtrunk
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 trunk, at includes/logs/EmailDigest.php

474 lines 22.6 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 $frequency = $this->readFrequencyOption($options);
259
260 if ($frequency === 'instant' || $frequency === 'never') {
261 return 'Digest skipped: frequency is ' . $frequency . '.';
262 }
263
264 // Centralized cadence gate: sendDigest() is reachable from more than
265 // one trigger (the dedicated abj404_send_digest WP-Cron event, and
266 // the plugin's daily maintenance cron via
267 // emailCaptured404Notification()). Without this check here, ANY
268 // trigger firing more often than the configured frequency (e.g. the
269 // daily maintenance cron running while frequency=weekly) sends a
270 // digest every time it runs, regardless of what the admin selected.
271 $cooldownSkip = $this->cooldownSkipMessage($frequency, $options);
272 if ($cooldownSkip !== '') {
273 return $cooldownSkip;
274 }
275
276 $to = isset($options['admin_notification_email']) && is_string($options['admin_notification_email'])
277 ? trim($options['admin_notification_email'])
278 : '';
279
280 if ($to === '') {
281 $adminEmail = function_exists('get_option') ? get_option('admin_email') : '';
282 $to = is_string($adminEmail) ? $adminEmail : '';
283 }
284
285 if ($to === '') {
286 return 'Digest skipped: no recipient email address configured.';
287 }
288
289 $limit = isset($options['admin_notification_digest_limit']) && is_numeric($options['admin_notification_digest_limit'])
290 ? max(1, intval($options['admin_notification_digest_limit']))
291 : 10;
292
293 // Pre-check rollup availability so the email distinguishes "rollup is
294 // being rebuilt" from "no captured 404s." Without this, a missing
295 // rollup silently produces an "No captured 404s in this period" cell
296 // even when captured rows exist — misleading to the admin.
297 $rollupAvailable = $this->logsRepo->logsHitsTableExists();
298 if (!$rollupAvailable) {
299 // Schedule a rebuild now so the next digest run has data.
300 $this->logsRepo->scheduleHitsTableRebuild();
301 $topCaptured = array();
302 } else {
303 $topCaptured = $this->statsRepo->getTopCapturedForDigest($limit);
304 }
305 $stats = $this->statsRepo->getDigestSummaryStats();
306
307 // Skip the email entirely only when there is genuinely nothing to report
308 // AND the rollup is healthy. If the rollup is unavailable but stats show
309 // captured rows exist, ship the email with a "top URLs unavailable" note
310 // so the admin learns about the rebuild rather than hearing silence.
311 if ($rollupAvailable && intval($stats['total_captured']) === 0 && empty($topCaptured)) {
312 return 'Digest skipped: no captured 404s to report.';
313 }
314
315 $dateRange = date('Y-m-d', abj_clock()->now());
316 $body = $this->generateDigestHTML($topCaptured, $stats, $dateRange, $rollupAvailable);
317
318 $subject = sprintf(
319 /* translators: %s: current date */
320 __('404 Solution Digest — %s', '404-solution'),
321 $dateRange
322 );
323
324 $adminEmail = function_exists('get_option') ? get_option('admin_email') : '';
325 $adminEmailStr = is_string($adminEmail) ? $adminEmail : '';
326 $headers = array(
327 'Content-Type: text/html; charset=UTF-8',
328 'From: ' . $adminEmailStr . ' <' . $adminEmailStr . '>',
329 );
330
331 $this->logger->debugMessage('Sending 404 digest email to: ' . $to);
332 $sent = wp_mail($to, $subject, $body, $headers);
333
334 if (!$sent) {
335 // Do not stamp admin_notification_last_sent on a failed send:
336 // that would suppress the cooldown gate's retry for up to a
337 // week even though nothing actually went out. A hosting-level
338 // mail failure is a warning (the plugin can still function),
339 // not an error -- the next daily-maintenance-cron trigger
340 // retries naturally since last_sent is unchanged.
341 $this->logger->warn('404 digest email failed to send to: ' . $to . ' (wp_mail() reported failure).');
342 return 'Digest email failed to send to: ' . $to;
343 }
344
345 $this->logger->debugMessage('404 digest email sent.');
346
347 // Write through the options repository (into the bundled
348 // abj404_settings option), NOT a bare update_option() call: this
349 // must land in the SAME storage location cooldownSkipMessage()
350 // reads via getOptions(), or the cooldown gate silently never
351 // engages (the storage-mismatch half of WP.org support topic
352 // weekly-digest-3 -- see EmailDigestCadenceRealStorageRoundTripTest).
353 $lastSent = abj_clock()->now();
354 abj_service('options_repository')->setRawSettingValue('admin_notification_last_sent', $lastSent);
355
356 // Compatibility-window dual-write: 4.3.1 (already shipped to
357 // production) persisted this same value as a STANDALONE WP option
358 // via a bare update_option('admin_notification_last_sent', ...)
359 // call. External integrations (other plugins, monitoring scripts,
360 // site-owner custom code) may read that released contract via
361 // get_option('admin_notification_last_sent'). The options_repository
362 // write above does not touch that standalone option -- it lands
363 // inside abj404_settings instead -- so without this second write,
364 // any such external reader would silently stop receiving updates
365 // after upgrading to 4.3.2+ (design-audit category 110 Contract
366 // Compatibility finding). Keep both writes: the repository write is
367 // load-bearing for this class's own cooldown gate, and this one
368 // preserves the public contract for everyone else.
369 if (function_exists('update_option')) {
370 update_option('admin_notification_last_sent', $lastSent);
371 }
372
373 return 'Digest email sent to: ' . $to;
374 }
375
376 /**
377 * Schedule the next digest send based on the frequency option.
378 * Reschedules or clears WP-Cron as needed.
379 *
380 * The dedicated `abj404_send_digest` WP-Cron event always runs at a
381 * fixed `daily` recurrence, regardless of the configured
382 * admin_notification_frequency (daily/weekly). WP-Cron only fires
383 * opportunistically on page loads with no guaranteed exact timing, so
384 * tying this event's OWN recurrence to the desired send interval means
385 * a single missed `weekly`-recurrence firing silently doubles the wait
386 * to two weeks. cooldownSkipMessage() (called from sendDigest(), the
387 * actual send boundary) is the single source of truth for cadence,
388 * keyed off `admin_notification_last_sent`. A daily trigger just needs
389 * to check in often enough that a missed firing costs at most a day,
390 * never a week -- the same pattern LoggingFeedbackDispatcher uses for
391 * its weekly heartbeat (frequent trigger, elapsed-time gate).
392 *
393 * @param string|null $frequencyOverride When provided, used instead of
394 * re-reading the option. Callers that just validated and are about
395 * to persist a new frequency value (e.g. SettingsNotificationPolicy)
396 * must pass it explicitly: the options repository write happens
397 * later in the same request, so a re-fetch here would read the
398 * stale pre-save value and could incorrectly clear/schedule against
399 * the wrong instant-vs-not state.
400 * @return void
401 */
402 public function scheduleNextDigest(?string $frequencyOverride = null): void {
403 $frequency = $frequencyOverride !== null ? $frequencyOverride : $this->readFrequencyOption($this->getOptions());
404
405 $scheduler = abj_cron_scheduler();
406 $hook = ABJ_404_Solution_CronScheduler::HOOK_SEND_DIGEST;
407
408 if ($frequency === 'instant' || $frequency === 'never') {
409 $scheduler->clearHook($hook);
410 return;
411 }
412
413 abj_cron_recurrence_migration()->ensureDailyRecurrence($hook);
414 }
415
416 /** @param array<string, mixed> $options */
417 private function readFrequencyOption(array $options): string {
418 return isset($options['admin_notification_frequency']) && is_string($options['admin_notification_frequency'])
419 ? $options['admin_notification_frequency']
420 : 'instant';
421 }
422
423 /**
424 * Returns a non-empty skip message when the configured cadence has not
425 * yet elapsed since the last successful send, or '' when sending is
426 * allowed. `admin_notification_last_sent` (written at the bottom of
427 * sendDigest()) is the single source of truth for cadence enforcement,
428 * independent of which caller/cron triggered this method.
429 *
430 * @param array<string, mixed> $options
431 */
432 private function cooldownSkipMessage(string $frequency, array $options): string {
433 $intervalSeconds = $frequency === 'weekly'
434 ? (defined('WEEK_IN_SECONDS') ? WEEK_IN_SECONDS : 604800)
435 : (defined('DAY_IN_SECONDS') ? DAY_IN_SECONDS : 86400);
436
437 $lastSentRaw = isset($options['admin_notification_last_sent']) && is_scalar($options['admin_notification_last_sent'])
438 ? $options['admin_notification_last_sent']
439 : 0;
440 $lastSent = intval($lastSentRaw);
441 if ($lastSent <= 0) {
442 return '';
443 }
444
445 $elapsed = abj_clock()->now() - $lastSent;
446 if ($elapsed < $intervalSeconds) {
447 return sprintf(
448 'Digest skipped: last sent %d seconds ago; next %s digest eligible in %d seconds.',
449 $elapsed,
450 $frequency,
451 $intervalSeconds - $elapsed
452 );
453 }
454
455 return '';
456 }
457
458 /** @return array<string, mixed> */
459 private function getOptions(): array {
460 return abj_service('options_repository')->getOptions(true);
461 }
462
463 /**
464 * Hook callback for the WP-Cron event 'abj404_send_digest'.
465 *
466 * @return void
467 */
468 public function onCronSendDigest(): void {
469 $result = $this->sendDigest();
470 $this->logger->debugMessage('onCronSendDigest: ' . $result);
471 }
472
473 }
474