PluginProbe
404 Solution / 4.1.19
404 Solution v4.1.19
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.1.19, at includes/EmailDigest.php

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