PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.7.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.7.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 1.0.2 1.1.0 1.10.0 All 48 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.7.0, at includes/seo/class-email-report-generator.php

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