PluginProbe
404 Solution / 4.2.0
404 Solution v4.2.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 / EmailDigest.php

EmailDigest.php in 404 Solution 4.2.0, at includes/EmailDigest.php

495 lines 20.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
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_StatsRepository */
21 private $statsRepo;
22
23 /** @var ABJ_404_Solution_Logging */
24 private $logger;
25
26 /**
27 * @param ABJ_404_Solution_LogsRepository|object $logsRepoOrLegacyDao
28 * @param ABJ_404_Solution_Logging|ABJ_404_Solution_StatsRepository $loggerOrStatsRepo
29 * @param ABJ_404_Solution_Logging|null $logger
30 */
31 public function __construct($logsRepoOrLegacyDao, $loggerOrStatsRepo = null, $logger = null) {
32 if ($logsRepoOrLegacyDao instanceof ABJ_404_Solution_LogsRepository) {
33 $this->logsRepo = $logsRepoOrLegacyDao;
34 $this->statsRepo = $loggerOrStatsRepo;
35 $this->logger = $logger !== null ? $logger : abj_service('logging');
36 } else {
37 $this->logsRepo = $logsRepoOrLegacyDao;
38 $this->statsRepo = $logsRepoOrLegacyDao;
39 $this->logger = $loggerOrStatsRepo !== null ? $loggerOrStatsRepo : abj_service('logging');
40 }
41 }
42
43 /**
44 * Generate HTML email body for the digest.
45 *
46 * @param array<int, array<string, mixed>> $topCaptured Array of captured 404 rows from getTopCapturedForDigest().
47 * @param array{total_captured: int, total_manual: int, total_auto: int} $stats From getDigestSummaryStats().
48 * @param string $dateRange Human-readable date range label for the digest header.
49 * @param bool $rollupAvailable Whether the logs_hits rollup is currently
50 * available. When false and $topCaptured is empty, the empty-state
51 * cell renders an "unavailable, rebuild scheduled" message instead of
52 * "No captured 404s in this period" so the admin can distinguish the
53 * two cases.
54 * @return string HTML email body with inline CSS.
55 */
56 public function generateDigestHTML(array $topCaptured, array $stats, string $dateRange = '', bool $rollupAvailable = true): string {
57 if ($dateRange === '') {
58 $dateRange = date('Y-m-d');
59 }
60
61 $adminUrl = function_exists('admin_url')
62 ? admin_url('options-general.php?page=' . ABJ404_PP . '&subpage=abj404_captured')
63 : '#';
64 $settingsUrl = function_exists('admin_url')
65 ? admin_url('options-general.php?page=' . ABJ404_PP . '&subpage=abj404_options')
66 : '#';
67
68 $s = $this->computeDigestStats($stats);
69 $tableRows = $this->buildDigestTableRows($topCaptured, $rollupAvailable);
70 $t = $this->getDigestTranslations((int) $s['resolved'], (int) $s['totalAll']);
71
72 $html = '<!DOCTYPE html>
73 <html lang="en">
74 <head>
75 <meta charset="UTF-8">
76 <meta name="viewport" content="width=device-width, initial-scale=1.0">
77 <title>' . $t['digest'] . '</title>
78 </head>
79 <body style="margin:0;padding:0;background-color:#f1f5f9;font-family:-apple-system,BlinkMacSystemFont,\'Segoe UI\',Roboto,Helvetica,Arial,sans-serif;font-size:14px;color:#1e293b;">
80
81 <!-- Outer wrapper -->
82 <table width="100%" cellpadding="0" cellspacing="0" role="presentation" style="background:#f1f5f9;">
83 <tr><td align="center" style="padding:32px 16px;">
84
85 <!-- Main card -->
86 <table width="600" cellpadding="0" cellspacing="0" role="presentation"
87 style="max-width:600px;width:100%;background:#ffffff;border-radius:12px;overflow:hidden;
88 box-shadow:0 4px 6px rgba(0,0,0,0.07),0 1px 3px rgba(0,0,0,0.06);">
89
90 <!-- ===== HEADER ===== -->
91 <tr>
92 <td style="background:#1d4ed8;padding:0;">
93 <table width="100%" cellpadding="0" cellspacing="0" role="presentation">
94 <tr>
95 <td style="padding:26px 32px 22px;">
96 <table cellpadding="0" cellspacing="0" role="presentation">
97 <tr>
98 <td style="background:rgba(255,255,255,0.18);border-radius:10px;padding:9px 11px;vertical-align:middle;">
99 <span style="font-size:22px;line-height:1;" role="img" aria-label="shield">&#x1F6E1;&#xFE0F;</span>
100 </td>
101 <td style="padding-left:14px;vertical-align:middle;">
102 <div style="color:#ffffff;font-size:19px;font-weight:700;letter-spacing:-0.2px;">404 Solution</div>
103 <div style="color:#93c5fd;font-size:11px;font-weight:600;letter-spacing:0.8px;text-transform:uppercase;margin-top:2px;">'
104 . $t['report'] . '</div>
105 </td>
106 </tr>
107 </table>
108 </td>
109 <td align="right" style="padding:26px 32px 22px;vertical-align:middle;">
110 <div style="background:rgba(255,255,255,0.15);border-radius:8px;padding:6px 14px;display:inline-block;">
111 <div style="color:#bfdbfe;font-size:12px;font-weight:500;">' . esc_html($dateRange) . '</div>
112 </div>
113 </td>
114 </tr>
115 </table>
116 </td>
117 </tr>
118
119 <!-- ===== SUMMARY STATS ===== -->
120 <tr>
121 <td style="padding:24px 32px 0;">
122 <div style="font-size:11px;font-weight:700;color:#94a3b8;text-transform:uppercase;letter-spacing:1px;margin-bottom:12px;">'
123 . $t['summary'] . '</div>
124 <table width="100%" cellpadding="0" cellspacing="0" role="presentation">
125 <tr>
126 <!-- Captured -->
127 <td style="width:32%;">
128 <table width="100%" cellpadding="0" cellspacing="0" role="presentation">
129 <tr><td style="background:#eff6ff;border:1px solid #bfdbfe;border-radius:10px;padding:14px 10px;text-align:center;">
130 <div style="font-size:10px;font-weight:700;color:#3b82f6;text-transform:uppercase;letter-spacing:0.8px;margin-bottom:7px;">&#x1F4CA; ' . $t['captured'] . '</div>
131 <div style="font-size:32px;font-weight:800;color:#1d4ed8;line-height:1;">' . $s['totalCaptured'] . '</div>
132 <div style="font-size:11px;color:#94a3b8;margin-top:5px;">' . $t['urls404'] . '</div>
133 </td></tr>
134 </table>
135 </td>
136 <td width="8">&nbsp;</td>
137 <!-- Auto -->
138 <td style="width:32%;">
139 <table width="100%" cellpadding="0" cellspacing="0" role="presentation">
140 <tr><td style="background:#f0fdf4;border:1px solid #bbf7d0;border-radius:10px;padding:14px 10px;text-align:center;">
141 <div style="font-size:10px;font-weight:700;color:#16a34a;text-transform:uppercase;letter-spacing:0.8px;margin-bottom:7px;">&#x2705; ' . $t['auto'] . '</div>
142 <div style="font-size:32px;font-weight:800;color:#15803d;line-height:1;">' . $s['totalAuto'] . '</div>
143 <div style="font-size:11px;color:#94a3b8;margin-top:5px;">' . $t['redirected'] . '</div>
144 </td></tr>
145 </table>
146 </td>
147 <td width="8">&nbsp;</td>
148 <!-- Manual -->
149 <td style="width:32%;">
150 <table width="100%" cellpadding="0" cellspacing="0" role="presentation">
151 <tr><td style="background:#faf5ff;border:1px solid #ddd6fe;border-radius:10px;padding:14px 10px;text-align:center;">
152 <div style="font-size:10px;font-weight:700;color:#7c3aed;text-transform:uppercase;letter-spacing:0.8px;margin-bottom:7px;">&#x270D; ' . $t['manual'] . '</div>
153 <div style="font-size:32px;font-weight:800;color:#6d28d9;line-height:1;">' . $s['totalManual'] . '</div>
154 <div style="font-size:11px;color:#94a3b8;margin-top:5px;">' . $t['configured'] . '</div>
155 </td></tr>
156 </table>
157 </td>
158 </tr>
159 </table>
160 </td>
161 </tr>
162
163 <!-- ===== RESOLUTION RATE BAR ===== -->
164 <tr>
165 <td style="padding:14px 32px 0;">
166 <table width="100%" cellpadding="0" cellspacing="0" role="presentation">
167 <tr><td style="background:#f8fafc;border:1px solid #e2e8f0;border-radius:8px;padding:12px 16px;">
168 <table width="100%" cellpadding="0" cellspacing="0" role="presentation">
169 <tr>
170 <td><span style="font-size:12px;font-weight:600;color:#475569;">' . $t['resolution'] . '</span></td>
171 <td align="right"><span style="font-size:12px;font-weight:800;color:#2563eb;">' . $s['resolutionPct'] . '%</span></td>
172 </tr>
173 </table>
174 <!-- Progress bar track -->
175 <table width="100%" cellpadding="0" cellspacing="0" role="presentation"
176 style="margin-top:8px;border-radius:3px;overflow:hidden;background:#e2e8f0;" height="6">
177 <tr>' . $s['progressBarFill'] . $s['progressBarEmpty'] . '</tr>
178 </table>
179 <div style="font-size:11px;color:#94a3b8;margin-top:7px;">' . $t['handled'] . '</div>
180 </td></tr>
181 </table>
182 </td>
183 </tr>
184
185 <!-- ===== TOP URLS TABLE ===== -->
186 <tr>
187 <td style="padding:20px 32px 0;">
188 <div style="font-size:11px;font-weight:700;color:#94a3b8;text-transform:uppercase;letter-spacing:1px;margin-bottom:10px;">'
189 . $t['top_urls'] . '</div>
190 <table width="100%" cellpadding="0" cellspacing="0" role="presentation"
191 style="border-collapse:collapse;border:1px solid #e2e8f0;border-radius:8px;overflow:hidden;">
192 <thead>
193 <tr style="background:#f8fafc;">
194 <th style="padding:10px 12px;text-align:left;font-size:11px;font-weight:700;color:#64748b;
195 text-transform:uppercase;letter-spacing:0.7px;border-bottom:1px solid #e2e8f0;">'
196 . $t['url'] . '</th>
197 <th style="padding:10px 12px;text-align:center;font-size:11px;font-weight:700;color:#64748b;
198 text-transform:uppercase;letter-spacing:0.7px;border-bottom:1px solid #e2e8f0;white-space:nowrap;">'
199 . $t['hits'] . '</th>
200 <th style="padding:10px 12px;text-align:center;font-size:11px;font-weight:700;color:#64748b;
201 text-transform:uppercase;letter-spacing:0.7px;border-bottom:1px solid #e2e8f0;white-space:nowrap;">'
202 . $t['first_seen'] . '</th>
203 </tr>
204 </thead>
205 <tbody>
206 ' . $tableRows . '
207 </tbody>
208 </table>
209 </td>
210 </tr>
211
212 <!-- ===== CTA BUTTONS ===== -->
213 <tr>
214 <td style="padding:20px 32px;">
215 <table width="100%" cellpadding="0" cellspacing="0" role="presentation">
216 <tr>
217 <td style="width:50%;padding-right:6px;">
218 <a href="' . esc_url($adminUrl) . '"
219 style="display:block;padding:12px 0;background:#2563eb;color:#ffffff;text-decoration:none;
220 border-radius:8px;font-size:13px;font-weight:700;text-align:center;letter-spacing:0.2px;">'
221 . $t['view_cta'] . ' &#x2192;</a>
222 </td>
223 <td style="width:50%;padding-left:6px;">
224 <a href="' . esc_url($settingsUrl) . '"
225 style="display:block;padding:12px 0;background:#f8fafc;color:#374151;text-decoration:none;
226 border-radius:8px;font-size:13px;font-weight:600;text-align:center;
227 border:1px solid #e2e8f0;letter-spacing:0.2px;">'
228 . $t['settings'] . '</a>
229 </td>
230 </tr>
231 </table>
232 </td>
233 </tr>
234
235 <!-- ===== FOOTER ===== -->
236 <tr>
237 <td style="padding:16px 32px;background:#f8fafc;border-top:1px solid #e2e8f0;border-radius:0 0 12px 12px;">
238 <p style="margin:0;font-size:12px;color:#94a3b8;text-align:center;">'
239 . $t['unsubscribe']
240 . ' <a href="' . esc_url($settingsUrl) . '" style="color:#2563eb;text-decoration:none;">'
241 . $t['manage'] . '</a></p>
242 <p style="margin:8px 0 0;font-size:11px;color:#cbd5e1;text-align:center;">404 Solution v'
243 . esc_html((string)$s['pluginVersion'])
244 . ' &nbsp;&#183;&nbsp; PHP ' . esc_html((string)$s['phpVersion'])
245 . ' &nbsp;&#183;&nbsp; ' . esc_html((string)$s['sentAt']) . '</p>
246 </td>
247 </tr>
248
249 </table>
250 </td></tr>
251 </table>
252 </body>
253 </html>';
254
255 return $html;
256 }
257
258 /**
259 * @param array{total_captured: int, total_manual: int, total_auto: int} $stats
260 * @return array<string, int|string>
261 */
262 private function computeDigestStats(array $stats): array {
263 $totalCaptured = intval($stats['total_captured']);
264 $totalManual = intval($stats['total_manual']);
265 $totalAuto = intval($stats['total_auto']);
266 $totalAll = $totalCaptured + $totalAuto + $totalManual;
267 $resolved = $totalAuto + $totalManual;
268 $resolutionPct = $totalAll > 0 ? min(100, (int) round($resolved / $totalAll * 100)) : 0;
269 $remainderPct = 100 - $resolutionPct;
270
271 $progressBarFill = $resolutionPct > 0
272 ? '<td width="' . $resolutionPct . '%" bgcolor="#2563eb" style="background:#2563eb;border-radius:3px;font-size:0;line-height:0;" height="6">&nbsp;</td>'
273 : '';
274 $progressBarEmpty = $remainderPct > 0
275 ? '<td width="' . $remainderPct . '%" style="font-size:0;line-height:0;" height="6">&nbsp;</td>'
276 : '';
277
278 return [
279 'totalCaptured' => $totalCaptured, 'totalManual' => $totalManual,
280 'totalAuto' => $totalAuto, 'totalAll' => $totalAll, 'resolved' => $resolved,
281 'resolutionPct' => $resolutionPct, 'progressBarFill' => $progressBarFill,
282 'progressBarEmpty' => $progressBarEmpty,
283 'pluginVersion' => defined('ABJ404_VERSION') ? ABJ404_VERSION : '',
284 'phpVersion' => PHP_VERSION, 'sentAt' => date('Y-m-d H:i T'),
285 ];
286 }
287
288 /**
289 * @param array<int, array<string, mixed>> $topCaptured
290 * @param bool $rollupAvailable
291 * @return string
292 */
293 private function buildDigestTableRows(array $topCaptured, bool $rollupAvailable): string {
294 if (empty($topCaptured)) {
295 $emptyMessage = $rollupAvailable
296 ? esc_html__('No captured 404s in this period.', '404-solution')
297 : esc_html__('Top URLs unavailable: log rollup is being rebuilt. Will be available in the next digest.', '404-solution');
298 return '<tr><td colspan="3" style="padding:14px;text-align:center;color:#94a3b8;font-size:13px;">'
299 . $emptyMessage
300 . '</td></tr>';
301 }
302
303 $tableRows = '';
304 $rowIndex = 0;
305 foreach ($topCaptured as $row) {
306 $rowIndex++;
307 $rawUrl = isset($row['url']) && is_string($row['url']) ? $row['url'] : '';
308 $urlText = esc_html($rawUrl);
309 $hits = isset($row['logshits']) ? intval(is_scalar($row['logshits']) ? $row['logshits'] : 0) : 0;
310 $created = isset($row['created']) ? date('Y-m-d', intval(is_scalar($row['created']) ? $row['created'] : 0)) : '';
311
312 $rowBg = ($rowIndex % 2 === 0) ? '#f8fafc' : '#ffffff';
313
314 if ($hits >= 100) {
315 $badgeBg = '#fee2e2'; $badgeFg = '#dc2626';
316 } elseif ($hits >= 20) {
317 $badgeBg = '#fef3c7'; $badgeFg = '#d97706';
318 } else {
319 $badgeBg = '#f1f5f9'; $badgeFg = '#475569';
320 }
321
322 $tableRows .= '<tr bgcolor="' . $rowBg . '" style="background:' . $rowBg . ';">'
323 . '<td style="padding:9px 12px;border-bottom:1px solid #f1f5f9;font-size:12px;'
324 . 'font-family:\'Courier New\',Courier,monospace;word-break:break-all;color:#334155;">'
325 . $urlText
326 . '</td>'
327 . '<td style="padding:9px 12px;border-bottom:1px solid #f1f5f9;text-align:center;white-space:nowrap;">'
328 . '<span style="display:inline-block;padding:2px 8px;background:' . $badgeBg . ';color:' . $badgeFg . ';'
329 . 'border-radius:12px;font-size:12px;font-weight:700;">' . $hits . '</span>'
330 . '</td>'
331 . '<td style="padding:9px 12px;border-bottom:1px solid #f1f5f9;text-align:center;'
332 . 'font-size:12px;color:#64748b;white-space:nowrap;">' . esc_html($created) . '</td>'
333 . '</tr>' . "\n";
334 }
335 return $tableRows;
336 }
337
338 /**
339 * @param int $resolved
340 * @param int $totalAll
341 * @return array<string, string>
342 */
343 private function getDigestTranslations(int $resolved, int $totalAll): array {
344 return [
345 'digest' => esc_html__('404 Solution Digest', '404-solution'),
346 'report' => esc_html__('Digest Report', '404-solution'),
347 'summary' => esc_html__('Summary', '404-solution'),
348 'captured' => esc_html__('Captured', '404-solution'),
349 'urls404' => esc_html__('404 URLs', '404-solution'),
350 'auto' => esc_html__('Auto', '404-solution'),
351 'redirected' => esc_html__('Redirected', '404-solution'),
352 'manual' => esc_html__('Manual', '404-solution'),
353 'configured' => esc_html__('Configured', '404-solution'),
354 'resolution' => esc_html__('Resolution Rate', '404-solution'),
355 'handled' => sprintf(
356 /* translators: 1: resolved count, 2: total count */
357 esc_html__('%1$d of %2$d URLs handled', '404-solution'),
358 $resolved,
359 $totalAll
360 ),
361 'top_urls' => esc_html__('Top Captured 404 URLs', '404-solution'),
362 'url' => esc_html__('URL', '404-solution'),
363 'hits' => esc_html__('Hits', '404-solution'),
364 'first_seen' => esc_html__('First Seen', '404-solution'),
365 'view_cta' => esc_html__('View Captured 404s', '404-solution'),
366 'settings' => esc_html__('Manage Settings', '404-solution'),
367 'unsubscribe' => esc_html__('To stop these emails, update your notification settings.', '404-solution'),
368 'manage' => esc_html__('Manage settings', '404-solution'),
369 ];
370 }
371
372 /**
373 * Send the digest email. Returns a description of what happened.
374 *
375 * @return string
376 */
377 public function sendDigest(): string {
378 $options = abj_service('plugin_logic')->getOptions(true);
379
380 $frequency = isset($options['admin_notification_frequency']) && is_string($options['admin_notification_frequency'])
381 ? $options['admin_notification_frequency']
382 : 'instant';
383
384 if ($frequency === 'instant') {
385 return 'Digest skipped: frequency is instant.';
386 }
387
388 $to = isset($options['admin_notification_email']) && is_string($options['admin_notification_email'])
389 ? trim($options['admin_notification_email'])
390 : '';
391
392 if ($to === '') {
393 $adminEmail = function_exists('get_option') ? get_option('admin_email') : '';
394 $to = is_string($adminEmail) ? $adminEmail : '';
395 }
396
397 if ($to === '') {
398 return 'Digest skipped: no recipient email address configured.';
399 }
400
401 $limit = isset($options['admin_notification_digest_limit']) && is_numeric($options['admin_notification_digest_limit'])
402 ? max(1, intval($options['admin_notification_digest_limit']))
403 : 10;
404
405 // Pre-check rollup availability so the email distinguishes "rollup is
406 // being rebuilt" from "no captured 404s." Without this, a missing
407 // rollup silently produces an "No captured 404s in this period" cell
408 // even when captured rows exist — misleading to the admin.
409 $rollupAvailable = $this->logsRepo->logsHitsTableExists();
410 if (!$rollupAvailable) {
411 // Schedule a rebuild now so the next digest run has data.
412 $this->logsRepo->scheduleHitsTableRebuild();
413 $topCaptured = array();
414 } else {
415 $topCaptured = $this->statsRepo->getTopCapturedForDigest($limit);
416 }
417 $stats = $this->statsRepo->getDigestSummaryStats();
418
419 // Skip the email entirely only when there is genuinely nothing to report
420 // AND the rollup is healthy. If the rollup is unavailable but stats show
421 // captured rows exist, ship the email with a "top URLs unavailable" note
422 // so the admin learns about the rebuild rather than hearing silence.
423 if ($rollupAvailable && intval($stats['total_captured']) === 0 && empty($topCaptured)) {
424 return 'Digest skipped: no captured 404s to report.';
425 }
426
427 $dateRange = date('Y-m-d');
428 $body = $this->generateDigestHTML($topCaptured, $stats, $dateRange, $rollupAvailable);
429
430 $subject = sprintf(
431 /* translators: %s: current date */
432 __('404 Solution Digest — %s', '404-solution'),
433 $dateRange
434 );
435
436 $adminEmail = function_exists('get_option') ? get_option('admin_email') : '';
437 $adminEmailStr = is_string($adminEmail) ? $adminEmail : '';
438 $headers = array(
439 'Content-Type: text/html; charset=UTF-8',
440 'From: ' . $adminEmailStr . ' <' . $adminEmailStr . '>',
441 );
442
443 $this->logger->debugMessage('Sending 404 digest email to: ' . $to);
444 wp_mail($to, $subject, $body, $headers);
445 $this->logger->debugMessage('404 digest email sent.');
446
447 if (function_exists('update_option')) {
448 update_option('admin_notification_last_sent', time());
449 }
450
451 return 'Digest email sent to: ' . $to;
452 }
453
454 /**
455 * Schedule the next digest send based on the frequency option.
456 * Reschedules or clears WP-Cron as needed.
457 *
458 * @return void
459 */
460 public function scheduleNextDigest(): void {
461 $options = abj_service('plugin_logic')->getOptions(true);
462 $frequency = isset($options['admin_notification_frequency']) && is_string($options['admin_notification_frequency'])
463 ? $options['admin_notification_frequency']
464 : 'instant';
465
466 $hook = 'abj404_send_digest';
467
468 if ($frequency === 'instant') {
469 if (function_exists('wp_clear_scheduled_hook')) {
470 wp_clear_scheduled_hook($hook);
471 }
472 return;
473 }
474
475 $recurrence = ($frequency === 'weekly') ? 'weekly' : 'daily';
476
477 if (function_exists('wp_next_scheduled') && !wp_next_scheduled($hook)) {
478 if (function_exists('wp_schedule_event')) {
479 wp_schedule_event(time(), $recurrence, $hook);
480 }
481 }
482 }
483
484 /**
485 * Hook callback for the WP-Cron event 'abj404_send_digest'.
486 *
487 * @return void
488 */
489 public function onCronSendDigest(): void {
490 $result = $this->sendDigest();
491 $this->logger->debugMessage('onCronSendDigest: ' . $result);
492 }
493
494 }
495