PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.14.1
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.14.1
2.14.1 2.14.0 2.13.0 2.12.0 2.11.0 2.10.0 2.9.0 2.8.0 2.7.0 2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.1 2.1.0 2.0.2 2.0.1 2.0.0 1.32.0 1.31.0 1.30.0 1.29.0 1.28.0 1.27.0 All 56 releases
thinkrank / includes / seo / class-email-report-generator.php

class-email-report-generator.php in ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO 2.14.1, at includes/seo/class-email-report-generator.php

601 lines 23.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Email Report Generator
4 *
5 * Orchestrates one report end-to-end: fetch data, run the section
6 * pipeline through the renderer, send via the mailer, and log to
7 * email_report_logs (insert-with-unique-key dedupe).
8 *
9 * Two entry points:
10 * - generate_for_due() : called by the scheduler when a site's next_scheduled_at is past
11 * - generate_test() : called by the REST endpoint's "Send Test Email" button
12 *
13 * @package ThinkRank
14 * @subpackage SEO
15 * @since 1.9.0
16 */
17
18 declare(strict_types=1);
19
20 namespace ThinkRank\SEO;
21
22 use Throwable;
23
24 if (!defined('ABSPATH')) {
25 exit;
26 }
27
28 /**
29 * Email_Report_Generator
30 *
31 * @since 1.9.0
32 */
33 final class Email_Report_Generator {
34
35 /**
36 * How long to wait before re-attempting a send that failed. Short
37 * enough that a transient SMTP problem doesn't cost the user a whole
38 * reporting period, long enough not to hammer a broken relay.
39 */
40 private const RETRY_DELAY_HOURS = 6;
41
42 /**
43 * Total send attempts per reporting period, including the first. Once
44 * spent, the schedule falls back to the normal cadence so a permanently
45 * misconfigured mailer doesn't retry forever.
46 */
47 private const MAX_SEND_ATTEMPTS = 3;
48
49 private Email_Report_Config $config;
50 private Email_Report_Renderer $renderer;
51 private Email_Report_Mailer $mailer;
52 private Email_Report_Data_Provider $data_provider;
53
54 public function __construct(
55 Email_Report_Config $config,
56 Email_Report_Renderer $renderer,
57 Email_Report_Mailer $mailer,
58 Email_Report_Data_Provider $data_provider
59 ) {
60 $this->config = $config;
61 $this->renderer = $renderer;
62 $this->mailer = $mailer;
63 $this->data_provider = $data_provider;
64 }
65
66 /**
67 * Run the scheduled-send path.
68 *
69 * @return array{success:bool,skipped?:string,result?:array,error?:string}
70 */
71 public function generate_for_due(): array {
72 $config = $this->config->get();
73
74 if (empty($config['enabled'])) {
75 return ['success' => false, 'skipped' => 'disabled'];
76 }
77 if (empty($config['recipients'])) {
78 return ['success' => false, 'skipped' => 'no_recipients'];
79 }
80
81 return $this->run($config, false);
82 }
83
84 /**
85 * Run the immediate "send test" path. Bypasses the schedule check
86 * and the dedupe log insert (a test isn't a real period send).
87 */
88 public function generate_test(): array {
89 $config = $this->config->get();
90 if (empty($config['recipients'])) {
91 return ['success' => false, 'error' => __('No recipients configured.', 'thinkrank')];
92 }
93 return $this->run($config, true);
94 }
95
96 /**
97 * Common send pipeline.
98 */
99 private function run(array $config, bool $is_test): array {
100 $frequency = (int) ($config['frequency_days'] ?? 30);
101
102 try {
103 // Is there a report to build? Asked before anything is fetched
104 // (#742). A site without Search Console used to be fetched,
105 // rendered and — before #611 — sent as a column of blanks.
106 $readiness = $this->data_provider->readiness();
107 if (empty($readiness['ready'])) {
108 return $this->handle_not_ready($config, $readiness, $is_test);
109 }
110
111 $shared = $this->data_provider->fetch($frequency);
112
113 $context = [
114 'period_start' => $shared['period_start'] ?? '',
115 'period_end' => $shared['period_end'] ?? '',
116 'period_label' => $shared['period_label'] ?? '',
117 'is_test' => $is_test,
118 'shared' => $shared,
119 ];
120
121 // Search Console answered with an error rather than with data.
122 // Every section is about to come back empty, and the no-data gate
123 // further down would read that as a property with no traffic: it
124 // would skip the period and tell the user Search Console "returned
125 // no data". Record it as the failure it is and retry soon, the way
126 // a failed send already is. Checked before the hook below so Pro
127 // does not build an AI summary for a report that cannot go out.
128 //
129 // A test send still goes out: it exists to prove delivery.
130 $fetch_error = self::fetch_error($shared);
131 if (!$is_test && $fetch_error !== '') {
132 return $this->handle_fetch_failure($config, $context, $fetch_error);
133 }
134
135 /**
136 * Fires before the report is rendered/sent. Pro plugin uses
137 * this to fetch + attach the AI Highlights summary.
138 *
139 * @since 1.9.0
140 *
141 * @param array $config Per-site config.
142 * @param array $context Render context with shared data.
143 */
144 do_action('thinkrank_email_report_before_generate', $config, $context);
145
146 // Nothing to report. Checked after the hook above so a section
147 // Pro registers there still counts. An email with a header, a
148 // footer and nothing between them isn't a successful send.
149 if (!$this->renderer->has_renderable_sections($config)) {
150 if (!$is_test) {
151 // Don't re-evaluate this every hour — wait out a period.
152 $this->config->update_schedule(
153 $config['last_sent_at'] ?? null,
154 $this->compute_next_run($frequency)
155 );
156 }
157 return ['success' => false, 'skipped' => 'no_sections'];
158 }
159
160 // Dedupe check for scheduled sends only (tests can repeat).
161 if (!$is_test) {
162 $dedupe = $this->record_attempt($config, $context);
163 if (!$dedupe['inserted']) {
164 // Out of retries for this period: stop re-attempting and
165 // rejoin the normal cadence rather than ticking forever.
166 if (!empty($dedupe['exhausted'])) {
167 $this->config->update_schedule(
168 $this->config->get()['last_sent_at'] ?? null,
169 $this->compute_next_run($frequency)
170 );
171 return ['success' => false, 'skipped' => 'retry_limit'];
172 }
173 return [
174 'success' => false,
175 'skipped' => empty($dedupe['write_failed']) ? 'duplicate' : 'log_write_failed',
176 ];
177 }
178 }
179
180 $html = $this->renderer->render($config, $context);
181
182 // Every section fell back to its "no data" notice, so this report
183 // is a header, a footer and a column of placeholders — the empty
184 // send the pre-check above was meant to stop, but could not: that
185 // check only knows what is *enabled*, and emptiness is only known
186 // once the sections have run.
187 //
188 // A test send still goes out. "Send Test Email" exists to prove
189 // delivery works, and it has to do that on a site with no data.
190 if (!$is_test && $this->renderer->sections_with_data() === 0) {
191 // record_attempt() already claimed this period's log row.
192 // Leaving it 'pending' would accumulate rows for periods that
193 // were deliberately never sent, so close it out honestly.
194 $this->mark_log_skipped($config, $context);
195
196 // Don't re-evaluate this every hour — wait out a period.
197 $this->config->update_schedule(
198 $config['last_sent_at'] ?? null,
199 $this->compute_next_run($frequency)
200 );
201 $this->config->record_skip('no_data');
202
203 return ['success' => false, 'skipped' => 'no_data'];
204 }
205
206 $tokens = Email_Report_Data_Provider::subject_tokens($shared, $frequency);
207 $result = $this->mailer->send($config, $html, $tokens);
208
209 if (!$is_test) {
210 $this->finalize_log($config, $context, $result);
211
212 if (!empty($result['success'])) {
213 $this->config->update_schedule(
214 current_time('mysql'),
215 $this->compute_next_run($frequency)
216 );
217 $this->config->clear_skip();
218 } else {
219 // A transient mail failure must not cost the user a whole
220 // period, and it must not stamp last_sent_at with a send
221 // that never happened. Retry soon; give up after
222 // MAX_SEND_ATTEMPTS and fall back to the normal cadence.
223 $attempts = (int) ($dedupe['attempts'] ?? 1);
224 $next = $attempts >= self::MAX_SEND_ATTEMPTS
225 ? $this->compute_next_run($frequency)
226 : $this->compute_retry_run();
227
228 $this->config->update_schedule(
229 $this->config->get()['last_sent_at'] ?? null,
230 $next
231 );
232 }
233 }
234
235 /**
236 * Fires after a report has been sent (or has failed).
237 *
238 * @since 1.9.0
239 *
240 * @param array $config
241 * @param array $result
242 * @param bool $is_test
243 */
244 do_action('thinkrank_email_report_after_send', $config, $result, $is_test);
245
246 return ['success' => (bool) ($result['success'] ?? false), 'result' => $result];
247 } catch (Throwable $e) {
248 return ['success' => false, 'error' => $e->getMessage()];
249 }
250 }
251
252 /**
253 * The report cannot be built — Search Console is not connected.
254 *
255 * A scheduled run records the reason for the panel and leaves the
256 * schedule exactly where it is: `next_scheduled_at` stays in the past,
257 * so the hourly tick keeps asking and the first tick after the
258 * connection is made sends the report. No log row is written — there
259 * was no attempt.
260 *
261 * A test send still goes out, as the one-card "connect Search Console"
262 * email, so "Send Test Email" proves delivery and shows the recipient
263 * what to do next instead of a report full of blanks.
264 *
265 * @return array{success:bool,skipped?:string,reason?:string,result?:array,not_connected?:bool}
266 */
267 private function handle_not_ready(array $config, array $readiness, bool $is_test): array {
268 $reason = (string) ($readiness['reason'] ?? 'not_ready');
269
270 if (!$is_test) {
271 $this->config->record_skip($reason);
272 return ['success' => false, 'skipped' => 'not_ready', 'reason' => $reason];
273 }
274
275 $context = [
276 'period_start' => '',
277 'period_end' => '',
278 'period_label' => '',
279 'is_test' => true,
280 'not_connected' => true,
281 'readiness' => $readiness,
282 'shared' => [],
283 ];
284
285 $html = $this->renderer->render($config, $context);
286 $tokens = [
287 '%period%' => '',
288 '%headline%' => __('Connect Google Search Console to start your SEO reports', 'thinkrank'),
289 ];
290 $result = $this->mailer->send($config, $html, $tokens);
291
292 do_action('thinkrank_email_report_after_send', $config, $result, true);
293
294 return [
295 'success' => (bool) ($result['success'] ?? false),
296 'result' => $result,
297 'not_connected' => true,
298 'reason' => $reason,
299 ];
300 }
301
302 /**
303 * The error behind a failed data fetch, or '' when the fetch worked.
304 *
305 * A fetch fails in two shapes. Email_Report_Data_Provider::fetch()
306 * catches a thrown API error and returns `available: false` with the
307 * message in `error`. Analytics_Manager::get_dashboard_data() catches
308 * its own and returns normally, with the message in `current.error`.
309 * `available: false` without an `error` is not a failure, so it is not
310 * reported as one.
311 *
312 * @since 2.15.0
313 *
314 * @param array $shared The data provider's fetch() result.
315 * @return string The error message, or '' when nothing failed.
316 */
317 private static function fetch_error(array $shared): string {
318 if (empty($shared['available']) && !empty($shared['error']) && is_scalar($shared['error'])) {
319 return (string) $shared['error'];
320 }
321
322 $current = is_array($shared['current'] ?? null) ? $shared['current'] : [];
323 if (!empty($current['error']) && is_scalar($current['error'])) {
324 return (string) $current['error'];
325 }
326
327 return '';
328 }
329
330 /**
331 * A scheduled run whose Search Console fetch failed.
332 *
333 * Treated like a failed send, not like a period with no data: the
334 * period's log row is claimed and closed as `failed` with the API's
335 * message, the next attempt is booked RETRY_DELAY_HOURS out, and once
336 * MAX_SEND_ATTEMPTS are spent the schedule rejoins the normal cadence.
337 * The reason and the message are recorded for the panel.
338 *
339 * @since 2.15.0
340 *
341 * @param array $config Per-site config.
342 * @param array $context Render context (period bounds).
343 * @param string $error The fetch error.
344 * @return array{success:bool,skipped:string,error?:string}
345 */
346 private function handle_fetch_failure(array $config, array $context, string $error): array {
347 $frequency = (int) ($config['frequency_days'] ?? 30);
348 $message = self::short_error($error);
349
350 $dedupe = $this->record_attempt($config, $context);
351 if (!$dedupe['inserted']) {
352 if (!empty($dedupe['exhausted'])) {
353 $this->config->update_schedule(
354 $this->config->get()['last_sent_at'] ?? null,
355 $this->compute_next_run($frequency)
356 );
357 $this->config->record_skip('fetch_failed', $message);
358 return ['success' => false, 'skipped' => 'retry_limit', 'error' => $message];
359 }
360 return [
361 'success' => false,
362 'skipped' => empty($dedupe['write_failed']) ? 'duplicate' : 'log_write_failed',
363 ];
364 }
365
366 $this->finalize_log($config, $context, ['success' => false, 'error' => $message]);
367
368 $attempts = (int) ($dedupe['attempts'] ?? 1);
369 $this->config->update_schedule(
370 $this->config->get()['last_sent_at'] ?? null,
371 $attempts >= self::MAX_SEND_ATTEMPTS
372 ? $this->compute_next_run($frequency)
373 : $this->compute_retry_run()
374 );
375 $this->config->record_skip('fetch_failed', $message);
376
377 return ['success' => false, 'skipped' => 'fetch_failed', 'error' => $message];
378 }
379
380 /**
381 * An error message fit for the log row and the panel: plain text, one
382 * line, at most 300 characters.
383 *
384 * @since 2.15.0
385 *
386 * @param string $error Raw error message.
387 * @return string
388 */
389 private static function short_error(string $error): string {
390 $error = trim((string) preg_replace('/\s+/', ' ', wp_strip_all_tags($error)));
391 return mb_strlen($error) > 300 ? rtrim(mb_substr($error, 0, 299)) . '…' : $error;
392 }
393
394 /**
395 * Claim this period's send by inserting a `pending` row in
396 * email_report_logs. The unique key on (site_id, period_start,
397 * recipient_hash) is the dedupe gate — a conflicting insert means the
398 * period is already accounted for.
399 *
400 * "Already accounted for" is not always "already delivered", though: a
401 * previous attempt may have failed. In that case we re-claim the same
402 * row for another attempt, up to MAX_SEND_ATTEMPTS, so the retry the
403 * scheduler booked can actually run.
404 *
405 * @return array{inserted:bool,log_id:int,attempts:int,retry?:bool,exhausted?:bool,write_failed?:bool}
406 */
407 private function record_attempt(array $config, array $context): array {
408 global $wpdb;
409 $table = $wpdb->prefix . 'thinkrank_email_report_logs';
410
411 $period_start = $context['period_start'] ?: current_time('mysql');
412 $period_end = $context['period_end'] ?: current_time('mysql');
413 $recipients = (array) ($config['recipients'] ?? []);
414 $hash = $this->recipient_hash($recipients);
415 $site_id = get_current_blog_id();
416
417 // The UNIQUE KEY on (site_id, period_start, recipient_hash) is the
418 // dedupe gate, so a colliding insert is an expected outcome on a
419 // normal tick — not an error. Suppress $wpdb's own error handling
420 // for the duration so a routine dedupe doesn't dump SQL and a stack
421 // trace into the log (or, under WP_DEBUG_DISPLAY, into cron output).
422 $suppressed = $wpdb->suppress_errors(true);
423 $rows = $wpdb->insert( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
424 $table,
425 [
426 'site_id' => $site_id,
427 'period_start' => $period_start,
428 'period_end' => $period_end,
429 'recipient_hash' => $hash,
430 'recipient_count' => count($recipients),
431 'frequency_days' => (int) ($config['frequency_days'] ?? 30),
432 'status' => 'pending',
433 'attempts' => 1,
434 'created_at' => current_time('mysql'),
435 ],
436 ['%d','%s','%s','%s','%d','%d','%s','%d','%s']
437 );
438 $last_error = (string) $wpdb->last_error;
439 $wpdb->suppress_errors($suppressed);
440
441 if ($rows) {
442 return [
443 'inserted' => true,
444 'log_id' => (int) $wpdb->insert_id,
445 'attempts' => 1,
446 ];
447 }
448
449 // A failed insert is only a dedupe signal when it failed *because of
450 // the unique key*. Anything else — missing table, wrong schema, disk
451 // full — must not masquerade as "already sent this period", or every
452 // scheduled send would be silently skipped forever with no alert.
453 if (stripos($last_error, 'duplicate entry') === false) {
454 error_log( // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
455 'ThinkRank email report: could not write the send log — '
456 . ($last_error !== '' ? $last_error : 'insert failed with no error reported.')
457 );
458 return ['inserted' => false, 'log_id' => 0, 'attempts' => 0, 'write_failed' => true];
459 }
460
461 return $this->claim_retry($table, $site_id, $period_start, $hash);
462 }
463
464 /**
465 * A row already exists for this period + recipient set. Decide whether
466 * it represents a completed send (skip) or a failed one we may retry.
467 *
468 * @return array{inserted:bool,log_id:int,attempts:int,retry?:bool,exhausted?:bool}
469 */
470 private function claim_retry(string $table, int $site_id, string $period_start, string $hash): array {
471 global $wpdb;
472
473 $existing = $wpdb->get_row( // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.NotPrepared
474 $wpdb->prepare(
475 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name is built from $wpdb->prefix.
476 "SELECT id, status, attempts FROM {$table} WHERE site_id = %d AND period_start = %s AND recipient_hash = %s",
477 $site_id,
478 $period_start,
479 $hash
480 ),
481 ARRAY_A
482 );
483
484 // No row behind the failed insert — the write itself broke, not a
485 // dedupe collision. Treat as "don't send" and leave it to the caller.
486 if (!is_array($existing)) {
487 return ['inserted' => false, 'log_id' => 0, 'attempts' => 0];
488 }
489
490 // Anything that isn't a recorded failure means this period is done
491 // (or in flight elsewhere) — the original dedupe behaviour.
492 if (($existing['status'] ?? '') !== 'failed') {
493 return ['inserted' => false, 'log_id' => (int) $existing['id'], 'attempts' => (int) $existing['attempts']];
494 }
495
496 $attempts = (int) ($existing['attempts'] ?? 1);
497 if ($attempts >= self::MAX_SEND_ATTEMPTS) {
498 return [
499 'inserted' => false,
500 'log_id' => (int) $existing['id'],
501 'attempts' => $attempts,
502 'exhausted' => true,
503 ];
504 }
505
506 $attempts++;
507 $wpdb->update( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
508 $table,
509 [
510 'status' => 'pending',
511 'attempts' => $attempts,
512 'error_message' => null,
513 ],
514 ['id' => (int) $existing['id']],
515 ['%s','%d','%s'],
516 ['%d']
517 );
518
519 return [
520 'inserted' => true,
521 'log_id' => (int) $existing['id'],
522 'attempts' => $attempts,
523 'retry' => true,
524 ];
525 }
526
527 /**
528 * Close out the row inserted by record_attempt() for a period that was
529 * deliberately not sent, so it doesn't linger as 'pending'.
530 */
531 private function mark_log_skipped(array $config, array $context): void {
532 global $wpdb;
533 $table = $wpdb->prefix . 'thinkrank_email_report_logs';
534
535 $wpdb->update( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
536 $table,
537 [
538 'status' => 'skipped',
539 'sent_at' => null,
540 'error_message' => null,
541 ],
542 [
543 'site_id' => get_current_blog_id(),
544 'period_start' => $context['period_start'] ?: current_time('mysql'),
545 'recipient_hash' => $this->recipient_hash((array) $config['recipients']),
546 ],
547 ['%s','%s','%s'],
548 ['%d','%s','%s']
549 );
550 }
551
552 /**
553 * Update the row inserted by record_attempt() with the send outcome.
554 */
555 private function finalize_log(array $config, array $context, array $result): void {
556 global $wpdb;
557 $table = $wpdb->prefix . 'thinkrank_email_report_logs';
558
559 $success = !empty($result['success']);
560 $status = $success ? 'sent' : 'failed';
561 $error = $success ? null : ($result['error'] ?? __('Unknown send failure.', 'thinkrank'));
562
563 $wpdb->update( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
564 $table,
565 [
566 'status' => $status,
567 // Only a real send has a send time. wpdb writes a literal
568 // NULL for a null value, which is what a failed row wants.
569 'sent_at' => $success ? current_time('mysql') : null,
570 'error_message' => $error,
571 ],
572 [
573 'site_id' => get_current_blog_id(),
574 'period_start' => $context['period_start'] ?: current_time('mysql'),
575 'recipient_hash' => $this->recipient_hash((array) $config['recipients']),
576 ],
577 ['%s','%s','%s'],
578 ['%d','%s','%s']
579 );
580 }
581
582 /**
583 * Stable hash of the recipient list. Lowercased + sorted so reordering
584 * doesn't bypass dedupe.
585 */
586 private function recipient_hash(array $recipients): string {
587 $normalized = array_values(array_unique(array_map('strtolower', array_map('trim', $recipients))));
588 sort($normalized);
589 return hash('sha256', implode(',', $normalized));
590 }
591
592 private function compute_next_run(int $frequency_days): string {
593 $frequency_days = max(1, $frequency_days);
594 return wp_date('Y-m-d H:i:s', strtotime('+' . $frequency_days . ' days'));
595 }
596
597 private function compute_retry_run(): string {
598 return wp_date('Y-m-d H:i:s', strtotime('+' . self::RETRY_DELAY_HOURS . ' hours'));
599 }
600 }
601