PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.10.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.10.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 1.26.0 1.25.0 trunk 1.0.0 1.0.1 All 51 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.10.0, at includes/seo/class-email-report-generator.php

495 lines 19.3 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 /**
122 * Fires before the report is rendered/sent. Pro plugin uses
123 * this to fetch + attach the AI Highlights summary.
124 *
125 * @since 1.9.0
126 *
127 * @param array $config Per-site config.
128 * @param array $context Render context with shared data.
129 */
130 do_action('thinkrank_email_report_before_generate', $config, $context);
131
132 // Nothing to report. Checked after the hook above so a section
133 // Pro registers there still counts. An email with a header, a
134 // footer and nothing between them isn't a successful send.
135 if (!$this->renderer->has_renderable_sections($config)) {
136 if (!$is_test) {
137 // Don't re-evaluate this every hour — wait out a period.
138 $this->config->update_schedule(
139 $config['last_sent_at'] ?? null,
140 $this->compute_next_run($frequency)
141 );
142 }
143 return ['success' => false, 'skipped' => 'no_sections'];
144 }
145
146 // Dedupe check for scheduled sends only (tests can repeat).
147 if (!$is_test) {
148 $dedupe = $this->record_attempt($config, $context);
149 if (!$dedupe['inserted']) {
150 // Out of retries for this period: stop re-attempting and
151 // rejoin the normal cadence rather than ticking forever.
152 if (!empty($dedupe['exhausted'])) {
153 $this->config->update_schedule(
154 $this->config->get()['last_sent_at'] ?? null,
155 $this->compute_next_run($frequency)
156 );
157 return ['success' => false, 'skipped' => 'retry_limit'];
158 }
159 return [
160 'success' => false,
161 'skipped' => empty($dedupe['write_failed']) ? 'duplicate' : 'log_write_failed',
162 ];
163 }
164 }
165
166 $html = $this->renderer->render($config, $context);
167
168 // Every section fell back to its "no data" notice, so this report
169 // is a header, a footer and a column of placeholders — the empty
170 // send the pre-check above was meant to stop, but could not: that
171 // check only knows what is *enabled*, and emptiness is only known
172 // once the sections have run.
173 //
174 // A test send still goes out. "Send Test Email" exists to prove
175 // delivery works, and it has to do that on a site with no data.
176 if (!$is_test && $this->renderer->sections_with_data() === 0) {
177 // record_attempt() already claimed this period's log row.
178 // Leaving it 'pending' would accumulate rows for periods that
179 // were deliberately never sent, so close it out honestly.
180 $this->mark_log_skipped($config, $context);
181
182 // Don't re-evaluate this every hour — wait out a period.
183 $this->config->update_schedule(
184 $config['last_sent_at'] ?? null,
185 $this->compute_next_run($frequency)
186 );
187 $this->config->record_skip('no_data');
188
189 return ['success' => false, 'skipped' => 'no_data'];
190 }
191
192 $tokens = Email_Report_Data_Provider::subject_tokens($shared, $frequency);
193 $result = $this->mailer->send($config, $html, $tokens);
194
195 if (!$is_test) {
196 $this->finalize_log($config, $context, $result);
197
198 if (!empty($result['success'])) {
199 $this->config->update_schedule(
200 current_time('mysql'),
201 $this->compute_next_run($frequency)
202 );
203 $this->config->clear_skip();
204 } else {
205 // A transient mail failure must not cost the user a whole
206 // period, and it must not stamp last_sent_at with a send
207 // that never happened. Retry soon; give up after
208 // MAX_SEND_ATTEMPTS and fall back to the normal cadence.
209 $attempts = (int) ($dedupe['attempts'] ?? 1);
210 $next = $attempts >= self::MAX_SEND_ATTEMPTS
211 ? $this->compute_next_run($frequency)
212 : $this->compute_retry_run();
213
214 $this->config->update_schedule(
215 $this->config->get()['last_sent_at'] ?? null,
216 $next
217 );
218 }
219 }
220
221 /**
222 * Fires after a report has been sent (or has failed).
223 *
224 * @since 1.9.0
225 *
226 * @param array $config
227 * @param array $result
228 * @param bool $is_test
229 */
230 do_action('thinkrank_email_report_after_send', $config, $result, $is_test);
231
232 return ['success' => (bool) ($result['success'] ?? false), 'result' => $result];
233 } catch (Throwable $e) {
234 return ['success' => false, 'error' => $e->getMessage()];
235 }
236 }
237
238 /**
239 * The report cannot be built — Search Console is not connected.
240 *
241 * A scheduled run records the reason for the panel and leaves the
242 * schedule exactly where it is: `next_scheduled_at` stays in the past,
243 * so the hourly tick keeps asking and the first tick after the
244 * connection is made sends the report. No log row is written — there
245 * was no attempt.
246 *
247 * A test send still goes out, as the one-card "connect Search Console"
248 * email, so "Send Test Email" proves delivery and shows the recipient
249 * what to do next instead of a report full of blanks.
250 *
251 * @return array{success:bool,skipped?:string,reason?:string,result?:array,not_connected?:bool}
252 */
253 private function handle_not_ready(array $config, array $readiness, bool $is_test): array {
254 $reason = (string) ($readiness['reason'] ?? 'not_ready');
255
256 if (!$is_test) {
257 $this->config->record_skip($reason);
258 return ['success' => false, 'skipped' => 'not_ready', 'reason' => $reason];
259 }
260
261 $context = [
262 'period_start' => '',
263 'period_end' => '',
264 'period_label' => '',
265 'is_test' => true,
266 'not_connected' => true,
267 'readiness' => $readiness,
268 'shared' => [],
269 ];
270
271 $html = $this->renderer->render($config, $context);
272 $tokens = [
273 '%period%' => '',
274 '%headline%' => __('Connect Google Search Console to start your SEO reports', 'thinkrank'),
275 ];
276 $result = $this->mailer->send($config, $html, $tokens);
277
278 do_action('thinkrank_email_report_after_send', $config, $result, true);
279
280 return [
281 'success' => (bool) ($result['success'] ?? false),
282 'result' => $result,
283 'not_connected' => true,
284 'reason' => $reason,
285 ];
286 }
287
288 /**
289 * Claim this period's send by inserting a `pending` row in
290 * email_report_logs. The unique key on (site_id, period_start,
291 * recipient_hash) is the dedupe gate — a conflicting insert means the
292 * period is already accounted for.
293 *
294 * "Already accounted for" is not always "already delivered", though: a
295 * previous attempt may have failed. In that case we re-claim the same
296 * row for another attempt, up to MAX_SEND_ATTEMPTS, so the retry the
297 * scheduler booked can actually run.
298 *
299 * @return array{inserted:bool,log_id:int,attempts:int,retry?:bool,exhausted?:bool,write_failed?:bool}
300 */
301 private function record_attempt(array $config, array $context): array {
302 global $wpdb;
303 $table = $wpdb->prefix . 'thinkrank_email_report_logs';
304
305 $period_start = $context['period_start'] ?: current_time('mysql');
306 $period_end = $context['period_end'] ?: current_time('mysql');
307 $recipients = (array) ($config['recipients'] ?? []);
308 $hash = $this->recipient_hash($recipients);
309 $site_id = get_current_blog_id();
310
311 // The UNIQUE KEY on (site_id, period_start, recipient_hash) is the
312 // dedupe gate, so a colliding insert is an expected outcome on a
313 // normal tick — not an error. Suppress $wpdb's own error handling
314 // for the duration so a routine dedupe doesn't dump SQL and a stack
315 // trace into the log (or, under WP_DEBUG_DISPLAY, into cron output).
316 $suppressed = $wpdb->suppress_errors(true);
317 $rows = $wpdb->insert( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
318 $table,
319 [
320 'site_id' => $site_id,
321 'period_start' => $period_start,
322 'period_end' => $period_end,
323 'recipient_hash' => $hash,
324 'recipient_count' => count($recipients),
325 'frequency_days' => (int) ($config['frequency_days'] ?? 30),
326 'status' => 'pending',
327 'attempts' => 1,
328 'created_at' => current_time('mysql'),
329 ],
330 ['%d','%s','%s','%s','%d','%d','%s','%d','%s']
331 );
332 $last_error = (string) $wpdb->last_error;
333 $wpdb->suppress_errors($suppressed);
334
335 if ($rows) {
336 return [
337 'inserted' => true,
338 'log_id' => (int) $wpdb->insert_id,
339 'attempts' => 1,
340 ];
341 }
342
343 // A failed insert is only a dedupe signal when it failed *because of
344 // the unique key*. Anything else — missing table, wrong schema, disk
345 // full — must not masquerade as "already sent this period", or every
346 // scheduled send would be silently skipped forever with no alert.
347 if (stripos($last_error, 'duplicate entry') === false) {
348 error_log( // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
349 'ThinkRank email report: could not write the send log — '
350 . ($last_error !== '' ? $last_error : 'insert failed with no error reported.')
351 );
352 return ['inserted' => false, 'log_id' => 0, 'attempts' => 0, 'write_failed' => true];
353 }
354
355 return $this->claim_retry($table, $site_id, $period_start, $hash);
356 }
357
358 /**
359 * A row already exists for this period + recipient set. Decide whether
360 * it represents a completed send (skip) or a failed one we may retry.
361 *
362 * @return array{inserted:bool,log_id:int,attempts:int,retry?:bool,exhausted?:bool}
363 */
364 private function claim_retry(string $table, int $site_id, string $period_start, string $hash): array {
365 global $wpdb;
366
367 $existing = $wpdb->get_row( // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.NotPrepared
368 $wpdb->prepare(
369 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name is built from $wpdb->prefix.
370 "SELECT id, status, attempts FROM {$table} WHERE site_id = %d AND period_start = %s AND recipient_hash = %s",
371 $site_id,
372 $period_start,
373 $hash
374 ),
375 ARRAY_A
376 );
377
378 // No row behind the failed insert — the write itself broke, not a
379 // dedupe collision. Treat as "don't send" and leave it to the caller.
380 if (!is_array($existing)) {
381 return ['inserted' => false, 'log_id' => 0, 'attempts' => 0];
382 }
383
384 // Anything that isn't a recorded failure means this period is done
385 // (or in flight elsewhere) — the original dedupe behaviour.
386 if (($existing['status'] ?? '') !== 'failed') {
387 return ['inserted' => false, 'log_id' => (int) $existing['id'], 'attempts' => (int) $existing['attempts']];
388 }
389
390 $attempts = (int) ($existing['attempts'] ?? 1);
391 if ($attempts >= self::MAX_SEND_ATTEMPTS) {
392 return [
393 'inserted' => false,
394 'log_id' => (int) $existing['id'],
395 'attempts' => $attempts,
396 'exhausted' => true,
397 ];
398 }
399
400 $attempts++;
401 $wpdb->update( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
402 $table,
403 [
404 'status' => 'pending',
405 'attempts' => $attempts,
406 'error_message' => null,
407 ],
408 ['id' => (int) $existing['id']],
409 ['%s','%d','%s'],
410 ['%d']
411 );
412
413 return [
414 'inserted' => true,
415 'log_id' => (int) $existing['id'],
416 'attempts' => $attempts,
417 'retry' => true,
418 ];
419 }
420
421 /**
422 * Close out the row inserted by record_attempt() for a period that was
423 * deliberately not sent, so it doesn't linger as 'pending'.
424 */
425 private function mark_log_skipped(array $config, array $context): void {
426 global $wpdb;
427 $table = $wpdb->prefix . 'thinkrank_email_report_logs';
428
429 $wpdb->update( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
430 $table,
431 [
432 'status' => 'skipped',
433 'sent_at' => null,
434 'error_message' => null,
435 ],
436 [
437 'site_id' => get_current_blog_id(),
438 'period_start' => $context['period_start'] ?: current_time('mysql'),
439 'recipient_hash' => $this->recipient_hash((array) $config['recipients']),
440 ],
441 ['%s','%s','%s'],
442 ['%d','%s','%s']
443 );
444 }
445
446 /**
447 * Update the row inserted by record_attempt() with the send outcome.
448 */
449 private function finalize_log(array $config, array $context, array $result): void {
450 global $wpdb;
451 $table = $wpdb->prefix . 'thinkrank_email_report_logs';
452
453 $success = !empty($result['success']);
454 $status = $success ? 'sent' : 'failed';
455 $error = $success ? null : ($result['error'] ?? __('Unknown send failure.', 'thinkrank'));
456
457 $wpdb->update( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
458 $table,
459 [
460 'status' => $status,
461 // Only a real send has a send time. wpdb writes a literal
462 // NULL for a null value, which is what a failed row wants.
463 'sent_at' => $success ? current_time('mysql') : null,
464 'error_message' => $error,
465 ],
466 [
467 'site_id' => get_current_blog_id(),
468 'period_start' => $context['period_start'] ?: current_time('mysql'),
469 'recipient_hash' => $this->recipient_hash((array) $config['recipients']),
470 ],
471 ['%s','%s','%s'],
472 ['%d','%s','%s']
473 );
474 }
475
476 /**
477 * Stable hash of the recipient list. Lowercased + sorted so reordering
478 * doesn't bypass dedupe.
479 */
480 private function recipient_hash(array $recipients): string {
481 $normalized = array_values(array_unique(array_map('strtolower', array_map('trim', $recipients))));
482 sort($normalized);
483 return hash('sha256', implode(',', $normalized));
484 }
485
486 private function compute_next_run(int $frequency_days): string {
487 $frequency_days = max(1, $frequency_days);
488 return wp_date('Y-m-d H:i:s', strtotime('+' . $frequency_days . ' days'));
489 }
490
491 private function compute_retry_run(): string {
492 return wp_date('Y-m-d H:i:s', strtotime('+' . self::RETRY_DELAY_HOURS . ' hours'));
493 }
494 }
495